Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 18 additions & 281 deletions mcp/server.mjs

Large diffs are not rendered by default.

292 changes: 292 additions & 0 deletions mcp/tools.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "klear-team-brain",
"version": "0.1.17",
"version": "0.1.18",
"type": "module",
"description": "Self-hosted team memory for AI coding agents — fuses Claude Code/Codex sessions, GitHub code state, and team docs into one git truth store, queryable from your editor over MCP.",
"license": "Apache-2.0",
Expand Down
5 changes: 5 additions & 0 deletions public_docs/CHANGELOG.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ This project follows [Semantic Versioning](https://semver.org/), formatted after

---

## [0.1.18] - 2026-06-25 · Mount the memory from any agent (HTTP-transport MCP)

### Added
- **Remote MCP endpoint (`POST /mcp`)**: any MCP-capable agent — a teammate's Cursor, an IM bot built on the Agent SDK / Codex SDK, a cloud agent — can now mount the memory with just a URL + bearer token, no client install or subprocess to spawn. It serves the same eight read-only primitives as the editor MCP, straight from the server (stateless, JSON responses), with secret/path redaction preserved at the `/read` exit. The tool definitions are single-sourced, so the stdio and HTTP transports can't drift.

## [0.1.17] - 2026-06-24 · Local trial + one-command Docker self-host

### Added
Expand Down
5 changes: 5 additions & 0 deletions public_docs/CHANGELOG.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

---

## [0.1.18] - 2026-06-25 · 任意 agent 远程挂载(HTTP 传输 MCP)

### 新增
- **远程 MCP 端点(`POST /mcp`)**:任何支持 MCP 的 agent——队友的 Cursor、基于 Agent SDK / Codex SDK 的 IM 机器人、云端 agent——现在只需「URL + Bearer token」即可挂载团队记忆,无需安装客户端、无需 spawn 子进程。与编辑器 MCP 同样的 8 个只读原语,由服务端直接提供(无状态、JSON 响应),`/read` 出口的密钥/路径脱敏照旧。工具定义单源化,stdio 与 HTTP 两种传输不会漂移。

## [0.1.17] - 2026-06-24 · 本地尝鲜 + Docker 一键自托管

### 新增
Expand Down
99 changes: 99 additions & 0 deletions server/mcpexec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// localExec:服务器进程内的 MCP 执行器 —— 直接调 query.mjs(零自打 HTTP)。
// 每个方法【逐路对齐】server.mjs 里对应的 REST 路由体(同样的归一/脱敏/传参),
// 返回值与该端点同形 → mcp/tools.mjs 的格式化对 stdio / HTTP 两边逐字一致。
// 不变量:read 出口必过 redactReadable;只读、不暴露 ingest;sessions/stats 透传 roster/registry。
import { readFileSync, existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { safeRelPath, safeSegment } from "../core/safe.mjs";
import { redactReadable } from "../core/redact.mjs";
import { grepTruth, findTruth, lsTruth, logTruth, sessionsTruth, statsTruth, spaceStatsTruth } from "./query.mjs";
import { readSpaceMeta } from "./space.mjs";
import { clientFor, ctxFor } from "../core/repohost.mjs";

// ctx:{ TRUTH, registry, roster, githubToken, resolvePath, resolveSpace } —— 后四个由 server.mjs 注入
// (resolvePath/resolveSpace 是 server.mjs 里的别名/历史坐标兜底闭包,复用以免重写归一逻辑)。
export function makeLocalExec({ TRUTH, registry, roster, githubToken, resolvePath, resolveSpace }) {
return {
async grep({ q, space, context, raw }) {
const r = await grepTruth(TRUTH, {
pattern: q || "", context, ignoreCase: true,
space: space ? resolveSpace(space) : undefined, raw: !!raw,
});
return { matches: r.matches, truncated: r.truncated };
},

find({ name, path, limit }) {
return findTruth(TRUTH, {
name: name || undefined,
path: path ? resolvePath(path) : undefined,
limit,
});
},

read({ path, offset, limit }) {
const rel = resolvePath(path || "");
const abs = safeRelPath(TRUTH, rel, "path");
if (!existsSync(abs)) throw new Error("not found");
if (statSync(abs).isDirectory()) throw new Error("是目录,请用 ls");
let text = redactReadable(readFileSync(abs, "utf8")); // 出口脱敏(不变量)
const off = Math.max(0, Number(offset) || 0);
const lim = Number(limit) || 0;
if (off || lim) text = text.split("\n").slice(off, lim ? off + lim : undefined).join("\n");
return { path: rel, text };
},

ls({ path }) {
const rel = resolvePath(path || ""); // 默认 "spaces" 由 tools.mjs handler 统一兜;这里与 REST /ls 同形透传
const top = !rel || rel === "spaces";
let r;
try { r = lsTruth(TRUTH, { path: rel }); }
catch (e) { if (!top) throw e; r = { path: rel, type: "dir", entries: [] }; } // spaces 还没建也别崩
if (top && Array.isArray(r.entries)) {
const stats = spaceStatsTruth(TRUTH);
for (const e of r.entries) { const s = stats[e.name]; if (s) Object.assign(e, s); }
}
return r;
},

async log({ space, author, since, grep, limit }) {
const commits = await logTruth(TRUTH, {
space: space || undefined, since: since || undefined,
author: author || undefined, grep: grep || undefined, limit, registry,
});
return { commits };
},

sessions({ author, space, since, until, limit }) {
return sessionsTruth(TRUTH, {
author: author || undefined, space: space || undefined,
since: since || undefined, until: until || undefined, limit, roster, registry,
});
},

stats({ by, split, metric, since, until, space, author, tool, limit, offset }) {
return statsTruth(TRUTH, {
by: by || undefined, split: split || undefined, metric: metric || undefined,
since: since || undefined, until: until || undefined,
space: space ? resolveSpace(space) : undefined,
author: author || undefined, tool: tool || undefined, limit, offset, roster, registry,
});
},

async github({ space_key, path, ref }) {
safeSegment(space_key || "", "space_key");
const meta = readSpaceMeta(TRUTH, space_key);
const client = clientFor(meta.provider);
if (!meta.owner || !meta.repo || !client) throw new Error("该 space 无可现拉的代码坐标");
const ctx = ctxFor(registry, meta, githubToken);
if (!ctx.token) throw new Error(meta.provider === "github"
? "该 space 无可用 GitHub PAT(registry 未配,且无全局 GITHUB_TOKEN)"
: `该 space 无可用 ${meta.provider} token(registry 未配该实例/项目)`);
if (path) {
const content = await client.fileContent(meta.owner, meta.repo, path, ref || undefined, ctx);
return { space_key, path, ref: ref || "default", content };
}
const csp = join(TRUTH, "spaces", space_key, "code-state.md");
return { space_key, code_state: existsSync(csp) ? readFileSync(csp, "utf8") : "(尚无 code-state,等首次 4h 轮询)" };
},
};
}
23 changes: 23 additions & 0 deletions server/mcphttp.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// 服务器进程内的【HTTP 传输 MCP】端点(POST /mcp):让任意远程 Agent(别人的 IM bot / 队友的 Cursor /
// 云端 agent)用「URL + Bearer token」即可挂载团队大脑真相库,无需安装我们的客户端。
// 工具定义与 stdio 版【完全共用】mcp/tools.mjs;这里注入 localExec(直接调 query.mjs)。
// 设计:无状态(sessionIdGenerator: undefined)+ JSON 响应(enableJsonResponse)——
// 不在服务器攒会话状态(省那台低内存机器),最贴「bot 一问一答」;多轮上下文是挂载方 agent 自己的事。
// 每个请求新建一对 server+transport、用完即弃,避免并发请求 id 串台。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { registerTools, INSTRUCTIONS, SERVER_INFO } from "../mcp/tools.mjs";
import { makeLocalExec } from "./mcpexec.mjs";

export function makeMcpHttpHandler(ctx) {
const exec = makeLocalExec(ctx);
// body:POST 时由 server.mjs 预解析好的 JSON-RPC 体;GET/DELETE 传 undefined(无状态下传输自行处理)。
return async function handleMcp(req, res, body) {
const server = new McpServer(SERVER_INFO, { instructions: INSTRUCTIONS });
registerTools(server, exec);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
res.on("close", () => { try { transport.close(); server.close(); } catch {} });
await server.connect(transport);
await transport.handleRequest(req, res, body);
};
}
16 changes: 16 additions & 0 deletions server/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { syncNotionDocs } from "./notiondocs.mjs";
import { syncGoogleDocs } from "./googledocs.mjs";
import { grepTruth, findTruth, lsTruth, logTruth, sessionsTruth, statsTruth, frontmatterOf, spaceStatsTruth } from "./query.mjs";
import { canonicalizePath, canonicalSpaceKey } from "../core/identity.mjs";
import { makeMcpHttpHandler } from "./mcphttp.mjs";

const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const TRUTH = process.env.TRUTH_DIR || join(ROOT, "truth-server");
Expand Down Expand Up @@ -212,6 +213,10 @@ const resolveSpace = (key) => {
return key;
};

// HTTP 传输的 MCP 端点处理器(POST /mcp):复用现有 8 个 query 函数(经 localExec)+ 现有鉴权。
// 与 stdio MCP 共用 mcp/tools.mjs 的工具定义;resolvePath/resolveSpace 闭包注入,复用别名/历史坐标兜底。
const mcpHandler = makeMcpHttpHandler({ TRUTH, registry, roster, githubToken: GITHUB_TOKEN, resolvePath, resolveSpace });

const server = http.createServer((req, res) => {
const t0 = Date.now();
for (const [k, v] of Object.entries(SEC_HEADERS)) res.setHeader(k, v);
Expand Down Expand Up @@ -285,6 +290,17 @@ async function handle(req, res, u) {
return createReadStream(CLIENT_TGZ).pipe(res);
}

// --- HTTP 传输的 MCP(远程 Agent 用「URL + Bearer token」挂载真相库;与 stdio MCP 同工具集)---
// 鉴权复用 authMember(与所有 query 端点同一套成员 token)。无状态:POST 带 JSON-RPC 体,传输回 JSON。
if (u.pathname === "/mcp") {
if (!authMember(req)) return json(res, 401, { error: "invalid token" });
let body;
if (req.method === "POST") {
try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "bad json" }); }
}
return mcpHandler(req, res, body); // transport 自行按 method(POST/GET/DELETE) 处理
}

// --- 校验 token 身份(brain join 自检用)---
if (req.method === "GET" && u.pathname === "/whoami") {
const m = authMember(req);
Expand Down
89 changes: 89 additions & 0 deletions test/mcphttp.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// HTTP 传输的 MCP 端点:起一个最小 http server 包住 makeMcpHttpHandler,用真 MCP 客户端
// (StreamableHTTPClientTransport)跑通 initialize → tools/list → 调 grep/read/ls。
// 验证「与 stdio 共用的工具集 + localExec 直连 query.mjs」整条链路在 HTTP 下成立。
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { execFileSync } from "node:child_process";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { makeMcpHttpHandler } from "../server/mcphttp.mjs";

// 临时 git 真相库:铺两条带 frontmatter 的 session 卡片(grep/read 要能命中正文)。
function gitTruth() {
const dir = mkdtempSync(join(tmpdir(), "tb-mcphttp-"));
execFileSync("git", ["-C", dir, "init", "-q"]);
const card = (body) => `---\nproducer_id: user2\nsubmitter: username2\nspace_key: github__o__r\nbranch: main\ndate: 2026-06-20T10:00:00+08:00\nupdated: 2026-06-20T11:00:00+08:00\ntool: claude-code\n---\n\n${body}\n`;
const files = {
"spaces/github__o__r/sessions/main/user2-s1.md": card("讨论了 ontology 重构方案,决定先做 schema 迁移。"),
"spaces/github__o__r/sessions/main/user2-s2.md": card("另一条 session:修了登录 bug。"),
};
for (const [rel, content] of Object.entries(files)) {
const abs = join(dir, rel);
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, content);
}
execFileSync("git", ["-C", dir, "add", "-A"]);
execFileSync("git", ["-C", dir, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "fixture"]);
return dir;
}

// 起服务:最小包装,模仿 server.mjs 里 /mcp 路由对 body 的预解析(POST → JSON.parse)。
async function startServer(TRUTH) {
const handler = makeMcpHttpHandler({
TRUTH, registry: {}, roster: { members: [] }, githubToken: "",
resolvePath: (p) => p || "", resolveSpace: (k) => k,
});
const server = http.createServer(async (req, res) => {
let body;
if (req.method === "POST") {
const chunks = [];
for await (const c of req) chunks.push(c);
try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { body = undefined; }
}
handler(req, res, body);
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const { port } = server.address();
return { server, url: new URL(`http://127.0.0.1:${port}/mcp`) };
}

// 每个用例的脚手架完全一致(起仓+起服务+连客户端+收尾)→ 收进一个 withClient 包装。
async function withClient(fn) {
const { server, url } = await startServer(gitTruth());
const client = new Client({ name: "test", version: "0" });
await client.connect(new StreamableHTTPClientTransport(url));
try { return await fn(client); } finally { await client.close(); server.close(); }
}
const textOf = (r) => (r.content || []).map((c) => c.text).join("\n");

test("HTTP MCP: tools/list 暴露全部 8 个只读原语", () => withClient(async (client) => {
const { tools } = await client.listTools();
const names = tools.map((t) => t.name).sort();
assert.deepEqual(names, ["find", "grep", "log", "ls", "read", "read_github", "sessions", "stats"]);
}));

test("HTTP MCP: grep 命中正文并返回 path", () => withClient(async (client) => {
const text = textOf(await client.callTool({ name: "grep", arguments: { q: "ontology" } }));
assert.match(text, /ontology/);
assert.match(text, /spaces\/github__o__r\/sessions\/main\/user2-s1\.md/);
}));

test("HTTP MCP: read 按 path 读全文", () => withClient(async (client) => {
const r = await client.callTool({ name: "read", arguments: { path: "spaces/github__o__r/sessions/main/user2-s2.md" } });
assert.match(textOf(r), /修了登录 bug/);
}));

test("HTTP MCP: ls 顶层列出 space", () => withClient(async (client) => {
const r = await client.callTool({ name: "ls", arguments: { path: "spaces" } });
assert.match(textOf(r), /github__o__r/);
}));

test("HTTP MCP: sessions 按人查命中工作时间", () => withClient(async (client) => {
const text = textOf(await client.callTool({ name: "sessions", arguments: { author: "user2" } }));
assert.match(text, /user2/);
assert.match(text, /2026-06-20/);
}));
Loading