@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
@@ -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 McpApiDispatcher_1 = require("./McpApiDispatcher");
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
- accessToken;
65
- credential;
66
- disconnectSignal;
67
- tools;
68
- constructor(accessToken, credential, disconnectSignal, tools) {
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
- * MCP adapter around the official SDK's request-scoped HTTP handler, serving BOTH wire eras from
93
- * one tool registry: modern (2026-07-28, per-request `_meta` envelope) and legacy (the 2025-11-25
94
- * family, negotiated by `initialize`). The SDK routes each request to the leg its shape selects and
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
- * Error boundaries: HTTP ingress owns pre-SDK replies; one shared SDK callback wrapper owns
100
- * protocol/list/name-resolution errors and authorized tool-result errors. The SDK writes callback
101
- * replies, so it cannot safely delegate those failures back to HTTP ingress.
102
- * `subscriptions/listen` is served entirely by the SDK's listen router, so Webpieces has no handler
103
- * (and no catch) there. The external bearer is verified exactly once per POST, at the HTTP
104
- * boundary, before the SDK is involved.
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
- * Before tools/list and tools/call hand an IMPLEMENTATION-kind failure to the translator, they
107
- * `log.error` it with its stack and the requestId ({@link logImplementationFailure}, #1055). The
108
- * translator reduces such a failure to "Internal Error" for the caller, so that line is the only place
109
- * its cause exists.
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
- /** tools/call reads `McpRegistry`; every other reply stays framework-owned. See McpErrorTranslator. */
127
- translator;
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.translator = new WpMcpErrorTranslator_1.WpMcpErrorTranslator(() => this.challenge());
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 protected resource, so this may be
145
- * called only once and `WpMcpServerConfig.setResource(...)`'s path must equal `endpointPath`.
146
- * That resource URI is fixed for the lifetime of the process and is never derived from the
147
- * request `Host` — a host-derived audience would make the boundary's own audience check compare
148
- * two caller-controlled values, which is the confused deputy this check exists to prevent.
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
- * The OAuth/discovery half stays app-owned: publish `protectedResourceMetadata()` at
152
- * `WpMcpServerConfig.resourceMetadataUrl`, which is where the 401 challenge sends clients.
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 the client
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.toolProfiles = this.config.toolProfiles;
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
- return this.config.protectedResourceMetadata();
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
- await this.ingress(req, res, 'serve', () => this.serveExpress(req, res, options));
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
- this.responseWriter.write(res, this.translator.toBearerBoundaryResponse(error));
199
+ const challenge = post.challenge ? this.challenge() : undefined;
200
+ this.responseWriter.write(res, this.translator.toBearerBoundaryResponse(error, challenge));
258
201
  }
259
- /** Run one un-filtered edge step under `LogApiCall`, so its failure gets exactly one log line. */
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
- /** Verifies the external bearer once for this POST. Handlers never re-verify it. */
279
- async authenticate(req) {
280
- const match = req.header('authorization')?.match(/^Bearer ([^\s]+)$/i);
281
- const token = match?.[1];
282
- if (!token)
283
- throw new core_util_1.ApiUnauthorizedError('MCP request has no bearer access token.');
284
- const credential = await this.config.accessTokenAuthority.verifyAccessToken(token, this.config.resource);
285
- this.validateCredential(credential);
286
- this.callerContext.publish(credential.caller);
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(false);
289
- const tools = await McpToolRegistry_1.AuthorizedMcpTools.create(this.requireRegistry(), this.config.authorizationService, this.toolProfiles);
290
- return new McpPostAuthentication(token, credential, this.disconnectSignal(req), tools);
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 external bearer is
309
- // verified. A factory call without it is a wiring mistake, never a client's doing.
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 server = new WpSdkMcpServer({
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.tools);
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 credential = authentication.credential;
276
+ const pass = authentication.pass;
458
277
  return {
459
- token: authentication.accessToken,
460
- clientId: credential.subject,
461
- scopes: [...credential.scopes],
462
- expiresAt: credential.expiresAtEpochSeconds,
463
- resource: new URL(credential.resource),
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
- calculateRegistryRevision(registry) {
517
- const surface = registry.tools.map((tool) => [
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.registry;
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;