@webpieces/mcp-server 0.4.989 → 0.4.991

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +199 -42
  2. package/package.json +7 -6
  3. package/src/McpApiDispatcher.d.ts +6 -3
  4. package/src/McpApiDispatcher.js +6 -2
  5. package/src/McpApiDispatcher.js.map +1 -1
  6. package/src/McpAuth.d.ts +115 -56
  7. package/src/McpAuth.js +180 -111
  8. package/src/McpAuth.js.map +1 -1
  9. package/src/McpAuthorityCompileAssertions.d.ts +12 -2
  10. package/src/McpAuthorityCompileAssertions.js +32 -4
  11. package/src/McpAuthorityCompileAssertions.js.map +1 -1
  12. package/src/McpBindOptions.d.ts +5 -20
  13. package/src/McpBindOptions.js +5 -15
  14. package/src/McpBindOptions.js.map +1 -1
  15. package/src/McpClientSet.d.ts +35 -0
  16. package/src/McpClientSet.js +86 -0
  17. package/src/McpClientSet.js.map +1 -0
  18. package/src/McpDoorAuth.d.ts +117 -0
  19. package/src/McpDoorAuth.js +455 -0
  20. package/src/McpDoorAuth.js.map +1 -0
  21. package/src/McpToolCallRendering.d.ts +5 -4
  22. package/src/McpToolCallRendering.js.map +1 -1
  23. package/src/McpToolCatalog.d.ts +1 -1
  24. package/src/McpToolCatalog.js +1 -1
  25. package/src/McpToolCatalog.js.map +1 -1
  26. package/src/McpToolRegistry.js +8 -2
  27. package/src/McpToolRegistry.js.map +1 -1
  28. package/src/McpToolset.d.ts +115 -0
  29. package/src/McpToolset.js +323 -0
  30. package/src/McpToolset.js.map +1 -0
  31. package/src/WpMcpErrorTranslator.d.ts +5 -7
  32. package/src/WpMcpErrorTranslator.js +7 -13
  33. package/src/WpMcpErrorTranslator.js.map +1 -1
  34. package/src/WpMcpInMemory.d.ts +30 -0
  35. package/src/WpMcpInMemory.js +97 -0
  36. package/src/WpMcpInMemory.js.map +1 -0
  37. package/src/WpMcpRemote.d.ts +23 -0
  38. package/src/WpMcpRemote.js +89 -0
  39. package/src/WpMcpRemote.js.map +1 -0
  40. package/src/WpMcpServer.d.ts +45 -76
  41. package/src/WpMcpServer.js +101 -327
  42. package/src/WpMcpServer.js.map +1 -1
  43. package/src/__tests__/McpTransportFixtures.d.ts +107 -0
  44. package/src/__tests__/McpTransportFixtures.js +266 -0
  45. package/src/__tests__/McpTransportFixtures.js.map +1 -0
  46. package/src/index.d.ts +9 -2
  47. package/src/index.js +17 -1
  48. package/src/index.js.map +1 -1
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.McpToolset = exports.McpToolRequest = void 0;
4
+ const node_crypto_1 = require("node:crypto");
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 http_routing_1 = require("@webpieces/http-routing");
9
+ const McpApiDispatcher_1 = require("./McpApiDispatcher");
10
+ const McpProtocol_1 = require("./McpProtocol");
11
+ const McpToolCallRendering_1 = require("./McpToolCallRendering");
12
+ const McpToolPolicy_1 = require("./McpToolPolicy");
13
+ const McpToolRegistry_1 = require("./McpToolRegistry");
14
+ const WpMcpErrorTranslator_1 = require("./WpMcpErrorTranslator");
15
+ const log = core_util_1.LogManager.getLogger('McpToolset');
16
+ /**
17
+ * The request DTO the edge `LogApiCall` lines report. These boundaries run outside any API dispatch,
18
+ * so there is no contract DTO to log; the step name is the fact worth having.
19
+ */
20
+ class McpEdgeRequest {
21
+ step;
22
+ constructor(step) {
23
+ this.step = step;
24
+ }
25
+ }
26
+ class McpSdkOperation {
27
+ name;
28
+ toolStage = false;
29
+ constructor(name) {
30
+ this.name = name;
31
+ }
32
+ }
33
+ /**
34
+ * `Tool.annotations` exactly as tools/list publishes it: the four hints PLUS the human-readable
35
+ * `title` (#1180). The Claude Connectors Directory checks `annotations.title`; clients on the
36
+ * 2025-06-18+ spec render the top-level `Tool.title`, so both carry the one title — the method's
37
+ * `@summary` JSDoc tag, read from the generated catalog (#1296).
38
+ */
39
+ class McpPublishedToolAnnotations {
40
+ title;
41
+ readOnlyHint;
42
+ destructiveHint;
43
+ idempotentHint;
44
+ openWorldHint;
45
+ constructor(tool) {
46
+ this.title = tool.title;
47
+ this.readOnlyHint = tool.annotations.readOnlyHint;
48
+ this.destructiveHint = tool.annotations.destructiveHint;
49
+ this.idempotentHint = tool.annotations.idempotentHint;
50
+ this.openWorldHint = tool.annotations.openWorldHint;
51
+ }
52
+ }
53
+ /**
54
+ * What ONE MCP request may see and how it is cancelled, established by the transport before the SDK
55
+ * handler runs: the caller's authorized projection, and the signal that fires when the caller goes away.
56
+ */
57
+ class McpToolRequest {
58
+ tools;
59
+ disconnectSignal;
60
+ constructor(tools, disconnectSignal) {
61
+ this.tools = tools;
62
+ this.disconnectSignal = disconnectSignal;
63
+ }
64
+ }
65
+ exports.McpToolRequest = McpToolRequest;
66
+ /**
67
+ * The official SDK server with Webpieces-owned tools/list and tools/call handlers. It answers
68
+ * `toolInputSchemaJson` from the request-authorized view when the transport has one, so the SDK's
69
+ * pre-dispatch SEP-2243 `Mcp-Param-*` header validation still runs without registering SDK-side tool
70
+ * callbacks.
71
+ */
72
+ class WpSdkMcpServer extends server_1.McpServer {
73
+ schemaSource;
74
+ constructor(info, options, schemaSource) {
75
+ super(info, options);
76
+ this.schemaSource = schemaSource;
77
+ }
78
+ // webpieces-disable no-any-unknown -- signature is fixed by the SDK's JSON Schema record type
79
+ toolInputSchemaJson(name) {
80
+ return this.schemaSource(name)?.inputSchema;
81
+ }
82
+ }
83
+ /**
84
+ * McpToolset — the shared core of every MCP transport: WHICH tools are served, and everything that
85
+ * happens to a request once its caller is established.
86
+ *
87
+ * ```ts
88
+ * const toolset = new McpToolset(
89
+ * bindings, // McpApiBinding.local / .remote
90
+ * McpToolCatalog.fromPackages(['@myorg/my-apis'], __dirname),
91
+ * router.authorizationService(),
92
+ * ['user'], // #1187 profiles: WHICH tools this toolset serves
93
+ * );
94
+ * const client = await WpMcpInMemory.connect(toolset); // in-process agent
95
+ * new WpMcpServer(config, toolset).bind(app, options); // HTTP
96
+ * ```
97
+ *
98
+ * It owns the {@link McpToolRegistry}, the per-caller {@link AuthorizedMcpTools} projection, input and
99
+ * output schema validation, dispatch through `binding.invoke`, and the error translation. Its ONLY input
100
+ * about the caller is the CURRENT `RequestContext` — the trusted USER_ID / USER_ROLES / ORG_ID and a
101
+ * `VerifiedMachineCaller`, exactly what `AuthorizationService.authorize` reads. A transport's only job
102
+ * is to establish that context; `@WpAuthorization` on the contract is the single policy everywhere.
103
+ *
104
+ * Profiles are fixed per toolset: `/mcp/user` and `/mcp/admin` are two toolsets (two servers), so paths
105
+ * split by WHICH TOOLS, never by HOW THE CALLER AUTHENTICATES.
106
+ *
107
+ * Before tools/list and tools/call hand an IMPLEMENTATION-kind failure to the translator, they
108
+ * `log.error` it with its stack and the requestId ({@link logImplementationFailure}, #1055). The
109
+ * translator reduces such a failure to "Internal Error" for the caller, so that line is the only place
110
+ * its cause exists.
111
+ */
112
+ class McpToolset {
113
+ authorizationService;
114
+ registry;
115
+ profiles;
116
+ /** A short hash of the served tool surface, appended to every server version and tool `_meta`. */
117
+ revision;
118
+ dispatcher = new McpApiDispatcher_1.McpApiDispatcher();
119
+ /** Classifies a failure exactly as the translator will render it, so the two cannot disagree. */
120
+ boundary = new core_util_1.ApiErrorBoundary();
121
+ outputSchemas = new core_util_1.ApiJsonSchemaValidator();
122
+ /**
123
+ * tools/list has NO filter chain above it — `LogApiFilter` never sees a list the SDK renders. Wrapping
124
+ * it in `LogApiCall` is this repo's one line on logging.
125
+ */
126
+ logApiCall = new core_util_1.LogApiCallImpl(new core_context_1.RequestContextApiCallContext());
127
+ /** tools/call reads `McpRegistry`; tools/list replies stay framework-owned. See McpErrorTranslator. */
128
+ translator = new WpMcpErrorTranslator_1.WpMcpErrorTranslator();
129
+ constructor(bindings,
130
+ /**
131
+ * The tools the build published — ONE catalog per bound contract, each the
132
+ * `mcp-<ContractClass>-tools.json` `wp-openapi` wrote beside its library's OpenAPI documents,
133
+ * read with `McpToolCatalog.fromPackages([...], __dirname)`. The only source of a tool's
134
+ * documentation and schemas; `McpToolRegistry` refuses to build unless it holds exactly one
135
+ * catalog per bound contract.
136
+ */
137
+ catalogs, authorizationService,
138
+ /** The union of `@WpMcpTool` profiles this toolset serves, at least one (`[Mcp.DEFAULT]` for untagged tools). */
139
+ profiles) {
140
+ this.authorizationService = authorizationService;
141
+ if (bindings.length === 0)
142
+ throw new Error('McpToolset requires at least one API contract binding.');
143
+ this.profiles = core_util_1.Mcp.profiles(profiles, 'McpToolset profiles');
144
+ this.registry = new McpToolRegistry_1.McpToolRegistry(bindings, catalogs, authorizationService);
145
+ this.revision = this.calculateRevision();
146
+ }
147
+ /** The tools the caller in the CURRENT RequestContext may see, sorted by name. */
148
+ async authorizedTools() {
149
+ return McpToolRegistry_1.AuthorizedMcpTools.create(this.registry, this.authorizationService, this.profiles);
150
+ }
151
+ /**
152
+ * A fresh SDK server whose tools/list and tools/call are this toolset's. `request` establishes, per
153
+ * MCP request, what the caller may see; `schemaView` answers the SDK's input-schema lookup (the HTTP
154
+ * transport passes the request's projection, the in-memory one the full registry).
155
+ */
156
+ createSdkServer(info, options, request, schemaView) {
157
+ const server = new WpSdkMcpServer(info, options, schemaView);
158
+ server.server.removeRequestHandler('tools/list');
159
+ server.server.removeRequestHandler('tools/call');
160
+ server.server.setRequestHandler('tools/list', async (_request) => this.sdkBoundary(new McpSdkOperation('tools/list'), () => this.logEdge('tools/list', async () => this.listTools((await request()).tools))));
161
+ server.server.setRequestHandler('tools/call', async (call, sdkContext) => this.handleCallTool(server, call, request, sdkContext));
162
+ return server;
163
+ }
164
+ /** Run one un-filtered edge step under `LogApiCall`, so its failure gets exactly one log line. */
165
+ async logEdge(step, work) {
166
+ return this.logApiCall.execute(new core_util_1.ApiMethodInfo('server', 'McpEndpoint', step, McpProtocol_1.McpProtocol), new McpEdgeRequest(step), work);
167
+ }
168
+ /** ONE SDK callback catch implementation; the SDK intercepts foreign throws itself. */
169
+ async sdkBoundary(operation, work) {
170
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- shared SDK response boundary, preserving protocol versus tool envelopes
171
+ try {
172
+ return await work();
173
+ }
174
+ catch (err) {
175
+ const error = (0, core_util_1.toError)(err);
176
+ if (error instanceof McpToolPolicy_1.McpToolDeniedError)
177
+ throw this.translator.unknownTool(error.toolName);
178
+ if (!operation.toolStage && error instanceof server_1.ProtocolError)
179
+ throw error;
180
+ this.logImplementationFailure(operation.name, error);
181
+ if (operation.toolStage)
182
+ return this.translator.toToolCallResult(error);
183
+ this.translator.toListError(error);
184
+ }
185
+ }
186
+ listTools(view) {
187
+ const tools = view.tools.map((tool) => this.toolDefinition(tool));
188
+ return { tools };
189
+ }
190
+ toolDefinition(tool) {
191
+ return {
192
+ name: tool.name,
193
+ title: tool.title,
194
+ description: tool.description,
195
+ inputSchema: tool.inputSchema,
196
+ outputSchema: tool.outputSchema,
197
+ annotations: new McpPublishedToolAnnotations(tool),
198
+ _meta: { webpiecesRegistryRevision: this.revision },
199
+ };
200
+ }
201
+ /**
202
+ * SDK tool callback: an unknown or hidden tool is a JSON-RPC -32602; every failure after the tool
203
+ * is found is an `isError: true` result, identically for local and remote bindings.
204
+ */
205
+ async handleCallTool(server, call, request, sdkContext) {
206
+ const name = call.params.name;
207
+ const operation = new McpSdkOperation(`tools/call ${name}`);
208
+ return this.sdkBoundary(operation, async () => {
209
+ McpToolCallRendering_1.McpCorrelation.stamp(new McpToolCallRendering_1.McpCorrelation(sdkContext.mcpReq.id, McpProtocol_1.McpProtocol, name));
210
+ const established = await request();
211
+ const tool = established.tools.find(name);
212
+ if (!tool)
213
+ throw this.translator.unknownTool(name);
214
+ McpToolCallRendering_1.McpCorrelation.stamp(new McpToolCallRendering_1.McpCorrelation(sdkContext.mcpReq.id, tool.apiClass, name, tool.operation));
215
+ operation.toolStage = true;
216
+ const result = await this.callTool(tool, call, established, sdkContext);
217
+ result._meta = this.translator.resultMeta(McpToolCallRendering_1.McpCorrelation.requestId());
218
+ return server.server.projectCallToolResult(result, tool.outputSchema);
219
+ });
220
+ }
221
+ /**
222
+ * ONE `log.error` line, with the stack and the requestId, for a failure that is THIS server's bug
223
+ * — the `implementation` kind, which the translator reduces to "Internal Error" and a requestId
224
+ * for the caller (#1055).
225
+ *
226
+ * `LogApiCall` does not cover this. An MCP output-schema violation is raised AFTER the controller
227
+ * returned, so the only `LogApiCall` line for that call reads `resp-SUCCESS`; before this line
228
+ * existed, the requestId support was handed led to a log with no error in it. Where a
229
+ * `[API-*-resp-FAIL]` line does exist it carries the error's message, not its stack. So this line
230
+ * is not the bare duplicate `ApiErrorBoundary` stopped writing: it is the one that says WHERE.
231
+ *
232
+ * Every other kind (bad-request, end-user, dependency, ...) is a caller's or a downstream's
233
+ * failure, published to the caller with its own text, and `LogApiCall` already records it.
234
+ */
235
+ logImplementationFailure(entryPoint, error) {
236
+ if (this.boundary.encode(error).kind !== 'implementation')
237
+ return;
238
+ log.error(`MCP ${entryPoint} failed with an implementation error requestId=${McpToolCallRendering_1.McpCorrelation.requestId()}`, error);
239
+ }
240
+ /** Everything a tool call does. No catch in here: failures propagate to handleCallTool. */
241
+ async callTool(tool, call, established, sdkContext) {
242
+ const caller = this.currentCaller();
243
+ const invocation = new core_util_1.McpInvocationContext(sdkContext.mcpReq.id, tool.name, caller.subject, caller.roles, AbortSignal.any([sdkContext.mcpReq.signal, established.disconnectSignal]), this.progressReporter(sdkContext));
244
+ const args = (call.params.arguments ?? {});
245
+ const value = await this.dispatcher.call(tool, args, invocation, this.authorizationService);
246
+ const outputFailure = this.outputSchemas.validate(tool.outputSchema, value);
247
+ if (outputFailure) {
248
+ throw new core_util_1.ApiImplementationError(`MCP output schema violation for ${tool.apiClass.name}.${tool.methodName}: ${outputFailure.message}`);
249
+ }
250
+ const structured = this.record(value);
251
+ return {
252
+ content: [{ type: 'text', text: JSON.stringify(structured) }],
253
+ structuredContent: structured,
254
+ };
255
+ }
256
+ /** The trusted caller of the CURRENT RequestContext: a verified user, else a verified machine. */
257
+ currentCaller() {
258
+ const userId = core_context_1.RequestContext.getTrusted(core_util_1.WebpiecesCoreHeaders.USER_ID);
259
+ if (typeof userId === 'string' && userId.trim() !== '') {
260
+ return new McpCallerFacts(userId, http_routing_1.CanonicalUserRoles.read());
261
+ }
262
+ const machine = core_context_1.RequestContext.getTrusted(http_routing_1.VERIFIED_MACHINE_CALLER);
263
+ if (machine instanceof http_routing_1.VerifiedMachineCaller)
264
+ return new McpCallerFacts(machine.identity, []);
265
+ throw new core_util_1.ApiUnauthorizedError('MCP tool call has no trusted caller in its RequestContext.');
266
+ }
267
+ progressReporter(context) {
268
+ const token = context.mcpReq._meta?.progressToken;
269
+ if (token === undefined)
270
+ return undefined;
271
+ return async (progress, total, message) => {
272
+ await context.mcpReq.notify({
273
+ method: 'notifications/progress',
274
+ params: this.progressParams(token, progress, total, message),
275
+ });
276
+ };
277
+ }
278
+ progressParams(progressToken, progress, total, message) {
279
+ const params = { progressToken, progress };
280
+ if (total !== undefined)
281
+ params['total'] = total;
282
+ if (message !== undefined)
283
+ params['message'] = message;
284
+ return params;
285
+ }
286
+ calculateRevision() {
287
+ const surface = this.registry.tools.map((tool) => [
288
+ tool.name,
289
+ tool.title,
290
+ tool.description,
291
+ tool.inputSchema,
292
+ tool.outputSchema,
293
+ tool.annotations,
294
+ tool.profiles,
295
+ ]);
296
+ return (0, node_crypto_1.createHash)('sha256')
297
+ .update(JSON.stringify([this.profiles, surface]))
298
+ .digest('hex')
299
+ .slice(0, 12);
300
+ }
301
+ record(value) {
302
+ const jsonValue = JSON.stringify(value);
303
+ if (jsonValue === undefined) {
304
+ throw new core_util_1.ApiImplementationError('MCP structured content is not JSON serializable.');
305
+ }
306
+ const parsed = JSON.parse(jsonValue);
307
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
308
+ return { value: parsed };
309
+ }
310
+ return parsed;
311
+ }
312
+ }
313
+ exports.McpToolset = McpToolset;
314
+ /** Who the current RequestContext says is calling, as McpInvocationContext reports it. */
315
+ class McpCallerFacts {
316
+ subject;
317
+ roles;
318
+ constructor(subject, roles) {
319
+ this.subject = subject;
320
+ this.roles = roles;
321
+ }
322
+ }
323
+ //# sourceMappingURL=McpToolset.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"McpToolset.js","sourceRoot":"","sources":["../../../../../packages/http/mcp-server/src/McpToolset.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash } from 'node:crypto';\nimport {\n CallToolRequest,\n CallToolResult,\n Implementation,\n ListToolsResult,\n McpServer,\n ProgressToken,\n ProtocolError,\n ServerContext,\n ServerOptions,\n Tool,\n} from '@modelcontextprotocol/server';\nimport {\n ApiErrorBoundary,\n ApiImplementationError,\n ApiJsonSchemaValidator,\n ApiMethodInfo,\n ApiUnauthorizedError,\n DtoValue,\n LogApiCallImpl,\n LogManager,\n Mcp,\n McpInvocationContext,\n McpProgressReporter,\n McpToolProfiles,\n toError,\n WebpiecesCoreHeaders,\n} from '@webpieces/core-util';\nimport { RequestContext, RequestContextApiCallContext } from '@webpieces/core-context';\nimport {\n AuthorizationService,\n CanonicalUserRoles,\n VERIFIED_MACHINE_CALLER,\n VerifiedMachineCaller,\n} from '@webpieces/http-routing';\nimport { McpApiBinding } from './McpApiBinding';\nimport { McpApiDispatcher } from './McpApiDispatcher';\nimport { McpProtocol } from './McpProtocol';\nimport { McpToolCatalog } from './McpToolCatalog';\nimport { McpCorrelation } from './McpToolCallRendering';\nimport { McpToolDeniedError } from './McpToolPolicy';\nimport { AuthorizedMcpTools, McpToolRegistry, RegisteredMcpTool } from './McpToolRegistry';\nimport { WpMcpErrorTranslator } from './WpMcpErrorTranslator';\n\nconst log = LogManager.getLogger('McpToolset');\n\n/**\n * The request DTO the edge `LogApiCall` lines report. These boundaries run outside any API dispatch,\n * so there is no contract DTO to log; the step name is the fact worth having.\n */\nclass McpEdgeRequest {\n constructor(public readonly step: string) {}\n}\n\nclass McpSdkOperation {\n toolStage = false;\n constructor(public readonly name: string) {}\n}\n\n/**\n * `Tool.annotations` exactly as tools/list publishes it: the four hints PLUS the human-readable\n * `title` (#1180). The Claude Connectors Directory checks `annotations.title`; clients on the\n * 2025-06-18+ spec render the top-level `Tool.title`, so both carry the one title — the method's\n * `@summary` JSDoc tag, read from the generated catalog (#1296).\n */\nclass McpPublishedToolAnnotations {\n readonly title: string;\n readonly readOnlyHint: boolean;\n readonly destructiveHint: boolean;\n readonly idempotentHint: boolean;\n readonly openWorldHint: boolean;\n\n constructor(tool: RegisteredMcpTool) {\n this.title = tool.title;\n this.readOnlyHint = tool.annotations.readOnlyHint;\n this.destructiveHint = tool.annotations.destructiveHint;\n this.idempotentHint = tool.annotations.idempotentHint;\n this.openWorldHint = tool.annotations.openWorldHint;\n }\n}\n\n/**\n * What ONE MCP request may see and how it is cancelled, established by the transport before the SDK\n * handler runs: the caller's authorized projection, and the signal that fires when the caller goes away.\n */\nexport class McpToolRequest {\n constructor(\n public readonly tools: AuthorizedMcpTools,\n public readonly disconnectSignal: AbortSignal,\n ) {}\n}\n\n/**\n * The official SDK server with Webpieces-owned tools/list and tools/call handlers. It answers\n * `toolInputSchemaJson` from the request-authorized view when the transport has one, so the SDK's\n * pre-dispatch SEP-2243 `Mcp-Param-*` header validation still runs without registering SDK-side tool\n * callbacks.\n */\nclass WpSdkMcpServer extends McpServer {\n constructor(\n info: Implementation,\n options: ServerOptions,\n private readonly schemaSource: (name: string) => RegisteredMcpTool | undefined,\n ) {\n super(info, options);\n }\n\n // webpieces-disable no-any-unknown -- signature is fixed by the SDK's JSON Schema record type\n override toolInputSchemaJson(name: string): Record<string, unknown> | undefined {\n return this.schemaSource(name)?.inputSchema as Record<string, DtoValue> | undefined;\n }\n}\n\n/**\n * McpToolset — the shared core of every MCP transport: WHICH tools are served, and everything that\n * happens to a request once its caller is established.\n *\n * ```ts\n * const toolset = new McpToolset(\n * bindings, // McpApiBinding.local / .remote\n * McpToolCatalog.fromPackages(['@myorg/my-apis'], __dirname),\n * router.authorizationService(),\n * ['user'], // #1187 profiles: WHICH tools this toolset serves\n * );\n * const client = await WpMcpInMemory.connect(toolset); // in-process agent\n * new WpMcpServer(config, toolset).bind(app, options); // HTTP\n * ```\n *\n * It owns the {@link McpToolRegistry}, the per-caller {@link AuthorizedMcpTools} projection, input and\n * output schema validation, dispatch through `binding.invoke`, and the error translation. Its ONLY input\n * about the caller is the CURRENT `RequestContext` — the trusted USER_ID / USER_ROLES / ORG_ID and a\n * `VerifiedMachineCaller`, exactly what `AuthorizationService.authorize` reads. A transport's only job\n * is to establish that context; `@WpAuthorization` on the contract is the single policy everywhere.\n *\n * Profiles are fixed per toolset: `/mcp/user` and `/mcp/admin` are two toolsets (two servers), so paths\n * split by WHICH TOOLS, never by HOW THE CALLER AUTHENTICATES.\n *\n * Before tools/list and tools/call hand an IMPLEMENTATION-kind failure to the translator, they\n * `log.error` it with its stack and the requestId ({@link logImplementationFailure}, #1055). The\n * translator reduces such a failure to \"Internal Error\" for the caller, so that line is the only place\n * its cause exists.\n */\nexport class McpToolset {\n readonly registry: McpToolRegistry;\n readonly profiles: readonly string[];\n /** A short hash of the served tool surface, appended to every server version and tool `_meta`. */\n readonly revision: string;\n private readonly dispatcher = new McpApiDispatcher();\n /** Classifies a failure exactly as the translator will render it, so the two cannot disagree. */\n private readonly boundary = new ApiErrorBoundary();\n private readonly outputSchemas = new ApiJsonSchemaValidator();\n /**\n * tools/list has NO filter chain above it — `LogApiFilter` never sees a list the SDK renders. Wrapping\n * it in `LogApiCall` is this repo's one line on logging.\n */\n private readonly logApiCall = new LogApiCallImpl(new RequestContextApiCallContext());\n /** tools/call reads `McpRegistry`; tools/list replies stay framework-owned. See McpErrorTranslator. */\n private readonly translator: WpMcpErrorTranslator = new WpMcpErrorTranslator();\n\n constructor(\n bindings: readonly McpApiBinding[],\n /**\n * The tools the build published — ONE catalog per bound contract, each the\n * `mcp-<ContractClass>-tools.json` `wp-openapi` wrote beside its library's OpenAPI documents,\n * read with `McpToolCatalog.fromPackages([...], __dirname)`. The only source of a tool's\n * documentation and schemas; `McpToolRegistry` refuses to build unless it holds exactly one\n * catalog per bound contract.\n */\n catalogs: readonly McpToolCatalog[],\n public readonly authorizationService: AuthorizationService,\n /** The union of `@WpMcpTool` profiles this toolset serves, at least one (`[Mcp.DEFAULT]` for untagged tools). */\n profiles: McpToolProfiles,\n ) {\n if (bindings.length === 0)\n throw new Error('McpToolset requires at least one API contract binding.');\n this.profiles = Mcp.profiles(profiles, 'McpToolset profiles');\n this.registry = new McpToolRegistry(bindings, catalogs, authorizationService);\n this.revision = this.calculateRevision();\n }\n\n /** The tools the caller in the CURRENT RequestContext may see, sorted by name. */\n async authorizedTools(): Promise<AuthorizedMcpTools> {\n return AuthorizedMcpTools.create(this.registry, this.authorizationService, this.profiles);\n }\n\n /**\n * A fresh SDK server whose tools/list and tools/call are this toolset's. `request` establishes, per\n * MCP request, what the caller may see; `schemaView` answers the SDK's input-schema lookup (the HTTP\n * transport passes the request's projection, the in-memory one the full registry).\n */\n createSdkServer(\n info: Implementation,\n options: ServerOptions,\n request: () => Promise<McpToolRequest>,\n schemaView: (name: string) => RegisteredMcpTool | undefined,\n ): McpServer {\n const server = new WpSdkMcpServer(info, options, schemaView);\n server.server.removeRequestHandler('tools/list');\n server.server.removeRequestHandler('tools/call');\n server.server.setRequestHandler(\n 'tools/list',\n async (_request: object): Promise<ListToolsResult> =>\n this.sdkBoundary(new McpSdkOperation('tools/list'), () =>\n this.logEdge('tools/list', async () => this.listTools((await request()).tools)),\n ) as Promise<ListToolsResult>,\n );\n server.server.setRequestHandler(\n 'tools/call',\n async (call: CallToolRequest, sdkContext: ServerContext): Promise<CallToolResult> =>\n this.handleCallTool(server, call, request, sdkContext),\n );\n return server;\n }\n\n /** Run one un-filtered edge step under `LogApiCall`, so its failure gets exactly one log line. */\n async logEdge<R>(step: string, work: () => Promise<R>): Promise<R> {\n return this.logApiCall.execute(\n new ApiMethodInfo('server', 'McpEndpoint', step, McpProtocol),\n new McpEdgeRequest(step),\n work,\n );\n }\n\n /** ONE SDK callback catch implementation; the SDK intercepts foreign throws itself. */\n private async sdkBoundary<R>(\n operation: McpSdkOperation,\n work: () => Promise<R>,\n ): Promise<R | CallToolResult> {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- shared SDK response boundary, preserving protocol versus tool envelopes\n try {\n return await work();\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof McpToolDeniedError)\n throw this.translator.unknownTool(error.toolName);\n if (!operation.toolStage && error instanceof ProtocolError) throw error;\n this.logImplementationFailure(operation.name, error);\n if (operation.toolStage) return this.translator.toToolCallResult(error);\n this.translator.toListError(error);\n }\n }\n\n private listTools(view: AuthorizedMcpTools): ListToolsResult {\n const tools: Tool[] = view.tools.map((tool: RegisteredMcpTool) =>\n this.toolDefinition(tool),\n );\n return { tools };\n }\n\n private toolDefinition(tool: RegisteredMcpTool): Tool {\n return {\n name: tool.name,\n title: tool.title,\n description: tool.description,\n inputSchema: tool.inputSchema as Tool['inputSchema'],\n outputSchema: tool.outputSchema as Tool['outputSchema'],\n annotations: new McpPublishedToolAnnotations(tool),\n _meta: { webpiecesRegistryRevision: this.revision },\n };\n }\n\n /**\n * SDK tool callback: an unknown or hidden tool is a JSON-RPC -32602; every failure after the tool\n * is found is an `isError: true` result, identically for local and remote bindings.\n */\n private async handleCallTool(\n server: McpServer,\n call: CallToolRequest,\n request: () => Promise<McpToolRequest>,\n sdkContext: ServerContext,\n ): Promise<CallToolResult> {\n const name = call.params.name;\n const operation = new McpSdkOperation(`tools/call ${name}`);\n return this.sdkBoundary(operation, async () => {\n McpCorrelation.stamp(new McpCorrelation(sdkContext.mcpReq.id, McpProtocol, name));\n const established = await request();\n const tool = established.tools.find(name);\n if (!tool) throw this.translator.unknownTool(name);\n McpCorrelation.stamp(\n new McpCorrelation(sdkContext.mcpReq.id, tool.apiClass, name, tool.operation),\n );\n operation.toolStage = true;\n const result = await this.callTool(tool, call, established, sdkContext);\n result._meta = this.translator.resultMeta(McpCorrelation.requestId());\n return server.server.projectCallToolResult(\n result,\n tool.outputSchema as Record<string, DtoValue>,\n );\n }) as Promise<CallToolResult>;\n }\n\n /**\n * ONE `log.error` line, with the stack and the requestId, for a failure that is THIS server's bug\n * — the `implementation` kind, which the translator reduces to \"Internal Error\" and a requestId\n * for the caller (#1055).\n *\n * `LogApiCall` does not cover this. An MCP output-schema violation is raised AFTER the controller\n * returned, so the only `LogApiCall` line for that call reads `resp-SUCCESS`; before this line\n * existed, the requestId support was handed led to a log with no error in it. Where a\n * `[API-*-resp-FAIL]` line does exist it carries the error's message, not its stack. So this line\n * is not the bare duplicate `ApiErrorBoundary` stopped writing: it is the one that says WHERE.\n *\n * Every other kind (bad-request, end-user, dependency, ...) is a caller's or a downstream's\n * failure, published to the caller with its own text, and `LogApiCall` already records it.\n */\n private logImplementationFailure(entryPoint: string, error: Error): void {\n if (this.boundary.encode(error).kind !== 'implementation') return;\n log.error(\n `MCP ${entryPoint} failed with an implementation error requestId=${McpCorrelation.requestId()}`,\n error,\n );\n }\n\n /** Everything a tool call does. No catch in here: failures propagate to handleCallTool. */\n private async callTool(\n tool: RegisteredMcpTool,\n call: CallToolRequest,\n established: McpToolRequest,\n sdkContext: ServerContext,\n ): Promise<CallToolResult> {\n const caller = this.currentCaller();\n const invocation = new McpInvocationContext(\n sdkContext.mcpReq.id,\n tool.name,\n caller.subject,\n caller.roles,\n AbortSignal.any([sdkContext.mcpReq.signal, established.disconnectSignal]),\n this.progressReporter(sdkContext),\n );\n const args = (call.params.arguments ?? {}) as DtoValue;\n const value = await this.dispatcher.call(tool, args, invocation, this.authorizationService);\n const outputFailure = this.outputSchemas.validate(tool.outputSchema, value);\n if (outputFailure) {\n throw new ApiImplementationError(\n `MCP output schema violation for ${tool.apiClass.name}.${tool.methodName}: ${outputFailure.message}`,\n );\n }\n const structured = this.record(value);\n return {\n content: [{ type: 'text', text: JSON.stringify(structured) }],\n structuredContent: structured,\n };\n }\n\n /** The trusted caller of the CURRENT RequestContext: a verified user, else a verified machine. */\n private currentCaller(): McpCallerFacts {\n const userId = RequestContext.getTrusted(WebpiecesCoreHeaders.USER_ID);\n if (typeof userId === 'string' && userId.trim() !== '') {\n return new McpCallerFacts(userId, CanonicalUserRoles.read());\n }\n const machine = RequestContext.getTrusted(VERIFIED_MACHINE_CALLER);\n if (machine instanceof VerifiedMachineCaller)\n return new McpCallerFacts(machine.identity, []);\n throw new ApiUnauthorizedError(\n 'MCP tool call has no trusted caller in its RequestContext.',\n );\n }\n\n private progressReporter(context: ServerContext): McpProgressReporter | undefined {\n const token = context.mcpReq._meta?.progressToken;\n if (token === undefined) return undefined;\n return async (progress: number, total?: number, message?: string): Promise<void> => {\n await context.mcpReq.notify({\n method: 'notifications/progress',\n params: this.progressParams(token, progress, total, message),\n });\n };\n }\n\n private progressParams(\n progressToken: ProgressToken,\n progress: number,\n total?: number,\n message?: string,\n ): Record<string, DtoValue> {\n const params: Record<string, DtoValue> = { progressToken, progress };\n if (total !== undefined) params['total'] = total;\n if (message !== undefined) params['message'] = message;\n return params;\n }\n\n private calculateRevision(): string {\n const surface = this.registry.tools.map((tool: RegisteredMcpTool) => [\n tool.name,\n tool.title,\n tool.description,\n tool.inputSchema,\n tool.outputSchema,\n tool.annotations,\n tool.profiles,\n ]);\n return createHash('sha256')\n .update(JSON.stringify([this.profiles, surface]))\n .digest('hex')\n .slice(0, 12);\n }\n\n private record(value: DtoValue): Record<string, DtoValue> {\n const jsonValue = JSON.stringify(value);\n if (jsonValue === undefined) {\n throw new ApiImplementationError('MCP structured content is not JSON serializable.');\n }\n const parsed = JSON.parse(jsonValue) as DtoValue;\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return { value: parsed };\n }\n return parsed as Record<string, DtoValue>;\n }\n}\n\n/** Who the current RequestContext says is calling, as McpInvocationContext reports it. */\nclass McpCallerFacts {\n constructor(\n public readonly subject: string,\n public readonly roles: readonly string[],\n ) {}\n}\n"]}
@@ -21,22 +21,20 @@ import { McpHttpErrorBody } from './McpToolCallRendering';
21
21
  * "Internal Error" and nothing else records WHERE it broke (#1055).
22
22
  */
23
23
  export declare class WpMcpErrorTranslator {
24
- private readonly challenge;
25
24
  private readonly boundary;
26
25
  private readonly defaultToolCall;
27
- /**
28
- * @param challenge - the `WWW-Authenticate` value for a 401, read lazily because
29
- * `WpMcpServerConfig` is only fully validated at `bind(...)` time.
30
- */
31
- constructor(challenge: () => string);
32
26
  /**
33
27
  * Boundary #1: everything webpieces itself rejects before the SDK is involved — bearer
34
28
  * verification, `Origin`, a malformed body. This is 401/403/400/500 today and any status
35
29
  * tomorrow, so it produces a VALUE like every other API in the framework and `WpMcpServer` hands
36
30
  * it to the shared express writer. The BODY stays JSON-RPC shaped because an MCP client expects
37
31
  * that — except for an {@link OAuthProtocolError}, which is answered in the RFC 6749/6750 shape; `HttpResponseDto` is generic, and its header LIST carries `WWW-Authenticate`.
32
+ *
33
+ * `challenge` is the `mcpOAuth(...)` discovery value, or `undefined` when this request is not an
34
+ * OAuth one: ONLY the OAuth mode emits `WWW-Authenticate`, so a rejected `jwt()`, OIDC or api-key
35
+ * credential is a plain 401 with no hint of which mode came closest.
38
36
  */
39
- toBearerBoundaryResponse(error: Error): HttpResponseDto<McpHttpErrorBody> | HttpResponseDto<OAuthErrorBody>;
37
+ toBearerBoundaryResponse(error: Error, challenge: string | undefined): HttpResponseDto<McpHttpErrorBody> | HttpResponseDto<OAuthErrorBody>;
40
38
  /**
41
39
  * Boundary #2: `tools/list` has no tool-result channel, so a failure IS a JSON-RPC error. It
42
40
  * THROWS rather than returning a value the caller must remember to throw, and is typed `never`
@@ -26,37 +26,31 @@ const log = core_util_1.LogManager.getLogger('WpMcpErrorTranslator');
26
26
  * "Internal Error" and nothing else records WHERE it broke (#1055).
27
27
  */
28
28
  class WpMcpErrorTranslator {
29
- challenge;
30
29
  boundary = new core_util_1.ApiErrorBoundary();
31
30
  defaultToolCall = new McpToolCallRendering_1.McpDefaultToolCallRenderer();
32
- /**
33
- * @param challenge - the `WWW-Authenticate` value for a 401, read lazily because
34
- * `WpMcpServerConfig` is only fully validated at `bind(...)` time.
35
- */
36
- constructor(challenge) {
37
- this.challenge = challenge;
38
- }
39
31
  /**
40
32
  * Boundary #1: everything webpieces itself rejects before the SDK is involved — bearer
41
33
  * verification, `Origin`, a malformed body. This is 401/403/400/500 today and any status
42
34
  * tomorrow, so it produces a VALUE like every other API in the framework and `WpMcpServer` hands
43
35
  * it to the shared express writer. The BODY stays JSON-RPC shaped because an MCP client expects
44
36
  * that — except for an {@link OAuthProtocolError}, which is answered in the RFC 6749/6750 shape; `HttpResponseDto` is generic, and its header LIST carries `WWW-Authenticate`.
37
+ *
38
+ * `challenge` is the `mcpOAuth(...)` discovery value, or `undefined` when this request is not an
39
+ * OAuth one: ONLY the OAuth mode emits `WWW-Authenticate`, so a rejected `jwt()`, OIDC or api-key
40
+ * credential is a plain 401 with no hint of which mode came closest.
45
41
  */
46
- toBearerBoundaryResponse(error) {
42
+ toBearerBoundaryResponse(error, challenge) {
47
43
  // An authorization service that refuses the bearer with an RFC 6750 code (invalid_token,
48
44
  // insufficient_scope) is answered in the RFC shape, and a 401 without its own challenge
49
45
  // gets this server's, so the client still finds `resource_metadata` (#1176).
50
46
  if (error instanceof core_util_1.OAuthProtocolError) {
51
- return core_util_1.OAuthErrorResponse.toResponse(error, this.challenge());
47
+ return core_util_1.OAuthErrorResponse.toResponse(error, challenge);
52
48
  }
53
49
  const payload = this.boundary.encode(error);
54
50
  const generic = payload.message;
55
51
  switch (payload.kind) {
56
52
  case 'unauthorized':
57
- return this.httpError(401, -32_000, generic, [
58
- new core_util_1.HttpHeader('WWW-Authenticate', this.challenge()),
59
- ]);
53
+ return this.httpError(401, -32_000, generic, challenge === undefined ? [] : [new core_util_1.HttpHeader('WWW-Authenticate', challenge)]);
60
54
  case 'forbidden':
61
55
  return this.httpError(403, -32_000, generic, []);
62
56
  case 'bad-request':
@@ -1 +1 @@
1
- {"version":3,"file":"WpMcpErrorTranslator.js","sourceRoot":"","sources":["../../../../../packages/http/mcp-server/src/WpMcpErrorTranslator.ts"],"names":[],"mappings":"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","sourcesContent":["import { CallToolResult, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server';\nimport {\n ApiErrorBoundary,\n HttpHeader,\n HttpResponseDto,\n HttpResponseStatus,\n LogManager,\n OAuthErrorBody,\n OAuthErrorResponse,\n OAuthProtocolError,\n toError,\n} from '@webpieces/core-util';\nimport { McpRegistry } from './McpRegistry';\nimport {\n McpCorrelation,\n McpDefaultToolCallRenderer,\n McpErrorData,\n McpErrorTranslator,\n McpHttpErrorBody,\n McpHttpErrorDetail,\n MCP_REQUEST_ID_META_KEY,\n} from './McpToolCallRendering';\n\nconst log = LogManager.getLogger('WpMcpErrorTranslator');\n\n/**\n * The ONE place every MCP failure becomes a reply, mirroring `WebpiecesDefaultErrorTranslator` for HTTP: an error\n * in, the exact wire shape out. There is one method per BOUNDARY, and the MCP spec is what makes\n * their renderings distinct at the two interception seams (HTTP ingress and shared SDK callbacks):\n *\n * | Boundary | Who writes HTTP | Failure shape |\n * |--------------------------------|-----------------|-------------------------------------|\n * | pre-SDK bearer / Origin / body | **webpieces** | `HttpResponseDto<McpHttpErrorBody>` |\n * | ... an `OAuthProtocolError` | **webpieces** | `HttpResponseDto<OAuthErrorBody>` |\n * | `tools/list` | the SDK | `throw ProtocolError` |\n * | `tools/call` | the SDK | `CallToolResult` with `isError` |\n *\n * Classification and published text are the shared {@link ApiErrorBoundary} rules. Operator logging\n * is NOT here: `LogApiCall` wraps each of those boundaries in `WpMcpServer`, so every failure gets\n * exactly one `[API-server-resp-*]` line with the request and the identity on it — and\n * `WpMcpServer` adds one `log.error` with the stack and requestId for an IMPLEMENTATION-kind failure\n * at tools/list and tools/call before handing it here, because this class renders that kind as\n * \"Internal Error\" and nothing else records WHERE it broke (#1055).\n */\nexport class WpMcpErrorTranslator {\n private readonly boundary = new ApiErrorBoundary();\n private readonly defaultToolCall = new McpDefaultToolCallRenderer();\n\n /**\n * @param challenge - the `WWW-Authenticate` value for a 401, read lazily because\n * `WpMcpServerConfig` is only fully validated at `bind(...)` time.\n */\n constructor(private readonly challenge: () => string) {}\n\n /**\n * Boundary #1: everything webpieces itself rejects before the SDK is involved — bearer\n * verification, `Origin`, a malformed body. This is 401/403/400/500 today and any status\n * tomorrow, so it produces a VALUE like every other API in the framework and `WpMcpServer` hands\n * it to the shared express writer. The BODY stays JSON-RPC shaped because an MCP client expects\n * that — except for an {@link OAuthProtocolError}, which is answered in the RFC 6749/6750 shape; `HttpResponseDto` is generic, and its header LIST carries `WWW-Authenticate`.\n */\n toBearerBoundaryResponse(\n error: Error,\n ): HttpResponseDto<McpHttpErrorBody> | HttpResponseDto<OAuthErrorBody> {\n // An authorization service that refuses the bearer with an RFC 6750 code (invalid_token,\n // insufficient_scope) is answered in the RFC shape, and a 401 without its own challenge\n // gets this server's, so the client still finds `resource_metadata` (#1176).\n if (error instanceof OAuthProtocolError) {\n return OAuthErrorResponse.toResponse(error, this.challenge());\n }\n const payload = this.boundary.encode(error);\n const generic = payload.message;\n switch (payload.kind) {\n case 'unauthorized':\n return this.httpError(401, -32_000, generic, [\n new HttpHeader('WWW-Authenticate', this.challenge()),\n ]);\n case 'forbidden':\n return this.httpError(403, -32_000, generic, []);\n case 'bad-request':\n return this.httpError(400, ProtocolErrorCode.ParseError, generic, []);\n default:\n return this.httpError(500, ProtocolErrorCode.InternalError, 'Internal Error', []);\n }\n }\n\n /**\n * Boundary #2: `tools/list` has no tool-result channel, so a failure IS a JSON-RPC error. It\n * THROWS rather than returning a value the caller must remember to throw, and is typed `never`\n * so the compiler knows the call is terminal.\n *\n * This is LOAD-BEARING for disclosure, not defence in depth. Measured on\n * `@modelcontextprotocol/server` 2.0.0 and pinned by `McpSdkForeignThrow.spec.ts`: the SDK wraps\n * a foreign (non-`ProtocolError`) throw in `-32603` but copies its `message` onto the wire\n * VERBATIM. So a `tools/list` handler that lets any error escape un-translated publishes that\n * error's operator text to the caller.\n */\n toListError(error: Error): never {\n const payload = this.boundary.encode(error);\n const requestId = McpCorrelation.requestId();\n if (payload.kind === 'bad-request') {\n throw new ProtocolError(\n ProtocolErrorCode.InvalidParams,\n payload.callerMessage ?? payload.message,\n new McpErrorData(requestId),\n );\n }\n const message = payload.kind === 'end-user' ? payload.message : 'Internal Error';\n throw new ProtocolError(\n ProtocolErrorCode.InternalError,\n message,\n new McpErrorData(requestId),\n );\n }\n\n /** tools/call with a name no binding registered: a JSON-RPC protocol error in either MCP era. */\n unknownTool(toolName: string): ProtocolError {\n const requestId = McpCorrelation.requestId();\n log.info(`Unknown MCP tool: ${toolName} requestId=${requestId}`);\n return new ProtocolError(\n ProtocolErrorCode.InvalidParams,\n `Unknown tool: ${toolName}`,\n new McpErrorData(requestId),\n );\n }\n\n /**\n * Boundary #3: every tools/call failure after the tool is found — an `isError: true` result the\n * model sees.\n *\n * ONE unconditional line: {@link McpRegistry.getErrorTranslator} is never undefined, so there is\n * no \"was one registered\" branch. A registered {@link McpErrorTranslator} REPLACES the default and\n * declines by delegating to {@link McpDefaultToolCallRenderer}, so \"no translator registered\" and\n * \"a translator that does not claim this error\" produce byte-identical results.\n */\n toToolCallResult(failure: Error): CallToolResult {\n return this.withDefaultMeta(this.appOrDefault(McpRegistry.getErrorTranslator(), failure));\n }\n\n /** The `_meta` every tools/call result carries so a user can quote its requestId. */\n resultMeta(requestId: string): Record<string, string> {\n return { [MCP_REQUEST_ID_META_KEY]: requestId };\n }\n\n /**\n * An app translator that THROWS must not replace the failure being reported: its own bug is\n * logged here and the ORIGINAL error still renders through the webpieces default, so the model\n * still gets a reply carrying the requestId.\n *\n * This is BUG CONTAINMENT, not a \"did anyone register one\" branch — the same shape as\n * `ClientErrorTranslator.throwIfFailure` on the HTTP side.\n */\n private appOrDefault(renderer: McpErrorTranslator, failure: Error): CallToolResult {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an app translator's own bug must not replace the failure it was asked to render\n try {\n return renderer.toWire(failure);\n } catch (err: unknown) {\n const error = toError(err);\n log.error(\n 'Application McpErrorTranslator.toWire threw; rendering the webpieces default.',\n error,\n );\n return this.defaultToolCall.toWire(failure);\n }\n }\n\n /**\n * webpieces fills `_meta` only when the app left it off, keeping the README's \"every reply\n * carries the requestId\" promise a DEFAULT rather than a restriction: an app that set `_meta`\n * keeps it byte-for-byte.\n */\n private withDefaultMeta(result: CallToolResult): CallToolResult {\n if (result._meta !== undefined) return result;\n return { ...result, _meta: this.resultMeta(McpCorrelation.requestId()) };\n }\n\n private httpError(\n status: number,\n code: number,\n message: string,\n headers: HttpHeader[],\n ): HttpResponseDto<McpHttpErrorBody> {\n const correlation = McpCorrelation.current();\n const requestId = McpCorrelation.requestId();\n return new HttpResponseDto<McpHttpErrorBody>(\n new HttpResponseStatus(status, message),\n headers,\n new McpHttpErrorBody(\n new McpHttpErrorDetail(code, message, new McpErrorData(requestId)),\n correlation.jsonRpcId,\n ),\n );\n }\n}\n"]}
1
+ {"version":3,"file":"WpMcpErrorTranslator.js","sourceRoot":"","sources":["../../../../../packages/http/mcp-server/src/WpMcpErrorTranslator.ts"],"names":[],"mappings":"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","sourcesContent":["import { CallToolResult, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server';\nimport {\n ApiErrorBoundary,\n HttpHeader,\n HttpResponseDto,\n HttpResponseStatus,\n LogManager,\n OAuthErrorBody,\n OAuthErrorResponse,\n OAuthProtocolError,\n toError,\n} from '@webpieces/core-util';\nimport { McpRegistry } from './McpRegistry';\nimport {\n McpCorrelation,\n McpDefaultToolCallRenderer,\n McpErrorData,\n McpErrorTranslator,\n McpHttpErrorBody,\n McpHttpErrorDetail,\n MCP_REQUEST_ID_META_KEY,\n} from './McpToolCallRendering';\n\nconst log = LogManager.getLogger('WpMcpErrorTranslator');\n\n/**\n * The ONE place every MCP failure becomes a reply, mirroring `WebpiecesDefaultErrorTranslator` for HTTP: an error\n * in, the exact wire shape out. There is one method per BOUNDARY, and the MCP spec is what makes\n * their renderings distinct at the two interception seams (HTTP ingress and shared SDK callbacks):\n *\n * | Boundary | Who writes HTTP | Failure shape |\n * |--------------------------------|-----------------|-------------------------------------|\n * | pre-SDK bearer / Origin / body | **webpieces** | `HttpResponseDto<McpHttpErrorBody>` |\n * | ... an `OAuthProtocolError` | **webpieces** | `HttpResponseDto<OAuthErrorBody>` |\n * | `tools/list` | the SDK | `throw ProtocolError` |\n * | `tools/call` | the SDK | `CallToolResult` with `isError` |\n *\n * Classification and published text are the shared {@link ApiErrorBoundary} rules. Operator logging\n * is NOT here: `LogApiCall` wraps each of those boundaries in `WpMcpServer`, so every failure gets\n * exactly one `[API-server-resp-*]` line with the request and the identity on it — and\n * `WpMcpServer` adds one `log.error` with the stack and requestId for an IMPLEMENTATION-kind failure\n * at tools/list and tools/call before handing it here, because this class renders that kind as\n * \"Internal Error\" and nothing else records WHERE it broke (#1055).\n */\nexport class WpMcpErrorTranslator {\n private readonly boundary = new ApiErrorBoundary();\n private readonly defaultToolCall = new McpDefaultToolCallRenderer();\n\n /**\n * Boundary #1: everything webpieces itself rejects before the SDK is involved — bearer\n * verification, `Origin`, a malformed body. This is 401/403/400/500 today and any status\n * tomorrow, so it produces a VALUE like every other API in the framework and `WpMcpServer` hands\n * it to the shared express writer. The BODY stays JSON-RPC shaped because an MCP client expects\n * that — except for an {@link OAuthProtocolError}, which is answered in the RFC 6749/6750 shape; `HttpResponseDto` is generic, and its header LIST carries `WWW-Authenticate`.\n *\n * `challenge` is the `mcpOAuth(...)` discovery value, or `undefined` when this request is not an\n * OAuth one: ONLY the OAuth mode emits `WWW-Authenticate`, so a rejected `jwt()`, OIDC or api-key\n * credential is a plain 401 with no hint of which mode came closest.\n */\n toBearerBoundaryResponse(\n error: Error,\n challenge: string | undefined,\n ): HttpResponseDto<McpHttpErrorBody> | HttpResponseDto<OAuthErrorBody> {\n // An authorization service that refuses the bearer with an RFC 6750 code (invalid_token,\n // insufficient_scope) is answered in the RFC shape, and a 401 without its own challenge\n // gets this server's, so the client still finds `resource_metadata` (#1176).\n if (error instanceof OAuthProtocolError) {\n return OAuthErrorResponse.toResponse(error, challenge);\n }\n const payload = this.boundary.encode(error);\n const generic = payload.message;\n switch (payload.kind) {\n case 'unauthorized':\n return this.httpError(\n 401,\n -32_000,\n generic,\n challenge === undefined ? [] : [new HttpHeader('WWW-Authenticate', challenge)],\n );\n case 'forbidden':\n return this.httpError(403, -32_000, generic, []);\n case 'bad-request':\n return this.httpError(400, ProtocolErrorCode.ParseError, generic, []);\n default:\n return this.httpError(500, ProtocolErrorCode.InternalError, 'Internal Error', []);\n }\n }\n\n /**\n * Boundary #2: `tools/list` has no tool-result channel, so a failure IS a JSON-RPC error. It\n * THROWS rather than returning a value the caller must remember to throw, and is typed `never`\n * so the compiler knows the call is terminal.\n *\n * This is LOAD-BEARING for disclosure, not defence in depth. Measured on\n * `@modelcontextprotocol/server` 2.0.0 and pinned by `McpSdkForeignThrow.spec.ts`: the SDK wraps\n * a foreign (non-`ProtocolError`) throw in `-32603` but copies its `message` onto the wire\n * VERBATIM. So a `tools/list` handler that lets any error escape un-translated publishes that\n * error's operator text to the caller.\n */\n toListError(error: Error): never {\n const payload = this.boundary.encode(error);\n const requestId = McpCorrelation.requestId();\n if (payload.kind === 'bad-request') {\n throw new ProtocolError(\n ProtocolErrorCode.InvalidParams,\n payload.callerMessage ?? payload.message,\n new McpErrorData(requestId),\n );\n }\n const message = payload.kind === 'end-user' ? payload.message : 'Internal Error';\n throw new ProtocolError(\n ProtocolErrorCode.InternalError,\n message,\n new McpErrorData(requestId),\n );\n }\n\n /** tools/call with a name no binding registered: a JSON-RPC protocol error in either MCP era. */\n unknownTool(toolName: string): ProtocolError {\n const requestId = McpCorrelation.requestId();\n log.info(`Unknown MCP tool: ${toolName} requestId=${requestId}`);\n return new ProtocolError(\n ProtocolErrorCode.InvalidParams,\n `Unknown tool: ${toolName}`,\n new McpErrorData(requestId),\n );\n }\n\n /**\n * Boundary #3: every tools/call failure after the tool is found — an `isError: true` result the\n * model sees.\n *\n * ONE unconditional line: {@link McpRegistry.getErrorTranslator} is never undefined, so there is\n * no \"was one registered\" branch. A registered {@link McpErrorTranslator} REPLACES the default and\n * declines by delegating to {@link McpDefaultToolCallRenderer}, so \"no translator registered\" and\n * \"a translator that does not claim this error\" produce byte-identical results.\n */\n toToolCallResult(failure: Error): CallToolResult {\n return this.withDefaultMeta(this.appOrDefault(McpRegistry.getErrorTranslator(), failure));\n }\n\n /** The `_meta` every tools/call result carries so a user can quote its requestId. */\n resultMeta(requestId: string): Record<string, string> {\n return { [MCP_REQUEST_ID_META_KEY]: requestId };\n }\n\n /**\n * An app translator that THROWS must not replace the failure being reported: its own bug is\n * logged here and the ORIGINAL error still renders through the webpieces default, so the model\n * still gets a reply carrying the requestId.\n *\n * This is BUG CONTAINMENT, not a \"did anyone register one\" branch — the same shape as\n * `ClientErrorTranslator.throwIfFailure` on the HTTP side.\n */\n private appOrDefault(renderer: McpErrorTranslator, failure: Error): CallToolResult {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an app translator's own bug must not replace the failure it was asked to render\n try {\n return renderer.toWire(failure);\n } catch (err: unknown) {\n const error = toError(err);\n log.error(\n 'Application McpErrorTranslator.toWire threw; rendering the webpieces default.',\n error,\n );\n return this.defaultToolCall.toWire(failure);\n }\n }\n\n /**\n * webpieces fills `_meta` only when the app left it off, keeping the README's \"every reply\n * carries the requestId\" promise a DEFAULT rather than a restriction: an app that set `_meta`\n * keeps it byte-for-byte.\n */\n private withDefaultMeta(result: CallToolResult): CallToolResult {\n if (result._meta !== undefined) return result;\n return { ...result, _meta: this.resultMeta(McpCorrelation.requestId()) };\n }\n\n private httpError(\n status: number,\n code: number,\n message: string,\n headers: HttpHeader[],\n ): HttpResponseDto<McpHttpErrorBody> {\n const correlation = McpCorrelation.current();\n const requestId = McpCorrelation.requestId();\n return new HttpResponseDto<McpHttpErrorBody>(\n new HttpResponseStatus(status, message),\n headers,\n new McpHttpErrorBody(\n new McpHttpErrorDetail(code, message, new McpErrorData(requestId)),\n correlation.jsonRpcId,\n ),\n );\n }\n}\n"]}
@@ -0,0 +1,30 @@
1
+ import { Client } from '@modelcontextprotocol/client';
2
+ import { McpToolset } from './McpToolset';
3
+ /**
4
+ * The in-memory transport over an {@link McpToolset}: an official MCP SDK `Client` wired to the
5
+ * toolset's server through the SDK's linked `InMemoryTransport` pair, for an agent that runs its model
6
+ * loop in-process and already holds a verified caller in its `RequestContext`.
7
+ *
8
+ * ```ts
9
+ * const client: Client = await WpMcpInMemory.connect(toolset); // once per process
10
+ * // ...inside a request whose caller AuthFilter already verified:
11
+ * const { tools } = await client.listTools(); // only what THIS caller may see
12
+ * await client.callTool({ name: 'stores_pause', arguments: { storeId } });
13
+ * ```
14
+ *
15
+ * NO CREDENTIAL. The caller is whoever is trusted in the `RequestContext` of the code calling
16
+ * `client.listTools()` / `client.callTool()`, READ PER REQUEST and NEVER captured at connect. Apps cache
17
+ * one client per process, so a connect-time capture would run user B's call as user A. The SDK's
18
+ * in-memory transport delivers each message synchronously inside the caller's own `send`, so the
19
+ * toolset's handlers run in the caller's async context; a call made outside any `RequestContext` is
20
+ * refused rather than run as nobody.
21
+ *
22
+ * The caller's scope is the one the tool runs in: a local binding's `InvocationAuthentication` needs
23
+ * that scope to have completed verified ingress (it did, if an endpoint's `AuthFilter` admitted the
24
+ * request). That is also why `McpToolset` refuses a SERVICE_ONLY tool: the caller's own service hop
25
+ * would satisfy it for every model behind it. No tools/list cache hint is sent, so one shared client
26
+ * never serves user A's list to user B.
27
+ */
28
+ export declare class WpMcpInMemory {
29
+ static connect(toolset: McpToolset): Promise<Client>;
30
+ }