@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.
- package/README.md +199 -42
- package/package.json +7 -6
- package/src/McpApiDispatcher.d.ts +6 -3
- package/src/McpApiDispatcher.js +6 -2
- package/src/McpApiDispatcher.js.map +1 -1
- package/src/McpAuth.d.ts +115 -56
- package/src/McpAuth.js +180 -111
- package/src/McpAuth.js.map +1 -1
- package/src/McpAuthorityCompileAssertions.d.ts +12 -2
- package/src/McpAuthorityCompileAssertions.js +32 -4
- package/src/McpAuthorityCompileAssertions.js.map +1 -1
- package/src/McpBindOptions.d.ts +5 -20
- package/src/McpBindOptions.js +5 -15
- package/src/McpBindOptions.js.map +1 -1
- package/src/McpClientSet.d.ts +35 -0
- package/src/McpClientSet.js +86 -0
- package/src/McpClientSet.js.map +1 -0
- package/src/McpDoorAuth.d.ts +117 -0
- package/src/McpDoorAuth.js +455 -0
- package/src/McpDoorAuth.js.map +1 -0
- package/src/McpToolCallRendering.d.ts +5 -4
- package/src/McpToolCallRendering.js.map +1 -1
- package/src/McpToolCatalog.d.ts +1 -1
- package/src/McpToolCatalog.js +1 -1
- package/src/McpToolCatalog.js.map +1 -1
- package/src/McpToolRegistry.js +8 -2
- package/src/McpToolRegistry.js.map +1 -1
- package/src/McpToolset.d.ts +115 -0
- package/src/McpToolset.js +323 -0
- package/src/McpToolset.js.map +1 -0
- package/src/WpMcpErrorTranslator.d.ts +5 -7
- package/src/WpMcpErrorTranslator.js +7 -13
- package/src/WpMcpErrorTranslator.js.map +1 -1
- package/src/WpMcpInMemory.d.ts +30 -0
- package/src/WpMcpInMemory.js +97 -0
- package/src/WpMcpInMemory.js.map +1 -0
- package/src/WpMcpRemote.d.ts +23 -0
- package/src/WpMcpRemote.js +89 -0
- package/src/WpMcpRemote.js.map +1 -0
- package/src/WpMcpServer.d.ts +45 -76
- package/src/WpMcpServer.js +101 -327
- package/src/WpMcpServer.js.map +1 -1
- package/src/__tests__/McpTransportFixtures.d.ts +107 -0
- package/src/__tests__/McpTransportFixtures.js +266 -0
- package/src/__tests__/McpTransportFixtures.js.map +1 -0
- package/src/index.d.ts +9 -2
- package/src/index.js +17 -1
- 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"]}
|
package/src/WpMcpServer.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
*
|
|
62
|
-
* `WpMcpServerConfig.resourceMetadataUrl`, which is where the
|
|
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
|
-
*
|
|
102
|
-
*
|
|
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
|
-
* `
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
|
115
|
-
|
|
116
|
-
private
|
|
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
|
|
128
|
-
private requireRegistry;
|
|
129
|
-
private record;
|
|
98
|
+
private requireDoor;
|
|
130
99
|
}
|