@absol-labs/agent 0.1.0
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/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/discovery/registry.d.ts +68 -0
- package/dist/discovery/registry.d.ts.map +1 -0
- package/dist/discovery/registry.js +148 -0
- package/dist/discovery/registry.js.map +1 -0
- package/dist/frameworks/agentkit.d.ts +158 -0
- package/dist/frameworks/agentkit.d.ts.map +1 -0
- package/dist/frameworks/agentkit.js +339 -0
- package/dist/frameworks/agentkit.js.map +1 -0
- package/dist/frameworks/crewai.d.ts +19 -0
- package/dist/frameworks/crewai.d.ts.map +1 -0
- package/dist/frameworks/crewai.js +73 -0
- package/dist/frameworks/crewai.js.map +1 -0
- package/dist/frameworks/eliza.d.ts +393 -0
- package/dist/frameworks/eliza.d.ts.map +1 -0
- package/dist/frameworks/eliza.js +542 -0
- package/dist/frameworks/eliza.js.map +1 -0
- package/dist/frameworks/langchain.d.ts +31 -0
- package/dist/frameworks/langchain.d.ts.map +1 -0
- package/dist/frameworks/langchain.js +206 -0
- package/dist/frameworks/langchain.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/mandates/env.d.ts +51 -0
- package/dist/mandates/env.d.ts.map +1 -0
- package/dist/mandates/env.js +148 -0
- package/dist/mandates/env.js.map +1 -0
- package/dist/mandates/mandate.d.ts +214 -0
- package/dist/mandates/mandate.d.ts.map +1 -0
- package/dist/mandates/mandate.js +172 -0
- package/dist/mandates/mandate.js.map +1 -0
- package/dist/mcp/http-server.d.ts +2 -0
- package/dist/mcp/http-server.d.ts.map +1 -0
- package/dist/mcp/http-server.js +32 -0
- package/dist/mcp/http-server.js.map +1 -0
- package/dist/mcp/http.d.ts +114 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/http.js +428 -0
- package/dist/mcp/http.js.map +1 -0
- package/dist/mcp/server.d.ts +119 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +679 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/stdio.d.ts +2 -0
- package/dist/mcp/stdio.d.ts.map +1 -0
- package/dist/mcp/stdio.js +6 -0
- package/dist/mcp/stdio.js.map +1 -0
- package/dist/sdk/client.d.ts +78 -0
- package/dist/sdk/client.d.ts.map +1 -0
- package/dist/sdk/client.js +99 -0
- package/dist/sdk/client.js.map +1 -0
- package/dist/wallet/provider.d.ts +51 -0
- package/dist/wallet/provider.d.ts.map +1 -0
- package/dist/wallet/provider.js +107 -0
- package/dist/wallet/provider.js.map +1 -0
- package/dist/x402/facilitator.d.ts +315 -0
- package/dist/x402/facilitator.d.ts.map +1 -0
- package/dist/x402/facilitator.js +194 -0
- package/dist/x402/facilitator.js.map +1 -0
- package/dist/zktls/reclaim.d.ts +137 -0
- package/dist/zktls/reclaim.d.ts.map +1 -0
- package/dist/zktls/reclaim.js +245 -0
- package/dist/zktls/reclaim.js.map +1 -0
- package/package.json +86 -0
- package/src/discovery/registry.ts +255 -0
- package/src/frameworks/agentkit.ts +537 -0
- package/src/frameworks/crewai.ts +102 -0
- package/src/frameworks/eliza.ts +776 -0
- package/src/frameworks/langchain.ts +325 -0
- package/src/index.ts +179 -0
- package/src/mandates/env.ts +222 -0
- package/src/mandates/mandate.ts +280 -0
- package/src/mcp/http-server.ts +38 -0
- package/src/mcp/http.ts +620 -0
- package/src/mcp/server.ts +1006 -0
- package/src/mcp/stdio.ts +8 -0
- package/src/sdk/client.ts +222 -0
- package/src/wallet/provider.ts +197 -0
- package/src/x402/facilitator.ts +336 -0
- package/src/zktls/reclaim.ts +424 -0
package/src/mcp/http.ts
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
createServer,
|
|
4
|
+
type IncomingMessage,
|
|
5
|
+
type Server,
|
|
6
|
+
type ServerResponse,
|
|
7
|
+
} from "node:http";
|
|
8
|
+
import type { AddressInfo } from "node:net";
|
|
9
|
+
|
|
10
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
11
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
12
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
createVerifiedStreamMcpServer,
|
|
16
|
+
createVerifiedStreamMcpServerOptionsFromEnv,
|
|
17
|
+
type VerifiedStreamMcpServerOptions,
|
|
18
|
+
type VerifiedStreamMcpServerRuntime,
|
|
19
|
+
} from "./server.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hosted, multi-tenant MCP transport over Streamable HTTP.
|
|
23
|
+
*
|
|
24
|
+
* This serves the SAME Metrik agent MCP tool set as the stdio transport
|
|
25
|
+
* (`mcp/stdio.ts`) over the MCP SDK's `StreamableHTTPServerTransport`, so the
|
|
26
|
+
* agent lane can be hosted as a remote endpoint.
|
|
27
|
+
*
|
|
28
|
+
* Security model (fail-closed):
|
|
29
|
+
* - Every request MUST carry `Authorization: Bearer <token>`. Requests without
|
|
30
|
+
* a valid bearer token are rejected with 401 BEFORE any MCP session is
|
|
31
|
+
* created or any tool executes.
|
|
32
|
+
* - Tokens are compared in constant time (SHA-256 + `timingSafeEqual`) and are
|
|
33
|
+
* never logged.
|
|
34
|
+
* - Each authenticated caller maps to its OWN tenant context — its own
|
|
35
|
+
* wallet-backed agent client and stream registry — so one caller can never
|
|
36
|
+
* drive another caller's funds or observe another caller's streams.
|
|
37
|
+
* - A caller may only use MCP sessions bound to its own tenant; presenting
|
|
38
|
+
* another tenant's session id is rejected (403).
|
|
39
|
+
* - If zero tenants (tokens) are configured, the transport refuses to start.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** Maximum accepted request body size (bytes) — bounds memory per request. */
|
|
43
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
44
|
+
/** Header carrying the Streamable HTTP session id (per the MCP spec). */
|
|
45
|
+
const SESSION_ID_HEADER = "mcp-session-id";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A single authenticated tenant: a bearer token plus the wallet/mandate-scoped
|
|
49
|
+
* MCP server options used to build that tenant's server instances.
|
|
50
|
+
*/
|
|
51
|
+
export interface HostedMcpTenant {
|
|
52
|
+
/** Stable, human-readable tenant identifier (used only for routing/logging). */
|
|
53
|
+
readonly id: string;
|
|
54
|
+
/** The tenant's secret bearer token. Never logged. */
|
|
55
|
+
readonly token: string;
|
|
56
|
+
/**
|
|
57
|
+
* The tenant's isolated MCP server options (its own agent client + registry).
|
|
58
|
+
* A fresh `McpServer` is built from these for each of the tenant's sessions.
|
|
59
|
+
*/
|
|
60
|
+
readonly options: VerifiedStreamMcpServerOptions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolves a presented bearer token to a tenant, or `null` when invalid. */
|
|
64
|
+
export interface TenantResolver {
|
|
65
|
+
authenticate(token: string): HostedMcpTenant | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sha256(value: string): Buffer {
|
|
69
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Constant-time, env-backed tenant resolver.
|
|
74
|
+
*
|
|
75
|
+
* Tokens are pre-hashed with SHA-256 (fixed 32-byte width) so every comparison
|
|
76
|
+
* uses `timingSafeEqual` on equal-length buffers, and the loop always visits
|
|
77
|
+
* every tenant with no early exit — neither the presented token's length nor
|
|
78
|
+
* which tenant matched is observable via timing.
|
|
79
|
+
*/
|
|
80
|
+
export class StaticTenantResolver implements TenantResolver {
|
|
81
|
+
private readonly entries: readonly {
|
|
82
|
+
readonly tenant: HostedMcpTenant;
|
|
83
|
+
readonly tokenHash: Buffer;
|
|
84
|
+
}[];
|
|
85
|
+
|
|
86
|
+
constructor(tenants: readonly HostedMcpTenant[]) {
|
|
87
|
+
if (tenants.length === 0) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
"hosted MCP: no tenants configured — refusing to start (fail-closed)",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const seenIds = new Set<string>();
|
|
93
|
+
const seenTokens = new Set<string>();
|
|
94
|
+
for (const tenant of tenants) {
|
|
95
|
+
if (tenant.token.length === 0) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`hosted MCP: tenant "${tenant.id}" has an empty token (fail-closed)`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (seenIds.has(tenant.id)) {
|
|
101
|
+
throw new Error(`hosted MCP: duplicate tenant id "${tenant.id}"`);
|
|
102
|
+
}
|
|
103
|
+
if (seenTokens.has(tenant.token)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`hosted MCP: duplicate token for tenant "${tenant.id}"`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
seenIds.add(tenant.id);
|
|
109
|
+
seenTokens.add(tenant.token);
|
|
110
|
+
}
|
|
111
|
+
this.entries = tenants.map((tenant) => ({
|
|
112
|
+
tenant,
|
|
113
|
+
tokenHash: sha256(tenant.token),
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
authenticate(token: string): HostedMcpTenant | null {
|
|
118
|
+
const presented = sha256(token);
|
|
119
|
+
let match: HostedMcpTenant | null = null;
|
|
120
|
+
for (const entry of this.entries) {
|
|
121
|
+
// timingSafeEqual on equal-length buffers; visit all entries so timing
|
|
122
|
+
// does not reveal which (if any) tenant matched.
|
|
123
|
+
if (timingSafeEqual(presented, entry.tokenHash)) {
|
|
124
|
+
match = entry.tenant;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return match;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface HostedMcpHttpServerOptions {
|
|
132
|
+
readonly resolver: TenantResolver;
|
|
133
|
+
/** HTTP path the MCP endpoint is served on. Default `/mcp`. */
|
|
134
|
+
readonly path?: string;
|
|
135
|
+
/**
|
|
136
|
+
* Allowed CORS origins. `["*"]` allows any origin. When omitted, no
|
|
137
|
+
* cross-origin `Access-Control-Allow-Origin` header is emitted (same-origin
|
|
138
|
+
* / non-browser clients only).
|
|
139
|
+
*/
|
|
140
|
+
readonly allowedOrigins?: readonly string[];
|
|
141
|
+
/** Session id generator. Default: cryptographically-random UUID v4. */
|
|
142
|
+
readonly generateSessionId?: () => string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface HostedSession {
|
|
146
|
+
readonly transport: StreamableHTTPServerTransport;
|
|
147
|
+
readonly runtime: VerifiedStreamMcpServerRuntime;
|
|
148
|
+
readonly tenantId: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface HostedMcpHttpServer {
|
|
152
|
+
readonly httpServer: Server;
|
|
153
|
+
/** Bind and start listening. Resolves with the actual bound address. */
|
|
154
|
+
listen(port: number, host?: string): Promise<{ port: number; host: string }>;
|
|
155
|
+
/** Stop accepting requests and tear down all live sessions. */
|
|
156
|
+
close(): Promise<void>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Creates (but does not start) a hosted multi-tenant MCP HTTP server.
|
|
161
|
+
*
|
|
162
|
+
* Use {@link HostedMcpHttpServer.listen} to bind it. The returned server is
|
|
163
|
+
* suitable for tests (bind to port 0 for an ephemeral port) and for
|
|
164
|
+
* deployment (bind to `0.0.0.0`/`PORT`).
|
|
165
|
+
*/
|
|
166
|
+
export function createHostedMcpHttpServer(
|
|
167
|
+
options: HostedMcpHttpServerOptions,
|
|
168
|
+
): HostedMcpHttpServer {
|
|
169
|
+
const path = options.path ?? "/mcp";
|
|
170
|
+
const allowedOrigins = options.allowedOrigins;
|
|
171
|
+
const generateSessionId = options.generateSessionId ?? (() => randomUUID());
|
|
172
|
+
const sessions = new Map<string, HostedSession>();
|
|
173
|
+
|
|
174
|
+
const httpServer = createServer((req, res) => {
|
|
175
|
+
handleHttpRequest(req, res, {
|
|
176
|
+
path,
|
|
177
|
+
allowedOrigins,
|
|
178
|
+
generateSessionId,
|
|
179
|
+
resolver: options.resolver,
|
|
180
|
+
sessions,
|
|
181
|
+
}).catch((error) => {
|
|
182
|
+
// Never leak internal error detail to the client.
|
|
183
|
+
logError("unhandled request error", error);
|
|
184
|
+
if (!res.headersSent) {
|
|
185
|
+
writeJsonRpcError(res, 500, -32603, "internal error");
|
|
186
|
+
} else {
|
|
187
|
+
res.end();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
httpServer,
|
|
194
|
+
listen: (port, host = "0.0.0.0") =>
|
|
195
|
+
new Promise((resolve, reject) => {
|
|
196
|
+
const onError = (error: Error) => reject(error);
|
|
197
|
+
httpServer.once("error", onError);
|
|
198
|
+
httpServer.listen(port, host, () => {
|
|
199
|
+
httpServer.removeListener("error", onError);
|
|
200
|
+
const address = httpServer.address() as AddressInfo;
|
|
201
|
+
resolve({ port: address.port, host });
|
|
202
|
+
});
|
|
203
|
+
}),
|
|
204
|
+
close: async () => {
|
|
205
|
+
// Closing the runtime cascades to its transport via the SDK lifecycle
|
|
206
|
+
// linkage; the transport's onclose then drops it from the map.
|
|
207
|
+
await Promise.all(
|
|
208
|
+
Array.from(sessions.values()).map((session) =>
|
|
209
|
+
session.runtime.close().catch(() => undefined),
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
sessions.clear();
|
|
213
|
+
await new Promise<void>((resolve, reject) => {
|
|
214
|
+
httpServer.close((error) => (error ? reject(error) : resolve()));
|
|
215
|
+
});
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
interface RequestContext {
|
|
221
|
+
readonly path: string;
|
|
222
|
+
readonly allowedOrigins: readonly string[] | undefined;
|
|
223
|
+
readonly generateSessionId: () => string;
|
|
224
|
+
readonly resolver: TenantResolver;
|
|
225
|
+
readonly sessions: Map<string, HostedSession>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function handleHttpRequest(
|
|
229
|
+
req: IncomingMessage,
|
|
230
|
+
res: ServerResponse,
|
|
231
|
+
ctx: RequestContext,
|
|
232
|
+
): Promise<void> {
|
|
233
|
+
applyCorsHeaders(req, res, ctx.allowedOrigins);
|
|
234
|
+
|
|
235
|
+
// CORS preflight — no auth, no side effects.
|
|
236
|
+
if (req.method === "OPTIONS") {
|
|
237
|
+
res.writeHead(204).end();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const requestUrl = new URL(req.url ?? "/", "http://localhost");
|
|
242
|
+
if (requestUrl.pathname !== ctx.path) {
|
|
243
|
+
writeJsonRpcError(res, 404, -32601, "not found");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const method = req.method ?? "";
|
|
248
|
+
if (method !== "POST" && method !== "GET" && method !== "DELETE") {
|
|
249
|
+
res.setHeader("Allow", "POST, GET, DELETE, OPTIONS");
|
|
250
|
+
writeJsonRpcError(res, 405, -32601, "method not allowed");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Authentication gate (runs before ANY MCP session / tool executes) ──
|
|
255
|
+
const tenant = authenticateRequest(req, ctx.resolver);
|
|
256
|
+
if (tenant === null) {
|
|
257
|
+
res.setHeader("WWW-Authenticate", 'Bearer realm="metrik-agent-mcp"');
|
|
258
|
+
writeJsonRpcError(res, 401, -32001, "unauthorized");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (method === "POST") {
|
|
263
|
+
await handlePost(req, res, ctx, tenant);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// GET (SSE stream) and DELETE (end session) both require an existing session.
|
|
268
|
+
const session = resolveSessionForTenant(req, res, ctx, tenant);
|
|
269
|
+
if (session === null) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
await session.transport.handleRequest(req, res);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function handlePost(
|
|
276
|
+
req: IncomingMessage,
|
|
277
|
+
res: ServerResponse,
|
|
278
|
+
ctx: RequestContext,
|
|
279
|
+
tenant: HostedMcpTenant,
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
let body: unknown;
|
|
282
|
+
try {
|
|
283
|
+
body = await readJsonBody(req);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const status = error instanceof BodyTooLargeError ? 413 : 400;
|
|
286
|
+
writeJsonRpcError(res, status, -32700, "invalid request body");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const sessionId = headerValue(req, SESSION_ID_HEADER);
|
|
291
|
+
|
|
292
|
+
if (sessionId !== undefined) {
|
|
293
|
+
const session = resolveSessionForTenant(req, res, ctx, tenant);
|
|
294
|
+
if (session === null) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
await session.transport.handleRequest(req, res, body);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// No session id: only an `initialize` request may open a new session.
|
|
302
|
+
if (!isInitializeRequest(body)) {
|
|
303
|
+
writeJsonRpcError(
|
|
304
|
+
res,
|
|
305
|
+
400,
|
|
306
|
+
-32000,
|
|
307
|
+
"missing session id (only initialize may open a session)",
|
|
308
|
+
);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const runtime = createVerifiedStreamMcpServer(tenant.options);
|
|
313
|
+
const transport = new StreamableHTTPServerTransport({
|
|
314
|
+
sessionIdGenerator: ctx.generateSessionId,
|
|
315
|
+
onsessioninitialized: (id) => {
|
|
316
|
+
ctx.sessions.set(id, { transport, runtime, tenantId: tenant.id });
|
|
317
|
+
},
|
|
318
|
+
onsessionclosed: (id) => {
|
|
319
|
+
ctx.sessions.delete(id);
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
// Only drop the session from the map on close. Do NOT call runtime.close()
|
|
323
|
+
// here: `mcpServer.connect` links server<->transport lifecycle, so closing
|
|
324
|
+
// the runtime already closes the transport (and vice versa). Closing one from
|
|
325
|
+
// the other's close handler would recurse infinitely.
|
|
326
|
+
transport.onclose = () => {
|
|
327
|
+
const id = transport.sessionId;
|
|
328
|
+
if (id !== undefined) {
|
|
329
|
+
ctx.sessions.delete(id);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// The SDK's StreamableHTTPServerTransport exposes onclose/onerror as
|
|
334
|
+
// getter/setter pairs typed `(() => void) | undefined`, which does not satisfy
|
|
335
|
+
// the `onclose?: () => void` optional-property shape under
|
|
336
|
+
// exactOptionalPropertyTypes. The runtime shape is fully compatible.
|
|
337
|
+
await runtime.mcpServer.connect(transport as unknown as Transport);
|
|
338
|
+
await transport.handleRequest(req, res, body);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Looks up the session named by the request's `mcp-session-id` header and
|
|
343
|
+
* enforces tenant ownership. Writes the appropriate error response and returns
|
|
344
|
+
* `null` when the session is missing (404) or owned by another tenant (403).
|
|
345
|
+
*/
|
|
346
|
+
function resolveSessionForTenant(
|
|
347
|
+
req: IncomingMessage,
|
|
348
|
+
res: ServerResponse,
|
|
349
|
+
ctx: RequestContext,
|
|
350
|
+
tenant: HostedMcpTenant,
|
|
351
|
+
): HostedSession | null {
|
|
352
|
+
const sessionId = headerValue(req, SESSION_ID_HEADER);
|
|
353
|
+
if (sessionId === undefined) {
|
|
354
|
+
writeJsonRpcError(res, 400, -32000, "missing session id");
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
const session = ctx.sessions.get(sessionId);
|
|
358
|
+
if (session === undefined) {
|
|
359
|
+
writeJsonRpcError(res, 404, -32001, "unknown session");
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
if (session.tenantId !== tenant.id) {
|
|
363
|
+
// Tenant isolation: a caller may never touch another tenant's session.
|
|
364
|
+
writeJsonRpcError(res, 403, -32001, "forbidden");
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
return session;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function authenticateRequest(
|
|
371
|
+
req: IncomingMessage,
|
|
372
|
+
resolver: TenantResolver,
|
|
373
|
+
): HostedMcpTenant | null {
|
|
374
|
+
const header = headerValue(req, "authorization");
|
|
375
|
+
if (header === undefined) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
const match = /^Bearer[ ]+(.+)$/i.exec(header.trim());
|
|
379
|
+
if (match === null) {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
const token = match[1]!.trim();
|
|
383
|
+
if (token.length === 0) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
return resolver.authenticate(token);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function applyCorsHeaders(
|
|
390
|
+
req: IncomingMessage,
|
|
391
|
+
res: ServerResponse,
|
|
392
|
+
allowedOrigins: readonly string[] | undefined,
|
|
393
|
+
): void {
|
|
394
|
+
if (allowedOrigins === undefined || allowedOrigins.length === 0) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const origin = headerValue(req, "origin");
|
|
398
|
+
const allowAny = allowedOrigins.includes("*");
|
|
399
|
+
if (allowAny) {
|
|
400
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
401
|
+
} else if (origin !== undefined && allowedOrigins.includes(origin)) {
|
|
402
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
403
|
+
res.setHeader("Vary", "Origin");
|
|
404
|
+
} else {
|
|
405
|
+
// Origin not allowed — emit no ACAO header (browser will block).
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS");
|
|
409
|
+
res.setHeader(
|
|
410
|
+
"Access-Control-Allow-Headers",
|
|
411
|
+
"Authorization, Content-Type, Mcp-Session-Id, Last-Event-ID, Mcp-Protocol-Version",
|
|
412
|
+
);
|
|
413
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
414
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
class BodyTooLargeError extends Error {}
|
|
418
|
+
|
|
419
|
+
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
420
|
+
const chunks: Buffer[] = [];
|
|
421
|
+
let size = 0;
|
|
422
|
+
for await (const chunk of req) {
|
|
423
|
+
const buffer = chunk as Buffer;
|
|
424
|
+
size += buffer.length;
|
|
425
|
+
if (size > MAX_BODY_BYTES) {
|
|
426
|
+
throw new BodyTooLargeError("request body too large");
|
|
427
|
+
}
|
|
428
|
+
chunks.push(buffer);
|
|
429
|
+
}
|
|
430
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
431
|
+
if (raw.trim().length === 0) {
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
return JSON.parse(raw);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function headerValue(req: IncomingMessage, name: string): string | undefined {
|
|
438
|
+
const value = req.headers[name];
|
|
439
|
+
if (value === undefined) {
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
return Array.isArray(value) ? value[0] : value;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function writeJsonRpcError(
|
|
446
|
+
res: ServerResponse,
|
|
447
|
+
status: number,
|
|
448
|
+
code: number,
|
|
449
|
+
message: string,
|
|
450
|
+
): void {
|
|
451
|
+
if (res.headersSent) {
|
|
452
|
+
res.end();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const payload = JSON.stringify({
|
|
456
|
+
jsonrpc: "2.0",
|
|
457
|
+
error: { code, message },
|
|
458
|
+
id: null,
|
|
459
|
+
});
|
|
460
|
+
res.writeHead(status, { "Content-Type": "application/json" }).end(payload);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function logError(context: string, error: unknown): void {
|
|
464
|
+
// Only the message — never headers/tokens/bodies.
|
|
465
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
466
|
+
console.error(`[metrik-agent-mcp-http] ${context}: ${message}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Environment configuration ────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Per-tenant configuration parsed from the hosted server environment.
|
|
473
|
+
*
|
|
474
|
+
* Each tenant carries the environment slice (`METRIK_AGENT_*`, wallet vars,
|
|
475
|
+
* optional Reclaim vars) that builds its OWN wallet-backed agent client, so
|
|
476
|
+
* tenants are fully fund-isolated from one another.
|
|
477
|
+
*/
|
|
478
|
+
export interface HostedMcpTenantConfig {
|
|
479
|
+
readonly id: string;
|
|
480
|
+
readonly token: string;
|
|
481
|
+
readonly env: Readonly<Record<string, string>>;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export interface HostedMcpServerConfig {
|
|
485
|
+
readonly host: string;
|
|
486
|
+
readonly port: number;
|
|
487
|
+
readonly path: string;
|
|
488
|
+
readonly allowedOrigins: readonly string[];
|
|
489
|
+
readonly tenants: readonly HostedMcpTenantConfig[];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Parses the hosted MCP server configuration from environment variables.
|
|
494
|
+
*
|
|
495
|
+
* Env contract:
|
|
496
|
+
* - `PORT` / `METRIK_MCP_PORT` — listen port (default 8080).
|
|
497
|
+
* - `METRIK_MCP_HOST` — bind host (default `0.0.0.0`).
|
|
498
|
+
* - `METRIK_MCP_PATH` — MCP endpoint path (default `/mcp`).
|
|
499
|
+
* - `METRIK_MCP_ALLOWED_ORIGINS` — comma-separated CORS origins, or `*`.
|
|
500
|
+
* - `METRIK_MCP_TENANTS` — JSON array of
|
|
501
|
+
* `{ "id": string, "token": string, "env"?: Record<string,string> }`.
|
|
502
|
+
* Each tenant's `env` is layered over the process environment and used to
|
|
503
|
+
* build that tenant's isolated, wallet-backed agent client.
|
|
504
|
+
*
|
|
505
|
+
* Throws (fail-closed) when no tenants/tokens are configured.
|
|
506
|
+
*/
|
|
507
|
+
export function parseHostedMcpServerConfig(
|
|
508
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
509
|
+
): HostedMcpServerConfig {
|
|
510
|
+
const portRaw = env.METRIK_MCP_PORT ?? env.PORT ?? "8080";
|
|
511
|
+
const port = Number(portRaw);
|
|
512
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
513
|
+
throw new Error(`invalid port: ${portRaw}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const host = env.METRIK_MCP_HOST ?? "0.0.0.0";
|
|
517
|
+
const path = env.METRIK_MCP_PATH ?? "/mcp";
|
|
518
|
+
const allowedOrigins = (env.METRIK_MCP_ALLOWED_ORIGINS ?? "")
|
|
519
|
+
.split(",")
|
|
520
|
+
.map((value) => value.trim())
|
|
521
|
+
.filter((value) => value.length > 0);
|
|
522
|
+
|
|
523
|
+
const tenantsRaw = env.METRIK_MCP_TENANTS;
|
|
524
|
+
if (tenantsRaw === undefined || tenantsRaw.trim().length === 0) {
|
|
525
|
+
throw new Error(
|
|
526
|
+
"METRIK_MCP_TENANTS is required (fail-closed: refusing to run an open, unauthenticated MCP endpoint)",
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let parsed: unknown;
|
|
531
|
+
try {
|
|
532
|
+
parsed = JSON.parse(tenantsRaw);
|
|
533
|
+
} catch {
|
|
534
|
+
throw new Error("METRIK_MCP_TENANTS must be valid JSON");
|
|
535
|
+
}
|
|
536
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
537
|
+
throw new Error("METRIK_MCP_TENANTS must be a non-empty JSON array");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const tenants = parsed.map((entry, index) => {
|
|
541
|
+
if (typeof entry !== "object" || entry === null) {
|
|
542
|
+
throw new Error(`METRIK_MCP_TENANTS[${index}] must be an object`);
|
|
543
|
+
}
|
|
544
|
+
const record = entry as Record<string, unknown>;
|
|
545
|
+
const id = record.id;
|
|
546
|
+
const token = record.token;
|
|
547
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`METRIK_MCP_TENANTS[${index}].id must be a non-empty string`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
553
|
+
throw new Error(
|
|
554
|
+
`METRIK_MCP_TENANTS[${index}].token must be a non-empty string`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
const tenantEnv: Record<string, string> = {};
|
|
558
|
+
if (record.env !== undefined) {
|
|
559
|
+
if (typeof record.env !== "object" || record.env === null) {
|
|
560
|
+
throw new Error(`METRIK_MCP_TENANTS[${index}].env must be an object`);
|
|
561
|
+
}
|
|
562
|
+
for (const [key, value] of Object.entries(
|
|
563
|
+
record.env as Record<string, unknown>,
|
|
564
|
+
)) {
|
|
565
|
+
if (typeof value !== "string") {
|
|
566
|
+
throw new Error(
|
|
567
|
+
`METRIK_MCP_TENANTS[${index}].env.${key} must be a string`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
tenantEnv[key] = value;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return { id, token, env: tenantEnv } satisfies HostedMcpTenantConfig;
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
return { host, port, path, allowedOrigins, tenants };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Builds fully-isolated {@link HostedMcpTenant}s from parsed config, resolving
|
|
581
|
+
* each tenant's wallet-backed agent client from its own environment slice.
|
|
582
|
+
*/
|
|
583
|
+
export async function createHostedMcpTenants(
|
|
584
|
+
config: HostedMcpServerConfig,
|
|
585
|
+
baseEnv: NodeJS.ProcessEnv = process.env,
|
|
586
|
+
): Promise<HostedMcpTenant[]> {
|
|
587
|
+
const tenants: HostedMcpTenant[] = [];
|
|
588
|
+
for (const tenantConfig of config.tenants) {
|
|
589
|
+
const options = await createVerifiedStreamMcpServerOptionsFromEnv({
|
|
590
|
+
...baseEnv,
|
|
591
|
+
...tenantConfig.env,
|
|
592
|
+
});
|
|
593
|
+
tenants.push({
|
|
594
|
+
id: tenantConfig.id,
|
|
595
|
+
token: tenantConfig.token,
|
|
596
|
+
options,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return tenants;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Builds and starts a hosted MCP HTTP server entirely from the environment.
|
|
604
|
+
*
|
|
605
|
+
* Fail-closed: throws before binding if no tenants/tokens are configured.
|
|
606
|
+
*/
|
|
607
|
+
export async function startHostedMcpHttpServerFromEnv(
|
|
608
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
609
|
+
): Promise<HostedMcpHttpServer & { readonly boundPort: number }> {
|
|
610
|
+
const config = parseHostedMcpServerConfig(env);
|
|
611
|
+
const tenants = await createHostedMcpTenants(config, env);
|
|
612
|
+
const resolver = new StaticTenantResolver(tenants);
|
|
613
|
+
const server = createHostedMcpHttpServer({
|
|
614
|
+
resolver,
|
|
615
|
+
path: config.path,
|
|
616
|
+
allowedOrigins: config.allowedOrigins,
|
|
617
|
+
});
|
|
618
|
+
const { port } = await server.listen(config.port, config.host);
|
|
619
|
+
return Object.assign(server, { boundPort: port });
|
|
620
|
+
}
|