Фаза 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:
sages-ds
2026-07-14 15:16:20 +03:00
parent 6b8989098a
commit b1e73cc2c0
15 changed files with 3070 additions and 8 deletions
+8
View File
@@ -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
+1 -1
View File
@@ -17,6 +17,6 @@
"repelStrength": 10,
"linkStrength": 1,
"linkDistance": 250,
"scale": 1,
"scale": 1.0000000000000024,
"close": true
}
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+54
View File
@@ -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);
+2381
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+13
View File
@@ -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 ?? "",
};
+48
View File
@@ -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();
}
}
}
+15
View File
@@ -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();
+75
View File
@@ -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`);
});
+230
View File
@@ -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 });
},
);
}
+144
View File
@@ -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 });
},
);
}
+16
View File
@@ -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/**/*"]
}