@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
package/src/WpMcpServer.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.WpMcpServer = void 0;
|
|
4
|
-
const McpToolPolicy_1 = require("./McpToolPolicy");
|
|
5
|
-
const node_crypto_1 = require("node:crypto");
|
|
6
4
|
const server_1 = require("@modelcontextprotocol/server");
|
|
7
5
|
const node_1 = require("@modelcontextprotocol/node");
|
|
8
6
|
const express_1 = require("express");
|
|
@@ -11,153 +9,88 @@ const core_context_1 = require("@webpieces/core-context");
|
|
|
11
9
|
const http_server_1 = require("@webpieces/http-server");
|
|
12
10
|
const http_routing_1 = require("@webpieces/http-routing");
|
|
13
11
|
const McpAuth_1 = require("./McpAuth");
|
|
14
|
-
const
|
|
15
|
-
const core_util_2 = require("@webpieces/core-util");
|
|
16
|
-
const McpToolRegistry_1 = require("./McpToolRegistry");
|
|
12
|
+
const McpDoorAuth_1 = require("./McpDoorAuth");
|
|
17
13
|
const McpToolCallRendering_1 = require("./McpToolCallRendering");
|
|
18
14
|
const McpProtocol_1 = require("./McpProtocol");
|
|
15
|
+
const McpToolset_1 = require("./McpToolset");
|
|
19
16
|
const WpMcpErrorTranslator_1 = require("./WpMcpErrorTranslator");
|
|
20
17
|
const log = core_util_1.LogManager.getLogger('WpMcpServer');
|
|
21
|
-
/**
|
|
22
|
-
* The request DTO the edge `LogApiCall` lines report. These boundaries run BEFORE (or outside) any
|
|
23
|
-
* API dispatch, so there is no contract DTO to log; the step name is the fact worth having.
|
|
24
|
-
*/
|
|
25
|
-
class McpSdkOperation {
|
|
26
|
-
name;
|
|
27
|
-
toolStage = false;
|
|
28
|
-
constructor(name) {
|
|
29
|
-
this.name = name;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
class McpEdgeRequest {
|
|
33
|
-
step;
|
|
34
|
-
constructor(step) {
|
|
35
|
-
this.step = step;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
/**
|
|
39
|
-
* `Tool.annotations` exactly as tools/list publishes it: the four hints PLUS the human-readable
|
|
40
|
-
* `title` (#1180). The Claude Connectors Directory checks `annotations.title`; clients on the
|
|
41
|
-
* 2025-06-18+ spec render the top-level `Tool.title`, so both carry the one title — the method's
|
|
42
|
-
* `@summary` JSDoc tag, read from the generated catalog (#1296).
|
|
43
|
-
*/
|
|
44
|
-
class McpPublishedToolAnnotations {
|
|
45
|
-
title;
|
|
46
|
-
readOnlyHint;
|
|
47
|
-
destructiveHint;
|
|
48
|
-
idempotentHint;
|
|
49
|
-
openWorldHint;
|
|
50
|
-
constructor(tool) {
|
|
51
|
-
this.title = tool.title;
|
|
52
|
-
this.readOnlyHint = tool.annotations.readOnlyHint;
|
|
53
|
-
this.destructiveHint = tool.annotations.destructiveHint;
|
|
54
|
-
this.idempotentHint = tool.annotations.idempotentHint;
|
|
55
|
-
this.openWorldHint = tool.annotations.openWorldHint;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
18
|
/** Body of the 405 answered for every non-POST method at the bound MCP endpoint path. */
|
|
59
19
|
class McpMethodNotAllowedBody {
|
|
60
20
|
error = 'method_not_allowed';
|
|
61
21
|
}
|
|
22
|
+
/** Per-POST state the response-owning ingress needs after `work` failed. */
|
|
23
|
+
class McpPostState {
|
|
24
|
+
/** Whether a 401 on this POST carries the `mcpOAuth(...)` discovery challenge. */
|
|
25
|
+
challenge = false;
|
|
26
|
+
}
|
|
62
27
|
/** Facts established once per POST at the bind boundary and handed to the SDK handlers. */
|
|
63
28
|
class McpPostAuthentication {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
this.accessToken = accessToken;
|
|
70
|
-
this.credential = credential;
|
|
71
|
-
this.disconnectSignal = disconnectSignal;
|
|
72
|
-
this.tools = tools;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* The official SDK server with Webpieces-owned tools/list and tools/call handlers. It answers
|
|
77
|
-
* `toolInputSchemaJson` from the request-authorized view so the SDK's pre-dispatch SEP-2243
|
|
78
|
-
* `Mcp-Param-*` header validation still runs without registering SDK-side tool callbacks.
|
|
79
|
-
*/
|
|
80
|
-
class WpSdkMcpServer extends server_1.McpServer {
|
|
81
|
-
registry;
|
|
82
|
-
constructor(info, options, registry) {
|
|
83
|
-
super(info, options);
|
|
84
|
-
this.registry = registry;
|
|
85
|
-
}
|
|
86
|
-
// webpieces-disable no-any-unknown -- signature is fixed by the SDK's JSON Schema record type
|
|
87
|
-
toolInputSchemaJson(name) {
|
|
88
|
-
return this.registry.find(name)?.inputSchema;
|
|
29
|
+
pass;
|
|
30
|
+
request;
|
|
31
|
+
constructor(pass, request) {
|
|
32
|
+
this.pass = pass;
|
|
33
|
+
this.request = request;
|
|
89
34
|
}
|
|
90
35
|
}
|
|
91
36
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* negotiates the revision; only a revision no era knows is refused. Pinning one era instead made
|
|
96
|
-
* every shipping client unreachable (issue #969), and the MCP lifecycle spec is explicit that a
|
|
97
|
-
* server answers with a revision it supports rather than erroring.
|
|
37
|
+
* The HTTP transport over an {@link McpToolset}: one server = one path = one toolset. `/mcp/user` and
|
|
38
|
+
* `/mcp/admin` are two servers over two toolsets with two profile sets — paths split by WHICH TOOLS,
|
|
39
|
+
* never by HOW THE CALLER AUTHENTICATES.
|
|
98
40
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* `
|
|
103
|
-
*
|
|
104
|
-
*
|
|
41
|
+
* HOW the caller authenticates is `WpMcpServerConfig.setAuth([...])`: an OR-set of the same `@WpAuth`
|
|
42
|
+
* builders endpoints use plus `mcpOAuth(...)`, classified from unverified surface facts and verified by
|
|
43
|
+
* exactly ONE verifier ({@link McpDoorAuthenticator}). That establishes the trusted `RequestContext` —
|
|
44
|
+
* the caller, surface `llm`, and (only from an OIDC / shared-secret hop) the forwarded trusted context —
|
|
45
|
+
* and the toolset does the rest exactly as it does for the in-memory transport.
|
|
46
|
+
*
|
|
47
|
+
* Serves BOTH wire eras from one registry: modern (2026-07-28, per-request `_meta` envelope) and legacy
|
|
48
|
+
* (the 2025-11-25 family, negotiated by `initialize`). The SDK routes each request to the leg its shape
|
|
49
|
+
* selects and negotiates the revision; only a revision no era knows is refused. Pinning one era
|
|
50
|
+
* instead made every shipping client unreachable (issue #969).
|
|
105
51
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
52
|
+
* Error boundaries: HTTP ingress owns pre-SDK replies; the toolset's one SDK callback wrapper owns
|
|
53
|
+
* protocol/list/name-resolution errors and authorized tool-result errors. The SDK writes callback
|
|
54
|
+
* replies, so it cannot safely delegate those failures back to HTTP ingress. `subscriptions/listen` is
|
|
55
|
+
* served entirely by the SDK's listen router. The credential is verified exactly once per POST, at the
|
|
56
|
+
* HTTP boundary, before the SDK is involved.
|
|
110
57
|
*/
|
|
111
58
|
class WpMcpServer {
|
|
112
59
|
config;
|
|
60
|
+
toolset;
|
|
113
61
|
callerContext = new http_routing_1.AuthenticatedCallerContext();
|
|
114
|
-
dispatcher = new McpApiDispatcher_1.McpApiDispatcher();
|
|
115
|
-
/** Classifies a failure exactly as the translator will render it, so the two cannot disagree. */
|
|
116
|
-
boundary = new core_util_1.ApiErrorBoundary();
|
|
117
|
-
outputSchemas = new core_util_1.ApiJsonSchemaValidator();
|
|
118
|
-
/**
|
|
119
|
-
* These edges have NO filter chain above them — `LogApiFilter` never sees a rejected bearer, a
|
|
120
|
-
* bad `Origin`, a body express could not parse, or a `tools/list` the SDK renders itself.
|
|
121
|
-
* Wrapping each in `LogApiCall` is this repo's one line on logging, and it is why
|
|
122
|
-
* `ApiErrorBoundary` no longer writes a second, barer line of its own.
|
|
123
|
-
*/
|
|
124
|
-
logApiCall = new core_util_1.LogApiCallImpl(new core_context_1.RequestContextApiCallContext());
|
|
125
62
|
responseWriter = new http_server_1.ExpressResponseWriter();
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
registry;
|
|
129
|
-
toolProfiles = [];
|
|
63
|
+
translator = new WpMcpErrorTranslator_1.WpMcpErrorTranslator();
|
|
64
|
+
door;
|
|
130
65
|
handler;
|
|
131
66
|
streamingHandler;
|
|
132
67
|
nodeHandler;
|
|
133
68
|
streamingNodeHandler;
|
|
134
|
-
revision;
|
|
135
69
|
icons;
|
|
136
|
-
constructor(config) {
|
|
70
|
+
constructor(config, toolset) {
|
|
137
71
|
this.config = config;
|
|
138
|
-
this.
|
|
72
|
+
this.toolset = toolset;
|
|
139
73
|
}
|
|
140
74
|
/**
|
|
141
75
|
* Mounts this MCP endpoint on `app`: POST at `options.endpointPath`, plus a 405 (`Allow: POST`)
|
|
142
76
|
* for every other method there.
|
|
143
77
|
*
|
|
144
|
-
* SINGLE CANONICAL ORIGIN: one server serves exactly one
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* Serving a second hostname is a second deployment, not a second audience.
|
|
78
|
+
* SINGLE CANONICAL ORIGIN: one server serves exactly one resource, so this may be called only once
|
|
79
|
+
* and `WpMcpServerConfig.setResource(...)`'s path must equal `endpointPath`. That resource URI is
|
|
80
|
+
* fixed for the lifetime of the process and is never derived from the request `Host` — a
|
|
81
|
+
* host-derived audience would make the boundary's own audience check compare two caller-controlled
|
|
82
|
+
* values, which is the confused deputy this check exists to prevent.
|
|
150
83
|
*
|
|
151
|
-
*
|
|
152
|
-
* `WpMcpServerConfig.resourceMetadataUrl`, which is where the
|
|
84
|
+
* With `mcpOAuth(...)` in the set, the discovery half stays app-owned: publish
|
|
85
|
+
* {@link protectedResourceMetadata} at `WpMcpServerConfig.resourceMetadataUrl`, which is where the
|
|
86
|
+
* 401 challenge sends clients.
|
|
153
87
|
*/
|
|
154
88
|
bind(app, options) {
|
|
155
89
|
if (this.handler) {
|
|
156
90
|
throw new Error('WpMcpServer.bind(...) may be called only once; an MCP resource has exactly one ' +
|
|
157
91
|
'canonical URI, fixed for the lifetime of the process.');
|
|
158
92
|
}
|
|
159
|
-
// Fail at boot naming the forgotten setter, never on the first request with a 401
|
|
160
|
-
// answers by re-authenticating (see WpMcpServerConfig).
|
|
93
|
+
// Fail at boot naming the forgotten setter, never on the first request with a 401.
|
|
161
94
|
this.config.validate();
|
|
162
95
|
const resourcePath = new URL(this.config.resource).pathname;
|
|
163
96
|
if (resourcePath !== options.endpointPath) {
|
|
@@ -165,10 +98,8 @@ class WpMcpServer {
|
|
|
165
98
|
`'${options.endpointPath}'; the canonical resource URI ` +
|
|
166
99
|
`'${this.config.resource}' and the bound route are the same endpoint.`);
|
|
167
100
|
}
|
|
168
|
-
this.
|
|
101
|
+
this.door = McpDoorAuth_1.McpDoorAuthenticator.create(this.config.auth, this.config.resource, this.config.credentialVerifiers);
|
|
169
102
|
this.icons = this.config.icons;
|
|
170
|
-
this.registry = new McpToolRegistry_1.McpToolRegistry(options.bindings, options.toolCatalogs, this.config.authorizationService);
|
|
171
|
-
this.revision = this.calculateRegistryRevision(this.registry);
|
|
172
103
|
this.handler = this.createHandler(options, 'auto');
|
|
173
104
|
this.streamingHandler = this.createHandler(options, 'sse');
|
|
174
105
|
this.nodeHandler = (0, node_1.toNodeHandler)(this.handler, {
|
|
@@ -192,8 +123,17 @@ class WpMcpServer {
|
|
|
192
123
|
res.status(405).json(new McpMethodNotAllowedBody());
|
|
193
124
|
});
|
|
194
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* RFC 9728 metadata for this resource. Only an `mcpOAuth(...)` server has any: it is what a
|
|
128
|
+
* connector reads at `WpMcpServerConfig.resourceMetadataUrl` to find the authorization server.
|
|
129
|
+
*/
|
|
195
130
|
protectedResourceMetadata() {
|
|
196
|
-
|
|
131
|
+
const oauth = this.config.auth.find((mode) => mode instanceof McpAuth_1.McpOAuthMode);
|
|
132
|
+
if (oauth === undefined) {
|
|
133
|
+
throw new Error('WpMcpServer.protectedResourceMetadata() requires mcpOAuth(...) in WpMcpServerConfig.setAuth([...]); ' +
|
|
134
|
+
'a server without it publishes no OAuth discovery.');
|
|
135
|
+
}
|
|
136
|
+
return oauth.protectedResourceMetadata(this.config.resource);
|
|
197
137
|
}
|
|
198
138
|
/** Gracefully closes active listen/request streams; clients must reconnect and refresh lists. */
|
|
199
139
|
async close() {
|
|
@@ -204,7 +144,7 @@ class WpMcpServer {
|
|
|
204
144
|
this.handler?.notify.toolsChanged();
|
|
205
145
|
}
|
|
206
146
|
get registryRevision() {
|
|
207
|
-
return this.revision;
|
|
147
|
+
return this.handler ? this.toolset.revision : undefined;
|
|
208
148
|
}
|
|
209
149
|
createHandler(options, responseMode) {
|
|
210
150
|
return (0, server_1.createMcpHandler)((context) => this.buildSdkServer(context, options), {
|
|
@@ -221,22 +161,24 @@ class WpMcpServer {
|
|
|
221
161
|
/** Each HTTP entry uses the same full-request scope and ingress translation seam. */
|
|
222
162
|
async handleHttp(req, res, options) {
|
|
223
163
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
224
|
-
|
|
164
|
+
const post = new McpPostState();
|
|
165
|
+
await this.ingress(req, res, 'serve', post, () => this.serveExpress(req, res, options, post));
|
|
225
166
|
}
|
|
226
167
|
async handleBodyFailure(cause, req, res) {
|
|
227
|
-
await this.ingress(req, res, 'body', () => this.rejectBody(cause));
|
|
168
|
+
await this.ingress(req, res, 'body', new McpPostState(), () => this.rejectBody(cause));
|
|
228
169
|
}
|
|
229
|
-
async ingress(req, res, step, work) {
|
|
170
|
+
async ingress(req, res, step, post, work) {
|
|
230
171
|
await core_context_1.RequestContext.run(async () => {
|
|
231
172
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- HTTP response-owning ingress boundary, including request publication and correlation failures
|
|
232
173
|
try {
|
|
233
174
|
new core_context_1.RequestContextHeaders().fillFromRequest(this.toHttpRequest(req));
|
|
234
175
|
McpToolCallRendering_1.McpCorrelation.stamp(this.correlationOf(req));
|
|
235
|
-
await this.logEdge(step, work);
|
|
176
|
+
await this.toolset.logEdge(step, work);
|
|
177
|
+
// webpieces-disable catch-must-propagate -- the response-owning ingress hands the error WHOLE to the translator, which writes it as this POST's reply; LogApiCall already logged it
|
|
236
178
|
}
|
|
237
179
|
catch (err) {
|
|
238
180
|
const error = (0, core_util_1.toError)(err);
|
|
239
|
-
this.writeBoundaryError(res, error);
|
|
181
|
+
this.writeBoundaryError(res, error, post);
|
|
240
182
|
}
|
|
241
183
|
});
|
|
242
184
|
}
|
|
@@ -249,23 +191,20 @@ class WpMcpServer {
|
|
|
249
191
|
* express writer `ExpressWrapper` uses. A failure after headers were sent can only end the
|
|
250
192
|
* stream.
|
|
251
193
|
*/
|
|
252
|
-
writeBoundaryError(res, error) {
|
|
194
|
+
writeBoundaryError(res, error, post) {
|
|
253
195
|
if (res.headersSent) {
|
|
254
196
|
res.end();
|
|
255
197
|
return;
|
|
256
198
|
}
|
|
257
|
-
|
|
199
|
+
const challenge = post.challenge ? this.challenge() : undefined;
|
|
200
|
+
this.responseWriter.write(res, this.translator.toBearerBoundaryResponse(error, challenge));
|
|
258
201
|
}
|
|
259
|
-
|
|
260
|
-
async logEdge(step, work) {
|
|
261
|
-
return this.logApiCall.execute(new core_util_1.ApiMethodInfo('server', 'McpEndpoint', step, McpProtocol_1.McpProtocol), new McpEdgeRequest(step), work);
|
|
262
|
-
}
|
|
263
|
-
async serveExpress(req, res, options) {
|
|
202
|
+
async serveExpress(req, res, options, post) {
|
|
264
203
|
const origin = req.header('origin');
|
|
265
204
|
if (origin && !options.allowedOrigins.includes(origin)) {
|
|
266
205
|
throw new core_util_1.ApiForbiddenError(`MCP request Origin '${origin}' is not allowed.`);
|
|
267
206
|
}
|
|
268
|
-
const authentication = await this.authenticate(req);
|
|
207
|
+
const authentication = await this.authenticate(req, post);
|
|
269
208
|
const request = req;
|
|
270
209
|
request.auth = this.authInfo(authentication);
|
|
271
210
|
const serve = this.hasProgressToken(req.body)
|
|
@@ -275,19 +214,25 @@ class WpMcpServer {
|
|
|
275
214
|
throw new core_util_1.ApiImplementationError('MCP Node handlers are not initialized.');
|
|
276
215
|
await serve(request, res, req.body);
|
|
277
216
|
}
|
|
278
|
-
/**
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Verifies the caller's credential once for this POST — classify, then exactly ONE verifier — and
|
|
219
|
+
* establishes the trusted context the toolset reads. Handlers never re-verify it.
|
|
220
|
+
*
|
|
221
|
+
* Surface `llm` is stamped BEFORE reconciliation, so on a caller-NOT-verified mode (jwt, apiKey,
|
|
222
|
+
* mcpOAuth) a client that also SENDS `x-wp-surface` must say `llm` or be refused; and again AFTER,
|
|
223
|
+
* because an OIDC / shared-secret hop admits its forwarded context wholesale, and the model on the
|
|
224
|
+
* other end of an MCP door is always the surface.
|
|
225
|
+
*/
|
|
226
|
+
async authenticate(req, post) {
|
|
227
|
+
const door = this.requireDoor();
|
|
228
|
+
const doorRequest = new McpDoorAuth_1.McpDoorRequest(this.toHttpRequest(req));
|
|
229
|
+
post.challenge = door.challengeApplies(doorRequest);
|
|
230
|
+
const pass = await door.authenticate(doorRequest);
|
|
287
231
|
core_context_1.RequestContext.putTrusted(core_util_1.WebpiecesCoreHeaders.SURFACE, 'llm');
|
|
288
|
-
this.callerContext.reconcileWireTrust(
|
|
289
|
-
|
|
290
|
-
|
|
232
|
+
this.callerContext.reconcileWireTrust(pass.callerVerified);
|
|
233
|
+
core_context_1.RequestContext.putTrusted(core_util_1.WebpiecesCoreHeaders.SURFACE, 'llm');
|
|
234
|
+
const tools = await this.toolset.authorizedTools();
|
|
235
|
+
return new McpPostAuthentication(pass, new McpToolset_1.McpToolRequest(tools, this.disconnectSignal(req)));
|
|
291
236
|
}
|
|
292
237
|
disconnectSignal(req) {
|
|
293
238
|
const disconnected = new AbortController();
|
|
@@ -305,18 +250,19 @@ class WpMcpServer {
|
|
|
305
250
|
}
|
|
306
251
|
buildSdkServer(context, options) {
|
|
307
252
|
// The era is the SDK's to decide and BOTH are served; what is not negotiable is that the
|
|
308
|
-
// request came through `bind`'s HTTP boundary, which is where the
|
|
309
|
-
//
|
|
253
|
+
// request came through `bind`'s HTTP boundary, which is where the credential is verified. A
|
|
254
|
+
// factory call without it is a wiring mistake, never a client's doing.
|
|
310
255
|
const authentication = context.authInfo?.extra?.['webpiecesAuthentication'];
|
|
311
256
|
if (!(authentication instanceof McpPostAuthentication)) {
|
|
312
257
|
throw new core_util_1.ApiImplementationError('WpMcpServer serves only MCP requests authenticated by its bind boundary.');
|
|
313
258
|
}
|
|
314
|
-
const
|
|
259
|
+
const view = authentication.request.tools;
|
|
260
|
+
return this.toolset.createSdkServer({
|
|
315
261
|
name: this.config.name,
|
|
316
262
|
title: this.config.title,
|
|
317
263
|
description: this.config.description,
|
|
318
264
|
websiteUrl: this.config.websiteUrl,
|
|
319
|
-
version: `${this.config.version}+${this.revision}`,
|
|
265
|
+
version: `${this.config.version}+${this.toolset.revision}`,
|
|
320
266
|
...(this.icons === undefined ? {} : { icons: [...this.icons] }),
|
|
321
267
|
}, {
|
|
322
268
|
capabilities: { tools: { listChanged: true } },
|
|
@@ -324,165 +270,19 @@ class WpMcpServer {
|
|
|
324
270
|
'tools/list': { ttlMs: options.deployment.ttlMs, cacheScope: 'private' },
|
|
325
271
|
'server/discover': { ttlMs: options.deployment.ttlMs, cacheScope: 'private' },
|
|
326
272
|
},
|
|
327
|
-
}, authentication.
|
|
328
|
-
server.server.removeRequestHandler('tools/list');
|
|
329
|
-
server.server.removeRequestHandler('tools/call');
|
|
330
|
-
server.server.setRequestHandler('tools/list', async (_request) => this.sdkBoundary(new McpSdkOperation('tools/list'), () => this.logEdge('tools/list', async () => this.listTools(authentication.tools))));
|
|
331
|
-
server.server.setRequestHandler('tools/call', async (request, sdkContext) => this.handleCallTool(server, request, authentication, sdkContext));
|
|
332
|
-
return server;
|
|
333
|
-
}
|
|
334
|
-
/** ONE SDK callback catch implementation; the SDK intercepts foreign throws itself. */
|
|
335
|
-
async sdkBoundary(operation, work) {
|
|
336
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- shared SDK response boundary, preserving protocol versus tool envelopes
|
|
337
|
-
try {
|
|
338
|
-
return await work();
|
|
339
|
-
}
|
|
340
|
-
catch (err) {
|
|
341
|
-
const error = (0, core_util_1.toError)(err);
|
|
342
|
-
if (error instanceof McpToolPolicy_1.McpToolDeniedError)
|
|
343
|
-
throw this.translator.unknownTool(error.toolName);
|
|
344
|
-
if (!operation.toolStage && error instanceof server_1.ProtocolError)
|
|
345
|
-
throw error;
|
|
346
|
-
this.logImplementationFailure(operation.name, error);
|
|
347
|
-
if (operation.toolStage)
|
|
348
|
-
return this.translator.toToolCallResult(error);
|
|
349
|
-
this.translator.toListError(error);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
listTools(view) {
|
|
353
|
-
const tools = view.tools.map((tool) => this.toolDefinition(tool));
|
|
354
|
-
return { tools };
|
|
355
|
-
}
|
|
356
|
-
toolDefinition(tool) {
|
|
357
|
-
return {
|
|
358
|
-
name: tool.name,
|
|
359
|
-
title: tool.title,
|
|
360
|
-
description: tool.description,
|
|
361
|
-
inputSchema: tool.inputSchema,
|
|
362
|
-
outputSchema: tool.outputSchema,
|
|
363
|
-
annotations: new McpPublishedToolAnnotations(tool),
|
|
364
|
-
_meta: { webpiecesRegistryRevision: this.revision },
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
/**
|
|
368
|
-
* SDK tool callback: an unknown or hidden tool is a JSON-RPC -32602; every failure after the tool is found
|
|
369
|
-
* is an `isError: true` result, identically for local and remote bindings.
|
|
370
|
-
*/
|
|
371
|
-
async handleCallTool(server, request, authentication, sdkContext) {
|
|
372
|
-
const name = request.params.name;
|
|
373
|
-
const operation = new McpSdkOperation(`tools/call ${name}`);
|
|
374
|
-
return this.sdkBoundary(operation, async () => {
|
|
375
|
-
McpToolCallRendering_1.McpCorrelation.stamp(new McpToolCallRendering_1.McpCorrelation(sdkContext.mcpReq.id, McpProtocol_1.McpProtocol, name));
|
|
376
|
-
const tool = authentication.tools.find(name);
|
|
377
|
-
if (!tool)
|
|
378
|
-
throw this.translator.unknownTool(name);
|
|
379
|
-
McpToolCallRendering_1.McpCorrelation.stamp(new McpToolCallRendering_1.McpCorrelation(sdkContext.mcpReq.id, tool.apiClass, name, tool.operation));
|
|
380
|
-
operation.toolStage = true;
|
|
381
|
-
const result = await this.callTool(tool, request, authentication, sdkContext);
|
|
382
|
-
result._meta = this.translator.resultMeta(McpToolCallRendering_1.McpCorrelation.requestId());
|
|
383
|
-
return server.server.projectCallToolResult(result, tool.outputSchema);
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
/**
|
|
387
|
-
* ONE `log.error` line, with the stack and the requestId, for a failure that is THIS server's bug
|
|
388
|
-
* — the `implementation` kind, which the translator reduces to "Internal Error" and a requestId
|
|
389
|
-
* for the caller (#1055).
|
|
390
|
-
*
|
|
391
|
-
* `LogApiCall` does not cover this. An MCP output-schema violation is raised AFTER the controller
|
|
392
|
-
* returned, so the only `LogApiCall` line for that call reads `resp-SUCCESS`; before this line
|
|
393
|
-
* existed, the requestId support was handed led to a log with no error in it. Where a
|
|
394
|
-
* `[API-*-resp-FAIL]` line does exist it carries the error's message, not its stack. So this line
|
|
395
|
-
* is not the bare duplicate `ApiErrorBoundary` stopped writing: it is the one that says WHERE.
|
|
396
|
-
*
|
|
397
|
-
* Every other kind (bad-request, end-user, dependency, ...) is a caller's or a downstream's
|
|
398
|
-
* failure, published to the caller with its own text, and `LogApiCall` already records it.
|
|
399
|
-
*/
|
|
400
|
-
logImplementationFailure(entryPoint, error) {
|
|
401
|
-
if (this.boundary.encode(error).kind !== 'implementation')
|
|
402
|
-
return;
|
|
403
|
-
log.error(`MCP ${entryPoint} failed with an implementation error requestId=${McpToolCallRendering_1.McpCorrelation.requestId()}`, error);
|
|
404
|
-
}
|
|
405
|
-
/** Everything a tool call does. No catch in here: failures propagate to handleCallTool. */
|
|
406
|
-
async callTool(tool, request, authentication, sdkContext) {
|
|
407
|
-
const credential = authentication.credential;
|
|
408
|
-
const invocation = new core_util_2.McpInvocationContext(sdkContext.mcpReq.id, tool.name, credential.subject, credential.principalRoles, AbortSignal.any([sdkContext.mcpReq.signal, authentication.disconnectSignal]), this.progressReporter(sdkContext));
|
|
409
|
-
const args = (request.params.arguments ?? {});
|
|
410
|
-
const value = await this.dispatcher.call(tool, args, credential, invocation, this.config.authorizationService);
|
|
411
|
-
const outputFailure = this.outputSchemas.validate(tool.outputSchema, value);
|
|
412
|
-
if (outputFailure) {
|
|
413
|
-
throw new core_util_1.ApiImplementationError(`MCP output schema violation for ${tool.apiClass.name}.${tool.methodName}: ${outputFailure.message}`);
|
|
414
|
-
}
|
|
415
|
-
const structured = this.record(value);
|
|
416
|
-
return {
|
|
417
|
-
content: [{ type: 'text', text: JSON.stringify(structured) }],
|
|
418
|
-
structuredContent: structured,
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
validateCredential(credential) {
|
|
422
|
-
const now = Math.floor(Date.now() / 1000);
|
|
423
|
-
const reject = (reason) => {
|
|
424
|
-
throw new core_util_1.ApiUnauthorizedError(`MCP access token rejected: ${reason}`);
|
|
425
|
-
};
|
|
426
|
-
if (credential.subject.trim() === '')
|
|
427
|
-
reject('no subject');
|
|
428
|
-
const stamps = [
|
|
429
|
-
credential.issuedAtEpochSeconds,
|
|
430
|
-
credential.expiresAtEpochSeconds,
|
|
431
|
-
credential.accountValidatedAtEpochSeconds,
|
|
432
|
-
];
|
|
433
|
-
if (!stamps.every(Number.isFinite))
|
|
434
|
-
reject('security timestamps must be finite');
|
|
435
|
-
if (credential.resource !== this.config.resource)
|
|
436
|
-
reject('resource mismatch');
|
|
437
|
-
if (!this.config.authorizationServers.includes(credential.issuer)) {
|
|
438
|
-
reject('issuer is not trusted');
|
|
439
|
-
}
|
|
440
|
-
if (credential.issuedAtEpochSeconds > now || credential.expiresAtEpochSeconds <= now) {
|
|
441
|
-
reject('outside its valid time window');
|
|
442
|
-
}
|
|
443
|
-
const lifetime = credential.expiresAtEpochSeconds - credential.issuedAtEpochSeconds;
|
|
444
|
-
if (lifetime > McpAuth_1.MAX_MCP_ACCESS_TOKEN_LIFETIME_SECONDS)
|
|
445
|
-
reject('lifetime exceeds 30 days');
|
|
446
|
-
for (const scope of this.config.requiredScopes) {
|
|
447
|
-
if (!credential.scopes.includes(scope))
|
|
448
|
-
reject(`missing required scope '${scope}'`);
|
|
449
|
-
}
|
|
450
|
-
const validatedAge = now - credential.accountValidatedAtEpochSeconds;
|
|
451
|
-
if (credential.accountValidatedAtEpochSeconds > now ||
|
|
452
|
-
validatedAge > this.config.maxAccountValidationAgeSeconds) {
|
|
453
|
-
reject('account authorization state is not fresh enough for dispatch');
|
|
454
|
-
}
|
|
273
|
+
}, async () => authentication.request, (name) => view.find(name));
|
|
455
274
|
}
|
|
456
275
|
authInfo(authentication) {
|
|
457
|
-
const
|
|
276
|
+
const pass = authentication.pass;
|
|
458
277
|
return {
|
|
459
|
-
token:
|
|
460
|
-
clientId:
|
|
461
|
-
scopes: [...
|
|
462
|
-
expiresAt:
|
|
463
|
-
resource: new URL(
|
|
278
|
+
token: pass.token,
|
|
279
|
+
clientId: pass.subject,
|
|
280
|
+
scopes: [...pass.scopes],
|
|
281
|
+
expiresAt: pass.expiresAtEpochSeconds,
|
|
282
|
+
resource: new URL(this.config.resource),
|
|
464
283
|
extra: { webpiecesAuthentication: authentication },
|
|
465
284
|
};
|
|
466
285
|
}
|
|
467
|
-
progressReporter(context) {
|
|
468
|
-
const token = context.mcpReq._meta?.progressToken;
|
|
469
|
-
if (token === undefined)
|
|
470
|
-
return undefined;
|
|
471
|
-
return async (progress, total, message) => {
|
|
472
|
-
await context.mcpReq.notify({
|
|
473
|
-
method: 'notifications/progress',
|
|
474
|
-
params: this.progressParams(token, progress, total, message),
|
|
475
|
-
});
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
progressParams(progressToken, progress, total, message) {
|
|
479
|
-
const params = { progressToken, progress };
|
|
480
|
-
if (total !== undefined)
|
|
481
|
-
params['total'] = total;
|
|
482
|
-
if (message !== undefined)
|
|
483
|
-
params['message'] = message;
|
|
484
|
-
return params;
|
|
485
|
-
}
|
|
486
286
|
/** What the raw POST body tells us before the SDK has parsed anything: the id, and the tool. */
|
|
487
287
|
correlationOf(req) {
|
|
488
288
|
const body = this.bodyObject(req.body);
|
|
@@ -513,36 +313,10 @@ class WpMcpServer {
|
|
|
513
313
|
}
|
|
514
314
|
return new core_context_1.HttpRequest(req.method, req.originalUrl || req.path, headers);
|
|
515
315
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
tool.name,
|
|
519
|
-
tool.title,
|
|
520
|
-
tool.description,
|
|
521
|
-
tool.inputSchema,
|
|
522
|
-
tool.outputSchema,
|
|
523
|
-
tool.annotations,
|
|
524
|
-
tool.profiles,
|
|
525
|
-
]);
|
|
526
|
-
return (0, node_crypto_1.createHash)('sha256')
|
|
527
|
-
.update(JSON.stringify([this.toolProfiles, surface]))
|
|
528
|
-
.digest('hex')
|
|
529
|
-
.slice(0, 12);
|
|
530
|
-
}
|
|
531
|
-
requireRegistry() {
|
|
532
|
-
if (!this.registry)
|
|
316
|
+
requireDoor() {
|
|
317
|
+
if (!this.door)
|
|
533
318
|
throw new Error('Call WpMcpServer.bind(...) before serving requests.');
|
|
534
|
-
return this.
|
|
535
|
-
}
|
|
536
|
-
record(value) {
|
|
537
|
-
const jsonValue = JSON.stringify(value);
|
|
538
|
-
if (jsonValue === undefined) {
|
|
539
|
-
throw new core_util_1.ApiImplementationError('MCP structured content is not JSON serializable.');
|
|
540
|
-
}
|
|
541
|
-
const parsed = JSON.parse(jsonValue);
|
|
542
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
543
|
-
return { value: parsed };
|
|
544
|
-
}
|
|
545
|
-
return parsed;
|
|
319
|
+
return this.door;
|
|
546
320
|
}
|
|
547
321
|
}
|
|
548
322
|
exports.WpMcpServer = WpMcpServer;
|