@webpieces/mcp-server 0.4.990 → 0.4.991

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.
Files changed (48) hide show
  1. package/README.md +199 -42
  2. package/package.json +7 -6
  3. package/src/McpApiDispatcher.d.ts +6 -3
  4. package/src/McpApiDispatcher.js +6 -2
  5. package/src/McpApiDispatcher.js.map +1 -1
  6. package/src/McpAuth.d.ts +115 -56
  7. package/src/McpAuth.js +180 -111
  8. package/src/McpAuth.js.map +1 -1
  9. package/src/McpAuthorityCompileAssertions.d.ts +12 -2
  10. package/src/McpAuthorityCompileAssertions.js +32 -4
  11. package/src/McpAuthorityCompileAssertions.js.map +1 -1
  12. package/src/McpBindOptions.d.ts +5 -20
  13. package/src/McpBindOptions.js +5 -15
  14. package/src/McpBindOptions.js.map +1 -1
  15. package/src/McpClientSet.d.ts +35 -0
  16. package/src/McpClientSet.js +86 -0
  17. package/src/McpClientSet.js.map +1 -0
  18. package/src/McpDoorAuth.d.ts +117 -0
  19. package/src/McpDoorAuth.js +455 -0
  20. package/src/McpDoorAuth.js.map +1 -0
  21. package/src/McpToolCallRendering.d.ts +5 -4
  22. package/src/McpToolCallRendering.js.map +1 -1
  23. package/src/McpToolCatalog.d.ts +1 -1
  24. package/src/McpToolCatalog.js +1 -1
  25. package/src/McpToolCatalog.js.map +1 -1
  26. package/src/McpToolRegistry.js +8 -2
  27. package/src/McpToolRegistry.js.map +1 -1
  28. package/src/McpToolset.d.ts +115 -0
  29. package/src/McpToolset.js +323 -0
  30. package/src/McpToolset.js.map +1 -0
  31. package/src/WpMcpErrorTranslator.d.ts +5 -7
  32. package/src/WpMcpErrorTranslator.js +7 -13
  33. package/src/WpMcpErrorTranslator.js.map +1 -1
  34. package/src/WpMcpInMemory.d.ts +30 -0
  35. package/src/WpMcpInMemory.js +97 -0
  36. package/src/WpMcpInMemory.js.map +1 -0
  37. package/src/WpMcpRemote.d.ts +23 -0
  38. package/src/WpMcpRemote.js +89 -0
  39. package/src/WpMcpRemote.js.map +1 -0
  40. package/src/WpMcpServer.d.ts +45 -76
  41. package/src/WpMcpServer.js +101 -327
  42. package/src/WpMcpServer.js.map +1 -1
  43. package/src/__tests__/McpTransportFixtures.d.ts +107 -0
  44. package/src/__tests__/McpTransportFixtures.js +266 -0
  45. package/src/__tests__/McpTransportFixtures.js.map +1 -0
  46. package/src/index.d.ts +9 -2
  47. package/src/index.js +17 -1
  48. package/src/index.js.map +1 -1
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WpMcpInMemory = void 0;
4
+ const client_1 = require("@modelcontextprotocol/client");
5
+ const server_1 = require("@modelcontextprotocol/server");
6
+ const core_util_1 = require("@webpieces/core-util");
7
+ const core_context_1 = require("@webpieces/core-context");
8
+ const McpToolset_1 = require("./McpToolset");
9
+ /** In-memory MCP has no socket to lose, so its disconnect signal never fires; cancellation is the SDK's. */
10
+ const NEVER_DISCONNECTED = new AbortController().signal;
11
+ /**
12
+ * One end of the SDK's linked in-memory pair, sending each message as the WIRE would: a JSON copy.
13
+ * The SDK pair hands the receiver the sender's object itself, so a class-typed schema or a caller's
14
+ * DTO instance would reach the other side by reference and fail the SDK's plain-JSON validation (or
15
+ * be mutated across the boundary). The copy is made synchronously inside `send`, so the receiver
16
+ * still runs in the SENDER's async context — which is what lets the toolset read the caller per request.
17
+ */
18
+ class McpWireCopyTransport {
19
+ inner;
20
+ constructor(inner) {
21
+ this.inner = inner;
22
+ }
23
+ get onmessage() {
24
+ return this.inner.onmessage;
25
+ }
26
+ set onmessage(handler) {
27
+ this.inner.onmessage = handler;
28
+ }
29
+ get onclose() {
30
+ return this.inner.onclose;
31
+ }
32
+ set onclose(handler) {
33
+ this.inner.onclose = handler;
34
+ }
35
+ get onerror() {
36
+ return this.inner.onerror;
37
+ }
38
+ set onerror(handler) {
39
+ this.inner.onerror = handler;
40
+ }
41
+ start() {
42
+ return this.inner.start();
43
+ }
44
+ close() {
45
+ return this.inner.close();
46
+ }
47
+ send(message, options) {
48
+ return this.inner.send(JSON.parse(JSON.stringify(message)), options);
49
+ }
50
+ }
51
+ /**
52
+ * The in-memory transport over an {@link McpToolset}: an official MCP SDK `Client` wired to the
53
+ * toolset's server through the SDK's linked `InMemoryTransport` pair, for an agent that runs its model
54
+ * loop in-process and already holds a verified caller in its `RequestContext`.
55
+ *
56
+ * ```ts
57
+ * const client: Client = await WpMcpInMemory.connect(toolset); // once per process
58
+ * // ...inside a request whose caller AuthFilter already verified:
59
+ * const { tools } = await client.listTools(); // only what THIS caller may see
60
+ * await client.callTool({ name: 'stores_pause', arguments: { storeId } });
61
+ * ```
62
+ *
63
+ * NO CREDENTIAL. The caller is whoever is trusted in the `RequestContext` of the code calling
64
+ * `client.listTools()` / `client.callTool()`, READ PER REQUEST and NEVER captured at connect. Apps cache
65
+ * one client per process, so a connect-time capture would run user B's call as user A. The SDK's
66
+ * in-memory transport delivers each message synchronously inside the caller's own `send`, so the
67
+ * toolset's handlers run in the caller's async context; a call made outside any `RequestContext` is
68
+ * refused rather than run as nobody.
69
+ *
70
+ * The caller's scope is the one the tool runs in: a local binding's `InvocationAuthentication` needs
71
+ * that scope to have completed verified ingress (it did, if an endpoint's `AuthFilter` admitted the
72
+ * request). That is also why `McpToolset` refuses a SERVICE_ONLY tool: the caller's own service hop
73
+ * would satisfy it for every model behind it. No tools/list cache hint is sent, so one shared client
74
+ * never serves user A's list to user B.
75
+ */
76
+ class WpMcpInMemory {
77
+ // webpieces-disable no-function-outside-class -- explicit public factory of a connected SDK client
78
+ static async connect(toolset) {
79
+ const pair = server_1.InMemoryTransport.createLinkedPair();
80
+ const server = toolset.createSdkServer({ name: 'webpieces-mcp-toolset', version: toolset.revision }, { capabilities: { tools: { listChanged: true } } }, async () => {
81
+ if (!core_context_1.RequestContext.isActive()) {
82
+ throw new core_util_1.ApiImplementationError('WpMcpInMemory client calls must run inside the RequestContext of a verified caller; ' +
83
+ 'the in-memory transport reads the caller from it on every request.');
84
+ }
85
+ return new McpToolset_1.McpToolRequest(await toolset.authorizedTools(), NEVER_DISCONNECTED);
86
+ }, (name) => toolset.registry.find(name));
87
+ await server.connect(new McpWireCopyTransport(pair[1]));
88
+ const client = new client_1.Client({
89
+ name: 'webpieces-in-memory-client',
90
+ version: toolset.revision,
91
+ });
92
+ await client.connect(new McpWireCopyTransport(pair[0]));
93
+ return client;
94
+ }
95
+ }
96
+ exports.WpMcpInMemory = WpMcpInMemory;
97
+ //# sourceMappingURL=WpMcpInMemory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WpMcpInMemory.js","sourceRoot":"","sources":["../../../../../packages/http/mcp-server/src/WpMcpInMemory.ts"],"names":[],"mappings":";;;AAAA,yDAAsD;AACtD,yDAKsC;AACtC,oDAA8D;AAC9D,0DAAyD;AACzD,6CAA0D;AAG1D,4GAA4G;AAC5G,MAAM,kBAAkB,GAAG,IAAI,eAAe,EAAE,CAAC,MAAM,CAAC;AAExD;;;;;;GAMG;AACH,MAAM,oBAAoB;IACO;IAA7B,YAA6B,KAAwB;QAAxB,UAAK,GAAL,KAAK,CAAmB;IAAG,CAAC;IAEzD,IAAI,SAAS;QACT,OAAO,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC;IAChC,CAAC;IAED,IAAI,SAAS,CAAC,OAA+B;QACzC,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,OAAO,CAAC;IACnC,CAAC;IAED,IAAI,OAAO;QACP,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC;IAC9B,CAAC;IAED,IAAI,OAAO,CAAC,OAA6B;QACrC,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,OAAO,CAAC;IACjC,CAAC;IAED,IAAI,OAAO;QACP,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC;IAC9B,CAAC;IAED,IAAI,OAAO,CAAC,OAA6B;QACrC,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,OAAO,CAAC;IACjC,CAAC;IAED,KAAK;QACD,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;IAC9B,CAAC;IAED,KAAK;QACD,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;IAC9B,CAAC;IAED,IAAI,CAAC,OAAuB,EAAE,OAA8B;QACxD,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAmB,EAAE,OAAO,CAAC,CAAC;IAC3F,CAAC;CACJ;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAa,aAAa;IACtB,mGAAmG;IACnG,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,OAAmB;QACpC,MAAM,IAAI,GAAG,0BAAiB,CAAC,gBAAgB,EAAE,CAAC;QAClD,MAAM,MAAM,GAAG,OAAO,CAAC,eAAe,CAClC,EAAE,IAAI,EAAE,uBAAuB,EAAE,OAAO,EAAE,OAAO,CAAC,QAAQ,EAAE,EAC5D,EAAE,YAAY,EAAE,EAAE,KAAK,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,EAAE,EAAE,EAClD,KAAK,IAA6B,EAAE;YAChC,IAAI,CAAC,6BAAc,CAAC,QAAQ,EAAE,EAAE,CAAC;gBAC7B,MAAM,IAAI,kCAAsB,CAC5B,sFAAsF;oBAClF,oEAAoE,CAC3E,CAAC;YACN,CAAC;YACD,OAAO,IAAI,2BAAc,CAAC,MAAM,OAAO,CAAC,eAAe,EAAE,EAAE,kBAAkB,CAAC,CAAC;QACnF,CAAC,EACD,CAAC,IAAY,EAAiC,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAC/E,CAAC;QACF,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACxD,MAAM,MAAM,GAAG,IAAI,eAAM,CAAC;YACtB,IAAI,EAAE,4BAA4B;YAClC,OAAO,EAAE,OAAO,CAAC,QAAQ;SAC5B,CAAC,CAAC;QACH,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACxD,OAAO,MAAM,CAAC;IAClB,CAAC;CACJ;AA1BD,sCA0BC","sourcesContent":["import { Client } from '@modelcontextprotocol/client';\nimport {\n InMemoryTransport,\n JSONRPCMessage,\n Transport,\n TransportSendOptions,\n} from '@modelcontextprotocol/server';\nimport { ApiImplementationError } from '@webpieces/core-util';\nimport { RequestContext } from '@webpieces/core-context';\nimport { McpToolRequest, McpToolset } from './McpToolset';\nimport { RegisteredMcpTool } from './McpToolRegistry';\n\n/** In-memory MCP has no socket to lose, so its disconnect signal never fires; cancellation is the SDK's. */\nconst NEVER_DISCONNECTED = new AbortController().signal;\n\n/**\n * One end of the SDK's linked in-memory pair, sending each message as the WIRE would: a JSON copy.\n * The SDK pair hands the receiver the sender's object itself, so a class-typed schema or a caller's\n * DTO instance would reach the other side by reference and fail the SDK's plain-JSON validation (or\n * be mutated across the boundary). The copy is made synchronously inside `send`, so the receiver\n * still runs in the SENDER's async context — which is what lets the toolset read the caller per request.\n */\nclass McpWireCopyTransport implements Transport {\n constructor(private readonly inner: InMemoryTransport) {}\n\n get onmessage(): Transport['onmessage'] {\n return this.inner.onmessage;\n }\n\n set onmessage(handler: Transport['onmessage']) {\n this.inner.onmessage = handler;\n }\n\n get onclose(): Transport['onclose'] {\n return this.inner.onclose;\n }\n\n set onclose(handler: Transport['onclose']) {\n this.inner.onclose = handler;\n }\n\n get onerror(): Transport['onerror'] {\n return this.inner.onerror;\n }\n\n set onerror(handler: Transport['onerror']) {\n this.inner.onerror = handler;\n }\n\n start(): Promise<void> {\n return this.inner.start();\n }\n\n close(): Promise<void> {\n return this.inner.close();\n }\n\n send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {\n return this.inner.send(JSON.parse(JSON.stringify(message)) as JSONRPCMessage, options);\n }\n}\n\n/**\n * The in-memory transport over an {@link McpToolset}: an official MCP SDK `Client` wired to the\n * toolset's server through the SDK's linked `InMemoryTransport` pair, for an agent that runs its model\n * loop in-process and already holds a verified caller in its `RequestContext`.\n *\n * ```ts\n * const client: Client = await WpMcpInMemory.connect(toolset); // once per process\n * // ...inside a request whose caller AuthFilter already verified:\n * const { tools } = await client.listTools(); // only what THIS caller may see\n * await client.callTool({ name: 'stores_pause', arguments: { storeId } });\n * ```\n *\n * NO CREDENTIAL. The caller is whoever is trusted in the `RequestContext` of the code calling\n * `client.listTools()` / `client.callTool()`, READ PER REQUEST and NEVER captured at connect. Apps cache\n * one client per process, so a connect-time capture would run user B's call as user A. The SDK's\n * in-memory transport delivers each message synchronously inside the caller's own `send`, so the\n * toolset's handlers run in the caller's async context; a call made outside any `RequestContext` is\n * refused rather than run as nobody.\n *\n * The caller's scope is the one the tool runs in: a local binding's `InvocationAuthentication` needs\n * that scope to have completed verified ingress (it did, if an endpoint's `AuthFilter` admitted the\n * request). That is also why `McpToolset` refuses a SERVICE_ONLY tool: the caller's own service hop\n * would satisfy it for every model behind it. No tools/list cache hint is sent, so one shared client\n * never serves user A's list to user B.\n */\nexport class WpMcpInMemory {\n // webpieces-disable no-function-outside-class -- explicit public factory of a connected SDK client\n static async connect(toolset: McpToolset): Promise<Client> {\n const pair = InMemoryTransport.createLinkedPair();\n const server = toolset.createSdkServer(\n { name: 'webpieces-mcp-toolset', version: toolset.revision },\n { capabilities: { tools: { listChanged: true } } },\n async (): Promise<McpToolRequest> => {\n if (!RequestContext.isActive()) {\n throw new ApiImplementationError(\n 'WpMcpInMemory client calls must run inside the RequestContext of a verified caller; ' +\n 'the in-memory transport reads the caller from it on every request.',\n );\n }\n return new McpToolRequest(await toolset.authorizedTools(), NEVER_DISCONNECTED);\n },\n (name: string): RegisteredMcpTool | undefined => toolset.registry.find(name),\n );\n await server.connect(new McpWireCopyTransport(pair[1]));\n const client = new Client({\n name: 'webpieces-in-memory-client',\n version: toolset.revision,\n });\n await client.connect(new McpWireCopyTransport(pair[0]));\n return client;\n }\n}\n"]}
@@ -0,0 +1,23 @@
1
+ import { Client } from '@modelcontextprotocol/client';
2
+ import { OidcAuthMethod } from '@webpieces/core-util';
3
+ /** What mints an OIDC ID token for a callee: `GcpOidc` from `@webpieces/gcp-identity` (or a test double). */
4
+ export interface McpIdTokenMinter {
5
+ mintIdToken(audience: string): Promise<string>;
6
+ }
7
+ /**
8
+ * The client for the HTTP transport's internal door: an official MCP SDK `Client` that calls another
9
+ * service's `WpMcpServer` whose `setAuth([...])` admits `oidcSameProject()` / `oidcSvcs([...])`.
10
+ *
11
+ * ```ts
12
+ * const client = await WpMcpRemote.connect('https://svc-b.example.com/mcp/user', oidcSameProject(), gcpOidc);
13
+ * ```
14
+ *
15
+ * PER POST, never at connect: each request mints its own OIDC ID token (audience = the callee origin)
16
+ * and carries the CURRENT `RequestContext`'s trusted keys, so the callee's `@WpAuthorization` judges the
17
+ * user on whose behalf THIS call is made. Apps cache one client per process, and a connect-time capture
18
+ * would run user B's call as user A — the same reason the in-memory transport reads the caller per
19
+ * request. No response is cached, for the same reason.
20
+ */
21
+ export declare class WpMcpRemote {
22
+ static connect(url: string, auth: OidcAuthMethod, minter: McpIdTokenMinter): Promise<Client>;
23
+ }
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WpMcpRemote = void 0;
4
+ const client_1 = require("@modelcontextprotocol/client");
5
+ const core_util_1 = require("@webpieces/core-util");
6
+ const core_context_1 = require("@webpieces/core-context");
7
+ /**
8
+ * A response cache that stores nothing. A remote MCP server answers tools/list per CALLER (its
9
+ * `@WpAuthorization` projection) with a `private` cache hint, and one cached client serves many
10
+ * callers, so a cached list would hand user A's tools to user B. Every request goes to the server.
11
+ */
12
+ class McpNoResponseCache {
13
+ get(_key) {
14
+ return undefined;
15
+ }
16
+ set(_key, _entry) {
17
+ return 0;
18
+ }
19
+ delete(_key) {
20
+ return;
21
+ }
22
+ evict(_method) {
23
+ return;
24
+ }
25
+ clear() {
26
+ return;
27
+ }
28
+ }
29
+ /**
30
+ * Per-POST credentials and context for one remote MCP endpoint: mints a fresh OIDC ID token for the
31
+ * callee's origin (exactly as `http-client-node`'s `OutboundAuthFilter` does) and forwards the CURRENT
32
+ * `RequestContext`'s transferable keys the way every OIDC hop does — trusted ones included, because an
33
+ * OIDC destination verifies its caller (`DestinationTrust.forAuthMode`).
34
+ */
35
+ class McpOidcPost {
36
+ audience;
37
+ minter;
38
+ headers = new core_context_1.RequestContextHeaders();
39
+ trust;
40
+ constructor(audience, auth, minter) {
41
+ this.audience = audience;
42
+ this.minter = minter;
43
+ this.trust = core_util_1.DestinationTrust.forAuthMode(auth);
44
+ }
45
+ async send(url, init) {
46
+ const headers = new Headers(init?.headers);
47
+ if (core_context_1.RequestContext.isActive()) {
48
+ for (const entry of this.headers.buildOutboundHeaders(this.trust)) {
49
+ headers.set(entry[0], entry[1]);
50
+ }
51
+ }
52
+ headers.set('Authorization', `Bearer ${await this.minter.mintIdToken(this.audience)}`);
53
+ // webpieces-disable no-fetch -- the MCP SDK's StreamableHTTPClientTransport is the protocol client; this is only the fetch it is handed, decorated with per-POST OIDC and context headers
54
+ return fetch(url, { ...init, headers });
55
+ }
56
+ }
57
+ /**
58
+ * The client for the HTTP transport's internal door: an official MCP SDK `Client` that calls another
59
+ * service's `WpMcpServer` whose `setAuth([...])` admits `oidcSameProject()` / `oidcSvcs([...])`.
60
+ *
61
+ * ```ts
62
+ * const client = await WpMcpRemote.connect('https://svc-b.example.com/mcp/user', oidcSameProject(), gcpOidc);
63
+ * ```
64
+ *
65
+ * PER POST, never at connect: each request mints its own OIDC ID token (audience = the callee origin)
66
+ * and carries the CURRENT `RequestContext`'s trusted keys, so the callee's `@WpAuthorization` judges the
67
+ * user on whose behalf THIS call is made. Apps cache one client per process, and a connect-time capture
68
+ * would run user B's call as user A — the same reason the in-memory transport reads the caller per
69
+ * request. No response is cached, for the same reason.
70
+ */
71
+ class WpMcpRemote {
72
+ // webpieces-disable no-function-outside-class -- explicit public factory of a connected SDK client
73
+ static async connect(url, auth, minter) {
74
+ if (!URL.canParse(url))
75
+ throw new Error(`WpMcpRemote.connect(...) requires an absolute URL, got '${url}'.`);
76
+ if (auth?.kind !== 'oidc') {
77
+ throw new Error('WpMcpRemote.connect(...) authenticates with oidcSameProject() or oidcSvcs([...]).');
78
+ }
79
+ const endpoint = new URL(url);
80
+ const post = new McpOidcPost(endpoint.origin, auth, minter);
81
+ const send = (target, init) => post.send(target, init);
82
+ const transport = new client_1.StreamableHTTPClientTransport(endpoint, { fetch: send });
83
+ const client = new client_1.Client({ name: 'webpieces-remote-client', version: '1.0.0' }, { responseCacheStore: new McpNoResponseCache() });
84
+ await client.connect(transport);
85
+ return client;
86
+ }
87
+ }
88
+ exports.WpMcpRemote = WpMcpRemote;
89
+ //# sourceMappingURL=WpMcpRemote.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WpMcpRemote.js","sourceRoot":"","sources":["../../../../../packages/http/mcp-server/src/WpMcpRemote.ts"],"names":[],"mappings":";;;AAAA,yDAOsC;AACtC,oDAAwE;AACxE,0DAAgF;AAOhF;;;;GAIG;AACH,MAAM,kBAAkB;IACpB,GAAG,CAAC,IAAc;QACd,OAAO,SAAS,CAAC;IACrB,CAAC;IAED,GAAG,CAAC,IAAc,EAAE,MAAc;QAC9B,OAAO,CAAC,CAAC;IACb,CAAC;IAED,MAAM,CAAC,IAAc;QACjB,OAAO;IACX,CAAC;IAED,KAAK,CAAC,OAAe;QACjB,OAAO;IACX,CAAC;IAED,KAAK;QACD,OAAO;IACX,CAAC;CACJ;AAED;;;;;GAKG;AACH,MAAM,WAAW;IAKQ;IAEA;IANJ,OAAO,GAAG,IAAI,oCAAqB,EAAE,CAAC;IACtC,KAAK,CAAmB;IAEzC,YACqB,QAAgB,EACjC,IAAoB,EACH,MAAwB;QAFxB,aAAQ,GAAR,QAAQ,CAAQ;QAEhB,WAAM,GAAN,MAAM,CAAkB;QAEzC,IAAI,CAAC,KAAK,GAAG,4BAAgB,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;IACpD,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,GAAiB,EAAE,IAAkB;QAC5C,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAC3C,IAAI,6BAAc,CAAC,QAAQ,EAAE,EAAE,CAAC;YAC5B,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,OAAO,CAAC,oBAAoB,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;gBAChE,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;YACpC,CAAC;QACL,CAAC;QACD,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,UAAU,MAAM,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;QACvF,0LAA0L;QAC1L,OAAO,KAAK,CAAC,GAAG,EAAE,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;IAC5C,CAAC;CACJ;AAED;;;;;;;;;;;;;GAaG;AACH,MAAa,WAAW;IACpB,mGAAmG;IACnG,MAAM,CAAC,KAAK,CAAC,OAAO,CAChB,GAAW,EACX,IAAoB,EACpB,MAAwB;QAExB,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC;YAClB,MAAM,IAAI,KAAK,CAAC,2DAA2D,GAAG,IAAI,CAAC,CAAC;QACxF,IAAI,IAAI,EAAE,IAAI,KAAK,MAAM,EAAE,CAAC;YACxB,MAAM,IAAI,KAAK,CACX,mFAAmF,CACtF,CAAC;QACN,CAAC;QACD,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QAC9B,MAAM,IAAI,GAAG,IAAI,WAAW,CAAC,QAAQ,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;QAC5D,MAAM,IAAI,GAAc,CAAC,MAAoB,EAAE,IAAkB,EAAqB,EAAE,CACpF,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAC5B,MAAM,SAAS,GAAG,IAAI,sCAA6B,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/E,MAAM,MAAM,GAAG,IAAI,eAAM,CACrB,EAAE,IAAI,EAAE,yBAAyB,EAAE,OAAO,EAAE,OAAO,EAAE,EACrD,EAAE,kBAAkB,EAAE,IAAI,kBAAkB,EAAE,EAAE,CACnD,CAAC;QACF,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QAChC,OAAO,MAAM,CAAC;IAClB,CAAC;CACJ;AA1BD,kCA0BC","sourcesContent":["import {\n CacheEntry,\n CacheKey,\n Client,\n FetchLike,\n ResponseCacheStore,\n StreamableHTTPClientTransport,\n} from '@modelcontextprotocol/client';\nimport { DestinationTrust, OidcAuthMethod } from '@webpieces/core-util';\nimport { RequestContext, RequestContextHeaders } from '@webpieces/core-context';\n\n/** What mints an OIDC ID token for a callee: `GcpOidc` from `@webpieces/gcp-identity` (or a test double). */\nexport interface McpIdTokenMinter {\n mintIdToken(audience: string): Promise<string>;\n}\n\n/**\n * A response cache that stores nothing. A remote MCP server answers tools/list per CALLER (its\n * `@WpAuthorization` projection) with a `private` cache hint, and one cached client serves many\n * callers, so a cached list would hand user A's tools to user B. Every request goes to the server.\n */\nclass McpNoResponseCache implements ResponseCacheStore {\n get(_key: CacheKey): CacheEntry | undefined {\n return undefined;\n }\n\n set(_key: CacheKey, _entry: object): number {\n return 0;\n }\n\n delete(_key: CacheKey): void {\n return;\n }\n\n evict(_method: string): void {\n return;\n }\n\n clear(): void {\n return;\n }\n}\n\n/**\n * Per-POST credentials and context for one remote MCP endpoint: mints a fresh OIDC ID token for the\n * callee's origin (exactly as `http-client-node`'s `OutboundAuthFilter` does) and forwards the CURRENT\n * `RequestContext`'s transferable keys the way every OIDC hop does — trusted ones included, because an\n * OIDC destination verifies its caller (`DestinationTrust.forAuthMode`).\n */\nclass McpOidcPost {\n private readonly headers = new RequestContextHeaders();\n private readonly trust: DestinationTrust;\n\n constructor(\n private readonly audience: string,\n auth: OidcAuthMethod,\n private readonly minter: McpIdTokenMinter,\n ) {\n this.trust = DestinationTrust.forAuthMode(auth);\n }\n\n async send(url: string | URL, init?: RequestInit): Promise<Response> {\n const headers = new Headers(init?.headers);\n if (RequestContext.isActive()) {\n for (const entry of this.headers.buildOutboundHeaders(this.trust)) {\n headers.set(entry[0], entry[1]);\n }\n }\n headers.set('Authorization', `Bearer ${await this.minter.mintIdToken(this.audience)}`);\n // webpieces-disable no-fetch -- the MCP SDK's StreamableHTTPClientTransport is the protocol client; this is only the fetch it is handed, decorated with per-POST OIDC and context headers\n return fetch(url, { ...init, headers });\n }\n}\n\n/**\n * The client for the HTTP transport's internal door: an official MCP SDK `Client` that calls another\n * service's `WpMcpServer` whose `setAuth([...])` admits `oidcSameProject()` / `oidcSvcs([...])`.\n *\n * ```ts\n * const client = await WpMcpRemote.connect('https://svc-b.example.com/mcp/user', oidcSameProject(), gcpOidc);\n * ```\n *\n * PER POST, never at connect: each request mints its own OIDC ID token (audience = the callee origin)\n * and carries the CURRENT `RequestContext`'s trusted keys, so the callee's `@WpAuthorization` judges the\n * user on whose behalf THIS call is made. Apps cache one client per process, and a connect-time capture\n * would run user B's call as user A — the same reason the in-memory transport reads the caller per\n * request. No response is cached, for the same reason.\n */\nexport class WpMcpRemote {\n // webpieces-disable no-function-outside-class -- explicit public factory of a connected SDK client\n static async connect(\n url: string,\n auth: OidcAuthMethod,\n minter: McpIdTokenMinter,\n ): Promise<Client> {\n if (!URL.canParse(url))\n throw new Error(`WpMcpRemote.connect(...) requires an absolute URL, got '${url}'.`);\n if (auth?.kind !== 'oidc') {\n throw new Error(\n 'WpMcpRemote.connect(...) authenticates with oidcSameProject() or oidcSvcs([...]).',\n );\n }\n const endpoint = new URL(url);\n const post = new McpOidcPost(endpoint.origin, auth, minter);\n const send: FetchLike = (target: string | URL, init?: RequestInit): Promise<Response> =>\n post.send(target, init);\n const transport = new StreamableHTTPClientTransport(endpoint, { fetch: send });\n const client = new Client(\n { name: 'webpieces-remote-client', version: '1.0.0' },\n { responseCacheStore: new McpNoResponseCache() },\n );\n await client.connect(transport);\n return client;\n }\n}\n"]}
@@ -1,67 +1,61 @@
1
1
  import { Express } from 'express';
2
2
  import { McpProtectedResourceMetadata, WpMcpServerConfig } from './McpAuth';
3
3
  import { McpBindOptions } from './McpBindOptions';
4
+ import { McpToolset } from './McpToolset';
4
5
  /**
5
- * MCP adapter around the official SDK's request-scoped HTTP handler, serving BOTH wire eras from
6
- * one tool registry: modern (2026-07-28, per-request `_meta` envelope) and legacy (the 2025-11-25
7
- * family, negotiated by `initialize`). The SDK routes each request to the leg its shape selects and
8
- * negotiates the revision; only a revision no era knows is refused. Pinning one era instead made
9
- * every shipping client unreachable (issue #969), and the MCP lifecycle spec is explicit that a
10
- * server answers with a revision it supports rather than erroring.
6
+ * The HTTP transport over an {@link McpToolset}: one server = one path = one toolset. `/mcp/user` and
7
+ * `/mcp/admin` are two servers over two toolsets with two profile sets — paths split by WHICH TOOLS,
8
+ * never by HOW THE CALLER AUTHENTICATES.
11
9
  *
12
- * Error boundaries: HTTP ingress owns pre-SDK replies; one shared SDK callback wrapper owns
13
- * protocol/list/name-resolution errors and authorized tool-result errors. The SDK writes callback
14
- * replies, so it cannot safely delegate those failures back to HTTP ingress.
15
- * `subscriptions/listen` is served entirely by the SDK's listen router, so Webpieces has no handler
16
- * (and no catch) there. The external bearer is verified exactly once per POST, at the HTTP
17
- * boundary, before the SDK is involved.
10
+ * HOW the caller authenticates is `WpMcpServerConfig.setAuth([...])`: an OR-set of the same `@WpAuth`
11
+ * builders endpoints use plus `mcpOAuth(...)`, classified from unverified surface facts and verified by
12
+ * exactly ONE verifier ({@link McpDoorAuthenticator}). That establishes the trusted `RequestContext` —
13
+ * the caller, surface `llm`, and (only from an OIDC / shared-secret hop) the forwarded trusted context —
14
+ * and the toolset does the rest exactly as it does for the in-memory transport.
15
+ *
16
+ * Serves BOTH wire eras from one registry: modern (2026-07-28, per-request `_meta` envelope) and legacy
17
+ * (the 2025-11-25 family, negotiated by `initialize`). The SDK routes each request to the leg its shape
18
+ * selects and negotiates the revision; only a revision no era knows is refused. Pinning one era
19
+ * instead made every shipping client unreachable (issue #969).
18
20
  *
19
- * Before tools/list and tools/call hand an IMPLEMENTATION-kind failure to the translator, they
20
- * `log.error` it with its stack and the requestId ({@link logImplementationFailure}, #1055). The
21
- * translator reduces such a failure to "Internal Error" for the caller, so that line is the only place
22
- * its cause exists.
21
+ * Error boundaries: HTTP ingress owns pre-SDK replies; the toolset's one SDK callback wrapper owns
22
+ * protocol/list/name-resolution errors and authorized tool-result errors. The SDK writes callback
23
+ * replies, so it cannot safely delegate those failures back to HTTP ingress. `subscriptions/listen` is
24
+ * served entirely by the SDK's listen router. The credential is verified exactly once per POST, at the
25
+ * HTTP boundary, before the SDK is involved.
23
26
  */
24
- export declare class WpMcpServer<TGrant> {
27
+ export declare class WpMcpServer {
25
28
  private readonly config;
29
+ private readonly toolset;
26
30
  private readonly callerContext;
27
- private readonly dispatcher;
28
- /** Classifies a failure exactly as the translator will render it, so the two cannot disagree. */
29
- private readonly boundary;
30
- private readonly outputSchemas;
31
- /**
32
- * These edges have NO filter chain above them — `LogApiFilter` never sees a rejected bearer, a
33
- * bad `Origin`, a body express could not parse, or a `tools/list` the SDK renders itself.
34
- * Wrapping each in `LogApiCall` is this repo's one line on logging, and it is why
35
- * `ApiErrorBoundary` no longer writes a second, barer line of its own.
36
- */
37
- private readonly logApiCall;
38
31
  private readonly responseWriter;
39
- /** tools/call reads `McpRegistry`; every other reply stays framework-owned. See McpErrorTranslator. */
40
32
  private readonly translator;
41
- private registry?;
42
- private toolProfiles;
33
+ private door?;
43
34
  private handler?;
44
35
  private streamingHandler?;
45
36
  private nodeHandler?;
46
37
  private streamingNodeHandler?;
47
- private revision?;
48
38
  private icons?;
49
- constructor(config: WpMcpServerConfig<TGrant>);
39
+ constructor(config: WpMcpServerConfig, toolset: McpToolset);
50
40
  /**
51
41
  * Mounts this MCP endpoint on `app`: POST at `options.endpointPath`, plus a 405 (`Allow: POST`)
52
42
  * for every other method there.
53
43
  *
54
- * SINGLE CANONICAL ORIGIN: one server serves exactly one protected resource, so this may be
55
- * called only once and `WpMcpServerConfig.setResource(...)`'s path must equal `endpointPath`.
56
- * That resource URI is fixed for the lifetime of the process and is never derived from the
57
- * request `Host` — a host-derived audience would make the boundary's own audience check compare
58
- * two caller-controlled values, which is the confused deputy this check exists to prevent.
59
- * Serving a second hostname is a second deployment, not a second audience.
44
+ * SINGLE CANONICAL ORIGIN: one server serves exactly one resource, so this may be called only once
45
+ * and `WpMcpServerConfig.setResource(...)`'s path must equal `endpointPath`. That resource URI is
46
+ * fixed for the lifetime of the process and is never derived from the request `Host` — a
47
+ * host-derived audience would make the boundary's own audience check compare two caller-controlled
48
+ * values, which is the confused deputy this check exists to prevent.
60
49
  *
61
- * The OAuth/discovery half stays app-owned: publish `protectedResourceMetadata()` at
62
- * `WpMcpServerConfig.resourceMetadataUrl`, which is where the 401 challenge sends clients.
50
+ * With `mcpOAuth(...)` in the set, the discovery half stays app-owned: publish
51
+ * {@link protectedResourceMetadata} at `WpMcpServerConfig.resourceMetadataUrl`, which is where the
52
+ * 401 challenge sends clients.
63
53
  */
64
54
  bind(app: Express, options: McpBindOptions): void;
55
+ /**
56
+ * RFC 9728 metadata for this resource. Only an `mcpOAuth(...)` server has any: it is what a
57
+ * connector reads at `WpMcpServerConfig.resourceMetadataUrl` to find the authorization server.
58
+ */
65
59
  protectedResourceMetadata(): McpProtectedResourceMetadata;
66
60
  /** Gracefully closes active listen/request streams; clients must reconnect and refresh lists. */
67
61
  close(): Promise<void>;
@@ -81,50 +75,25 @@ export declare class WpMcpServer<TGrant> {
81
75
  * stream.
82
76
  */
83
77
  private writeBoundaryError;
84
- /** Run one un-filtered edge step under `LogApiCall`, so its failure gets exactly one log line. */
85
- private logEdge;
86
78
  private serveExpress;
87
- /** Verifies the external bearer once for this POST. Handlers never re-verify it. */
88
- private authenticate;
89
- private disconnectSignal;
90
- private buildSdkServer;
91
- /** ONE SDK callback catch implementation; the SDK intercepts foreign throws itself. */
92
- private sdkBoundary;
93
- private listTools;
94
- private toolDefinition;
95
- /**
96
- * SDK tool callback: an unknown or hidden tool is a JSON-RPC -32602; every failure after the tool is found
97
- * is an `isError: true` result, identically for local and remote bindings.
98
- */
99
- private handleCallTool;
100
79
  /**
101
- * ONE `log.error` line, with the stack and the requestId, for a failure that is THIS server's bug
102
- * — the `implementation` kind, which the translator reduces to "Internal Error" and a requestId
103
- * for the caller (#1055).
80
+ * Verifies the caller's credential once for this POST — classify, then exactly ONE verifier — and
81
+ * establishes the trusted context the toolset reads. Handlers never re-verify it.
104
82
  *
105
- * `LogApiCall` does not cover this. An MCP output-schema violation is raised AFTER the controller
106
- * returned, so the only `LogApiCall` line for that call reads `resp-SUCCESS`; before this line
107
- * existed, the requestId support was handed led to a log with no error in it. Where a
108
- * `[API-*-resp-FAIL]` line does exist it carries the error's message, not its stack. So this line
109
- * is not the bare duplicate `ApiErrorBoundary` stopped writing: it is the one that says WHERE.
110
- *
111
- * Every other kind (bad-request, end-user, dependency, ...) is a caller's or a downstream's
112
- * failure, published to the caller with its own text, and `LogApiCall` already records it.
83
+ * Surface `llm` is stamped BEFORE reconciliation, so on a caller-NOT-verified mode (jwt, apiKey,
84
+ * mcpOAuth) a client that also SENDS `x-wp-surface` must say `llm` or be refused; and again AFTER,
85
+ * because an OIDC / shared-secret hop admits its forwarded context wholesale, and the model on the
86
+ * other end of an MCP door is always the surface.
113
87
  */
114
- private logImplementationFailure;
115
- /** Everything a tool call does. No catch in here: failures propagate to handleCallTool. */
116
- private callTool;
117
- private validateCredential;
88
+ private authenticate;
89
+ private disconnectSignal;
90
+ private buildSdkServer;
118
91
  private authInfo;
119
- private progressReporter;
120
- private progressParams;
121
92
  /** What the raw POST body tells us before the SDK has parsed anything: the id, and the tool. */
122
93
  private correlationOf;
123
94
  private challenge;
124
95
  private bodyObject;
125
96
  private hasProgressToken;
126
97
  private toHttpRequest;
127
- private calculateRegistryRevision;
128
- private requireRegistry;
129
- private record;
98
+ private requireDoor;
130
99
  }