Фаза 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,
|
||||
"linkStrength": 1,
|
||||
"linkDistance": 250,
|
||||
"scale": 1,
|
||||
"scale": 1.0000000000000024,
|
||||
"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 — Сервер пам'яті (поточна)
|
||||
|
||||
- [ ] Скелет репозиторію: TypeScript, `@modelcontextprotocol/sdk`, структура src/
|
||||
- [ ] Схема БД + міграції ([[Модель даних]])
|
||||
- [ ] MCP сервер: Streamable HTTP + API-ключ
|
||||
- [ ] Інструменти групи «Пам'ять» (FTS-пошук) ([[MCP інструменти]])
|
||||
- [ ] Інструменти групи «Проекти» (аналог project-agent)
|
||||
- [ ] Абстракція `EmbeddingProvider` (заглушка `NullEmbeddingProvider`)
|
||||
- [ ] Тести (локально, проти dev-БД)
|
||||
- [x] Скелет репозиторію: TypeScript, `@modelcontextprotocol/sdk`, структура src/ ✅ 2026-07-14
|
||||
- [x] Схема БД + міграції ([[Модель даних]]) ✅ 2026-07-14
|
||||
- [x] MCP сервер: Streamable HTTP + API-ключ ✅ 2026-07-14
|
||||
- [x] Інструменти групи «Пам'ять» (FTS-пошук) ([[MCP інструменти]]) ✅ 2026-07-14
|
||||
- [x] Інструменти групи «Проекти» (аналог project-agent) ✅ 2026-07-14
|
||||
- [x] Абстракція `EmbeddingProvider` (заглушка `NullEmbeddingProvider`) ✅ 2026-07-14
|
||||
- [ ] Тести/смоук проти реальної БД — Docker локально відсутній, виконати на етапі деплою (фаза 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