@seanhogg/builderforce-memory-mcp 2026.6.31 → 2026.6.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/backends/memory-store.d.ts +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/transports/auth.d.ts +36 -0
- package/dist/transports/auth.d.ts.map +1 -0
- package/dist/transports/auth.js +78 -0
- package/dist/transports/auth.js.map +1 -0
- package/dist/transports/http.d.ts +41 -7
- package/dist/transports/http.d.ts.map +1 -1
- package/dist/transports/http.js +79 -12
- package/dist/transports/http.js.map +1 -1
- package/package.json +3 -3
- package/src/backends/memory-store.ts +1 -1
- package/src/index.ts +1 -0
- package/src/transports/auth.ts +78 -0
- package/src/transports/http.ts +88 -15
package/README.md
CHANGED
|
@@ -7,7 +7,9 @@ tool core over a pluggable `MemoryBackend`, three transports:
|
|
|
7
7
|
|---|---|---|---|
|
|
8
8
|
| **In-process (Claude Agent SDK)** | `createMemoryMcpServer(backend)` | the returned `type:"sdk"` object | the consuming product is TS and runs `@anthropic-ai/claude-agent-sdk` in-process — lowest latency |
|
|
9
9
|
| **stdio** | `runStdio(backend)` / `npx @seanhogg/builderforce-memory-mcp` | `{ type:"stdio", command, args }` | any language / separate process; decouples the SSM+IndexedDB deps from the consumer |
|
|
10
|
-
| **HTTP (Streamable)** | `createMemoryHttpHandler(backend, { authToken })` | `{ type:"http", url, headers }` | multi-tenant / networked (builderforce.ai hosting, remote
|
|
10
|
+
| **HTTP (Streamable)** | `createMemoryHttpHandler(backend, { authToken })` | `{ type:"http", url, headers }` | multi-tenant / networked (builderforce.ai hosting, remote agents) |
|
|
11
|
+
|
|
12
|
+
> **Technical report & peer review.** This transport is reviewed in the Evermind technical report's adversarial [`PEER-REVIEW.md`](../../publication/evermind/PEER-REVIEW.md). Note before exposing the HTTP handler publicly: the `authToken` check is a **non-constant-time** string compare (timing channel) and the handler shares **one `backend` with no per-tenant isolation** — a single token reads every tenant's facts. Despite the "multi-tenant" note above, real isolation (token→namespace mapping, constant-time compare, rate limiting) is **not yet implemented**. Tracked as `EVM-3` in the Builderforce.ai Consolidated Gap Register.
|
|
11
13
|
|
|
12
14
|
## Why this saves tokens
|
|
13
15
|
|
|
@@ -67,7 +69,7 @@ for await (const msg of query({
|
|
|
67
69
|
|
|
68
70
|
The local backend defaults to **lexical (Jaccard)** recall. For SSM-embedding
|
|
69
71
|
cosine recall, pass an SSM runtime — e.g. reuse the agent-runtime's already-loaded
|
|
70
|
-
|
|
72
|
+
Evermind runtime instead of standing up a second model:
|
|
71
73
|
|
|
72
74
|
```ts
|
|
73
75
|
const backend = await createLocalMemoryStoreBackend({ runtime: ssmMemoryService.runtime });
|
|
@@ -47,7 +47,7 @@ export interface LocalBackendOptions {
|
|
|
47
47
|
/**
|
|
48
48
|
* Optional SSMRuntime for embedding-based recall. Omit for lexical (Jaccard)
|
|
49
49
|
* recall. In the agent-runtime, pass `ssmMemoryService.runtime` here to reuse
|
|
50
|
-
* the already-loaded
|
|
50
|
+
* the already-loaded Evermind runtime instead of standing up a second model.
|
|
51
51
|
*/
|
|
52
52
|
runtime?: unknown;
|
|
53
53
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type { McpServerOptions } from "./transports/mcp-server.js";
|
|
|
18
18
|
export { runStdio } from "./transports/stdio.js";
|
|
19
19
|
export { createMemoryHttpHandler } from "./transports/http.js";
|
|
20
20
|
export type { HttpHandlerOptions } from "./transports/http.js";
|
|
21
|
+
export { hashToken, timingSafeEqualStr, buildTenantIndex, bearerToken, RateLimiter } from "./transports/auth.js";
|
|
21
22
|
export { buildServerSpec, MCP_PACKAGE, MCP_BIN, RUNTIME_PEERS } from "./install/server-spec.js";
|
|
22
23
|
export type { StdioServerSpec, ServerSpecOptions } from "./install/server-spec.js";
|
|
23
24
|
export { installMemoryServer } from "./install/install.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,YAAY,EAAE,aAAa,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAG5E,OAAO,EAAE,kBAAkB,EAAE,6BAA6B,EAAE,MAAM,4BAA4B,CAAC;AAC/F,YAAY,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAGtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC9C,YAAY,EAAE,UAAU,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAG7E,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,YAAY,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAEhF,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAC5D,YAAY,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAEnE,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAEjD,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAC/D,YAAY,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,YAAY,EAAE,aAAa,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAG5E,OAAO,EAAE,kBAAkB,EAAE,6BAA6B,EAAE,MAAM,4BAA4B,CAAC;AAC/F,YAAY,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAGtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC9C,YAAY,EAAE,UAAU,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAG7E,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,YAAY,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAEhF,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAC5D,YAAY,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAEnE,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAEjD,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAC/D,YAAY,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC/D,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAGjH,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAChG,YAAY,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC/G,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AACjE,YAAY,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAG7E,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AACpH,YAAY,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { createMemoryMcpServer } from "./transports/sdk.js";
|
|
|
15
15
|
export { buildMcpServer } from "./transports/mcp-server.js";
|
|
16
16
|
export { runStdio } from "./transports/stdio.js";
|
|
17
17
|
export { createMemoryHttpHandler } from "./transports/http.js";
|
|
18
|
+
export { hashToken, timingSafeEqualStr, buildTenantIndex, bearerToken, RateLimiter } from "./transports/auth.js";
|
|
18
19
|
// ── Multi-host installer (wire the stdio server into any MCP-capable agent) ───
|
|
19
20
|
export { buildServerSpec, MCP_PACKAGE, MCP_BIN, RUNTIME_PEERS } from "./install/server-spec.js";
|
|
20
21
|
export { installMemoryServer } from "./install/install.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,0FAA0F;AAC1F,OAAO,EAAE,kBAAkB,EAAE,6BAA6B,EAAE,MAAM,4BAA4B,CAAC;AAG/F,iFAAiF;AACjF,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAG9C,+EAA+E;AAC/E,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAG5D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAG5D,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAEjD,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,0FAA0F;AAC1F,OAAO,EAAE,kBAAkB,EAAE,6BAA6B,EAAE,MAAM,4BAA4B,CAAC;AAG/F,iFAAiF;AACjF,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAG9C,+EAA+E;AAC/E,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAG5D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAG5D,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAEjD,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAE/D,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEjH,iFAAiF;AACjF,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEhG,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAE3D,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAEjE,qFAAqF;AACrF,4FAA4F;AAC5F,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transports/auth.ts — auth + rate-limiting primitives for the HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* The networked MCP surface is multi-tenant: one bearer token must map to ONE
|
|
5
|
+
* tenant's memory namespace, the comparison must not leak the secret through a
|
|
6
|
+
* timing side channel, and a noisy or hostile caller must not be able to hammer
|
|
7
|
+
* the server. These helpers provide exactly those three pieces, dependency-free
|
|
8
|
+
* (node:crypto only).
|
|
9
|
+
*/
|
|
10
|
+
/** SHA-256 hex digest of a token — the stable, non-reversible lookup key. */
|
|
11
|
+
export declare function hashToken(token: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Constant-time string equality. Hashes both sides to fixed-length digests
|
|
14
|
+
* first, so neither the comparison nor the input length leaks the secret.
|
|
15
|
+
*/
|
|
16
|
+
export declare function timingSafeEqualStr(a: string, b: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Build a tenant lookup keyed by token HASH (never the raw token). Resolving a
|
|
19
|
+
* presented token is a hash + map lookup — no per-character secret comparison,
|
|
20
|
+
* so there is no early-return timing channel on the token value.
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildTenantIndex<B>(tenants: Record<string, B>): Map<string, B>;
|
|
23
|
+
/** Fixed-window rate limiter. Memory-bounded by pruning expired windows. */
|
|
24
|
+
export declare class RateLimiter {
|
|
25
|
+
private readonly windowMs;
|
|
26
|
+
private readonly max;
|
|
27
|
+
private readonly now;
|
|
28
|
+
private readonly _hits;
|
|
29
|
+
constructor(windowMs: number, max: number, now?: () => number);
|
|
30
|
+
/** Returns true when the call is allowed; false when the window is exhausted. */
|
|
31
|
+
check(key: string): boolean;
|
|
32
|
+
private _prune;
|
|
33
|
+
}
|
|
34
|
+
/** Extract the bearer token from an Authorization header value, or undefined. */
|
|
35
|
+
export declare function bearerToken(header: string | string[] | undefined): string | undefined;
|
|
36
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/transports/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,6EAA6E;AAC7E,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAIhE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAM9E;AAED,4EAA4E;AAC5E,qBAAa,WAAW;IAIhB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,GAAG;IALxB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAyD;gBAG1D,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,EACX,GAAG,GAAE,MAAM,MAAyB;IAGzD,iFAAiF;IACjF,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAa3B,OAAO,CAAC,MAAM;CAKjB;AAED,iFAAiF;AACjF,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAKrF"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transports/auth.ts — auth + rate-limiting primitives for the HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* The networked MCP surface is multi-tenant: one bearer token must map to ONE
|
|
5
|
+
* tenant's memory namespace, the comparison must not leak the secret through a
|
|
6
|
+
* timing side channel, and a noisy or hostile caller must not be able to hammer
|
|
7
|
+
* the server. These helpers provide exactly those three pieces, dependency-free
|
|
8
|
+
* (node:crypto only).
|
|
9
|
+
*/
|
|
10
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
11
|
+
/** SHA-256 hex digest of a token — the stable, non-reversible lookup key. */
|
|
12
|
+
export function hashToken(token) {
|
|
13
|
+
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Constant-time string equality. Hashes both sides to fixed-length digests
|
|
17
|
+
* first, so neither the comparison nor the input length leaks the secret.
|
|
18
|
+
*/
|
|
19
|
+
export function timingSafeEqualStr(a, b) {
|
|
20
|
+
const ha = createHash("sha256").update(a, "utf8").digest();
|
|
21
|
+
const hb = createHash("sha256").update(b, "utf8").digest();
|
|
22
|
+
return timingSafeEqual(ha, hb);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build a tenant lookup keyed by token HASH (never the raw token). Resolving a
|
|
26
|
+
* presented token is a hash + map lookup — no per-character secret comparison,
|
|
27
|
+
* so there is no early-return timing channel on the token value.
|
|
28
|
+
*/
|
|
29
|
+
export function buildTenantIndex(tenants) {
|
|
30
|
+
const index = new Map();
|
|
31
|
+
for (const [token, backend] of Object.entries(tenants)) {
|
|
32
|
+
if (token)
|
|
33
|
+
index.set(hashToken(token), backend);
|
|
34
|
+
}
|
|
35
|
+
return index;
|
|
36
|
+
}
|
|
37
|
+
/** Fixed-window rate limiter. Memory-bounded by pruning expired windows. */
|
|
38
|
+
export class RateLimiter {
|
|
39
|
+
windowMs;
|
|
40
|
+
max;
|
|
41
|
+
now;
|
|
42
|
+
_hits = new Map();
|
|
43
|
+
constructor(windowMs, max, now = () => Date.now()) {
|
|
44
|
+
this.windowMs = windowMs;
|
|
45
|
+
this.max = max;
|
|
46
|
+
this.now = now;
|
|
47
|
+
}
|
|
48
|
+
/** Returns true when the call is allowed; false when the window is exhausted. */
|
|
49
|
+
check(key) {
|
|
50
|
+
const t = this.now();
|
|
51
|
+
const entry = this._hits.get(key);
|
|
52
|
+
if (!entry || t >= entry.resetAt) {
|
|
53
|
+
if (this._hits.size > 10_000)
|
|
54
|
+
this._prune(t);
|
|
55
|
+
this._hits.set(key, { count: 1, resetAt: t + this.windowMs });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (entry.count >= this.max)
|
|
59
|
+
return false;
|
|
60
|
+
entry.count++;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
_prune(t) {
|
|
64
|
+
for (const [k, v] of this._hits) {
|
|
65
|
+
if (t >= v.resetAt)
|
|
66
|
+
this._hits.delete(k);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Extract the bearer token from an Authorization header value, or undefined. */
|
|
71
|
+
export function bearerToken(header) {
|
|
72
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
73
|
+
if (!value)
|
|
74
|
+
return undefined;
|
|
75
|
+
const m = /^Bearer\s+(.+)$/i.exec(value.trim());
|
|
76
|
+
return m ? m[1].trim() : undefined;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/transports/auth.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP transport — the multi-tenant / networked path (builderforce.ai hosting,
|
|
3
|
-
* remote
|
|
3
|
+
* remote hosts). Stateless Streamable HTTP: one short-lived McpServer +
|
|
4
4
|
* transport per request, so it scales horizontally with no sticky sessions.
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* Auth model (pick one, in precedence order):
|
|
7
|
+
* • `tenants` — a `{ token: backend }` map. The bearer token selects the
|
|
8
|
+
* tenant's OWN backend (namespace isolation); an unknown token is 401. This
|
|
9
|
+
* is the production multi-tenant mode — one token can never read another
|
|
10
|
+
* tenant's facts.
|
|
11
|
+
* • `resolveBackend` — a function `(token) => backend` for dynamic tenant
|
|
12
|
+
* resolution (e.g. a gateway lookup).
|
|
13
|
+
* • `authToken` — a single shared secret guarding the one positional backend
|
|
14
|
+
* (single-tenant / reference hosting).
|
|
15
|
+
* • none — open, only valid behind a trusted gateway that already authed.
|
|
16
|
+
*
|
|
17
|
+
* Token comparison is constant-time (hash + timingSafeEqual / hash-keyed lookup),
|
|
18
|
+
* and an optional fixed-window rate limit (per tenant, or per client IP when
|
|
19
|
+
* unauthenticated) caps abuse.
|
|
6
20
|
*
|
|
7
21
|
* Consumed from the Claude Agent SDK as:
|
|
8
22
|
* mcpServers: {
|
|
@@ -15,16 +29,36 @@ import type { MemoryBackend } from "../backend.js";
|
|
|
15
29
|
import { type McpServerOptions } from "./mcp-server.js";
|
|
16
30
|
export interface HttpHandlerOptions extends McpServerOptions {
|
|
17
31
|
/**
|
|
18
|
-
* Shared secret required in `Authorization: Bearer <token
|
|
19
|
-
* request without a matching token is rejected 401.
|
|
20
|
-
* gateway that has already authenticated the caller.
|
|
32
|
+
* Shared secret required in `Authorization: Bearer <token>` for the single
|
|
33
|
+
* positional backend. Every request without a matching token is rejected 401.
|
|
34
|
+
* Omit only behind a trusted gateway that has already authenticated the caller.
|
|
21
35
|
*/
|
|
22
36
|
authToken?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Multi-tenant map: bearer token → that tenant's backend. When set, the token
|
|
39
|
+
* selects the backend (namespace isolation) and the positional `backend` is
|
|
40
|
+
* only used as a fallback when no token-auth modes match. Lookups are keyed by
|
|
41
|
+
* token hash, never the raw token.
|
|
42
|
+
*/
|
|
43
|
+
tenants?: Record<string, MemoryBackend>;
|
|
44
|
+
/**
|
|
45
|
+
* Dynamic tenant resolver: `(token) => backend | undefined`. Used when tenants
|
|
46
|
+
* live in an external store. Returning undefined → 401.
|
|
47
|
+
*/
|
|
48
|
+
resolveBackend?: (token: string) => MemoryBackend | undefined | Promise<MemoryBackend | undefined>;
|
|
49
|
+
/** Fixed-window rate limit applied per tenant (or per client IP if open). */
|
|
50
|
+
rateLimit?: {
|
|
51
|
+
windowMs: number;
|
|
52
|
+
max: number;
|
|
53
|
+
};
|
|
54
|
+
/** Injectable clock (ms) for deterministic rate-limit tests. */
|
|
55
|
+
now?: () => number;
|
|
23
56
|
}
|
|
24
57
|
/**
|
|
25
58
|
* Returns a `(req, res) => Promise<void>` handler you mount on any Node HTTP
|
|
26
|
-
* server (or Express). Per request it
|
|
27
|
-
*
|
|
59
|
+
* server (or Express). Per request it authenticates, resolves the tenant
|
|
60
|
+
* backend, rate-limits, spins up a stateless MCP server bound to that backend,
|
|
61
|
+
* handles the MCP exchange, and tears down on response close.
|
|
28
62
|
*/
|
|
29
63
|
export declare function createMemoryHttpHandler(backend: MemoryBackend, opts?: HttpHandlerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
30
64
|
//# sourceMappingURL=http.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,EAAkB,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAGxE,MAAM,WAAW,kBAAmB,SAAQ,gBAAgB;IACxD;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IACxC;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,aAAa,GAAG,SAAS,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,CAAC;IACnG,6EAA6E;IAC7E,SAAS,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9C,gEAAgE;IAChE,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACtB;AAcD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACnC,OAAO,EAAE,aAAa,EACtB,IAAI,GAAE,kBAAuB,GAC9B,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAyD9D"}
|
package/dist/transports/http.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP transport — the multi-tenant / networked path (builderforce.ai hosting,
|
|
3
|
-
* remote
|
|
3
|
+
* remote hosts). Stateless Streamable HTTP: one short-lived McpServer +
|
|
4
4
|
* transport per request, so it scales horizontally with no sticky sessions.
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* Auth model (pick one, in precedence order):
|
|
7
|
+
* • `tenants` — a `{ token: backend }` map. The bearer token selects the
|
|
8
|
+
* tenant's OWN backend (namespace isolation); an unknown token is 401. This
|
|
9
|
+
* is the production multi-tenant mode — one token can never read another
|
|
10
|
+
* tenant's facts.
|
|
11
|
+
* • `resolveBackend` — a function `(token) => backend` for dynamic tenant
|
|
12
|
+
* resolution (e.g. a gateway lookup).
|
|
13
|
+
* • `authToken` — a single shared secret guarding the one positional backend
|
|
14
|
+
* (single-tenant / reference hosting).
|
|
15
|
+
* • none — open, only valid behind a trusted gateway that already authed.
|
|
16
|
+
*
|
|
17
|
+
* Token comparison is constant-time (hash + timingSafeEqual / hash-keyed lookup),
|
|
18
|
+
* and an optional fixed-window rate limit (per tenant, or per client IP when
|
|
19
|
+
* unauthenticated) caps abuse.
|
|
6
20
|
*
|
|
7
21
|
* Consumed from the Claude Agent SDK as:
|
|
8
22
|
* mcpServers: {
|
|
@@ -12,25 +26,78 @@
|
|
|
12
26
|
*/
|
|
13
27
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
14
28
|
import { buildMcpServer } from "./mcp-server.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
res.
|
|
29
|
+
import { RateLimiter, bearerToken, buildTenantIndex, hashToken, timingSafeEqualStr } from "./auth.js";
|
|
30
|
+
function send(res, status, body) {
|
|
31
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
32
|
+
res.end(JSON.stringify(body));
|
|
33
|
+
}
|
|
34
|
+
/** First-hop client IP, for rate-limiting unauthenticated callers. */
|
|
35
|
+
function clientIp(req) {
|
|
36
|
+
const fwd = req.headers["x-forwarded-for"];
|
|
37
|
+
const first = Array.isArray(fwd) ? fwd[0] : fwd?.split(",")[0];
|
|
38
|
+
return (first?.trim() || req.socket?.remoteAddress || "unknown");
|
|
18
39
|
}
|
|
19
40
|
/**
|
|
20
41
|
* Returns a `(req, res) => Promise<void>` handler you mount on any Node HTTP
|
|
21
|
-
* server (or Express). Per request it
|
|
22
|
-
*
|
|
42
|
+
* server (or Express). Per request it authenticates, resolves the tenant
|
|
43
|
+
* backend, rate-limits, spins up a stateless MCP server bound to that backend,
|
|
44
|
+
* handles the MCP exchange, and tears down on response close.
|
|
23
45
|
*/
|
|
24
46
|
export function createMemoryHttpHandler(backend, opts = {}) {
|
|
47
|
+
const tenantIndex = opts.tenants ? buildTenantIndex(opts.tenants) : undefined;
|
|
48
|
+
const limiter = opts.rateLimit
|
|
49
|
+
? new RateLimiter(opts.rateLimit.windowMs, opts.rateLimit.max, opts.now ?? (() => Date.now()))
|
|
50
|
+
: undefined;
|
|
25
51
|
return async (req, res) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
52
|
+
const token = bearerToken(req.headers["authorization"]);
|
|
53
|
+
// ── Resolve the per-request backend + a rate-limit key ────────────────
|
|
54
|
+
let resolved;
|
|
55
|
+
let rateKey;
|
|
56
|
+
if (tenantIndex) {
|
|
57
|
+
// Multi-tenant: token → that tenant's backend (hash-keyed lookup).
|
|
58
|
+
if (!token) {
|
|
59
|
+
send(res, 401, { error: "unauthorized" });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
resolved = tenantIndex.get(hashToken(token));
|
|
63
|
+
if (!resolved) {
|
|
64
|
+
send(res, 401, { error: "unauthorized" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
rateKey = `t:${hashToken(token)}`;
|
|
68
|
+
}
|
|
69
|
+
else if (opts.resolveBackend) {
|
|
70
|
+
if (!token) {
|
|
71
|
+
send(res, 401, { error: "unauthorized" });
|
|
30
72
|
return;
|
|
31
73
|
}
|
|
74
|
+
resolved = await opts.resolveBackend(token);
|
|
75
|
+
if (!resolved) {
|
|
76
|
+
send(res, 401, { error: "unauthorized" });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
rateKey = `t:${hashToken(token)}`;
|
|
80
|
+
}
|
|
81
|
+
else if (opts.authToken) {
|
|
82
|
+
// Single shared secret, constant-time compare.
|
|
83
|
+
if (!token || !timingSafeEqualStr(token, opts.authToken)) {
|
|
84
|
+
send(res, 401, { error: "unauthorized" });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
resolved = backend;
|
|
88
|
+
rateKey = `t:${hashToken(token)}`;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// Open (trusted gateway): rate-limit by client IP.
|
|
92
|
+
resolved = backend;
|
|
93
|
+
rateKey = `ip:${clientIp(req)}`;
|
|
94
|
+
}
|
|
95
|
+
// ── Rate limit ────────────────────────────────────────────────────────
|
|
96
|
+
if (limiter && !limiter.check(rateKey)) {
|
|
97
|
+
send(res, 429, { error: "rate_limited" });
|
|
98
|
+
return;
|
|
32
99
|
}
|
|
33
|
-
const server = buildMcpServer(
|
|
100
|
+
const server = buildMcpServer(resolved, opts);
|
|
34
101
|
// Stateless: no session id generator → a fresh transport per request.
|
|
35
102
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
36
103
|
res.on("close", () => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http.js","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"http.js","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,EAAE,6BAA6B,EAAE,MAAM,oDAAoD,CAAC;AAEnG,OAAO,EAAE,cAAc,EAAyB,MAAM,iBAAiB,CAAC;AACxE,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,gBAAgB,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AA2BtG,SAAS,IAAI,CAAC,GAAmB,EAAE,MAAc,EAAE,IAAa;IAC5D,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,CAAC,CAAC;IAC9D,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;AAClC,CAAC;AAED,sEAAsE;AACtE,SAAS,QAAQ,CAAC,GAAoB;IAClC,MAAM,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAC3C,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAC/D,OAAO,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,GAAG,CAAC,MAAM,EAAE,aAAa,IAAI,SAAS,CAAC,CAAC;AACrE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,uBAAuB,CACnC,OAAsB,EACtB,OAA2B,EAAE;IAE7B,MAAM,WAAW,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,gBAAgB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAC9E,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS;QAC1B,CAAC,CAAC,IAAI,WAAW,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;QAC9F,CAAC,CAAC,SAAS,CAAC;IAEhB,OAAO,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;QACtB,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC;QAExD,yEAAyE;QACzE,IAAI,QAAmC,CAAC;QACxC,IAAI,OAAe,CAAC;QAEpB,IAAI,WAAW,EAAE,CAAC;YACd,mEAAmE;YACnE,IAAI,CAAC,KAAK,EAAE,CAAC;gBAAC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;gBAAC,OAAO;YAAC,CAAC;YAClE,QAAQ,GAAG,WAAW,CAAC,GAAG,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC;YAC7C,IAAI,CAAC,QAAQ,EAAE,CAAC;gBAAC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;gBAAC,OAAO;YAAC,CAAC;YACrE,OAAO,GAAG,KAAK,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC;QACtC,CAAC;aAAM,IAAI,IAAI,CAAC,cAAc,EAAE,CAAC;YAC7B,IAAI,CAAC,KAAK,EAAE,CAAC;gBAAC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;gBAAC,OAAO;YAAC,CAAC;YAClE,QAAQ,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC;YAC5C,IAAI,CAAC,QAAQ,EAAE,CAAC;gBAAC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;gBAAC,OAAO;YAAC,CAAC;YACrE,OAAO,GAAG,KAAK,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC;QACtC,CAAC;aAAM,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YACxB,+CAA+C;YAC/C,IAAI,CAAC,KAAK,IAAI,CAAC,kBAAkB,CAAC,KAAK,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;gBACvD,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;gBAC1C,OAAO;YACX,CAAC;YACD,QAAQ,GAAG,OAAO,CAAC;YACnB,OAAO,GAAG,KAAK,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC;QACtC,CAAC;aAAM,CAAC;YACJ,mDAAmD;YACnD,QAAQ,GAAG,OAAO,CAAC;YACnB,OAAO,GAAG,MAAM,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QACpC,CAAC;QAED,yEAAyE;QACzE,IAAI,OAAO,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;YACrC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;YAC1C,OAAO;QACX,CAAC;QAED,MAAM,MAAM,GAAG,cAAc,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QAC9C,sEAAsE;QACtE,MAAM,SAAS,GAAG,IAAI,6BAA6B,CAAC,EAAE,kBAAkB,EAAE,SAAS,EAAE,CAAC,CAAC;QAEvF,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YACjB,KAAK,SAAS,CAAC,KAAK,EAAE,CAAC;YACvB,KAAK,MAAM,CAAC,KAAK,EAAE,CAAC;QACxB,CAAC,CAAC,CAAC;QAEH,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QAChC,8EAA8E;QAC9E,MAAM,SAAS,CAAC,aAAa,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAC5C,CAAC,CAAC;AACN,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seanhogg/builderforce-memory-mcp",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.34",
|
|
4
4
|
"description": "BuilderForce Agent Memory — MCP transport. Exposes @seanhogg/builderforce-memory to any MCP client (Claude Agent SDK in-process, stdio, or HTTP) over a pluggable MemoryBackend, with token-saving fetch-on-demand recall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"fake-indexeddb": "^6.0.0",
|
|
74
74
|
"typescript": "^6.0.3",
|
|
75
75
|
"@typescript/native-preview": "7.0.0-dev.20260627.2",
|
|
76
|
-
"@seanhogg/builderforce-memory": "2026.6.
|
|
76
|
+
"@seanhogg/builderforce-memory": "2026.6.34"
|
|
77
77
|
},
|
|
78
78
|
"engines": {
|
|
79
79
|
"node": ">=18.0.0"
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
},
|
|
84
84
|
"scripts": {
|
|
85
85
|
"build": "tsc",
|
|
86
|
-
"test": "tsc && node --test tests/install.test.mjs tests/tools.test.mjs",
|
|
86
|
+
"test": "tsc && node --test tests/install.test.mjs tests/tools.test.mjs tests/http.test.mjs",
|
|
87
87
|
"lint": "eslint src/",
|
|
88
88
|
"typecheck:native": "tsgo --noEmit"
|
|
89
89
|
}
|
|
@@ -81,7 +81,7 @@ export interface LocalBackendOptions {
|
|
|
81
81
|
/**
|
|
82
82
|
* Optional SSMRuntime for embedding-based recall. Omit for lexical (Jaccard)
|
|
83
83
|
* recall. In the agent-runtime, pass `ssmMemoryService.runtime` here to reuse
|
|
84
|
-
* the already-loaded
|
|
84
|
+
* the already-loaded Evermind runtime instead of standing up a second model.
|
|
85
85
|
*/
|
|
86
86
|
runtime?: unknown;
|
|
87
87
|
/**
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export { runStdio } from "./transports/stdio.js";
|
|
|
29
29
|
|
|
30
30
|
export { createMemoryHttpHandler } from "./transports/http.js";
|
|
31
31
|
export type { HttpHandlerOptions } from "./transports/http.js";
|
|
32
|
+
export { hashToken, timingSafeEqualStr, buildTenantIndex, bearerToken, RateLimiter } from "./transports/auth.js";
|
|
32
33
|
|
|
33
34
|
// ── Multi-host installer (wire the stdio server into any MCP-capable agent) ───
|
|
34
35
|
export { buildServerSpec, MCP_PACKAGE, MCP_BIN, RUNTIME_PEERS } from "./install/server-spec.js";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transports/auth.ts — auth + rate-limiting primitives for the HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* The networked MCP surface is multi-tenant: one bearer token must map to ONE
|
|
5
|
+
* tenant's memory namespace, the comparison must not leak the secret through a
|
|
6
|
+
* timing side channel, and a noisy or hostile caller must not be able to hammer
|
|
7
|
+
* the server. These helpers provide exactly those three pieces, dependency-free
|
|
8
|
+
* (node:crypto only).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
/** SHA-256 hex digest of a token — the stable, non-reversible lookup key. */
|
|
14
|
+
export function hashToken(token: string): string {
|
|
15
|
+
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Constant-time string equality. Hashes both sides to fixed-length digests
|
|
20
|
+
* first, so neither the comparison nor the input length leaks the secret.
|
|
21
|
+
*/
|
|
22
|
+
export function timingSafeEqualStr(a: string, b: string): boolean {
|
|
23
|
+
const ha = createHash("sha256").update(a, "utf8").digest();
|
|
24
|
+
const hb = createHash("sha256").update(b, "utf8").digest();
|
|
25
|
+
return timingSafeEqual(ha, hb);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a tenant lookup keyed by token HASH (never the raw token). Resolving a
|
|
30
|
+
* presented token is a hash + map lookup — no per-character secret comparison,
|
|
31
|
+
* so there is no early-return timing channel on the token value.
|
|
32
|
+
*/
|
|
33
|
+
export function buildTenantIndex<B>(tenants: Record<string, B>): Map<string, B> {
|
|
34
|
+
const index = new Map<string, B>();
|
|
35
|
+
for (const [token, backend] of Object.entries(tenants)) {
|
|
36
|
+
if (token) index.set(hashToken(token), backend);
|
|
37
|
+
}
|
|
38
|
+
return index;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Fixed-window rate limiter. Memory-bounded by pruning expired windows. */
|
|
42
|
+
export class RateLimiter {
|
|
43
|
+
private readonly _hits = new Map<string, { count: number; resetAt: number }>();
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
private readonly windowMs: number,
|
|
47
|
+
private readonly max: number,
|
|
48
|
+
private readonly now: () => number = () => Date.now(),
|
|
49
|
+
) {}
|
|
50
|
+
|
|
51
|
+
/** Returns true when the call is allowed; false when the window is exhausted. */
|
|
52
|
+
check(key: string): boolean {
|
|
53
|
+
const t = this.now();
|
|
54
|
+
const entry = this._hits.get(key);
|
|
55
|
+
if (!entry || t >= entry.resetAt) {
|
|
56
|
+
if (this._hits.size > 10_000) this._prune(t);
|
|
57
|
+
this._hits.set(key, { count: 1, resetAt: t + this.windowMs });
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (entry.count >= this.max) return false;
|
|
61
|
+
entry.count++;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private _prune(t: number): void {
|
|
66
|
+
for (const [k, v] of this._hits) {
|
|
67
|
+
if (t >= v.resetAt) this._hits.delete(k);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Extract the bearer token from an Authorization header value, or undefined. */
|
|
73
|
+
export function bearerToken(header: string | string[] | undefined): string | undefined {
|
|
74
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
75
|
+
if (!value) return undefined;
|
|
76
|
+
const m = /^Bearer\s+(.+)$/i.exec(value.trim());
|
|
77
|
+
return m ? m[1]!.trim() : undefined;
|
|
78
|
+
}
|
package/src/transports/http.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP transport — the multi-tenant / networked path (builderforce.ai hosting,
|
|
3
|
-
* remote
|
|
3
|
+
* remote hosts). Stateless Streamable HTTP: one short-lived McpServer +
|
|
4
4
|
* transport per request, so it scales horizontally with no sticky sessions.
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* Auth model (pick one, in precedence order):
|
|
7
|
+
* • `tenants` — a `{ token: backend }` map. The bearer token selects the
|
|
8
|
+
* tenant's OWN backend (namespace isolation); an unknown token is 401. This
|
|
9
|
+
* is the production multi-tenant mode — one token can never read another
|
|
10
|
+
* tenant's facts.
|
|
11
|
+
* • `resolveBackend` — a function `(token) => backend` for dynamic tenant
|
|
12
|
+
* resolution (e.g. a gateway lookup).
|
|
13
|
+
* • `authToken` — a single shared secret guarding the one positional backend
|
|
14
|
+
* (single-tenant / reference hosting).
|
|
15
|
+
* • none — open, only valid behind a trusted gateway that already authed.
|
|
16
|
+
*
|
|
17
|
+
* Token comparison is constant-time (hash + timingSafeEqual / hash-keyed lookup),
|
|
18
|
+
* and an optional fixed-window rate limit (per tenant, or per client IP when
|
|
19
|
+
* unauthenticated) caps abuse.
|
|
6
20
|
*
|
|
7
21
|
* Consumed from the Claude Agent SDK as:
|
|
8
22
|
* mcpServers: {
|
|
@@ -15,40 +29,99 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
15
29
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
16
30
|
import type { MemoryBackend } from "../backend.js";
|
|
17
31
|
import { buildMcpServer, type McpServerOptions } from "./mcp-server.js";
|
|
32
|
+
import { RateLimiter, bearerToken, buildTenantIndex, hashToken, timingSafeEqualStr } from "./auth.js";
|
|
18
33
|
|
|
19
34
|
export interface HttpHandlerOptions extends McpServerOptions {
|
|
20
35
|
/**
|
|
21
|
-
* Shared secret required in `Authorization: Bearer <token
|
|
22
|
-
* request without a matching token is rejected 401.
|
|
23
|
-
* gateway that has already authenticated the caller.
|
|
36
|
+
* Shared secret required in `Authorization: Bearer <token>` for the single
|
|
37
|
+
* positional backend. Every request without a matching token is rejected 401.
|
|
38
|
+
* Omit only behind a trusted gateway that has already authenticated the caller.
|
|
24
39
|
*/
|
|
25
40
|
authToken?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Multi-tenant map: bearer token → that tenant's backend. When set, the token
|
|
43
|
+
* selects the backend (namespace isolation) and the positional `backend` is
|
|
44
|
+
* only used as a fallback when no token-auth modes match. Lookups are keyed by
|
|
45
|
+
* token hash, never the raw token.
|
|
46
|
+
*/
|
|
47
|
+
tenants?: Record<string, MemoryBackend>;
|
|
48
|
+
/**
|
|
49
|
+
* Dynamic tenant resolver: `(token) => backend | undefined`. Used when tenants
|
|
50
|
+
* live in an external store. Returning undefined → 401.
|
|
51
|
+
*/
|
|
52
|
+
resolveBackend?: (token: string) => MemoryBackend | undefined | Promise<MemoryBackend | undefined>;
|
|
53
|
+
/** Fixed-window rate limit applied per tenant (or per client IP if open). */
|
|
54
|
+
rateLimit?: { windowMs: number; max: number };
|
|
55
|
+
/** Injectable clock (ms) for deterministic rate-limit tests. */
|
|
56
|
+
now?: () => number;
|
|
26
57
|
}
|
|
27
58
|
|
|
28
|
-
function
|
|
29
|
-
res.writeHead(
|
|
30
|
-
res.end(JSON.stringify(
|
|
59
|
+
function send(res: ServerResponse, status: number, body: unknown): void {
|
|
60
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
61
|
+
res.end(JSON.stringify(body));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** First-hop client IP, for rate-limiting unauthenticated callers. */
|
|
65
|
+
function clientIp(req: IncomingMessage): string {
|
|
66
|
+
const fwd = req.headers["x-forwarded-for"];
|
|
67
|
+
const first = Array.isArray(fwd) ? fwd[0] : fwd?.split(",")[0];
|
|
68
|
+
return (first?.trim() || req.socket?.remoteAddress || "unknown");
|
|
31
69
|
}
|
|
32
70
|
|
|
33
71
|
/**
|
|
34
72
|
* Returns a `(req, res) => Promise<void>` handler you mount on any Node HTTP
|
|
35
|
-
* server (or Express). Per request it
|
|
36
|
-
*
|
|
73
|
+
* server (or Express). Per request it authenticates, resolves the tenant
|
|
74
|
+
* backend, rate-limits, spins up a stateless MCP server bound to that backend,
|
|
75
|
+
* handles the MCP exchange, and tears down on response close.
|
|
37
76
|
*/
|
|
38
77
|
export function createMemoryHttpHandler(
|
|
39
78
|
backend: MemoryBackend,
|
|
40
79
|
opts: HttpHandlerOptions = {},
|
|
41
80
|
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
81
|
+
const tenantIndex = opts.tenants ? buildTenantIndex(opts.tenants) : undefined;
|
|
82
|
+
const limiter = opts.rateLimit
|
|
83
|
+
? new RateLimiter(opts.rateLimit.windowMs, opts.rateLimit.max, opts.now ?? (() => Date.now()))
|
|
84
|
+
: undefined;
|
|
85
|
+
|
|
42
86
|
return async (req, res) => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
87
|
+
const token = bearerToken(req.headers["authorization"]);
|
|
88
|
+
|
|
89
|
+
// ── Resolve the per-request backend + a rate-limit key ────────────────
|
|
90
|
+
let resolved: MemoryBackend | undefined;
|
|
91
|
+
let rateKey: string;
|
|
92
|
+
|
|
93
|
+
if (tenantIndex) {
|
|
94
|
+
// Multi-tenant: token → that tenant's backend (hash-keyed lookup).
|
|
95
|
+
if (!token) { send(res, 401, { error: "unauthorized" }); return; }
|
|
96
|
+
resolved = tenantIndex.get(hashToken(token));
|
|
97
|
+
if (!resolved) { send(res, 401, { error: "unauthorized" }); return; }
|
|
98
|
+
rateKey = `t:${hashToken(token)}`;
|
|
99
|
+
} else if (opts.resolveBackend) {
|
|
100
|
+
if (!token) { send(res, 401, { error: "unauthorized" }); return; }
|
|
101
|
+
resolved = await opts.resolveBackend(token);
|
|
102
|
+
if (!resolved) { send(res, 401, { error: "unauthorized" }); return; }
|
|
103
|
+
rateKey = `t:${hashToken(token)}`;
|
|
104
|
+
} else if (opts.authToken) {
|
|
105
|
+
// Single shared secret, constant-time compare.
|
|
106
|
+
if (!token || !timingSafeEqualStr(token, opts.authToken)) {
|
|
107
|
+
send(res, 401, { error: "unauthorized" });
|
|
47
108
|
return;
|
|
48
109
|
}
|
|
110
|
+
resolved = backend;
|
|
111
|
+
rateKey = `t:${hashToken(token)}`;
|
|
112
|
+
} else {
|
|
113
|
+
// Open (trusted gateway): rate-limit by client IP.
|
|
114
|
+
resolved = backend;
|
|
115
|
+
rateKey = `ip:${clientIp(req)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Rate limit ────────────────────────────────────────────────────────
|
|
119
|
+
if (limiter && !limiter.check(rateKey)) {
|
|
120
|
+
send(res, 429, { error: "rate_limited" });
|
|
121
|
+
return;
|
|
49
122
|
}
|
|
50
123
|
|
|
51
|
-
const server = buildMcpServer(
|
|
124
|
+
const server = buildMcpServer(resolved, opts);
|
|
52
125
|
// Stateless: no session id generator → a fresh transport per request.
|
|
53
126
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
54
127
|
|