@seanhogg/builderforce-memory-mcp 2026.6.30 → 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.
@@ -1,8 +1,22 @@
1
1
  /**
2
2
  * HTTP transport — the multi-tenant / networked path (builderforce.ai hosting,
3
- * remote claws). Stateless Streamable HTTP: one short-lived McpServer +
3
+ * remote hosts). Stateless Streamable HTTP: one short-lived McpServer +
4
4
  * transport per request, so it scales horizontally with no sticky sessions.
5
- * Bearer-token auth gates every request.
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>`. When set, every
22
- * request without a matching token is rejected 401. Omit only behind a trusted
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 unauthorized(res: ServerResponse): void {
29
- res.writeHead(401, { "content-type": "application/json" });
30
- res.end(JSON.stringify({ error: "unauthorized" }));
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 spins up a stateless MCP server bound to
36
- * `backend`, handles the MCP exchange, and tears down on response close.
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
- if (opts.authToken) {
44
- const header = req.headers["authorization"];
45
- if (header !== `Bearer ${opts.authToken}`) {
46
- unauthorized(res);
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(backend, opts);
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