@webpieces/mcp-server 0.4.772 → 0.4.774
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 +114 -8
- package/package.json +11 -5
- package/src/McpApiBinding.d.ts +14 -0
- package/src/McpApiBinding.js +42 -0
- package/src/McpApiBinding.js.map +1 -0
- package/src/McpApiDispatcher.d.ts +10 -19
- package/src/McpApiDispatcher.js +35 -44
- package/src/McpApiDispatcher.js.map +1 -1
- package/src/McpAuth.d.ts +11 -2
- package/src/McpAuth.js +5 -1
- package/src/McpAuth.js.map +1 -1
- package/src/McpAuthorityCompileAssertions.js.map +1 -1
- package/src/McpBindOptions.d.ts +17 -0
- package/src/McpBindOptions.js +36 -0
- package/src/McpBindOptions.js.map +1 -0
- package/src/McpDeployment.d.ts +11 -0
- package/src/McpDeployment.js +32 -0
- package/src/McpDeployment.js.map +1 -0
- package/src/McpInvocationContext.d.ts +14 -0
- package/src/McpInvocationContext.js +25 -0
- package/src/McpInvocationContext.js.map +1 -0
- package/src/McpToolRegistry.d.ts +7 -4
- package/src/McpToolRegistry.js +20 -16
- package/src/McpToolRegistry.js.map +1 -1
- package/src/WpMcpErrorTranslator.d.ts +69 -0
- package/src/WpMcpErrorTranslator.js +168 -0
- package/src/WpMcpErrorTranslator.js.map +1 -0
- package/src/WpMcpServer.d.ts +55 -24
- package/src/WpMcpServer.js +331 -154
- package/src/WpMcpServer.js.map +1 -1
- package/src/__tests__/McpHttpTestHarness.d.ts +40 -0
- package/src/__tests__/McpHttpTestHarness.js +158 -0
- package/src/__tests__/McpHttpTestHarness.js.map +1 -0
- package/src/__tests__/McpRemoteFixtures.d.ts +93 -0
- package/src/__tests__/McpRemoteFixtures.js +409 -0
- package/src/__tests__/McpRemoteFixtures.js.map +1 -0
- package/src/__tests__/WpMcpServerTestFixtures.d.ts +60 -0
- package/src/__tests__/WpMcpServerTestFixtures.js +269 -0
- package/src/__tests__/WpMcpServerTestFixtures.js.map +1 -0
- package/src/index.d.ts +10 -3
- package/src/index.js +18 -4
- package/src/index.js.map +1 -1
package/src/WpMcpServer.js
CHANGED
|
@@ -1,212 +1,389 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.WpMcpServer =
|
|
4
|
-
const
|
|
5
|
-
const
|
|
3
|
+
exports.WpMcpServer = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const server_1 = require("@modelcontextprotocol/server");
|
|
6
|
+
const node_1 = require("@modelcontextprotocol/node");
|
|
7
|
+
const express_1 = require("express");
|
|
6
8
|
const core_util_1 = require("@webpieces/core-util");
|
|
9
|
+
const core_context_1 = require("@webpieces/core-context");
|
|
7
10
|
const McpAuth_1 = require("./McpAuth");
|
|
8
11
|
const McpApiDispatcher_1 = require("./McpApiDispatcher");
|
|
12
|
+
const McpInvocationContext_1 = require("./McpInvocationContext");
|
|
9
13
|
const McpToolRegistry_1 = require("./McpToolRegistry");
|
|
14
|
+
const WpMcpErrorTranslator_1 = require("./WpMcpErrorTranslator");
|
|
10
15
|
const log = core_util_1.LogManager.getLogger('WpMcpServer');
|
|
11
|
-
/**
|
|
12
|
-
class
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
constructor(kind, message, requestId, field, callerMessage, errorCode, retryAfterSeconds) {
|
|
21
|
-
this.kind = kind;
|
|
22
|
-
this.message = message;
|
|
23
|
-
this.requestId = requestId;
|
|
24
|
-
this.field = field;
|
|
25
|
-
this.callerMessage = callerMessage;
|
|
26
|
-
this.errorCode = errorCode;
|
|
27
|
-
this.retryAfterSeconds = retryAfterSeconds;
|
|
16
|
+
/** Facts established once per POST at the bind boundary and handed to the SDK handlers. */
|
|
17
|
+
class McpPostAuthentication {
|
|
18
|
+
accessToken;
|
|
19
|
+
credential;
|
|
20
|
+
disconnectSignal;
|
|
21
|
+
constructor(accessToken, credential, disconnectSignal) {
|
|
22
|
+
this.accessToken = accessToken;
|
|
23
|
+
this.credential = credential;
|
|
24
|
+
this.disconnectSignal = disconnectSignal;
|
|
28
25
|
}
|
|
29
26
|
}
|
|
30
|
-
exports.ModelVisibleToolError = ModelVisibleToolError;
|
|
31
27
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* The official SDK server with Webpieces-owned tools/list and tools/call handlers. It answers
|
|
29
|
+
* `toolInputSchemaJson` from the Webpieces registry so the SDK's pre-dispatch SEP-2243
|
|
30
|
+
* `Mcp-Param-*` header validation still runs without registering SDK-side tool callbacks.
|
|
31
|
+
*/
|
|
32
|
+
class WpSdkMcpServer extends server_1.McpServer {
|
|
33
|
+
registry;
|
|
34
|
+
constructor(info, options, registry) {
|
|
35
|
+
super(info, options);
|
|
36
|
+
this.registry = registry;
|
|
37
|
+
}
|
|
38
|
+
// webpieces-disable no-any-unknown -- signature is fixed by the SDK's JSON Schema record type
|
|
39
|
+
toolInputSchemaJson(name) {
|
|
40
|
+
return this.registry.find(name)?.inputSchema;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Modern-only MCP 2026-07-28 adapter around the official SDK's request-scoped HTTP handler.
|
|
45
|
+
*
|
|
46
|
+
* Error boundary: every failure is mapped by the one `WpMcpErrorTranslator`, and each entry point
|
|
47
|
+
* has exactly one catch that only delegates to it: the bind HTTP handler (`toHttp`), tools/list
|
|
48
|
+
* (`toProtocolError`) and tools/call (`toToolResult`). `subscriptions/listen` is served entirely by
|
|
49
|
+
* the SDK's listen router, so Webpieces has no handler (and no catch) there. The external bearer is
|
|
50
|
+
* verified exactly once per POST, at the HTTP boundary, before the SDK is involved.
|
|
34
51
|
*/
|
|
35
52
|
class WpMcpServer {
|
|
36
53
|
config;
|
|
54
|
+
dispatcher = new McpApiDispatcher_1.McpApiDispatcher();
|
|
55
|
+
translator = new WpMcpErrorTranslator_1.WpMcpErrorTranslator();
|
|
37
56
|
registry;
|
|
38
|
-
|
|
39
|
-
|
|
57
|
+
handler;
|
|
58
|
+
streamingHandler;
|
|
59
|
+
nodeHandler;
|
|
60
|
+
streamingNodeHandler;
|
|
61
|
+
revision;
|
|
62
|
+
constructor(config) {
|
|
40
63
|
this.config = config;
|
|
41
|
-
this.registry = new McpToolRegistry_1.McpToolRegistry(apiClasses);
|
|
42
|
-
this.dispatcher = new McpApiDispatcher_1.McpApiDispatcher(apiFactory);
|
|
43
64
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
65
|
+
bind(app, options) {
|
|
66
|
+
if (this.handler)
|
|
67
|
+
throw new Error('WpMcpServer.bind(...) may be called only once.');
|
|
68
|
+
this.registry = new McpToolRegistry_1.McpToolRegistry(options.bindings);
|
|
69
|
+
this.revision = this.calculateRegistryRevision(this.registry);
|
|
70
|
+
this.handler = this.createHandler(options, 'auto');
|
|
71
|
+
this.streamingHandler = this.createHandler(options, 'sse');
|
|
72
|
+
this.nodeHandler = (0, node_1.toNodeHandler)(this.handler, {
|
|
73
|
+
onerror: (error) => log.error('MCP Node transport failed', error),
|
|
74
|
+
});
|
|
75
|
+
this.streamingNodeHandler = (0, node_1.toNodeHandler)(this.streamingHandler, {
|
|
76
|
+
onerror: (error) => log.error('MCP streaming Node transport failed', error),
|
|
77
|
+
});
|
|
78
|
+
app.post(options.endpointPath, (0, express_1.json)(), async (req, res) => this.handleHttp(req, res, options),
|
|
79
|
+
// Express routes body-parser failures (bad JSON, body too large) to an error handler.
|
|
80
|
+
(err, req, res, _next) => core_context_1.RequestContext.run(() => {
|
|
81
|
+
new core_context_1.RequestContextHeaders().fillFromRequest(this.toHttpRequest(req));
|
|
82
|
+
this.translator.toHttpBodyFailure(err, res, this.httpScope(req), this.challenge());
|
|
83
|
+
}));
|
|
47
84
|
}
|
|
48
85
|
protectedResourceMetadata() {
|
|
49
86
|
return this.config.protectedResourceMetadata();
|
|
50
87
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
/** Gracefully closes active listen/request streams; clients must reconnect and refresh lists. */
|
|
89
|
+
async close() {
|
|
90
|
+
await Promise.all([this.handler?.close(), this.streamingHandler?.close()]);
|
|
91
|
+
}
|
|
92
|
+
/** Level-triggered invalidation; the authoritative list is always fetched again by clients. */
|
|
93
|
+
toolsChanged() {
|
|
94
|
+
this.handler?.notify.toolsChanged();
|
|
95
|
+
}
|
|
96
|
+
get registryRevision() {
|
|
97
|
+
return this.revision;
|
|
98
|
+
}
|
|
99
|
+
createHandler(options, responseMode) {
|
|
100
|
+
return (0, server_1.createMcpHandler)((context) => this.buildSdkServer(context, options), {
|
|
101
|
+
legacy: 'reject',
|
|
102
|
+
responseMode,
|
|
103
|
+
bus: options.deployment.bus,
|
|
104
|
+
maxSubscriptions: options.maxSubscriptions,
|
|
105
|
+
keepAliveMs: options.keepAliveMs,
|
|
106
|
+
onerror: (error) => log.warn('MCP protocol request rejected', error),
|
|
70
107
|
});
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
108
|
+
}
|
|
109
|
+
/** Entry point #1: the one catch for everything before and around the SDK exchange. */
|
|
110
|
+
async handleHttp(req, res, options) {
|
|
111
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
112
|
+
await core_context_1.RequestContext.run(async () => {
|
|
113
|
+
new core_context_1.RequestContextHeaders().fillFromRequest(this.toHttpRequest(req));
|
|
114
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- MCP HTTP entry point; delegates only to WpMcpErrorTranslator
|
|
115
|
+
try {
|
|
116
|
+
await this.serveExpress(req, res, options);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
const error = (0, core_util_1.toError)(err);
|
|
120
|
+
this.translator.toHttp(error, res, this.httpScope(req), this.challenge());
|
|
75
121
|
}
|
|
76
|
-
return this.call(tool, request.params.arguments ?? {}, accessToken);
|
|
77
122
|
});
|
|
123
|
+
}
|
|
124
|
+
async serveExpress(req, res, options) {
|
|
125
|
+
const origin = req.header('origin');
|
|
126
|
+
if (origin && !options.allowedOrigins.includes(origin)) {
|
|
127
|
+
throw new core_util_1.ApiForbiddenError(`MCP request Origin '${origin}' is not allowed.`);
|
|
128
|
+
}
|
|
129
|
+
const authentication = await this.authenticate(req);
|
|
130
|
+
const request = req;
|
|
131
|
+
request.auth = this.authInfo(authentication);
|
|
132
|
+
const serve = this.hasProgressToken(req.body)
|
|
133
|
+
? this.streamingNodeHandler
|
|
134
|
+
: this.nodeHandler;
|
|
135
|
+
if (!serve)
|
|
136
|
+
throw new core_util_1.ApiImplementationError('MCP Node handlers are not initialized.');
|
|
137
|
+
await serve(request, res, req.body);
|
|
138
|
+
}
|
|
139
|
+
/** Verifies the external bearer once for this POST. Handlers never re-verify it. */
|
|
140
|
+
async authenticate(req) {
|
|
141
|
+
const match = req.header('authorization')?.match(/^Bearer ([^\s]+)$/i);
|
|
142
|
+
const token = match?.[1];
|
|
143
|
+
if (!token)
|
|
144
|
+
throw new core_util_1.ApiUnauthorizedError('MCP request has no bearer access token.');
|
|
145
|
+
const credential = await this.config.accessTokenAuthority.verifyAccessToken(token, this.config.resource);
|
|
146
|
+
this.validateCredential(credential);
|
|
147
|
+
return new McpPostAuthentication(token, credential, this.disconnectSignal(req));
|
|
148
|
+
}
|
|
149
|
+
disconnectSignal(req) {
|
|
150
|
+
const disconnected = new AbortController();
|
|
151
|
+
const res = req.res;
|
|
152
|
+
const cancel = () => disconnected.abort();
|
|
153
|
+
req.once('aborted', cancel);
|
|
154
|
+
req.socket.once('close', cancel);
|
|
155
|
+
res?.once('close', cancel);
|
|
156
|
+
res?.once('finish', () => {
|
|
157
|
+
req.off('aborted', cancel);
|
|
158
|
+
req.socket.off('close', cancel);
|
|
159
|
+
res.off('close', cancel);
|
|
160
|
+
});
|
|
161
|
+
return disconnected.signal;
|
|
162
|
+
}
|
|
163
|
+
buildSdkServer(context, options) {
|
|
164
|
+
const authentication = context.authInfo?.extra?.['webpiecesAuthentication'];
|
|
165
|
+
if (context.era !== 'modern' || !(authentication instanceof McpPostAuthentication)) {
|
|
166
|
+
throw new core_util_1.ApiImplementationError('WpMcpServer serves only MCP 2026-07-28 requests authenticated by its bind boundary.');
|
|
167
|
+
}
|
|
168
|
+
const registry = this.requireRegistry();
|
|
169
|
+
const server = new WpSdkMcpServer({ name: this.config.name, version: `${this.config.version}+${this.revision}` }, {
|
|
170
|
+
capabilities: { tools: { listChanged: true } },
|
|
171
|
+
cacheHints: {
|
|
172
|
+
'tools/list': { ttlMs: options.deployment.ttlMs, cacheScope: 'private' },
|
|
173
|
+
'server/discover': { ttlMs: options.deployment.ttlMs, cacheScope: 'private' },
|
|
174
|
+
},
|
|
175
|
+
}, registry);
|
|
176
|
+
server.server.removeRequestHandler('tools/list');
|
|
177
|
+
server.server.removeRequestHandler('tools/call');
|
|
178
|
+
server.server.setRequestHandler('tools/list', async (_request, sdkContext) => this.handleListTools(authentication, sdkContext));
|
|
179
|
+
server.server.setRequestHandler('tools/call', async (request, sdkContext) => this.handleCallTool(server, request, authentication, sdkContext));
|
|
78
180
|
return server;
|
|
79
181
|
}
|
|
80
|
-
|
|
81
|
-
|
|
182
|
+
/** Entry point #2: tools/list has no tool-result channel, so failures are JSON-RPC errors. */
|
|
183
|
+
async handleListTools(authentication, sdkContext) {
|
|
184
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- MCP tools/list entry point; delegates only to WpMcpErrorTranslator
|
|
82
185
|
try {
|
|
83
|
-
|
|
84
|
-
this.validateCredential(credential);
|
|
85
|
-
return credential;
|
|
186
|
+
return this.listTools(authentication.credential);
|
|
86
187
|
}
|
|
87
188
|
catch (err) {
|
|
88
189
|
const error = (0, core_util_1.toError)(err);
|
|
89
|
-
|
|
90
|
-
throw new core_util_1.ApiUnauthorizedError('MCP access token rejected.', undefined, error);
|
|
190
|
+
throw this.translator.toProtocolError(error, this.sdkScope(sdkContext, 'tools/list'));
|
|
91
191
|
}
|
|
92
192
|
}
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (!Number.isFinite(credential.issuedAtEpochSeconds) ||
|
|
99
|
-
!Number.isFinite(credential.expiresAtEpochSeconds) ||
|
|
100
|
-
!Number.isFinite(credential.accountValidatedAtEpochSeconds)) {
|
|
101
|
-
throw new Error('MCP access token security timestamps must be finite.');
|
|
102
|
-
}
|
|
103
|
-
if (credential.resource !== this.config.resource) {
|
|
104
|
-
throw new Error('MCP access token was not issued for this protected resource.');
|
|
105
|
-
}
|
|
106
|
-
if (!this.config.authorizationServers.includes(credential.issuer)) {
|
|
107
|
-
throw new Error('MCP access token issuer is not trusted by this protected resource.');
|
|
108
|
-
}
|
|
109
|
-
if (credential.issuedAtEpochSeconds > now) {
|
|
110
|
-
throw new Error('MCP access token was issued in the future.');
|
|
111
|
-
}
|
|
112
|
-
if (credential.expiresAtEpochSeconds <= now) {
|
|
113
|
-
throw new Error('MCP access token has expired.');
|
|
114
|
-
}
|
|
115
|
-
if (credential.expiresAtEpochSeconds - credential.issuedAtEpochSeconds >
|
|
116
|
-
McpAuth_1.MAX_MCP_ACCESS_TOKEN_LIFETIME_SECONDS) {
|
|
117
|
-
throw new Error('MCP access token lifetime exceeds 30 days.');
|
|
118
|
-
}
|
|
119
|
-
for (const scope of this.config.requiredScopes) {
|
|
120
|
-
if (!credential.scopes.includes(scope)) {
|
|
121
|
-
throw new Error(`MCP access token is missing required scope '${scope}'.`);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (credential.accountValidatedAtEpochSeconds > now ||
|
|
125
|
-
now - credential.accountValidatedAtEpochSeconds > this.config.maxAccountValidationAgeSeconds) {
|
|
126
|
-
throw new Error('MCP account authorization state is not fresh enough for dispatch.');
|
|
193
|
+
listTools(credential) {
|
|
194
|
+
const tools = [];
|
|
195
|
+
for (const tool of this.requireRegistry().tools) {
|
|
196
|
+
if (tool.isVisibleTo(credential.listingRoles))
|
|
197
|
+
tools.push(this.toolDefinition(tool));
|
|
127
198
|
}
|
|
199
|
+
return { tools };
|
|
128
200
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
201
|
+
toolDefinition(tool) {
|
|
202
|
+
return {
|
|
203
|
+
name: tool.name,
|
|
204
|
+
description: tool.description,
|
|
205
|
+
inputSchema: tool.inputSchema,
|
|
206
|
+
outputSchema: tool.outputSchema,
|
|
207
|
+
annotations: tool.annotations,
|
|
208
|
+
_meta: { webpiecesRegistryRevision: this.revision },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Entry point #3: an unknown tool is a JSON-RPC -32602; every failure after the tool is found
|
|
213
|
+
* is an `isError: true` result, identically for local and remote bindings.
|
|
214
|
+
*/
|
|
215
|
+
async handleCallTool(server, request, authentication, sdkContext) {
|
|
216
|
+
const name = request.params.name;
|
|
217
|
+
const scope = this.sdkScope(sdkContext, 'tools/call', name);
|
|
218
|
+
const tool = this.requireRegistry().find(name);
|
|
219
|
+
if (!tool)
|
|
220
|
+
throw this.translator.unknownTool(name, scope);
|
|
221
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- MCP tools/call entry point; delegates only to WpMcpErrorTranslator
|
|
143
222
|
try {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
this.validateEndpointJwt(endpointJwt, accessToken);
|
|
223
|
+
const result = await this.callTool(tool, request, authentication, sdkContext);
|
|
224
|
+
result._meta = this.translator.resultMeta(scope.requestId);
|
|
225
|
+
return server.server.projectCallToolResult(result, tool.outputSchema);
|
|
148
226
|
}
|
|
149
227
|
catch (err) {
|
|
150
228
|
const error = (0, core_util_1.toError)(err);
|
|
151
|
-
|
|
152
|
-
throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, 'Internal Error');
|
|
229
|
+
return this.translator.toToolResult(error, scope);
|
|
153
230
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
231
|
+
}
|
|
232
|
+
/** Everything a tool call does. No catch in here: failures propagate to handleCallTool. */
|
|
233
|
+
async callTool(tool, request, authentication, sdkContext) {
|
|
234
|
+
const credential = authentication.credential;
|
|
235
|
+
const endpointJwt = tool.binding.topology === 'local'
|
|
236
|
+
? await this.mintEndpointJwt(tool, authentication)
|
|
237
|
+
: undefined;
|
|
238
|
+
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));
|
|
239
|
+
const args = (request.params.arguments ?? {});
|
|
240
|
+
const value = await this.dispatcher.call(tool, args, credential, invocation, endpointJwt?.token);
|
|
241
|
+
const outputFailure = this.requireRegistry().schemaBuilder.validate(tool.responseClass, value);
|
|
158
242
|
if (outputFailure) {
|
|
159
|
-
|
|
160
|
-
return this.errorResult(new ModelVisibleToolError('implementation', 'Internal Error', result.requestId));
|
|
161
|
-
}
|
|
162
|
-
let structured;
|
|
163
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- transport boundary sanitizes serialization failures
|
|
164
|
-
try {
|
|
165
|
-
structured = this.record(result.value);
|
|
166
|
-
}
|
|
167
|
-
catch (err) {
|
|
168
|
-
const error = (0, core_util_1.toError)(err);
|
|
169
|
-
log.error(`MCP response serialization failed for ${tool.apiClass.name}.${tool.methodName}`, error);
|
|
170
|
-
return this.errorResult(new ModelVisibleToolError('implementation', 'Internal Error', result.requestId));
|
|
243
|
+
throw new core_util_1.ApiImplementationError(`MCP output schema violation for ${tool.apiClass.name}.${tool.methodName}: ${outputFailure.message}`);
|
|
171
244
|
}
|
|
245
|
+
const structured = this.record(value);
|
|
172
246
|
return {
|
|
173
247
|
content: [{ type: 'text', text: JSON.stringify(structured) }],
|
|
174
248
|
structuredContent: structured,
|
|
175
249
|
};
|
|
176
250
|
}
|
|
177
|
-
|
|
251
|
+
async mintEndpointJwt(tool, authentication) {
|
|
252
|
+
const descriptor = new McpAuth_1.McpEndpointDescriptor(tool.name, tool.apiClass.name, tool.methodName);
|
|
253
|
+
const minted = await this.config.endpointJwtAuthority.mint(this.config.endpointMintRequest(authentication.credential, descriptor));
|
|
178
254
|
const now = Math.floor(Date.now() / 1000);
|
|
179
|
-
if (
|
|
180
|
-
throw new
|
|
181
|
-
}
|
|
182
|
-
if (endpointJwt.expiresAtEpochSeconds <= now) {
|
|
183
|
-
throw new Error('MCP endpoint JWT has already expired.');
|
|
255
|
+
if (minted.token === authentication.accessToken) {
|
|
256
|
+
throw new core_util_1.ApiImplementationError(`MCP access-token passthrough is forbidden (endpoint JWT for ${tool.name}).`);
|
|
184
257
|
}
|
|
185
|
-
if (
|
|
186
|
-
this.config.maxEndpointJwtLifetimeSeconds) {
|
|
187
|
-
throw new
|
|
258
|
+
if (minted.expiresAtEpochSeconds <= now ||
|
|
259
|
+
minted.expiresAtEpochSeconds - now > this.config.maxEndpointJwtLifetimeSeconds) {
|
|
260
|
+
throw new core_util_1.ApiImplementationError(`MCP endpoint JWT lifetime is invalid for ${tool.name}.`);
|
|
188
261
|
}
|
|
262
|
+
return minted;
|
|
189
263
|
}
|
|
190
|
-
|
|
191
|
-
|
|
264
|
+
validateCredential(credential) {
|
|
265
|
+
const now = Math.floor(Date.now() / 1000);
|
|
266
|
+
const reject = (reason) => {
|
|
267
|
+
throw new core_util_1.ApiUnauthorizedError(`MCP access token rejected: ${reason}`);
|
|
268
|
+
};
|
|
269
|
+
if (credential.subject.trim() === '')
|
|
270
|
+
reject('no subject');
|
|
271
|
+
const stamps = [
|
|
272
|
+
credential.issuedAtEpochSeconds,
|
|
273
|
+
credential.expiresAtEpochSeconds,
|
|
274
|
+
credential.accountValidatedAtEpochSeconds,
|
|
275
|
+
];
|
|
276
|
+
if (!stamps.every(Number.isFinite))
|
|
277
|
+
reject('security timestamps must be finite');
|
|
278
|
+
if (credential.resource !== this.config.resource)
|
|
279
|
+
reject('resource mismatch');
|
|
280
|
+
if (!this.config.authorizationServers.includes(credential.issuer)) {
|
|
281
|
+
reject('issuer is not trusted');
|
|
282
|
+
}
|
|
283
|
+
if (credential.issuedAtEpochSeconds > now || credential.expiresAtEpochSeconds <= now) {
|
|
284
|
+
reject('outside its valid time window');
|
|
285
|
+
}
|
|
286
|
+
const lifetime = credential.expiresAtEpochSeconds - credential.issuedAtEpochSeconds;
|
|
287
|
+
if (lifetime > McpAuth_1.MAX_MCP_ACCESS_TOKEN_LIFETIME_SECONDS)
|
|
288
|
+
reject('lifetime exceeds 30 days');
|
|
289
|
+
for (const scope of this.config.requiredScopes) {
|
|
290
|
+
if (!credential.scopes.includes(scope))
|
|
291
|
+
reject(`missing required scope '${scope}'`);
|
|
292
|
+
}
|
|
293
|
+
const validatedAge = now - credential.accountValidatedAtEpochSeconds;
|
|
294
|
+
if (credential.accountValidatedAtEpochSeconds > now ||
|
|
295
|
+
validatedAge > this.config.maxAccountValidationAgeSeconds) {
|
|
296
|
+
reject('account authorization state is not fresh enough for dispatch');
|
|
297
|
+
}
|
|
192
298
|
}
|
|
193
|
-
|
|
194
|
-
const
|
|
299
|
+
authInfo(authentication) {
|
|
300
|
+
const credential = authentication.credential;
|
|
195
301
|
return {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
302
|
+
token: authentication.accessToken,
|
|
303
|
+
clientId: credential.subject,
|
|
304
|
+
scopes: [...credential.scopes],
|
|
305
|
+
expiresAt: credential.expiresAtEpochSeconds,
|
|
306
|
+
resource: new URL(credential.resource),
|
|
307
|
+
extra: { webpiecesAuthentication: authentication },
|
|
201
308
|
};
|
|
202
309
|
}
|
|
310
|
+
progressReporter(context) {
|
|
311
|
+
const token = context.mcpReq._meta?.progressToken;
|
|
312
|
+
if (token === undefined)
|
|
313
|
+
return undefined;
|
|
314
|
+
return async (progress, total, message) => {
|
|
315
|
+
await context.mcpReq.notify({
|
|
316
|
+
method: 'notifications/progress',
|
|
317
|
+
params: this.progressParams(token, progress, total, message),
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
progressParams(progressToken, progress, total, message) {
|
|
322
|
+
const params = { progressToken, progress };
|
|
323
|
+
if (total !== undefined)
|
|
324
|
+
params['total'] = total;
|
|
325
|
+
if (message !== undefined)
|
|
326
|
+
params['message'] = message;
|
|
327
|
+
return params;
|
|
328
|
+
}
|
|
329
|
+
requestId() {
|
|
330
|
+
return core_context_1.RequestContext.getUntrusted(core_util_1.WebpiecesCoreHeaders.REQUEST_ID) ?? 'missing-request-id';
|
|
331
|
+
}
|
|
332
|
+
sdkScope(context, method, toolName) {
|
|
333
|
+
return new WpMcpErrorTranslator_1.McpFailureScope(this.requestId(), context.mcpReq.id, method, toolName);
|
|
334
|
+
}
|
|
335
|
+
httpScope(req) {
|
|
336
|
+
const body = this.bodyObject(req.body);
|
|
337
|
+
const id = body?.['id'];
|
|
338
|
+
const method = body?.['method'];
|
|
339
|
+
const params = this.bodyObject(body?.['params']);
|
|
340
|
+
const name = params?.['name'];
|
|
341
|
+
return new WpMcpErrorTranslator_1.McpFailureScope(this.requestId(), typeof id === 'string' || typeof id === 'number' ? id : null, typeof method === 'string' ? method : 'unknown', typeof name === 'string' ? name : undefined);
|
|
342
|
+
}
|
|
343
|
+
challenge() {
|
|
344
|
+
return `Bearer resource_metadata="${this.config.resource}"`;
|
|
345
|
+
}
|
|
346
|
+
bodyObject(value) {
|
|
347
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
348
|
+
return undefined;
|
|
349
|
+
return value;
|
|
350
|
+
}
|
|
351
|
+
hasProgressToken(body) {
|
|
352
|
+
const params = this.bodyObject(this.bodyObject(body)?.['params']);
|
|
353
|
+
return this.bodyObject(params?.['_meta'])?.['progressToken'] !== undefined;
|
|
354
|
+
}
|
|
355
|
+
toHttpRequest(req) {
|
|
356
|
+
const headers = new Map();
|
|
357
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
358
|
+
if (typeof value === 'string')
|
|
359
|
+
headers.set(name, [value]);
|
|
360
|
+
else if (Array.isArray(value))
|
|
361
|
+
headers.set(name, value);
|
|
362
|
+
}
|
|
363
|
+
return new core_context_1.HttpRequest(req.method, req.originalUrl || req.path, headers);
|
|
364
|
+
}
|
|
365
|
+
calculateRegistryRevision(registry) {
|
|
366
|
+
const surface = registry.tools.map((tool) => [
|
|
367
|
+
tool.name,
|
|
368
|
+
tool.description,
|
|
369
|
+
tool.inputSchema,
|
|
370
|
+
tool.outputSchema,
|
|
371
|
+
tool.annotations,
|
|
372
|
+
]);
|
|
373
|
+
return (0, node_crypto_1.createHash)('sha256').update(JSON.stringify(surface)).digest('hex').slice(0, 12);
|
|
374
|
+
}
|
|
375
|
+
requireRegistry() {
|
|
376
|
+
if (!this.registry)
|
|
377
|
+
throw new Error('Call WpMcpServer.bind(...) before serving requests.');
|
|
378
|
+
return this.registry;
|
|
379
|
+
}
|
|
203
380
|
record(value) {
|
|
204
|
-
const
|
|
205
|
-
if (
|
|
206
|
-
throw new
|
|
207
|
-
|
|
381
|
+
const jsonValue = JSON.stringify(value);
|
|
382
|
+
if (jsonValue === undefined) {
|
|
383
|
+
throw new core_util_1.ApiImplementationError('MCP structured content is not JSON serializable.');
|
|
384
|
+
}
|
|
385
|
+
const parsed = JSON.parse(jsonValue);
|
|
208
386
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
209
|
-
// webpieces-disable no-anonymous-object-literals -- external MCP structured-content wrapper
|
|
210
387
|
return { value: parsed };
|
|
211
388
|
}
|
|
212
389
|
return parsed;
|