Фаза 1: скелет MCP-сервера пам'яті
- TypeScript + @modelcontextprotocol/sdk, Streamable HTTP (stateless), API-ключ - Схема БД: projects, memories (FTS + vector 1024), memory_links, sessions - Інструменти: memory_* (save/query/get/update/delete/link/graph), project_* (list/create/get_state/save_state) - EmbeddingProvider абстракція (Null у фазі 1, Ollama bge-m3 у фазі 2) - Dockerfile + compose.yml (ai-memory-server + ai-memory-db pgvector/pgvector:pg18) - Оновлено нотатки: PostgreSQL 18 як сервіс у stack'у проекту Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
# Пароль користувача ai_memory у PostgreSQL (контейнер ai-memory-db)
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
|
||||||
|
# Кома-розділений список API-ключів для клієнтів MCP (порожньо = без автентифікації, лише dev)
|
||||||
|
API_KEYS=
|
||||||
|
|
||||||
|
# Фаза 2: адреса Ollama на окремому сервері
|
||||||
|
# OLLAMA_URL=http://<ollama-host>:11434
|
||||||
Vendored
+1
-1
@@ -17,6 +17,6 @@
|
|||||||
"repelStrength": 10,
|
"repelStrength": 10,
|
||||||
"linkStrength": 1,
|
"linkStrength": 1,
|
||||||
"linkDistance": 250,
|
"linkDistance": 250,
|
||||||
"scale": 1,
|
"scale": 1.0000000000000024,
|
||||||
"close": true
|
"close": true
|
||||||
}
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY migrations ./migrations
|
||||||
|
EXPOSE 8767
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
+7
-7
@@ -7,13 +7,13 @@ created: 2026-07-14
|
|||||||
|
|
||||||
## Фаза 1 — Сервер пам'яті (поточна)
|
## Фаза 1 — Сервер пам'яті (поточна)
|
||||||
|
|
||||||
- [ ] Скелет репозиторію: TypeScript, `@modelcontextprotocol/sdk`, структура src/
|
- [x] Скелет репозиторію: TypeScript, `@modelcontextprotocol/sdk`, структура src/ ✅ 2026-07-14
|
||||||
- [ ] Схема БД + міграції ([[Модель даних]])
|
- [x] Схема БД + міграції ([[Модель даних]]) ✅ 2026-07-14
|
||||||
- [ ] MCP сервер: Streamable HTTP + API-ключ
|
- [x] MCP сервер: Streamable HTTP + API-ключ ✅ 2026-07-14
|
||||||
- [ ] Інструменти групи «Пам'ять» (FTS-пошук) ([[MCP інструменти]])
|
- [x] Інструменти групи «Пам'ять» (FTS-пошук) ([[MCP інструменти]]) ✅ 2026-07-14
|
||||||
- [ ] Інструменти групи «Проекти» (аналог project-agent)
|
- [x] Інструменти групи «Проекти» (аналог project-agent) ✅ 2026-07-14
|
||||||
- [ ] Абстракція `EmbeddingProvider` (заглушка `NullEmbeddingProvider`)
|
- [x] Абстракція `EmbeddingProvider` (заглушка `NullEmbeddingProvider`) ✅ 2026-07-14
|
||||||
- [ ] Тести (локально, проти dev-БД)
|
- [ ] Тести/смоук проти реальної БД — Docker локально відсутній, виконати на етапі деплою (фаза 2)
|
||||||
|
|
||||||
## Фаза 2 — Деплой
|
## Фаза 2 — Деплой
|
||||||
|
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# Portainer stack: ai-memory-network
|
||||||
|
# Volumes у Portainer підмінити на bind до датасету homeLab/portainer/apps/ai-memory-network
|
||||||
|
services:
|
||||||
|
ai-memory-server:
|
||||||
|
build: .
|
||||||
|
container_name: ai-memory-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8767:8767"
|
||||||
|
environment:
|
||||||
|
- PORT=8767
|
||||||
|
- DATABASE_URL=postgres://ai_memory:${POSTGRES_PASSWORD}@ai-memory-db:5432/ai_memory
|
||||||
|
- API_KEYS=${API_KEYS}
|
||||||
|
# Фаза 2: - OLLAMA_URL=${OLLAMA_URL}
|
||||||
|
depends_on:
|
||||||
|
ai-memory-db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
ai-memory-db:
|
||||||
|
image: pgvector/pgvector:pg18
|
||||||
|
container_name: ai-memory-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=ai_memory
|
||||||
|
- POSTGRES_USER=ai_memory
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ai_memory -d ai_memory"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Початкова схема ai_memory (див. нотатку "Модель даних")
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
summary text,
|
||||||
|
status text NOT NULL DEFAULT 'active',
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS memories (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
project_id bigint REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
type text NOT NULL DEFAULT 'note',
|
||||||
|
title text NOT NULL,
|
||||||
|
content text NOT NULL DEFAULT '',
|
||||||
|
content_tsv tsvector GENERATED ALWAYS AS (
|
||||||
|
to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(content, ''))
|
||||||
|
) STORED,
|
||||||
|
embedding vector(1024),
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS memories_tsv_idx ON memories USING gin (content_tsv);
|
||||||
|
CREATE INDEX IF NOT EXISTS memories_project_idx ON memories (project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS memories_type_idx ON memories (type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS memory_links (
|
||||||
|
source_id bigint NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
|
||||||
|
target_id bigint NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
|
||||||
|
link_type text NOT NULL DEFAULT 'related',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (source_id, target_id, link_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS memory_links_target_idx ON memory_links (target_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
session_summary text,
|
||||||
|
todo_json jsonb NOT NULL DEFAULT '{"tasks": []}',
|
||||||
|
changes text,
|
||||||
|
bugs text,
|
||||||
|
architecture_notes text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sessions_project_idx ON sessions (project_id, created_at DESC);
|
||||||
Generated
+2381
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-memory-network-mcp-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Мережевий MCP-сервер пам'яті для ШІ-агентів (Claude Code та інші)",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const config = {
|
||||||
|
port: Number(process.env.PORT ?? 8767),
|
||||||
|
databaseUrl:
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgres://ai_memory:ai_memory@localhost:5432/ai_memory",
|
||||||
|
// Кома-розділений список дійсних API-ключів; порожньо = автентифікація вимкнена (dev)
|
||||||
|
apiKeys: (process.env.API_KEYS ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((k) => k.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
// Фаза 2: адреса Ollama для embeddings
|
||||||
|
ollamaUrl: process.env.OLLAMA_URL ?? "",
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import pg from "pg";
|
||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
|
||||||
|
export const pool = new pg.Pool({ connectionString: config.databaseUrl });
|
||||||
|
|
||||||
|
const MIGRATIONS_DIR = join(
|
||||||
|
dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
"migrations",
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function runMigrations(): Promise<void> {
|
||||||
|
await pool.query(
|
||||||
|
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
name text PRIMARY KEY,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
const files = (await readdir(MIGRATIONS_DIR))
|
||||||
|
.filter((f) => f.endsWith(".sql"))
|
||||||
|
.sort();
|
||||||
|
for (const file of files) {
|
||||||
|
const { rowCount } = await pool.query(
|
||||||
|
"SELECT 1 FROM schema_migrations WHERE name = $1",
|
||||||
|
[file],
|
||||||
|
);
|
||||||
|
if (rowCount) continue;
|
||||||
|
const sql = await readFile(join(MIGRATIONS_DIR, file), "utf8");
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query(sql);
|
||||||
|
await client.query("INSERT INTO schema_migrations (name) VALUES ($1)", [
|
||||||
|
file,
|
||||||
|
]);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
console.log(`Міграцію застосовано: ${file}`);
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Абстракція embeddings: фаза 1 працює без векторів (NullEmbeddingProvider),
|
||||||
|
// фаза 2 додасть OllamaEmbeddingProvider (bge-m3, 1024 вим.) без зміни API.
|
||||||
|
export interface EmbeddingProvider {
|
||||||
|
readonly dimensions: number | null;
|
||||||
|
embed(text: string): Promise<number[] | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NullEmbeddingProvider implements EmbeddingProvider {
|
||||||
|
readonly dimensions = null;
|
||||||
|
async embed(): Promise<null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const embeddings: EmbeddingProvider = new NullEmbeddingProvider();
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import express from "express";
|
||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { runMigrations } from "./db.js";
|
||||||
|
import { registerMemoryTools } from "./tools/memory.js";
|
||||||
|
import { registerProjectTools } from "./tools/projects.js";
|
||||||
|
|
||||||
|
function buildServer(): McpServer {
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "ai-memory-network",
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
registerMemoryTools(server);
|
||||||
|
registerProjectTools(server);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticate(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (!config.apiKeys.length) return next(); // dev-режим без ключів
|
||||||
|
const header = req.headers.authorization ?? "";
|
||||||
|
const key = header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||||
|
if (key && config.apiKeys.includes(key)) return next();
|
||||||
|
res.status(401).json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: { code: -32001, message: "Unauthorized: невірний API-ключ" },
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ limit: "4mb" }));
|
||||||
|
|
||||||
|
app.get("/healthz", (_req, res) => {
|
||||||
|
res.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stateless режим: окремий transport на кожен запит
|
||||||
|
app.post("/mcp", authenticate, async (req, res) => {
|
||||||
|
const server = buildServer();
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: undefined,
|
||||||
|
});
|
||||||
|
res.on("close", () => {
|
||||||
|
transport.close();
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await server.connect(transport);
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Помилка обробки MCP-запиту:", err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: { code: -32603, message: "Internal server error" },
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.all("/mcp", (_req, res) => {
|
||||||
|
res.status(405).json({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: { code: -32000, message: "Method not allowed (stateless режим)" },
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await runMigrations();
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
console.log(`ai-memory-network MCP сервер: http://0.0.0.0:${config.port}/mcp`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { pool } from "../db.js";
|
||||||
|
|
||||||
|
function text(data: unknown) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: JSON.stringify(data, null, 2) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveProjectId(
|
||||||
|
projectName: string | undefined,
|
||||||
|
): Promise<number | null> {
|
||||||
|
if (!projectName) return null;
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT id FROM projects WHERE name = $1",
|
||||||
|
[projectName],
|
||||||
|
);
|
||||||
|
if (!rows.length) throw new Error(`Проект "${projectName}" не знайдено`);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMemoryTools(server: McpServer): void {
|
||||||
|
server.registerTool(
|
||||||
|
"memory_save",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Зберігає запис пам'яті. Без projectName — глобальний scope. links — зв'язки з існуючими записами (аналог wiki-посилань).",
|
||||||
|
inputSchema: {
|
||||||
|
type: z
|
||||||
|
.string()
|
||||||
|
.default("note")
|
||||||
|
.describe("fact | note | session_state | document | entity | ..."),
|
||||||
|
title: z.string(),
|
||||||
|
content: z.string().describe("Вміст у markdown"),
|
||||||
|
projectName: z.string().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
links: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
targetId: z.number(),
|
||||||
|
linkType: z.string().default("related"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ type, title, content, projectName, metadata, links }) => {
|
||||||
|
const projectId = await resolveProjectId(projectName);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO memories (project_id, type, title, content, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING id, created_at`,
|
||||||
|
[projectId, type, title, content, JSON.stringify(metadata ?? {})],
|
||||||
|
);
|
||||||
|
const memory = rows[0];
|
||||||
|
for (const link of links ?? []) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO memory_links (source_id, target_id, link_type)
|
||||||
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||||
|
[memory.id, link.targetId, link.linkType],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return text({ id: memory.id, created_at: memory.created_at });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"memory_query",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Пошук у пам'яті (фаза 1 — повнотекстовий FTS + фільтри). Повертає ранжовані збіги зі сніпетами.",
|
||||||
|
inputSchema: {
|
||||||
|
query: z.string(),
|
||||||
|
projectName: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
limit: z.number().int().min(1).max(50).default(10),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ query, projectName, type, limit }) => {
|
||||||
|
const projectId = await resolveProjectId(projectName);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT m.id, m.type, m.title, left(m.content, 500) AS snippet,
|
||||||
|
p.name AS project, ts_rank(m.content_tsv, q) AS rank,
|
||||||
|
m.created_at
|
||||||
|
FROM memories m
|
||||||
|
LEFT JOIN projects p ON p.id = m.project_id
|
||||||
|
CROSS JOIN websearch_to_tsquery('simple', $1) AS q
|
||||||
|
WHERE (m.content_tsv @@ q OR m.title ILIKE '%' || $1 || '%')
|
||||||
|
AND ($2::bigint IS NULL OR m.project_id = $2)
|
||||||
|
AND ($3::text IS NULL OR m.type = $3)
|
||||||
|
ORDER BY rank DESC, m.created_at DESC
|
||||||
|
LIMIT $4`,
|
||||||
|
[query, projectId, type ?? null, limit],
|
||||||
|
);
|
||||||
|
return text({ total: rows.length, results: rows });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"memory_get",
|
||||||
|
{
|
||||||
|
description: "Повний запис пам'яті + його вихідні та вхідні зв'язки.",
|
||||||
|
inputSchema: { id: z.number().int() },
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT m.*, p.name AS project FROM memories m
|
||||||
|
LEFT JOIN projects p ON p.id = m.project_id WHERE m.id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!rows.length) throw new Error(`Запис ${id} не знайдено`);
|
||||||
|
const { embedding, content_tsv, ...memory } = rows[0];
|
||||||
|
const links = await pool.query(
|
||||||
|
`SELECT l.source_id, l.target_id, l.link_type, m.title AS target_title
|
||||||
|
FROM memory_links l
|
||||||
|
JOIN memories m ON m.id = CASE WHEN l.source_id = $1 THEN l.target_id ELSE l.source_id END
|
||||||
|
WHERE l.source_id = $1 OR l.target_id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return text({ ...memory, links: links.rows });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"memory_update",
|
||||||
|
{
|
||||||
|
description: "Часткове оновлення запису пам'яті.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().int(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id, title, content, type, metadata }) => {
|
||||||
|
const { rowCount } = await pool.query(
|
||||||
|
`UPDATE memories SET
|
||||||
|
title = coalesce($2, title),
|
||||||
|
content = coalesce($3, content),
|
||||||
|
type = coalesce($4, type),
|
||||||
|
metadata = coalesce($5::jsonb, metadata),
|
||||||
|
embedding = CASE WHEN $3 IS NULL THEN embedding ELSE NULL END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
title ?? null,
|
||||||
|
content ?? null,
|
||||||
|
type ?? null,
|
||||||
|
metadata ? JSON.stringify(metadata) : null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!rowCount) throw new Error(`Запис ${id} не знайдено`);
|
||||||
|
return text({ id, updated: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"memory_delete",
|
||||||
|
{
|
||||||
|
description: "Видаляє запис пам'яті разом з його зв'язками.",
|
||||||
|
inputSchema: { id: z.number().int() },
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
const { rowCount } = await pool.query(
|
||||||
|
"DELETE FROM memories WHERE id = $1",
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!rowCount) throw new Error(`Запис ${id} не знайдено`);
|
||||||
|
return text({ id, deleted: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"memory_link",
|
||||||
|
{
|
||||||
|
description: "Створює зв'язок між двома записами (аналог wiki-посилання).",
|
||||||
|
inputSchema: {
|
||||||
|
sourceId: z.number().int(),
|
||||||
|
targetId: z.number().int(),
|
||||||
|
linkType: z.string().default("related"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ sourceId, targetId, linkType }) => {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO memory_links (source_id, target_id, link_type)
|
||||||
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||||
|
[sourceId, targetId, linkType],
|
||||||
|
);
|
||||||
|
return text({ sourceId, targetId, linkType, linked: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"memory_graph",
|
||||||
|
{
|
||||||
|
description: "Обхід графа зв'язків від запису на задану глибину.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().int(),
|
||||||
|
depth: z.number().int().min(1).max(5).default(2),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id, depth }) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`WITH RECURSIVE walk (memory_id, depth) AS (
|
||||||
|
SELECT $1::bigint, 0
|
||||||
|
UNION
|
||||||
|
SELECT CASE WHEN l.source_id = w.memory_id THEN l.target_id ELSE l.source_id END,
|
||||||
|
w.depth + 1
|
||||||
|
FROM memory_links l
|
||||||
|
JOIN walk w ON w.memory_id IN (l.source_id, l.target_id)
|
||||||
|
WHERE w.depth < $2
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON (m.id) m.id, m.type, m.title, w.depth
|
||||||
|
FROM walk w JOIN memories m ON m.id = w.memory_id
|
||||||
|
ORDER BY m.id, w.depth`,
|
||||||
|
[id, depth],
|
||||||
|
);
|
||||||
|
const edges = await pool.query(
|
||||||
|
`SELECT source_id, target_id, link_type FROM memory_links
|
||||||
|
WHERE source_id = ANY($1::bigint[]) AND target_id = ANY($1::bigint[])`,
|
||||||
|
[rows.map((r) => r.id)],
|
||||||
|
);
|
||||||
|
return text({ nodes: rows, edges: edges.rows });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { pool } from "../db.js";
|
||||||
|
|
||||||
|
function text(data: unknown) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: JSON.stringify(data, null, 2) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoJsonSchema = z.object({
|
||||||
|
tasks: z.array(
|
||||||
|
z.object({
|
||||||
|
task: z.string(),
|
||||||
|
priority: z.enum(["high", "medium", "low"]).default("medium"),
|
||||||
|
note: z.string().optional(),
|
||||||
|
done: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function registerProjectTools(server: McpServer): void {
|
||||||
|
server.registerTool(
|
||||||
|
"project_list",
|
||||||
|
{
|
||||||
|
description: "Список усіх проектів.",
|
||||||
|
inputSchema: {},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT p.id, p.name, p.summary, p.status, p.updated_at,
|
||||||
|
(SELECT count(*) FROM memories m WHERE m.project_id = p.id) AS memories_count
|
||||||
|
FROM projects p ORDER BY p.updated_at DESC`,
|
||||||
|
);
|
||||||
|
return text({ total: rows.length, projects: rows });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"project_create",
|
||||||
|
{
|
||||||
|
description: "Створює новий проект (kebab-case назва).",
|
||||||
|
inputSchema: {
|
||||||
|
projectName: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, {
|
||||||
|
message: "Назва має бути в kebab-case",
|
||||||
|
}),
|
||||||
|
summary: z.string(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ projectName, summary, metadata }) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO projects (name, summary, metadata)
|
||||||
|
VALUES ($1, $2, $3) RETURNING id, name, created_at`,
|
||||||
|
[projectName, summary, JSON.stringify(metadata ?? {})],
|
||||||
|
);
|
||||||
|
return text(rows[0]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"project_get_state",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Відновлює контекст проекту: дані проекту, останній стан сесії (summary, todo, changes, bugs, architecture notes) та останні записи пам'яті.",
|
||||||
|
inputSchema: { projectName: z.string() },
|
||||||
|
},
|
||||||
|
async ({ projectName }) => {
|
||||||
|
const project = await pool.query(
|
||||||
|
"SELECT * FROM projects WHERE name = $1",
|
||||||
|
[projectName],
|
||||||
|
);
|
||||||
|
if (!project.rows.length)
|
||||||
|
throw new Error(`Проект "${projectName}" не знайдено`);
|
||||||
|
const projectId = project.rows[0].id;
|
||||||
|
const session = await pool.query(
|
||||||
|
`SELECT * FROM sessions WHERE project_id = $1
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
[projectId],
|
||||||
|
);
|
||||||
|
const memories = await pool.query(
|
||||||
|
`SELECT id, type, title, left(content, 200) AS snippet, updated_at
|
||||||
|
FROM memories WHERE project_id = $1
|
||||||
|
ORDER BY updated_at DESC LIMIT 20`,
|
||||||
|
[projectId],
|
||||||
|
);
|
||||||
|
return text({
|
||||||
|
project: project.rows[0],
|
||||||
|
last_session: session.rows[0] ?? null,
|
||||||
|
recent_memories: memories.rows,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"project_save_state",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Зберігає стан сесії проекту: summary, todo-список, зміни, баги, архітектурні нотатки.",
|
||||||
|
inputSchema: {
|
||||||
|
projectName: z.string(),
|
||||||
|
sessionSummary: z.string(),
|
||||||
|
todoJson: todoJsonSchema,
|
||||||
|
changes: z.string().optional(),
|
||||||
|
bugs: z.string().optional(),
|
||||||
|
architectureNotes: z.string().optional(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({
|
||||||
|
projectName,
|
||||||
|
sessionSummary,
|
||||||
|
todoJson,
|
||||||
|
changes,
|
||||||
|
bugs,
|
||||||
|
architectureNotes,
|
||||||
|
}) => {
|
||||||
|
const project = await pool.query(
|
||||||
|
"SELECT id FROM projects WHERE name = $1",
|
||||||
|
[projectName],
|
||||||
|
);
|
||||||
|
if (!project.rows.length)
|
||||||
|
throw new Error(`Проект "${projectName}" не знайдено`);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO sessions
|
||||||
|
(project_id, session_summary, todo_json, changes, bugs, architecture_notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at`,
|
||||||
|
[
|
||||||
|
project.rows[0].id,
|
||||||
|
sessionSummary,
|
||||||
|
JSON.stringify(todoJson),
|
||||||
|
changes ?? null,
|
||||||
|
bugs ?? null,
|
||||||
|
architectureNotes ?? null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await pool.query("UPDATE projects SET updated_at = now() WHERE id = $1", [
|
||||||
|
project.rows[0].id,
|
||||||
|
]);
|
||||||
|
return text({ session_id: rows[0].id, saved_at: rows[0].created_at });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user