Skip to content

Commit ecd0dbc

Browse files
authored
Merge pull request #136 from CommonsEngine/feat/mcp-server
Add `mcp-server`
2 parents 84cb583 + 1fa19d5 commit ecd0dbc

File tree

5 files changed

+534
-0
lines changed

5 files changed

+534
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"postbuild": "yarn build:manifest",
3333
"dev": "yarn workspace @sovereign/platform dev",
3434
"start": "yarn workspace @sovereign/platform start",
35+
"start:mcp": "node ./packages/mcp-server/server.mjs",
3536
"format:check": "prettier --check .",
3637
"format": "prettier --ignore-path .prettierignore --write .",
3738
"lint:check": "eslint .",

packages/mcp-server/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Model Context Protocol Server
2+
3+
The MCP (`Model Context Protocol`) server keeps a lightweight store of contextual snapshots that can be shared with LLMs or automation agents. It is intentionally small—contexts, metadata, and sessions are serialized to disk so you can restart the process without losing the last trusted state.
4+
5+
## Running the server
6+
7+
```bash
8+
yarn mcp:serve
9+
```
10+
11+
You can also launch it directly so you can tweak CLI flags or env vars:
12+
13+
```bash
14+
node tools/mcp-server/server.mjs --port=4070 --store=data/mcp-server
15+
```
16+
17+
| Flag | Description |
18+
| --------- | ----------------------------------------------------- |
19+
| `--port` | Port that the MCP server listens on (default `4050`). |
20+
| `--host` | Optional hostname binding; defaults to `127.0.0.1`. |
21+
| `--store` | Relative path where contexts/sessions are persisted. |
22+
23+
Environment variables (`MCP_PORT`, `MCP_HOST`, `MCP_STORE_DIR`) mirror the CLI flags.
24+
25+
## Storage
26+
27+
The service writes `contexts.json` and `sessions.json` under the configured store directory (default: `data/mcp-server`). Because `/data` is ignored by Git, no runtime data will accidentally be committed.
28+
29+
## API surface
30+
31+
- `GET /mcp/health``{ status: "ok", uptime }`
32+
- `GET /mcp/info` → high-level stats (`contexts`, `sessions`, `version`, etc.)
33+
- `GET /mcp/schema` → published schema for contexts/sessions
34+
35+
### Context collection
36+
37+
- `GET /mcp/contexts` accepts query filters (`namespace`, `model`, `tags`, `ids`, `limit`, `offset`, etc.).
38+
- `POST /mcp/contexts` creates or updates a context. JSON body fields: `namespace`, `model`, `metadata`, `tags`, `payload`.
39+
- `GET /mcp/contexts/:id` retrieves a single context.
40+
- `PATCH /mcp/contexts/:id` patches existing metadata/tags/payload.
41+
- `DELETE /mcp/contexts/:id` removes it.
42+
43+
### Session collection
44+
45+
- `GET /mcp/sessions` lists sessions (filter by `contextId`, `model`, `ids`, `limit`, `offset`).
46+
- `POST /mcp/sessions` records a session; body fields: `contextId`, `model`, `tags`, `meta`, optional `contextSnapshot`.
47+
- `GET /mcp/sessions/:id` gets a session record.
48+
- `PATCH /mcp/sessions/:id` updates tags/meta.
49+
- `DELETE /mcp/sessions/:id` deletes it.
50+
51+
Every response is JSON and the server exposes permissive CORS headers so you can consume it from browser tooling or automation agents.

packages/mcp-server/data-store.mjs

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { promises as fs } from "node:fs";
2+
import { join, resolve } from "node:path";
3+
import { randomUUID } from "node:crypto";
4+
5+
export class NotFoundError extends Error {
6+
constructor(resource, id) {
7+
super(`${resource} not found: ${id}`);
8+
this.name = "NotFoundError";
9+
}
10+
}
11+
12+
function normalizeDir(dir) {
13+
if (!dir) return join(process.cwd(), "data", "mcp-server");
14+
return resolve(process.cwd(), dir);
15+
}
16+
17+
function jsonCopy(value) {
18+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
19+
}
20+
21+
export function createMcpStore(options = {}) {
22+
const storeDir = normalizeDir(options.storeDir ?? process.env.MCP_STORE_DIR);
23+
const contextsPath = join(storeDir, "contexts.json");
24+
const sessionsPath = join(storeDir, "sessions.json");
25+
26+
async function ensureStore() {
27+
await fs.mkdir(storeDir, { recursive: true });
28+
}
29+
30+
async function readFile(path, fallback = []) {
31+
try {
32+
const raw = await fs.readFile(path, "utf-8");
33+
return JSON.parse(raw);
34+
} catch (err) {
35+
if (err.code === "ENOENT") return fallback.slice();
36+
throw err;
37+
}
38+
}
39+
40+
async function writeFile(path, value) {
41+
await ensureStore();
42+
await fs.writeFile(path, JSON.stringify(value, null, 2) + "\n", "utf-8");
43+
}
44+
45+
function applyContextFilters(contexts, filters) {
46+
const { namespace, model, tags, updatedAfter, updatedBefore, ids } = filters;
47+
let result = contexts;
48+
if (namespace) {
49+
result = result.filter((ctx) => ctx.namespace?.toLowerCase() === namespace.toLowerCase());
50+
}
51+
if (model) {
52+
result = result.filter((ctx) => ctx.model?.toLowerCase() === model.toLowerCase());
53+
}
54+
if (tags?.length) {
55+
result = result.filter((ctx) =>
56+
tags.every((tag) => ctx.tags?.map((t) => t.toLowerCase()).includes(tag.toLowerCase()))
57+
);
58+
}
59+
if (ids?.length) {
60+
const idSet = new Set(ids);
61+
result = result.filter((ctx) => idSet.has(ctx.id));
62+
}
63+
if (updatedAfter) {
64+
result = result.filter((ctx) => ctx.updatedAt >= updatedAfter);
65+
}
66+
if (updatedBefore) {
67+
result = result.filter((ctx) => ctx.updatedAt <= updatedBefore);
68+
}
69+
return result;
70+
}
71+
72+
return {
73+
async listContexts(filters = {}) {
74+
const contexts = await readFile(contextsPath, []);
75+
return applyContextFilters(contexts, filters);
76+
},
77+
78+
async getContext(id) {
79+
const contexts = await readFile(contextsPath, []);
80+
const ctx = contexts.find((candidate) => candidate.id === id);
81+
if (!ctx) throw new NotFoundError("Context", id);
82+
return ctx;
83+
},
84+
85+
async upsertContext(payload) {
86+
const contexts = await readFile(contextsPath, []);
87+
const now = new Date().toISOString();
88+
const incoming = {
89+
...jsonCopy(payload),
90+
id: payload.id || randomUUID(),
91+
};
92+
const idx = contexts.findIndex((ctx) => ctx.id === incoming.id);
93+
if (idx >= 0) {
94+
const previous = contexts[idx];
95+
contexts[idx] = {
96+
...previous,
97+
...incoming,
98+
metadata: { ...previous.metadata, ...incoming.metadata },
99+
tags: incoming.tags ?? previous.tags,
100+
updatedAt: now,
101+
};
102+
} else {
103+
contexts.unshift({
104+
...incoming,
105+
tags: incoming.tags ?? [],
106+
metadata: incoming.metadata ?? {},
107+
createdAt: now,
108+
updatedAt: now,
109+
});
110+
}
111+
await writeFile(contextsPath, contexts);
112+
return contexts.find((ctx) => ctx.id === incoming.id);
113+
},
114+
115+
async deleteContext(id) {
116+
const contexts = await readFile(contextsPath, []);
117+
const idx = contexts.findIndex((ctx) => ctx.id === id);
118+
if (idx === -1) throw new NotFoundError("Context", id);
119+
contexts.splice(idx, 1);
120+
await writeFile(contextsPath, contexts);
121+
return true;
122+
},
123+
124+
async listSessions(filters = {}) {
125+
const sessions = await readFile(sessionsPath, []);
126+
let result = sessions;
127+
if (filters.contextId) {
128+
result = result.filter((s) => s.contextId === filters.contextId);
129+
}
130+
if (filters.model) {
131+
result = result.filter((s) => s.model?.toLowerCase() === filters.model.toLowerCase());
132+
}
133+
if (filters.ids?.length) {
134+
const idSet = new Set(filters.ids);
135+
result = result.filter((s) => idSet.has(s.id));
136+
}
137+
return result;
138+
},
139+
140+
async getSession(id) {
141+
const sessions = await readFile(sessionsPath, []);
142+
const session = sessions.find((entry) => entry.id === id);
143+
if (!session) throw new NotFoundError("Session", id);
144+
return session;
145+
},
146+
147+
async createSession(payload) {
148+
const sessions = await readFile(sessionsPath, []);
149+
const contexts = await readFile(contextsPath, []);
150+
const now = new Date().toISOString();
151+
const session = {
152+
id: payload.id || randomUUID(),
153+
contextId: payload.contextId ?? null,
154+
model: payload.model ?? "generic",
155+
tags: payload.tags ?? [],
156+
meta: payload.meta ?? {},
157+
createdAt: now,
158+
updatedAt: now,
159+
contextSnapshot:
160+
payload.contextSnapshot ??
161+
jsonCopy(contexts.find((ctx) => ctx.id === payload.contextId)) ??
162+
null,
163+
};
164+
sessions.unshift(session);
165+
await writeFile(sessionsPath, sessions);
166+
return session;
167+
},
168+
169+
async updateSession(id, patch) {
170+
const sessions = await readFile(sessionsPath, []);
171+
const idx = sessions.findIndex((entry) => entry.id === id);
172+
if (idx === -1) throw new NotFoundError("Session", id);
173+
const now = new Date().toISOString();
174+
const existing = sessions[idx];
175+
sessions[idx] = {
176+
...existing,
177+
...jsonCopy(patch),
178+
id: existing.id,
179+
createdAt: existing.createdAt,
180+
updatedAt: now,
181+
};
182+
await writeFile(sessionsPath, sessions);
183+
return sessions[idx];
184+
},
185+
186+
async deleteSession(id) {
187+
const sessions = await readFile(sessionsPath, []);
188+
const idx = sessions.findIndex((entry) => entry.id === id);
189+
if (idx === -1) throw new NotFoundError("Session", id);
190+
sessions.splice(idx, 1);
191+
await writeFile(sessionsPath, sessions);
192+
return true;
193+
},
194+
195+
async stats() {
196+
const contexts = await readFile(contextsPath, []);
197+
const sessions = await readFile(sessionsPath, []);
198+
return {
199+
contexts: contexts.length,
200+
sessions: sessions.length,
201+
lastUpdated: new Date().toISOString(),
202+
};
203+
},
204+
};
205+
}

0 commit comments

Comments
 (0)