@webpieces/mcp-server 0.4.840 → 0.4.841
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 +30 -6
- package/package.json +6 -6
- package/src/McpApiBinding.d.ts +2 -2
- package/src/McpApiBinding.js +7 -3
- package/src/McpApiBinding.js.map +1 -1
- package/src/McpApiDispatcher.d.ts +3 -7
- package/src/McpApiDispatcher.js +10 -39
- package/src/McpApiDispatcher.js.map +1 -1
- package/src/McpAuth.d.ts +9 -12
- package/src/McpAuth.js +10 -10
- package/src/McpAuth.js.map +1 -1
- package/src/McpAuthorityCompileAssertions.d.ts +4 -0
- package/src/McpAuthorityCompileAssertions.js +11 -2
- package/src/McpAuthorityCompileAssertions.js.map +1 -1
- package/src/McpToolPolicy.d.ts +14 -0
- package/src/McpToolPolicy.js +39 -0
- package/src/McpToolPolicy.js.map +1 -0
- package/src/McpToolRegistry.d.ts +8 -3
- package/src/McpToolRegistry.js +27 -8
- package/src/McpToolRegistry.js.map +1 -1
- package/src/WpMcpErrorTranslator.d.ts +1 -1
- package/src/WpMcpErrorTranslator.js +1 -1
- package/src/WpMcpErrorTranslator.js.map +1 -1
- package/src/WpMcpServer.d.ts +9 -8
- package/src/WpMcpServer.js +58 -57
- package/src/WpMcpServer.js.map +1 -1
- package/src/__tests__/WpMcpServerTestFixtures.js +2 -2
- package/src/__tests__/WpMcpServerTestFixtures.js.map +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"McpToolPolicy.js","sourceRoot":"","sources":["../../../../../packages/http/mcp-server/src/McpToolPolicy.ts"],"names":[],"mappings":";;;AAAA,oDAAkF;AAGlF,uGAAuG;AACvG,MAAa,kBAAmB,SAAQ,KAAK;IACb;IAA5B,YAA4B,QAAgB;QACxC,KAAK,CAAC,+BAA+B,CAAC,CAAC;QADf,aAAQ,GAAR,QAAQ,CAAQ;IAE5C,CAAC;CACJ;AAJD,gDAIC;AAED,sFAAsF;AACtF,MAAa,aAAa;IACO;IAA7B,YAA6B,MAAkB;QAAlB,WAAM,GAAN,MAAM,CAAY;IAAG,CAAC;IAEnD,KAAK,CAAC,OAAO,CAAC,MAA2B,EAAE,WAA2B;QAClE,iJAAiJ;QACjJ,IAAI,CAAC;YACD,MAAM,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;YACpD,OAAO,IAAI,CAAC;QAChB,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,mBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,IAAI,KAAK,YAAY,6BAAiB;gBAAE,OAAO,KAAK,CAAC;YACrD,MAAM,KAAK,CAAC;QAChB,CAAC;IACL,CAAC;IAED,KAAK,CAAC,OAAO,CACT,MAA2B,EAC3B,WAA2B,EAC3B,IAAY;QAEZ,IAAI,CAAC,CAAC,MAAM,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;YAAE,MAAM,IAAI,kBAAkB,CAAC,IAAI,CAAC,CAAC;IACvF,CAAC;CACJ;AAtBD,sCAsBC","sourcesContent":["import { ApiForbiddenError, JwtRequirement, toError } from '@webpieces/core-util';\nimport { AuthenticatedCaller, JwtHook } from '@webpieces/http-routing';\n\n/** Internal denial marker; only the shared SDK boundary converts it to unknown-tool protocol shape. */\nexport class McpToolDeniedError extends Error {\n constructor(public readonly toolName: string) {\n super('MCP tool authorization denied');\n }\n}\n\n/** Application policy is the only predicate used for both projection and dispatch. */\nexport class McpToolPolicy<T> {\n constructor(private readonly policy: JwtHook<T>) {}\n\n async permits(caller: AuthenticatedCaller, requirement: JwtRequirement): Promise<boolean> {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- only explicit policy denials are a negative predicate; other failures propagate\n try {\n await this.policy.authorizeJwt(caller, requirement);\n return true;\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof ApiForbiddenError) return false;\n throw error;\n }\n }\n\n async require(\n caller: AuthenticatedCaller,\n requirement: JwtRequirement,\n name: string,\n ): Promise<void> {\n if (!(await this.permits(caller, requirement))) throw new McpToolDeniedError(name);\n }\n}\n"]}
|
package/src/McpToolRegistry.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ApiJsonSchema, AuthMeta, EndpointOperation, WpMcpJwtAuthMetadata, WpMcpToolHints } from '@webpieces/core-util';
|
|
2
|
-
import { ClassType } from '@webpieces/http-routing';
|
|
2
|
+
import { AuthenticatedCaller, ClassType, JwtHook } from '@webpieces/http-routing';
|
|
3
3
|
import { McpApiBinding } from './McpApiBinding';
|
|
4
4
|
import { McpToolCatalog } from './McpToolCatalog';
|
|
5
5
|
/** Fully resolved contract metadata for one MCP tool. */
|
|
@@ -16,8 +16,6 @@ export declare class RegisteredMcpTool {
|
|
|
16
16
|
readonly inputSchema: ApiJsonSchema;
|
|
17
17
|
readonly outputSchema: ApiJsonSchema;
|
|
18
18
|
constructor(apiClass: ClassType, methodName: string, name: string, description: string, annotations: WpMcpToolHints, operation: EndpointOperation, authMeta: AuthMeta, mcpAuth: WpMcpJwtAuthMetadata, binding: McpApiBinding, inputSchema: ApiJsonSchema, outputSchema: ApiJsonSchema);
|
|
19
|
-
/** Listing is advisory; MCP authorization is rechecked before the endpoint auth boundary. */
|
|
20
|
-
isVisibleTo(listingRoles: readonly string[]): boolean;
|
|
21
19
|
}
|
|
22
20
|
/**
|
|
23
21
|
* Fail-fast registry: the CONTRACT says which methods are tools and how they are authorized, and the
|
|
@@ -57,3 +55,10 @@ export declare class McpToolRegistry {
|
|
|
57
55
|
/** The generated entry for this tool, or a recorded refusal naming what the build DID publish. */
|
|
58
56
|
private published;
|
|
59
57
|
}
|
|
58
|
+
/** Request-specific authorized projection, shared by documentation, schema lookup and name lookup. */
|
|
59
|
+
export declare class AuthorizedMcpTools {
|
|
60
|
+
readonly tools: readonly RegisteredMcpTool[];
|
|
61
|
+
constructor(tools: readonly RegisteredMcpTool[]);
|
|
62
|
+
find(name: string): RegisteredMcpTool | undefined;
|
|
63
|
+
static create<T>(registry: McpToolRegistry, caller: AuthenticatedCaller, policy: JwtHook<T>): Promise<AuthorizedMcpTools>;
|
|
64
|
+
}
|
package/src/McpToolRegistry.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.McpToolRegistry = exports.RegisteredMcpTool = void 0;
|
|
3
|
+
exports.AuthorizedMcpTools = exports.McpToolRegistry = exports.RegisteredMcpTool = void 0;
|
|
4
|
+
const McpToolPolicy_1 = require("./McpToolPolicy");
|
|
4
5
|
const core_util_1 = require("@webpieces/core-util");
|
|
5
6
|
/** Fully resolved contract metadata for one MCP tool. */
|
|
6
7
|
class RegisteredMcpTool {
|
|
@@ -28,11 +29,6 @@ class RegisteredMcpTool {
|
|
|
28
29
|
this.inputSchema = inputSchema;
|
|
29
30
|
this.outputSchema = outputSchema;
|
|
30
31
|
}
|
|
31
|
-
/** Listing is advisory; MCP authorization is rechecked before the endpoint auth boundary. */
|
|
32
|
-
isVisibleTo(listingRoles) {
|
|
33
|
-
const required = (0, core_util_1.rolesRequired)(this.mcpAuth.requirement);
|
|
34
|
-
return (required.length === 0 || required.some((role) => listingRoles.includes(role)));
|
|
35
|
-
}
|
|
36
32
|
}
|
|
37
33
|
exports.RegisteredMcpTool = RegisteredMcpTool;
|
|
38
34
|
/**
|
|
@@ -166,7 +162,7 @@ class McpCatalogPairing {
|
|
|
166
162
|
`wrote no ${core_util_1.McpToolCatalogFile.fileNameFor(apiClass.name)} for it. Delete its ` +
|
|
167
163
|
'McpApiBinding — it publishes nothing.'
|
|
168
164
|
: `The server binds ${apiClass.name}, and no ${core_util_1.McpToolCatalogFile.fileNameFor(apiClass.name)} ` +
|
|
169
|
-
|
|
165
|
+
'was handed to it. Add the api library that declares it to McpToolCatalog.fromPackages([...]) ' +
|
|
170
166
|
'and rebuild that library (its openapi-generate target writes the file).');
|
|
171
167
|
return undefined;
|
|
172
168
|
}
|
|
@@ -185,7 +181,9 @@ class McpCatalogPairing {
|
|
|
185
181
|
}
|
|
186
182
|
if (this.problems.length === 0)
|
|
187
183
|
return;
|
|
188
|
-
const directories = [
|
|
184
|
+
const directories = [
|
|
185
|
+
...new Set(this.catalogs.map((catalog) => catalog.directory)),
|
|
186
|
+
];
|
|
189
187
|
throw new core_util_1.McpToolCatalogError('The MCP tool catalogs do not match the bound contracts:\n' +
|
|
190
188
|
this.problems.map((text) => ` - ${text}`).join('\n') +
|
|
191
189
|
'\nDirectories searched for mcp-<ContractClass>-tools.json:\n' +
|
|
@@ -195,4 +193,25 @@ class McpCatalogPairing {
|
|
|
195
193
|
'\n', 'Each bound contract needs exactly the one catalog its api library build writes, and nothing else.');
|
|
196
194
|
}
|
|
197
195
|
}
|
|
196
|
+
/** Request-specific authorized projection, shared by documentation, schema lookup and name lookup. */
|
|
197
|
+
class AuthorizedMcpTools {
|
|
198
|
+
tools;
|
|
199
|
+
constructor(tools) {
|
|
200
|
+
this.tools = tools;
|
|
201
|
+
}
|
|
202
|
+
find(name) {
|
|
203
|
+
return this.tools.find((tool) => tool.name === name);
|
|
204
|
+
}
|
|
205
|
+
// webpieces-disable no-function-outside-class -- asynchronous projection factory
|
|
206
|
+
static async create(registry, caller, policy) {
|
|
207
|
+
const tools = [];
|
|
208
|
+
const authorization = new McpToolPolicy_1.McpToolPolicy(policy);
|
|
209
|
+
for (const tool of registry.tools) {
|
|
210
|
+
if (await authorization.permits(caller, tool.mcpAuth.requirement))
|
|
211
|
+
tools.push(tool);
|
|
212
|
+
}
|
|
213
|
+
return new AuthorizedMcpTools(tools);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
exports.AuthorizedMcpTools = AuthorizedMcpTools;
|
|
198
217
|
//# sourceMappingURL=McpToolRegistry.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"McpToolRegistry.js","sourceRoot":"","sources":["../../../../../packages/http/mcp-server/src/McpToolRegistry.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n ApiJsonSchema,\n AuthMeta,\n assertApiTypeMatchesMcpTools,\n getAuthMeta,\n getEndpointKind,\n getEndpointOperation,\n getEndpoints,\n getWpMcpTools,\n getWpMcpAuthJwt,\n rolesRequired,\n EndpointOperation,\n McpToolCatalogError,\n McpToolCatalogFile,\n McpToolDefinition,\n WpMcpJwtAuthMetadata,\n WpMcpToolHints,\n WpMcpToolMetadata,\n} from '@webpieces/core-util';\nimport { ClassType } from '@webpieces/http-routing';\nimport { McpApiBinding } from './McpApiBinding';\nimport { McpToolCatalog } from './McpToolCatalog';\n\n/** Fully resolved contract metadata for one MCP tool. */\nexport class RegisteredMcpTool {\n constructor(\n public readonly apiClass: ClassType,\n public readonly methodName: string,\n public readonly name: string,\n public readonly description: string,\n public readonly annotations: WpMcpToolHints,\n public readonly operation: EndpointOperation,\n public readonly authMeta: AuthMeta,\n public readonly mcpAuth: WpMcpJwtAuthMetadata,\n public readonly binding: McpApiBinding,\n public readonly inputSchema: ApiJsonSchema,\n public readonly outputSchema: ApiJsonSchema,\n ) {}\n\n /** Listing is advisory; MCP authorization is rechecked before the endpoint auth boundary. */\n isVisibleTo(listingRoles: readonly string[]): boolean {\n const required = rolesRequired(this.mcpAuth.requirement);\n return (\n required.length === 0 || required.some((role: string) => listingRoles.includes(role))\n );\n }\n}\n\n/**\n * Fail-fast registry: the CONTRACT says which methods are tools and how they are authorized, and the\n * build's generated catalog OF THAT CONTRACT says what each tool's documentation and schemas are.\n *\n * ## Why the schemas come from the catalog and not from here\n *\n * They used to be built at boot by `DtoSchemaBuilder` walking reflect-metadata. That made the\n * document a partner reads and the schema a server accepts two independent derivations of one\n * contract, free to disagree; and it could not see a field's declared TYPE at all, so every fact\n * about a field had to be restated in a `@WpDtoField` argument. #983 measured the compiler against\n * it and found the compiler reproduces all of it, so #984 deleted the decorator and this registry\n * now READS the same catalogs the documents were rendered beside.\n *\n * ## One catalog per bound contract, exactly (#1021)\n *\n * `wp-openapi` writes one `mcp-<ContractClass>-tools.json` per contract, and this registry pairs every\n * `McpApiBinding` with the file of its own contract. Three mismatches refuse the boot, all reported at\n * once, each naming every directory the catalogs were read from:\n *\n * - a bound contract with no catalog — its tools were never given a schema by the build;\n * - a catalog whose contract is not bound — the server was handed tools it does not serve;\n * - a tool name declared twice — the protocol namespace is flat.\n *\n * A registered `@WpMcpTool` the catalog does not name is likewise a HARD FAILURE, not a fallback: a\n * tool the build never saw is a tool whose schema nobody checked.\n */\nexport class McpToolRegistry {\n readonly tools: readonly RegisteredMcpTool[];\n\n constructor(bindings: readonly McpApiBinding[], catalogs: readonly McpToolCatalog[]) {\n const pairing = new McpCatalogPairing(catalogs);\n const registered: RegisteredMcpTool[] = [];\n const names = new Set<string>();\n for (const binding of bindings) {\n const apiClass = binding.api;\n // MCP membership is declared twice — on @ApiType and on the methods — and this is the\n // one call that makes the two disagree impossible. It existed before #984 and nothing\n // reached it, so a contract could carry tools nobody published to agents.\n assertApiTypeMatchesMcpTools(apiClass);\n const declared = getWpMcpTools(apiClass);\n const catalog = pairing.claim(apiClass, declared.length);\n for (const metadata of declared) {\n if (names.has(metadata.name)) {\n pairing.problem(\n `Duplicate @WpMcpTool name '${metadata.name}' (again on ${apiClass.name}). Tool ` +\n 'names are the protocol identity and must be globally unique — rename one.',\n );\n continue;\n }\n names.add(metadata.name);\n const tool = this.resolve(apiClass, metadata, binding, catalog, pairing);\n if (tool !== undefined) registered.push(tool);\n }\n }\n pairing.assertComplete();\n this.tools = registered;\n }\n\n find(name: string): RegisteredMcpTool | undefined {\n return this.tools.find((tool: RegisteredMcpTool) => tool.name === name);\n }\n\n /**\n * The contract checks THROW at once — they are about the contract source, not the build. A tool\n * missing from its catalog is RECORDED on `pairing`, so one boot names every catalog mismatch.\n */\n private resolve(\n apiClass: ClassType,\n metadata: WpMcpToolMetadata,\n binding: McpApiBinding,\n catalog: McpToolCatalog | undefined,\n pairing: McpCatalogPairing,\n ): RegisteredMcpTool | undefined {\n const endpoints = getEndpoints(apiClass) ?? {};\n if (!endpoints[metadata.methodName]) {\n throw new Error(\n `@WpMcpTool ${apiClass.name}.${metadata.methodName} must also be an @Endpoint.`,\n );\n }\n if (getEndpointKind(apiClass, metadata.methodName) !== 'rpc') {\n throw new Error(\n `@WpMcpTool ${apiClass.name}.${metadata.methodName} must be an RPC endpoint.`,\n );\n }\n const authMeta = getAuthMeta(apiClass, metadata.methodName);\n if (!authMeta)\n throw new Error(`@WpMcpTool ${apiClass.name}.${metadata.methodName} has no HTTP auth.`);\n const mcpAuth = getWpMcpAuthJwt(apiClass, metadata.methodName);\n if (!mcpAuth) {\n throw new Error(\n `@WpMcpTool ${apiClass.name}.${metadata.methodName} must declare @WpMcpAuthJwt(...).`,\n );\n }\n binding.validateMethod(metadata.methodName);\n if (catalog === undefined) return undefined;\n const published = this.published(apiClass, metadata, catalog, pairing);\n if (published === undefined) return undefined;\n return new RegisteredMcpTool(\n apiClass,\n metadata.methodName,\n metadata.name,\n published.description,\n published.hints,\n getEndpointOperation(apiClass, metadata.methodName),\n authMeta,\n mcpAuth,\n binding,\n published.inputSchema,\n published.outputSchema,\n );\n }\n\n /** The generated entry for this tool, or a recorded refusal naming what the build DID publish. */\n private published(\n apiClass: ClassType,\n metadata: WpMcpToolMetadata,\n catalog: McpToolCatalog,\n pairing: McpCatalogPairing,\n ): McpToolDefinition | undefined {\n const published = catalog.find(metadata.name);\n if (published === undefined) {\n pairing.problem(\n `@WpMcpTool ${apiClass.name}.${metadata.methodName} publishes '${metadata.name}', which ` +\n `${catalog.file.fileName} (${catalog.directory}) does not contain. It has: ` +\n `${catalog.names().join(', ') || '(nothing)'}. The catalog is older than the contract — ` +\n 'rebuild the api library; a tool the build never saw is a tool whose schema nobody checked.',\n );\n }\n return published;\n }\n}\n\n/**\n * Pairs each bound contract with the catalog of exactly that contract, and collects every mismatch so\n * the boot refusal lists them all at once instead of one per restart.\n */\nclass McpCatalogPairing {\n private readonly byContract = new Map<string, McpToolCatalog>();\n private readonly claimed = new Set<string>();\n private readonly problems: string[] = [];\n\n constructor(private readonly catalogs: readonly McpToolCatalog[]) {\n for (const catalog of catalogs) {\n const earlier = this.byContract.get(catalog.contractName);\n if (earlier !== undefined) {\n this.problem(\n `Two MCP tool catalogs for ${catalog.contractName}: ${earlier.directory} and ` +\n `${catalog.directory}. Name each api library in fromPackages(...) once, and bind ` +\n 'each contract from exactly one of them.',\n );\n continue;\n }\n this.byContract.set(catalog.contractName, catalog);\n }\n }\n\n /** The catalog of `apiClass`, recording a problem when there is none. */\n claim(apiClass: ClassType, declaredTools: number): McpToolCatalog | undefined {\n const catalog = this.byContract.get(apiClass.name);\n this.claimed.add(apiClass.name);\n if (catalog !== undefined) return catalog;\n this.problem(\n declaredTools === 0\n ? `The server binds ${apiClass.name}, which declares no @WpMcpTool method, so the build ` +\n `wrote no ${McpToolCatalogFile.fileNameFor(apiClass.name)} for it. Delete its ` +\n 'McpApiBinding — it publishes nothing.'\n : `The server binds ${apiClass.name}, and no ${McpToolCatalogFile.fileNameFor(apiClass.name)} ` +\n \"was handed to it. Add the api library that declares it to McpToolCatalog.fromPackages([...]) \" +\n 'and rebuild that library (its openapi-generate target writes the file).',\n );\n return undefined;\n }\n\n problem(text: string): void {\n this.problems.push(text);\n }\n\n /** Refuse the boot on any mismatch, naming every problem and every directory searched. */\n assertComplete(): void {\n for (const catalog of this.byContract.values()) {\n if (this.claimed.has(catalog.contractName)) continue;\n this.problem(\n `${catalog.file.fileName} (${catalog.directory}) is the catalog of ${catalog.contractName}, ` +\n `which the server does not bind. Bind it with McpApiBinding.local(${catalog.contractName}, ...) ` +\n 'or .remote(...), or leave it out of toolCatalogs, e.g. ' +\n `catalogs.filter((catalog: McpToolCatalog) => catalog.contractName !== '${catalog.contractName}').`,\n );\n }\n if (this.problems.length === 0) return;\n const directories = [...new Set(this.catalogs.map((catalog: McpToolCatalog) => catalog.directory))];\n throw new McpToolCatalogError(\n 'The MCP tool catalogs do not match the bound contracts:\\n' +\n this.problems.map((text: string) => ` - ${text}`).join('\\n') +\n '\\nDirectories searched for mcp-<ContractClass>-tools.json:\\n' +\n (directories.length === 0\n ? ' (none — toolCatalogs is empty)'\n : directories.map((dir: string) => ` ${dir}`).join('\\n')) +\n '\\n',\n 'Each bound contract needs exactly the one catalog its api library build writes, and nothing else.',\n );\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"McpToolRegistry.js","sourceRoot":"","sources":["../../../../../packages/http/mcp-server/src/McpToolRegistry.ts"],"names":[],"mappings":"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","sourcesContent":["import { McpToolPolicy } from './McpToolPolicy';\nimport {\n ApiJsonSchema,\n AuthMeta,\n assertApiTypeMatchesMcpTools,\n getAuthMeta,\n getEndpointKind,\n getEndpointOperation,\n getEndpoints,\n getWpMcpTools,\n getWpMcpAuthJwt,\n EndpointOperation,\n McpToolCatalogError,\n McpToolCatalogFile,\n McpToolDefinition,\n WpMcpJwtAuthMetadata,\n WpMcpToolHints,\n WpMcpToolMetadata,\n} from '@webpieces/core-util';\nimport { AuthenticatedCaller, ClassType, JwtHook } from '@webpieces/http-routing';\nimport { McpApiBinding } from './McpApiBinding';\nimport { McpToolCatalog } from './McpToolCatalog';\n\n/** Fully resolved contract metadata for one MCP tool. */\nexport class RegisteredMcpTool {\n constructor(\n public readonly apiClass: ClassType,\n public readonly methodName: string,\n public readonly name: string,\n public readonly description: string,\n public readonly annotations: WpMcpToolHints,\n public readonly operation: EndpointOperation,\n public readonly authMeta: AuthMeta,\n public readonly mcpAuth: WpMcpJwtAuthMetadata,\n public readonly binding: McpApiBinding,\n public readonly inputSchema: ApiJsonSchema,\n public readonly outputSchema: ApiJsonSchema,\n ) {}\n}\n\n/**\n * Fail-fast registry: the CONTRACT says which methods are tools and how they are authorized, and the\n * build's generated catalog OF THAT CONTRACT says what each tool's documentation and schemas are.\n *\n * ## Why the schemas come from the catalog and not from here\n *\n * They used to be built at boot by `DtoSchemaBuilder` walking reflect-metadata. That made the\n * document a partner reads and the schema a server accepts two independent derivations of one\n * contract, free to disagree; and it could not see a field's declared TYPE at all, so every fact\n * about a field had to be restated in a `@WpDtoField` argument. #983 measured the compiler against\n * it and found the compiler reproduces all of it, so #984 deleted the decorator and this registry\n * now READS the same catalogs the documents were rendered beside.\n *\n * ## One catalog per bound contract, exactly (#1021)\n *\n * `wp-openapi` writes one `mcp-<ContractClass>-tools.json` per contract, and this registry pairs every\n * `McpApiBinding` with the file of its own contract. Three mismatches refuse the boot, all reported at\n * once, each naming every directory the catalogs were read from:\n *\n * - a bound contract with no catalog — its tools were never given a schema by the build;\n * - a catalog whose contract is not bound — the server was handed tools it does not serve;\n * - a tool name declared twice — the protocol namespace is flat.\n *\n * A registered `@WpMcpTool` the catalog does not name is likewise a HARD FAILURE, not a fallback: a\n * tool the build never saw is a tool whose schema nobody checked.\n */\nexport class McpToolRegistry {\n readonly tools: readonly RegisteredMcpTool[];\n\n constructor(bindings: readonly McpApiBinding[], catalogs: readonly McpToolCatalog[]) {\n const pairing = new McpCatalogPairing(catalogs);\n const registered: RegisteredMcpTool[] = [];\n const names = new Set<string>();\n for (const binding of bindings) {\n const apiClass = binding.api;\n // MCP membership is declared twice — on @ApiType and on the methods — and this is the\n // one call that makes the two disagree impossible. It existed before #984 and nothing\n // reached it, so a contract could carry tools nobody published to agents.\n assertApiTypeMatchesMcpTools(apiClass);\n const declared = getWpMcpTools(apiClass);\n const catalog = pairing.claim(apiClass, declared.length);\n for (const metadata of declared) {\n if (names.has(metadata.name)) {\n pairing.problem(\n `Duplicate @WpMcpTool name '${metadata.name}' (again on ${apiClass.name}). Tool ` +\n 'names are the protocol identity and must be globally unique — rename one.',\n );\n continue;\n }\n names.add(metadata.name);\n const tool = this.resolve(apiClass, metadata, binding, catalog, pairing);\n if (tool !== undefined) registered.push(tool);\n }\n }\n pairing.assertComplete();\n this.tools = registered;\n }\n\n find(name: string): RegisteredMcpTool | undefined {\n return this.tools.find((tool: RegisteredMcpTool) => tool.name === name);\n }\n\n /**\n * The contract checks THROW at once — they are about the contract source, not the build. A tool\n * missing from its catalog is RECORDED on `pairing`, so one boot names every catalog mismatch.\n */\n private resolve(\n apiClass: ClassType,\n metadata: WpMcpToolMetadata,\n binding: McpApiBinding,\n catalog: McpToolCatalog | undefined,\n pairing: McpCatalogPairing,\n ): RegisteredMcpTool | undefined {\n const endpoints = getEndpoints(apiClass) ?? {};\n if (!endpoints[metadata.methodName]) {\n throw new Error(\n `@WpMcpTool ${apiClass.name}.${metadata.methodName} must also be an @Endpoint.`,\n );\n }\n if (getEndpointKind(apiClass, metadata.methodName) !== 'rpc') {\n throw new Error(\n `@WpMcpTool ${apiClass.name}.${metadata.methodName} must be an RPC endpoint.`,\n );\n }\n const authMeta = getAuthMeta(apiClass, metadata.methodName);\n if (!authMeta)\n throw new Error(`@WpMcpTool ${apiClass.name}.${metadata.methodName} has no HTTP auth.`);\n const mcpAuth = getWpMcpAuthJwt(apiClass, metadata.methodName);\n if (!mcpAuth) {\n throw new Error(\n `@WpMcpTool ${apiClass.name}.${metadata.methodName} must declare @WpMcpAuthJwt(...).`,\n );\n }\n binding.validateMethod(metadata.methodName);\n if (catalog === undefined) return undefined;\n const published = this.published(apiClass, metadata, catalog, pairing);\n if (published === undefined) return undefined;\n return new RegisteredMcpTool(\n apiClass,\n metadata.methodName,\n metadata.name,\n published.description,\n published.hints,\n getEndpointOperation(apiClass, metadata.methodName),\n authMeta,\n mcpAuth,\n binding,\n published.inputSchema,\n published.outputSchema,\n );\n }\n\n /** The generated entry for this tool, or a recorded refusal naming what the build DID publish. */\n private published(\n apiClass: ClassType,\n metadata: WpMcpToolMetadata,\n catalog: McpToolCatalog,\n pairing: McpCatalogPairing,\n ): McpToolDefinition | undefined {\n const published = catalog.find(metadata.name);\n if (published === undefined) {\n pairing.problem(\n `@WpMcpTool ${apiClass.name}.${metadata.methodName} publishes '${metadata.name}', which ` +\n `${catalog.file.fileName} (${catalog.directory}) does not contain. It has: ` +\n `${catalog.names().join(', ') || '(nothing)'}. The catalog is older than the contract — ` +\n 'rebuild the api library; a tool the build never saw is a tool whose schema nobody checked.',\n );\n }\n return published;\n }\n}\n\n/**\n * Pairs each bound contract with the catalog of exactly that contract, and collects every mismatch so\n * the boot refusal lists them all at once instead of one per restart.\n */\nclass McpCatalogPairing {\n private readonly byContract = new Map<string, McpToolCatalog>();\n private readonly claimed = new Set<string>();\n private readonly problems: string[] = [];\n\n constructor(private readonly catalogs: readonly McpToolCatalog[]) {\n for (const catalog of catalogs) {\n const earlier = this.byContract.get(catalog.contractName);\n if (earlier !== undefined) {\n this.problem(\n `Two MCP tool catalogs for ${catalog.contractName}: ${earlier.directory} and ` +\n `${catalog.directory}. Name each api library in fromPackages(...) once, and bind ` +\n 'each contract from exactly one of them.',\n );\n continue;\n }\n this.byContract.set(catalog.contractName, catalog);\n }\n }\n\n /** The catalog of `apiClass`, recording a problem when there is none. */\n claim(apiClass: ClassType, declaredTools: number): McpToolCatalog | undefined {\n const catalog = this.byContract.get(apiClass.name);\n this.claimed.add(apiClass.name);\n if (catalog !== undefined) return catalog;\n this.problem(\n declaredTools === 0\n ? `The server binds ${apiClass.name}, which declares no @WpMcpTool method, so the build ` +\n `wrote no ${McpToolCatalogFile.fileNameFor(apiClass.name)} for it. Delete its ` +\n 'McpApiBinding — it publishes nothing.'\n : `The server binds ${apiClass.name}, and no ${McpToolCatalogFile.fileNameFor(apiClass.name)} ` +\n 'was handed to it. Add the api library that declares it to McpToolCatalog.fromPackages([...]) ' +\n 'and rebuild that library (its openapi-generate target writes the file).',\n );\n return undefined;\n }\n\n problem(text: string): void {\n this.problems.push(text);\n }\n\n /** Refuse the boot on any mismatch, naming every problem and every directory searched. */\n assertComplete(): void {\n for (const catalog of this.byContract.values()) {\n if (this.claimed.has(catalog.contractName)) continue;\n this.problem(\n `${catalog.file.fileName} (${catalog.directory}) is the catalog of ${catalog.contractName}, ` +\n `which the server does not bind. Bind it with McpApiBinding.local(${catalog.contractName}, ...) ` +\n 'or .remote(...), or leave it out of toolCatalogs, e.g. ' +\n `catalogs.filter((catalog: McpToolCatalog) => catalog.contractName !== '${catalog.contractName}').`,\n );\n }\n if (this.problems.length === 0) return;\n const directories = [\n ...new Set(this.catalogs.map((catalog: McpToolCatalog) => catalog.directory)),\n ];\n throw new McpToolCatalogError(\n 'The MCP tool catalogs do not match the bound contracts:\\n' +\n this.problems.map((text: string) => ` - ${text}`).join('\\n') +\n '\\nDirectories searched for mcp-<ContractClass>-tools.json:\\n' +\n (directories.length === 0\n ? ' (none — toolCatalogs is empty)'\n : directories.map((dir: string) => ` ${dir}`).join('\\n')) +\n '\\n',\n 'Each bound contract needs exactly the one catalog its api library build writes, and nothing else.',\n );\n }\n}\n\n/** Request-specific authorized projection, shared by documentation, schema lookup and name lookup. */\nexport class AuthorizedMcpTools {\n constructor(public readonly tools: readonly RegisteredMcpTool[]) {}\n\n find(name: string): RegisteredMcpTool | undefined {\n return this.tools.find((tool: RegisteredMcpTool) => tool.name === name);\n }\n\n // webpieces-disable no-function-outside-class -- asynchronous projection factory\n static async create<T>(\n registry: McpToolRegistry,\n caller: AuthenticatedCaller,\n policy: JwtHook<T>,\n ): Promise<AuthorizedMcpTools> {\n const tools: RegisteredMcpTool[] = [];\n const authorization = new McpToolPolicy(policy);\n for (const tool of registry.tools) {\n if (await authorization.permits(caller, tool.mcpAuth.requirement)) tools.push(tool);\n }\n return new AuthorizedMcpTools(tools);\n }\n}\n"]}
|
|
@@ -4,7 +4,7 @@ import { McpHttpErrorBody } from './McpToolCallRendering';
|
|
|
4
4
|
/**
|
|
5
5
|
* The ONE place every MCP failure becomes a reply, mirroring `WebpiecesDefaultErrorTranslator` for HTTP: an error
|
|
6
6
|
* in, the exact wire shape out. There is one method per BOUNDARY, and the MCP spec is what makes
|
|
7
|
-
*
|
|
7
|
+
* their renderings distinct at the two interception seams (HTTP ingress and shared SDK callbacks):
|
|
8
8
|
*
|
|
9
9
|
* | Boundary | Who writes HTTP | Failure shape |
|
|
10
10
|
* |--------------------------------|-----------------|-------------------------------------|
|
|
@@ -9,7 +9,7 @@ const log = core_util_1.LogManager.getLogger('WpMcpErrorTranslator');
|
|
|
9
9
|
/**
|
|
10
10
|
* The ONE place every MCP failure becomes a reply, mirroring `WebpiecesDefaultErrorTranslator` for HTTP: an error
|
|
11
11
|
* in, the exact wire shape out. There is one method per BOUNDARY, and the MCP spec is what makes
|
|
12
|
-
*
|
|
12
|
+
* their renderings distinct at the two interception seams (HTTP ingress and shared SDK callbacks):
|
|
13
13
|
*
|
|
14
14
|
* | Boundary | Who writes HTTP | Failure shape |
|
|
15
15
|
* |--------------------------------|-----------------|-------------------------------------|
|
|
@@ -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 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 * them three rather than one:\n *\n * | Boundary | Who writes HTTP | Failure shape |\n * |--------------------------------|-----------------|-------------------------------------|\n * | pre-SDK bearer / Origin / body | **webpieces** | `HttpResponseDto<McpHttpErrorBody>` |\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; `HttpResponseDto` is generic, and its header LIST carries `WWW-Authenticate`.\n */\n toBearerBoundaryResponse(error: Error): HttpResponseDto<McpHttpErrorBody> {\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 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 * | `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; `HttpResponseDto` is generic, and its header LIST carries `WWW-Authenticate`.\n */\n toBearerBoundaryResponse(error: Error): HttpResponseDto<McpHttpErrorBody> {\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"]}
|
package/src/WpMcpServer.d.ts
CHANGED
|
@@ -9,9 +9,9 @@ import { McpBindOptions } from './McpBindOptions';
|
|
|
9
9
|
* every shipping client unreachable (issue #969), and the MCP lifecycle spec is explicit that a
|
|
10
10
|
* server answers with a revision it supports rather than erroring.
|
|
11
11
|
*
|
|
12
|
-
* Error
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Error boundaries: HTTP ingress owns pre-SDK replies; one shared SDK callback wrapper owns
|
|
13
|
+
* protocol/list/name-resolution errors and authorized tool-result errors. The SDK writes callback
|
|
14
|
+
* replies, so it cannot safely delegate those failures back to HTTP ingress.
|
|
15
15
|
* `subscriptions/listen` is served entirely by the SDK's listen router, so Webpieces has no handler
|
|
16
16
|
* (and no catch) there. The external bearer is verified exactly once per POST, at the HTTP
|
|
17
17
|
* boundary, before the SDK is involved.
|
|
@@ -23,6 +23,7 @@ import { McpBindOptions } from './McpBindOptions';
|
|
|
23
23
|
*/
|
|
24
24
|
export declare class WpMcpServer<TGrant, TMintRequest> {
|
|
25
25
|
private readonly config;
|
|
26
|
+
private readonly callerContext;
|
|
26
27
|
private readonly dispatcher;
|
|
27
28
|
/** Classifies a failure exactly as the translator will render it, so the two cannot disagree. */
|
|
28
29
|
private readonly boundary;
|
|
@@ -66,10 +67,10 @@ export declare class WpMcpServer<TGrant, TMintRequest> {
|
|
|
66
67
|
toolsChanged(): void;
|
|
67
68
|
get registryRevision(): string | undefined;
|
|
68
69
|
private createHandler;
|
|
69
|
-
/**
|
|
70
|
+
/** Each HTTP entry uses the same full-request scope and ingress translation seam. */
|
|
70
71
|
private handleHttp;
|
|
71
|
-
/** Express hands body-parser failures (bad JSON, body too large) here, outside every filter. */
|
|
72
72
|
private handleBodyFailure;
|
|
73
|
+
private ingress;
|
|
73
74
|
/** A body express could not parse is a caller input error, reported through the same edge. */
|
|
74
75
|
private rejectBody;
|
|
75
76
|
/**
|
|
@@ -85,12 +86,12 @@ export declare class WpMcpServer<TGrant, TMintRequest> {
|
|
|
85
86
|
private authenticate;
|
|
86
87
|
private disconnectSignal;
|
|
87
88
|
private buildSdkServer;
|
|
88
|
-
/**
|
|
89
|
-
private
|
|
89
|
+
/** ONE SDK callback catch implementation; the SDK intercepts foreign throws itself. */
|
|
90
|
+
private sdkBoundary;
|
|
90
91
|
private listTools;
|
|
91
92
|
private toolDefinition;
|
|
92
93
|
/**
|
|
93
|
-
*
|
|
94
|
+
* SDK tool callback: an unknown or hidden tool is a JSON-RPC -32602; every failure after the tool is found
|
|
94
95
|
* is an `isError: true` result, identically for local and remote bindings.
|
|
95
96
|
*/
|
|
96
97
|
private handleCallTool;
|
package/src/WpMcpServer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.WpMcpServer = void 0;
|
|
4
|
+
const McpToolPolicy_1 = require("./McpToolPolicy");
|
|
4
5
|
const node_crypto_1 = require("node:crypto");
|
|
5
6
|
const server_1 = require("@modelcontextprotocol/server");
|
|
6
7
|
const node_1 = require("@modelcontextprotocol/node");
|
|
@@ -8,6 +9,7 @@ const express_1 = require("express");
|
|
|
8
9
|
const core_util_1 = require("@webpieces/core-util");
|
|
9
10
|
const core_context_1 = require("@webpieces/core-context");
|
|
10
11
|
const http_server_1 = require("@webpieces/http-server");
|
|
12
|
+
const http_routing_1 = require("@webpieces/http-routing");
|
|
11
13
|
const McpAuth_1 = require("./McpAuth");
|
|
12
14
|
const McpApiDispatcher_1 = require("./McpApiDispatcher");
|
|
13
15
|
const McpInvocationContext_1 = require("./McpInvocationContext");
|
|
@@ -19,6 +21,13 @@ const log = core_util_1.LogManager.getLogger('WpMcpServer');
|
|
|
19
21
|
* The request DTO the edge `LogApiCall` lines report. These boundaries run BEFORE (or outside) any
|
|
20
22
|
* API dispatch, so there is no contract DTO to log; the step name is the fact worth having.
|
|
21
23
|
*/
|
|
24
|
+
class McpSdkOperation {
|
|
25
|
+
name;
|
|
26
|
+
toolStage = false;
|
|
27
|
+
constructor(name) {
|
|
28
|
+
this.name = name;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
22
31
|
class McpEdgeRequest {
|
|
23
32
|
step;
|
|
24
33
|
constructor(step) {
|
|
@@ -34,15 +43,17 @@ class McpPostAuthentication {
|
|
|
34
43
|
accessToken;
|
|
35
44
|
credential;
|
|
36
45
|
disconnectSignal;
|
|
37
|
-
|
|
46
|
+
tools;
|
|
47
|
+
constructor(accessToken, credential, disconnectSignal, tools) {
|
|
38
48
|
this.accessToken = accessToken;
|
|
39
49
|
this.credential = credential;
|
|
40
50
|
this.disconnectSignal = disconnectSignal;
|
|
51
|
+
this.tools = tools;
|
|
41
52
|
}
|
|
42
53
|
}
|
|
43
54
|
/**
|
|
44
55
|
* The official SDK server with Webpieces-owned tools/list and tools/call handlers. It answers
|
|
45
|
-
* `toolInputSchemaJson` from the
|
|
56
|
+
* `toolInputSchemaJson` from the request-authorized view so the SDK's pre-dispatch SEP-2243
|
|
46
57
|
* `Mcp-Param-*` header validation still runs without registering SDK-side tool callbacks.
|
|
47
58
|
*/
|
|
48
59
|
class WpSdkMcpServer extends server_1.McpServer {
|
|
@@ -64,9 +75,9 @@ class WpSdkMcpServer extends server_1.McpServer {
|
|
|
64
75
|
* every shipping client unreachable (issue #969), and the MCP lifecycle spec is explicit that a
|
|
65
76
|
* server answers with a revision it supports rather than erroring.
|
|
66
77
|
*
|
|
67
|
-
* Error
|
|
68
|
-
*
|
|
69
|
-
*
|
|
78
|
+
* Error boundaries: HTTP ingress owns pre-SDK replies; one shared SDK callback wrapper owns
|
|
79
|
+
* protocol/list/name-resolution errors and authorized tool-result errors. The SDK writes callback
|
|
80
|
+
* replies, so it cannot safely delegate those failures back to HTTP ingress.
|
|
70
81
|
* `subscriptions/listen` is served entirely by the SDK's listen router, so Webpieces has no handler
|
|
71
82
|
* (and no catch) there. The external bearer is verified exactly once per POST, at the HTTP
|
|
72
83
|
* boundary, before the SDK is involved.
|
|
@@ -78,6 +89,7 @@ class WpSdkMcpServer extends server_1.McpServer {
|
|
|
78
89
|
*/
|
|
79
90
|
class WpMcpServer {
|
|
80
91
|
config;
|
|
92
|
+
callerContext = new http_routing_1.AuthenticatedCallerContext();
|
|
81
93
|
dispatcher = new McpApiDispatcher_1.McpApiDispatcher();
|
|
82
94
|
/** Classifies a failure exactly as the translator will render it, so the two cannot disagree. */
|
|
83
95
|
boundary = new core_util_1.ApiErrorBoundary();
|
|
@@ -181,30 +193,21 @@ class WpMcpServer {
|
|
|
181
193
|
onerror: (error) => log.warn('MCP protocol request rejected', error),
|
|
182
194
|
});
|
|
183
195
|
}
|
|
184
|
-
/**
|
|
196
|
+
/** Each HTTP entry uses the same full-request scope and ingress translation seam. */
|
|
185
197
|
async handleHttp(req, res, options) {
|
|
186
198
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
187
|
-
await
|
|
188
|
-
new core_context_1.RequestContextHeaders().fillFromRequest(this.toHttpRequest(req));
|
|
189
|
-
McpToolCallRendering_1.McpCorrelation.stamp(this.correlationOf(req));
|
|
190
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- MCP HTTP entry point; delegates only to WpMcpErrorTranslator
|
|
191
|
-
try {
|
|
192
|
-
await this.logEdge('serve', () => this.serveExpress(req, res, options));
|
|
193
|
-
}
|
|
194
|
-
catch (err) {
|
|
195
|
-
const error = (0, core_util_1.toError)(err);
|
|
196
|
-
this.writeBoundaryError(res, error);
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
+
await this.ingress(req, res, 'serve', () => this.serveExpress(req, res, options));
|
|
199
200
|
}
|
|
200
|
-
/** Express hands body-parser failures (bad JSON, body too large) here, outside every filter. */
|
|
201
201
|
async handleBodyFailure(cause, req, res) {
|
|
202
|
+
await this.ingress(req, res, 'body', () => this.rejectBody(cause));
|
|
203
|
+
}
|
|
204
|
+
async ingress(req, res, step, work) {
|
|
202
205
|
await core_context_1.RequestContext.run(async () => {
|
|
203
|
-
|
|
204
|
-
McpToolCallRendering_1.McpCorrelation.stamp(this.correlationOf(req));
|
|
205
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- MCP body entry point; delegates only to WpMcpErrorTranslator
|
|
206
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- HTTP response-owning ingress boundary, including request publication and correlation failures
|
|
206
207
|
try {
|
|
207
|
-
|
|
208
|
+
new core_context_1.RequestContextHeaders().fillFromRequest(this.toHttpRequest(req));
|
|
209
|
+
McpToolCallRendering_1.McpCorrelation.stamp(this.correlationOf(req));
|
|
210
|
+
await this.logEdge(step, work);
|
|
208
211
|
}
|
|
209
212
|
catch (err) {
|
|
210
213
|
const error = (0, core_util_1.toError)(err);
|
|
@@ -255,7 +258,11 @@ class WpMcpServer {
|
|
|
255
258
|
throw new core_util_1.ApiUnauthorizedError('MCP request has no bearer access token.');
|
|
256
259
|
const credential = await this.config.accessTokenAuthority.verifyAccessToken(token, this.config.resource);
|
|
257
260
|
this.validateCredential(credential);
|
|
258
|
-
|
|
261
|
+
this.callerContext.publish(credential.caller);
|
|
262
|
+
core_context_1.RequestContext.putTrusted(core_util_1.WebpiecesCoreHeaders.SURFACE, 'llm');
|
|
263
|
+
this.callerContext.reconcileWireTrust(false);
|
|
264
|
+
const tools = await McpToolRegistry_1.AuthorizedMcpTools.create(this.requireRegistry(), credential.caller, this.config.endpointJwtAuthority);
|
|
265
|
+
return new McpPostAuthentication(token, credential, this.disconnectSignal(req), tools);
|
|
259
266
|
}
|
|
260
267
|
disconnectSignal(req) {
|
|
261
268
|
const disconnected = new AbortController();
|
|
@@ -279,38 +286,39 @@ class WpMcpServer {
|
|
|
279
286
|
if (!(authentication instanceof McpPostAuthentication)) {
|
|
280
287
|
throw new core_util_1.ApiImplementationError('WpMcpServer serves only MCP requests authenticated by its bind boundary.');
|
|
281
288
|
}
|
|
282
|
-
const registry = this.requireRegistry();
|
|
283
289
|
const server = new WpSdkMcpServer({ name: this.config.name, version: `${this.config.version}+${this.revision}` }, {
|
|
284
290
|
capabilities: { tools: { listChanged: true } },
|
|
285
291
|
cacheHints: {
|
|
286
292
|
'tools/list': { ttlMs: options.deployment.ttlMs, cacheScope: 'private' },
|
|
287
293
|
'server/discover': { ttlMs: options.deployment.ttlMs, cacheScope: 'private' },
|
|
288
294
|
},
|
|
289
|
-
},
|
|
295
|
+
}, authentication.tools);
|
|
290
296
|
server.server.removeRequestHandler('tools/list');
|
|
291
297
|
server.server.removeRequestHandler('tools/call');
|
|
292
|
-
server.server.setRequestHandler('tools/list', async (_request) => this.
|
|
298
|
+
server.server.setRequestHandler('tools/list', async (_request) => this.sdkBoundary(new McpSdkOperation('tools/list'), () => this.logEdge('tools/list', async () => this.listTools(authentication.tools))));
|
|
293
299
|
server.server.setRequestHandler('tools/call', async (request, sdkContext) => this.handleCallTool(server, request, authentication, sdkContext));
|
|
294
300
|
return server;
|
|
295
301
|
}
|
|
296
|
-
/**
|
|
297
|
-
async
|
|
298
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions --
|
|
302
|
+
/** ONE SDK callback catch implementation; the SDK intercepts foreign throws itself. */
|
|
303
|
+
async sdkBoundary(operation, work) {
|
|
304
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- shared SDK response boundary, preserving protocol versus tool envelopes
|
|
299
305
|
try {
|
|
300
|
-
return await
|
|
306
|
+
return await work();
|
|
301
307
|
}
|
|
302
308
|
catch (err) {
|
|
303
309
|
const error = (0, core_util_1.toError)(err);
|
|
304
|
-
|
|
310
|
+
if (error instanceof McpToolPolicy_1.McpToolDeniedError)
|
|
311
|
+
throw this.translator.unknownTool(error.toolName);
|
|
312
|
+
if (!operation.toolStage && error instanceof server_1.ProtocolError)
|
|
313
|
+
throw error;
|
|
314
|
+
this.logImplementationFailure(operation.name, error);
|
|
315
|
+
if (operation.toolStage)
|
|
316
|
+
return this.translator.toToolCallResult(error);
|
|
305
317
|
this.translator.toListError(error);
|
|
306
318
|
}
|
|
307
319
|
}
|
|
308
|
-
listTools(
|
|
309
|
-
const tools =
|
|
310
|
-
for (const tool of this.requireRegistry().tools) {
|
|
311
|
-
if (tool.isVisibleTo(credential.listingRoles))
|
|
312
|
-
tools.push(this.toolDefinition(tool));
|
|
313
|
-
}
|
|
320
|
+
listTools(view) {
|
|
321
|
+
const tools = view.tools.map((tool) => this.toolDefinition(tool));
|
|
314
322
|
return { tools };
|
|
315
323
|
}
|
|
316
324
|
toolDefinition(tool) {
|
|
@@ -324,27 +332,23 @@ class WpMcpServer {
|
|
|
324
332
|
};
|
|
325
333
|
}
|
|
326
334
|
/**
|
|
327
|
-
*
|
|
335
|
+
* SDK tool callback: an unknown or hidden tool is a JSON-RPC -32602; every failure after the tool is found
|
|
328
336
|
* is an `isError: true` result, identically for local and remote bindings.
|
|
329
337
|
*/
|
|
330
338
|
async handleCallTool(server, request, authentication, sdkContext) {
|
|
331
339
|
const name = request.params.name;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
340
|
+
const operation = new McpSdkOperation(`tools/call ${name}`);
|
|
341
|
+
return this.sdkBoundary(operation, async () => {
|
|
342
|
+
McpToolCallRendering_1.McpCorrelation.stamp(new McpToolCallRendering_1.McpCorrelation(sdkContext.mcpReq.id, name));
|
|
343
|
+
const tool = authentication.tools.find(name);
|
|
344
|
+
if (!tool)
|
|
345
|
+
throw this.translator.unknownTool(name);
|
|
346
|
+
McpToolCallRendering_1.McpCorrelation.stamp(new McpToolCallRendering_1.McpCorrelation(sdkContext.mcpReq.id, name, tool.operation));
|
|
347
|
+
operation.toolStage = true;
|
|
339
348
|
const result = await this.callTool(tool, request, authentication, sdkContext);
|
|
340
349
|
result._meta = this.translator.resultMeta(McpToolCallRendering_1.McpCorrelation.requestId());
|
|
341
350
|
return server.server.projectCallToolResult(result, tool.outputSchema);
|
|
342
|
-
}
|
|
343
|
-
catch (err) {
|
|
344
|
-
const error = (0, core_util_1.toError)(err);
|
|
345
|
-
this.logImplementationFailure(`tools/call ${name}`, error);
|
|
346
|
-
return this.translator.toToolCallResult(error);
|
|
347
|
-
}
|
|
351
|
+
});
|
|
348
352
|
}
|
|
349
353
|
/**
|
|
350
354
|
* ONE `log.error` line, with the stack and the requestId, for a failure that is THIS server's bug
|
|
@@ -368,12 +372,9 @@ class WpMcpServer {
|
|
|
368
372
|
/** Everything a tool call does. No catch in here: failures propagate to handleCallTool. */
|
|
369
373
|
async callTool(tool, request, authentication, sdkContext) {
|
|
370
374
|
const credential = authentication.credential;
|
|
371
|
-
const
|
|
372
|
-
? await this.mintEndpointJwt(tool, authentication)
|
|
373
|
-
: undefined;
|
|
374
|
-
const invocation = new McpInvocationContext_1.McpInvocationContext(sdkContext.mcpReq.id, tool.name, credential.subject, credential.listingRoles, AbortSignal.any([sdkContext.mcpReq.signal, authentication.disconnectSignal]), this.progressReporter(sdkContext));
|
|
375
|
+
const invocation = new McpInvocationContext_1.McpInvocationContext(sdkContext.mcpReq.id, tool.name, credential.subject, credential.principalRoles, AbortSignal.any([sdkContext.mcpReq.signal, authentication.disconnectSignal]), this.progressReporter(sdkContext));
|
|
375
376
|
const args = (request.params.arguments ?? {});
|
|
376
|
-
const value = await this.dispatcher.call(tool, args, credential, invocation,
|
|
377
|
+
const value = await this.dispatcher.call(tool, args, credential, invocation, this.config.endpointJwtAuthority, async () => (await this.mintEndpointJwt(tool, authentication)).token);
|
|
377
378
|
const outputFailure = this.outputSchemas.validate(tool.outputSchema, value);
|
|
378
379
|
if (outputFailure) {
|
|
379
380
|
throw new core_util_1.ApiImplementationError(`MCP output schema violation for ${tool.apiClass.name}.${tool.methodName}: ${outputFailure.message}`);
|