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
2 changes: 2 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ brain.yourdomain.com {

Point the domain's A record at this VPS; Caddy issues and renews certs automatically.

> The reverse proxy forwards everything on the host, so `POST https://brain.yourdomain.com/mcp` (the HTTP-transport MCP endpoint for remote/cloud agents — see [README](./README.md)) is reachable with no extra config — it shares the same member tokens as every other endpoint. Server-side only (no CORS): point agents at it, not browsers.

### 1.6 Run as a service (systemd)

`/etc/systemd/system/team-brain.service`:
Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,28 @@ Server-side queries run via `git grep` / `git ls-files` / `git log` / `fs` — *

> **Wiring other editors:** the MCP server is a stdio server; the command is always `<node> <install-dir>/mcp/server.mjs` (`brain mcp` prints your exact path). Add that as a stdio MCP server in Claude Code, Codex, or any MCP-capable client (Gemini CLI / Cursor / Cline / opencode…).
>
> **Remote / cloud agents (HTTP transport):** an agent that can't run the local stdio binary can mount the memory over HTTP instead — point it at `https://your-server/mcp` with your member token as a `Bearer` header. Same tools, no local install.
> **Remote / cloud agents (HTTP transport):** an agent that can't run the local stdio binary can mount the memory over HTTP instead — same tools, no local install. Point any MCP client at `POST https://your-server/mcp` with your member token as a `Bearer` header.
>
> Claude Code:
> ```bash
> claude mcp add --transport http team-brain https://your-server/mcp \
> --header "Authorization: Bearer <your-member-token>"
> ```
> Cursor / Cline / any `mcp.json`:
> ```json
> { "mcpServers": { "team-brain": {
> "type": "http",
> "url": "https://your-server/mcp",
> "headers": { "Authorization": "Bearer <your-member-token>" }
> } } }
> ```
> Smoke-test from a shell (the endpoint accepts plain JSON-RPC — no special `Accept` header needed):
> ```bash
> curl -s https://your-server/mcp -H "Authorization: Bearer <token>" \
> -H "Content-Type: application/json" \
> -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
> ```
> It's a stateless, one-shot endpoint (POST in, JSON out — no session, no server-side LLM); multi-turn context stays on the calling agent's side.

### ② Browse the web UI

Expand Down
16 changes: 10 additions & 6 deletions client/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,20 @@ function loadState() {
}

// 流水线升级 → 一次性重收受影响的历史(与"范围变了清 seen"同一套路)。
// 历来场景都在 Codex:代次 2 = slim 之前整丢 token_count;代次 3 = redact 误抹数值型 token 计数(统计为 0)
// + slim 改每北京日留末条 token_count(Codex token 按天精确)。都得用新 slim/redact 重新蒸馏才补得回。
// 只清【Codex】的 seen(本机原文还在 ~/.codex)→ 重传量最小;CC 历史的 token 在服务端 rebuild-cards 补,不必重传。
// 代次 2 = slim 之前整丢 token_count;代次 3 = redact 误抹数值型 token 计数(统计为 0)+ slim 每北京日留末条
// token_count(Codex token 按天精确)→ 都只能从本机 ~/.codex 原文重新蒸馏捞回,故清【Codex】seen。
// 代次 4 = slim 不再把 session_history *.md 截成头尾 3KB → 原文还在 upload_folders,清这些 .md 的 seen 重收整篇。
// 只清受影响来源的 seen(本机原文还在)→ 重传量最小;CC 历史的 token 在服务端 rebuild-cards 补,不必重传。
function reconcilePipeline() {
if (prevPipeline >= PIPELINE_VERSION) return;
let cleared = 0;
let codex = 0, history = 0;
for (const file of [...seenSession.keys()]) {
if (file.startsWith(CODEX_ROOT)) { seenSession.delete(file); cleared++; }
if (file.startsWith(CODEX_ROOT)) { seenSession.delete(file); codex++; }
// session_history 文档:upload_folders 内 session_history/ 下的 *.md(CC/Codex/Trae 的 seen key 都是 .jsonl)。
// 按「session_history/ 目录 + .md」认,比单看后缀稳——不会误清将来可能进 seen 的其它 .md。
else if (file.endsWith(".md") && /[\\/]session_history[\\/]/.test(file)) { seenSession.delete(file); history++; }
}
if (cleared) log.info("[pipeline-upgrade] generation bumped -> re-collect Codex history (recover token usage)", { from: prevPipeline, to: PIPELINE_VERSION, codex_resync: cleared });
if (codex || history) log.info("[pipeline-upgrade] generation bumped -> re-collect affected history", { from: prevPipeline, to: PIPELINE_VERSION, codex_resync: codex, session_history_resync: history });
prevPipeline = PIPELINE_VERSION;
}

Expand Down
10 changes: 8 additions & 2 deletions core/redact.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ export const PREFIX_RULES = [
[/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, "[REDACTED_JWT]"],
];

// 占位/示例值放行:文档正文里 YOUR_API_KEY_HERE、<token>、changeme、example-xxx、xxxx… 这类明显是
// 占位符/标识符、绝不是真密钥 → 不脱敏(纯精度收益,不抬漏抹风险)。整段值匹配、大小写无关。
const PLACEHOLDER = /^(?:x{3,}|\*{3,}|\.{3,}|-{3,}|<[^>]*>|\{\{?[^}]*\}?\}|your[_-].*|my[_-].*|example[_.-].*|placeholder.*|change[_-]?me.*|redacted.*|todo.*|dummy.*|sample.*|test[_-].*|none|null|undefined|true|false|enabled|disabled)$/i;

// 「赋值型」兜底(贪婪,吃掉「键名+值」整段):只给 markdown/纯文本用(不保 JSON 结构)。
const GENERIC_RULE = [/(?:Bearer|token|api[_-]?key|password|secret)["'\s:=]+[A-Za-z0-9._\-]{12,}/gi, "[REDACTED_SECRET]"];
// 值单列一组,便于放行占位符;命中真密钥时整段(键名+值)一起换占位符。
const GENERIC_RULE = [/((?:Bearer|token|api[_-]?key|password|secret)["'\s:=]+)([A-Za-z0-9._\-]{12,})/gi,
(m, _head, val) => (PLACEHOLDER.test(val) ? m : "[REDACTED_SECRET]")];
export const RULES = [...PREFIX_RULES, GENERIC_RULE];

export const redact = (s) => RULES.reduce((a, [re, to]) => a.replace(re, to), s || "");
Expand All @@ -36,7 +42,7 @@ const URL_CRED = /([a-z][a-z0-9+.-]*:\/\/[^\s:@/"'`\\]+):([^\s:@/"'`\\]+)@/gi;
// 目的:真相库的 .jsonl 原文本身就不含密钥——不再只靠派生 .md / 读出口(redactReadable)兜底;完整原文留产出者本机。
export const redactJsonl = (s) =>
PREFIX_RULES.reduce((a, [re, to]) => a.replace(re, to), String(s || ""))
.replace(ASSIGN_JSONL, (m, head, val) => (/^[\d.]+$/.test(val) ? m : head + "[REDACTED_SECRET]"))
.replace(ASSIGN_JSONL, (m, head, val) => (/^[\d.]+$/.test(val) || PLACEHOLDER.test(val) ? m : head + "[REDACTED_SECRET]"))
.replace(URL_CRED, (_m, head) => head + ":[REDACTED_SECRET]@");

// 把本机家目录路径抹成 ~(agent 配置里满是机器级绝对路径,跨人/入库前必须中和)。
Expand Down
3 changes: 3 additions & 0 deletions core/slim.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ function capContent(c) { // tool_result.content:string
}
function slimCC(o) {
if (CC_DROP.has(o.type)) return null;
// session_history 本地 .md:整篇人/agent 写的文档本身就是信号,不能当 tool 输出走兜底 capDeep
// 截成头尾 3KB(图片/base64 已在 feedLine 文本级剥过;超大文档由 sync 的 MAX_RAW/MAX_UPLOAD 闸门挡)。
if (o.type === "session_history_markdown" || o.type === "session_history_meta") return o;
const c = o.message && o.message.content;
if (Array.isArray(c)) {
for (const b of c) {
Expand Down
4 changes: 3 additions & 1 deletion core/version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ export const CLIENT_VERSION = v;
// 3 → ① redact 不再误伤数值型 token 计数(input_tokens 等 ≥8 位曾被抹成 [REDACTED_SECRET]、还破坏 JSON →
// 重度 Codex 用户 token 统计为 0);② slim 每北京日留末条 token_count、parse 作差 → Codex token 按天精确。
// 两者都只能从本机原文捞回 → 重收 Codex 历史。
export const PIPELINE_VERSION = 3;
// 4 → slim 不再把 session_history *.md 文档当 tool 输出截成头尾 3KB(之前 >3KB 的文档中段全丢)。
// 原文还在 upload_folders → 重收 session_history *.md 以补回整篇正文。
export const PIPELINE_VERSION = 4;
7 changes: 7 additions & 0 deletions docs/CHANGELOG.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ This project follows [Semantic Versioning](https://semver.org/), formatted after

---

## [0.1.23] - 2026-06-26 · Full-length session_history docs + HTTP MCP works with any client

### Fixed
- **`session_history` `*.md` docs are no longer truncated to ~3KB**: the upload-time slimmer treated whole human/agent-written docs as tool output and capped them to a 2KB head + 1KB tail, gutting the middle of anything longer (e.g. `agent-task-*.md` specs). `slimCC` now keeps `session_history_markdown` / `session_history_meta` records in full (images/base64 are still stripped; oversized docs are still bounded by the existing `MAX_RAW` / `MAX_UPLOAD` size gates). A pipeline-generation bump (3 → 4) re-collects affected `*.md` from `upload_folders` so the lost body is restored on next sync.
- **Secret redaction spares obvious placeholders/identifiers**: doc text like `token: example-token-here`, `api_key: YOUR_API_KEY_HERE`, `<your-token>`, `changeme-…`, `xxxx…` is no longer masked to `[REDACTED_SECRET]`. Both the JSON-safe `ASSIGN_JSONL` rule and the markdown `GENERIC_RULE` now skip a placeholder allowlist before redacting. Real, high-entropy secrets are still masked — this is a conservative tightening, so example values that genuinely look like real keys are still redacted.
- **HTTP-transport MCP (`POST /mcp`) works with bare HTTP clients**: the Streamable-HTTP SDK rejected anything not sending `Accept: application/json, text/event-stream` + `Content-Type: application/json` (so plain `curl` / hand-written bots got `406` / `415`), and a `GET /mcp` hung the connection on the stateless endpoint. `/mcp` now pins those headers on the inbound request (via `rawHeaders`, which the underlying Hono request-builder actually reads) and returns `405` for non-`POST`, so "URL + Bearer token" mounts the memory from any client. Docs: README now has copy-paste Claude Code / `mcp.json` / `curl` config; DEPLOY notes the endpoint rides the same HTTPS proxy + member tokens.

## [0.1.22] - 2026-06-25 · Hardened read-only queries + observable status

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

---

## [0.1.23] - 2026-06-26 · session_history 文档不再被截断 + HTTP MCP 任意客户端都能连

### 修复
- **`session_history` `*.md` 文档不再被截成 ~3KB**:上传前的蒸馏把整篇人/agent 写的文档当成 tool 输出,截成头 2KB + 尾 1KB,超过的中段全丢(如 `agent-task-*.md` 这类方案文档)。`slimCC` 现在对 `session_history_markdown` / `session_history_meta` 整篇保留(图片/base64 仍剥;超大文档仍由 `MAX_RAW` / `MAX_UPLOAD` 大小闸门挡)。流水线代次 3 → 4,下次同步会从 `upload_folders` 重收受影响的 `*.md`,补回丢掉的正文。
- **脱敏放行明显的占位/标识符**:文档里 `token: example-token-here`、`api_key: YOUR_API_KEY_HERE`、`<your-token>`、`changeme-…`、`xxxx…` 这类不再被抹成 `[REDACTED_SECRET]`。保结构的 `ASSIGN_JSONL` 与 markdown 的 `GENERIC_RULE` 在脱敏前都先过一遍占位放行清单。真·高熵密钥照常抹——这是保守的一档收紧,所以长得像真密钥的示例值仍会被抹。
- **HTTP 传输的 MCP(`POST /mcp`)裸客户端也能连**:Streamable-HTTP 的 SDK 强制要 `Accept: application/json, text/event-stream` + `Content-Type: application/json`,否则 `406` / `415`(裸 `curl` / 手写 bot 默认头会被挡);无状态端点上 `GET /mcp` 还会挂住连接。现在 `/mcp` 在入口把这些头钉到请求上(改 `rawHeaders`——底层 Hono 重建请求实际读它,改 `headers` 无效),非 `POST` 一律 `405`,「URL + Bearer token」从任意客户端都能挂载。文档:README 补了 Claude Code / `mcp.json` / `curl` 的可复制配置;DEPLOY 说明该端点走同一套 HTTPS 反代 + 成员 token。

## [0.1.22] - 2026-06-25 · 只读查询加固 + status 看得见采集足迹

### 新增
Expand Down
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.22",
"version": "0.1.23",
"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
21 changes: 21 additions & 0 deletions server/mcphttp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
import { registerTools, INSTRUCTIONS, SERVER_INFO } from "../mcp/tools.mjs";
import { makeLocalExec } from "./mcpexec.mjs";

// 覆写/追加一个请求头——同时改 req.headers 与 req.rawHeaders。必须改 rawHeaders:
// StreamableHTTP transport 经 Hono getRequestListener 从 rawHeaders(而非 headers)重建 Web Request。
function setRaw(req, name, value) {
req.headers[name] = value;
const rh = req.rawHeaders;
for (let i = 0; i < rh.length; i += 2) if (rh[i].toLowerCase() === name) { rh[i + 1] = value; return; }
rh.push(name, value);
}

// /mcp 入口预处理:让裸 curl / 手写 bot 也能开箱即用。
// SDK 严格要求 Accept 同含 application/json + text/event-stream、Content-Type 为 json,否则 406/415;
// 默认 */*、漏填、或 fetch 自动塞的 text/plain 都会被挡。这里统一钉成规范头(服务器本就是无状态 JSON 响应,
// 且 body 由路由自己 JSON.parse 校验 → 强制 json 无副作用)。返回 false = 非 POST,调用方回 405(GET 的 SSE
// 长流在无状态下永不出数据、白占连接,对低内存机器纯负担)。
export function normalizeMcpRequest(req) {
if (req.method !== "POST") return false;
setRaw(req, "accept", "application/json, text/event-stream");
setRaw(req, "content-type", "application/json");
return true;
}

export function makeMcpHttpHandler(ctx) {
const exec = makeLocalExec(ctx);
// body:POST 时由 server.mjs 预解析好的 JSON-RPC 体;GET/DELETE 传 undefined(无状态下传输自行处理)。
Expand Down
10 changes: 5 additions & 5 deletions server/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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";
import { makeMcpHttpHandler, normalizeMcpRequest } 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 @@ -315,11 +315,11 @@ async function handle(req, res, u) {
// 鉴权复用 authMember(与所有 query 端点同一套成员 token)。无状态:POST 带 JSON-RPC 体,传输回 JSON。
if (u.pathname === "/mcp") {
if (!authMember(req)) return json(res, 401, { error: "invalid token" });
// 非 POST(含会挂住连接的 GET SSE)→ 405;POST 则把 Accept/Content-Type 钉成 SDK 要的规范头(见 normalizeMcpRequest)。
if (!normalizeMcpRequest(req)) { res.setHeader("allow", "POST"); return json(res, 405, { error: "method not allowed; POST only" }); }
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) 处理
try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "bad json" }); }
return mcpHandler(req, res, body);
}

// --- 校验 token 身份(brain join 自检用)---
Expand Down
42 changes: 35 additions & 7 deletions test/mcphttp.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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";
import { makeMcpHttpHandler, normalizeMcpRequest } from "../server/mcphttp.mjs";

// 临时 git 真相库:铺两条带 frontmatter 的 session 卡片(grep/read 要能命中正文)。
function gitTruth() {
Expand Down Expand Up @@ -38,12 +38,11 @@ async function startServer(TRUTH) {
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; }
}
// 复刻 server.mjs /mcp 路由:非 POST → 405;POST 经 normalizeMcpRequest 钉规范头再交给 handler。
if (!normalizeMcpRequest(req)) { res.setHeader("allow", "POST"); res.writeHead(405, { "content-type": "application/json" }); return res.end(JSON.stringify({ error: "method not allowed; POST only" })); }
const chunks = [];
for await (const c of req) chunks.push(c);
let body; 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));
Expand Down Expand Up @@ -87,3 +86,32 @@ test("HTTP MCP: sessions 按人查命中工作时间", () => withClient(async (c
assert.match(text, /user2/);
assert.match(text, /2026-06-20/);
}));

// 裸 HTTP 客户端(curl / 手写 bot):默认 Accept */*、漏 Content-Type 也要能用,不被 SDK 的严格头校验挡成 406/415。
const INIT = { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "curl", version: "0" } } };
async function raw(method, headers, body) {
const { server, url } = await startServer(gitTruth());
try {
const r = await fetch(url, { method, headers, body: body && JSON.stringify(body) });
return { status: r.status, text: await r.text() };
} finally { server.close(); }
}

test("HTTP MCP: 裸 POST(Accept */*、Content-Type 缺/text-plain)也能 initialize → 200", async () => {
for (const headers of [
{ accept: "*/*", "content-type": "application/json" }, // curl 默认 Accept
{ accept: "application/json" }, // 只列 json(缺 sse)
{}, // 啥都不带(fetch 会塞 text/plain)
]) {
const r = await raw("POST", headers, INIT);
assert.equal(r.status, 200, `headers=${JSON.stringify(headers)} -> ${r.text.slice(0, 80)}`);
assert.match(r.text, /protocolVersion/);
}
});

test("HTTP MCP: 非 POST(GET/DELETE)→ 405,不挂连接", async () => {
for (const method of ["GET", "DELETE"]) {
const r = await raw(method, { accept: "application/json, text/event-stream" });
assert.equal(r.status, 405, `${method} 应 405`);
}
});
Loading
Loading