toolhome 0.6.6 → 0.6.8
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 +27 -0
- package/README.zh.md +27 -0
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/app.js +1 -1
- package/dist/server/app.js.map +1 -1
- package/dist/server/cli/main.js +7 -0
- package/dist/server/cli/main.js.map +1 -1
- package/dist/server/config.d.ts +12 -0
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +4 -0
- package/dist/server/config.js.map +1 -1
- package/dist/server/data-plane/compact-protocol.d.ts +111 -0
- package/dist/server/data-plane/compact-protocol.d.ts.map +1 -0
- package/dist/server/data-plane/compact-protocol.js +416 -0
- package/dist/server/data-plane/compact-protocol.js.map +1 -0
- package/dist/server/data-plane/compact-state.d.ts +28 -0
- package/dist/server/data-plane/compact-state.d.ts.map +1 -0
- package/dist/server/data-plane/compact-state.js +97 -0
- package/dist/server/data-plane/compact-state.js.map +1 -0
- package/dist/server/data-plane/data-plane.d.ts.map +1 -1
- package/dist/server/data-plane/data-plane.js +4 -0
- package/dist/server/data-plane/data-plane.js.map +1 -1
- package/dist/server/data-plane/gateway-server.d.ts +7 -1
- package/dist/server/data-plane/gateway-server.d.ts.map +1 -1
- package/dist/server/data-plane/gateway-server.js +424 -38
- package/dist/server/data-plane/gateway-server.js.map +1 -1
- package/dist/server/data-plane/registry.d.ts +1 -0
- package/dist/server/data-plane/registry.d.ts.map +1 -1
- package/dist/server/data-plane/registry.js +3 -0
- package/dist/server/data-plane/registry.js.map +1 -1
- package/dist/server/data-plane/tool-catalog.d.ts +71 -0
- package/dist/server/data-plane/tool-catalog.d.ts.map +1 -0
- package/dist/server/data-plane/tool-catalog.js +667 -0
- package/dist/server/data-plane/tool-catalog.js.map +1 -0
- package/dist/server/node/local-gateway.d.ts +20 -0
- package/dist/server/node/local-gateway.d.ts.map +1 -1
- package/dist/server/node/local-gateway.js +85 -1
- package/dist/server/node/local-gateway.js.map +1 -1
- package/dist/server/upstream/adapter.d.ts +39 -2
- package/dist/server/upstream/adapter.d.ts.map +1 -1
- package/dist/server/upstream/adapter.js +37 -6
- package/dist/server/upstream/adapter.js.map +1 -1
- package/dist/server/upstream/manager.d.ts +9 -2
- package/dist/server/upstream/manager.d.ts.map +1 -1
- package/dist/server/upstream/manager.js +24 -4
- package/dist/server/upstream/manager.js.map +1 -1
- package/package.json +1 -1
- package/skills/toolhome/SKILL.md +23 -0
- package/skills/toolhome/references/cli-reference.md +19 -0
- package/skills/toolhome/references/deployment.md +3 -0
- package/skills/toolhome/references/troubleshooting.md +18 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { CallToolRequestParamsSchema, CallToolResultSchema, ClientCapabilitiesSchema, CompleteResultSchema, EmptyResultSchema, GetPromptResultSchema, ListPromptsResultSchema, ListResourceTemplatesResultSchema, ListResourcesResultSchema, ListToolsResultSchema, ReadResourceResultSchema, ResultSchema, } from '@modelcontextprotocol/core';
|
|
2
|
-
import { CLIENT_CAPABILITIES_META_KEY, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode, Server, createRequestStateCodec, isInputRequiredResult, } from '@modelcontextprotocol/server';
|
|
2
|
+
import { CLIENT_CAPABILITIES_META_KEY, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode, Server, createRequestStateCodec, fromJsonSchema, isInputRequiredResult, } from '@modelcontextprotocol/server';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
+
import { CompactState } from './compact-state.js';
|
|
5
|
+
import { ToolCatalog } from './tool-catalog.js';
|
|
6
|
+
import { CompactError, compactErrorResult, compactTools, parseExecArgs, executionBoundaryForTool, } from './compact-protocol.js';
|
|
4
7
|
import { z } from 'zod';
|
|
5
8
|
import { AppError } from '../domain/errors.js';
|
|
6
9
|
import { fingerprint } from '../upstream/stable-json.js';
|
|
@@ -47,12 +50,15 @@ export class GatewayServerFactory {
|
|
|
47
50
|
#cursors;
|
|
48
51
|
#projections;
|
|
49
52
|
#recorder;
|
|
50
|
-
|
|
53
|
+
#compactState = new CompactState();
|
|
54
|
+
#options;
|
|
55
|
+
constructor(registry, upstreams, cursors, masterKey, projections, recorder, options = {}) {
|
|
51
56
|
this.#registry = registry;
|
|
52
57
|
this.#upstreams = upstreams;
|
|
53
58
|
this.#cursors = cursors;
|
|
54
59
|
this.#projections = projections;
|
|
55
60
|
this.#recorder = recorder;
|
|
61
|
+
this.#options = options;
|
|
56
62
|
this.#stateCodec = createRequestStateCodec({
|
|
57
63
|
key: createHash('sha256').update(masterKey).digest(),
|
|
58
64
|
ttlSeconds: 86_400,
|
|
@@ -62,23 +68,32 @@ export class GatewayServerFactory {
|
|
|
62
68
|
aggregate() {
|
|
63
69
|
const aggregate = this.#registry.aggregate();
|
|
64
70
|
const server = new Server({ name: 'toolhome', version: '0.1.0', title: 'ToolHome' }, {
|
|
65
|
-
capabilities:
|
|
71
|
+
capabilities: this.#options.toolMode === 'compact'
|
|
72
|
+
? { ...aggregate.capabilities, tools: { listChanged: true } }
|
|
73
|
+
: aggregate.capabilities,
|
|
66
74
|
instructions: 'ToolHome aggregates enabled servers. Tools use server_slug_encodedToolName; prompts use server_slug.name. Resources use toolhome:// virtual URIs. Use an individual /mcp/{server_slug} endpoint for exact upstream names and extension semantics.',
|
|
67
75
|
requestState: { verify: this.#stateCodec.verify },
|
|
68
76
|
inputRequired: { legacyShim: true },
|
|
69
77
|
});
|
|
70
78
|
this.#aggregateServers.add(server);
|
|
71
|
-
if (
|
|
79
|
+
if (this.#options.toolMode === 'compact') {
|
|
80
|
+
this.#installCompactTools(server);
|
|
81
|
+
}
|
|
82
|
+
else if (aggregate.capabilities.tools) {
|
|
72
83
|
server.setRequestHandler('tools/list', async (request, context) => {
|
|
73
84
|
const entries = this.#registry.entries();
|
|
74
|
-
const
|
|
75
|
-
const
|
|
85
|
+
const listed = await this.#aggregateTools(server, entries, context, request.params);
|
|
86
|
+
const tools = listed.items;
|
|
87
|
+
const page = this.#page(tools, request.params?.cursor, fingerprint({ tools, failedServers: listed.failedServers }));
|
|
76
88
|
return {
|
|
77
89
|
tools: page.items,
|
|
78
90
|
...this.#nextCursor(page),
|
|
79
91
|
ttlMs: 0,
|
|
80
92
|
cacheScope: 'private',
|
|
81
|
-
_meta: {
|
|
93
|
+
_meta: {
|
|
94
|
+
'toolhome/server-count': entries.length,
|
|
95
|
+
...this.#aggregateListMeta(listed),
|
|
96
|
+
},
|
|
82
97
|
};
|
|
83
98
|
});
|
|
84
99
|
server.setRequestHandler('tools/call', { params: CallToolRequestParamsSchema, result: gatewayCallResultSchema }, async (request, context) => {
|
|
@@ -114,13 +129,15 @@ export class GatewayServerFactory {
|
|
|
114
129
|
}
|
|
115
130
|
if (aggregate.capabilities.prompts) {
|
|
116
131
|
server.setRequestHandler('prompts/list', async (request, context) => {
|
|
117
|
-
const
|
|
118
|
-
const
|
|
132
|
+
const listed = await this.#aggregatePrompts(server, this.#registry.entries(), context, request.params);
|
|
133
|
+
const prompts = listed.items;
|
|
134
|
+
const page = this.#page(prompts, request.params?.cursor, fingerprint({ prompts, failedServers: listed.failedServers }));
|
|
119
135
|
return {
|
|
120
136
|
prompts: page.items,
|
|
121
137
|
...this.#nextCursor(page),
|
|
122
138
|
ttlMs: 0,
|
|
123
139
|
cacheScope: 'private',
|
|
140
|
+
_meta: this.#aggregateListMeta(listed),
|
|
124
141
|
};
|
|
125
142
|
});
|
|
126
143
|
server.setRequestHandler('prompts/get', async (request, context) => {
|
|
@@ -135,23 +152,27 @@ export class GatewayServerFactory {
|
|
|
135
152
|
}
|
|
136
153
|
if (aggregate.capabilities.resources) {
|
|
137
154
|
server.setRequestHandler('resources/list', async (request, context) => {
|
|
138
|
-
const
|
|
139
|
-
const
|
|
155
|
+
const listed = await this.#aggregateResources(server, this.#registry.entries(), context, request.params);
|
|
156
|
+
const resources = listed.items;
|
|
157
|
+
const page = this.#page(resources, request.params?.cursor, fingerprint({ resources, failedServers: listed.failedServers }));
|
|
140
158
|
return {
|
|
141
159
|
resources: page.items,
|
|
142
160
|
...this.#nextCursor(page),
|
|
143
161
|
ttlMs: 0,
|
|
144
162
|
cacheScope: 'private',
|
|
163
|
+
_meta: this.#aggregateListMeta(listed),
|
|
145
164
|
};
|
|
146
165
|
});
|
|
147
166
|
server.setRequestHandler('resources/templates/list', async (request, context) => {
|
|
148
|
-
const
|
|
149
|
-
const
|
|
167
|
+
const listed = await this.#aggregateResourceTemplates(server, this.#registry.entries(), context, request.params);
|
|
168
|
+
const resourceTemplates = listed.items;
|
|
169
|
+
const page = this.#page(resourceTemplates, request.params?.cursor, fingerprint({ resourceTemplates, failedServers: listed.failedServers }));
|
|
150
170
|
return {
|
|
151
171
|
resourceTemplates: page.items,
|
|
152
172
|
...this.#nextCursor(page),
|
|
153
173
|
ttlMs: 0,
|
|
154
174
|
cacheScope: 'private',
|
|
175
|
+
_meta: this.#aggregateListMeta(listed),
|
|
155
176
|
};
|
|
156
177
|
});
|
|
157
178
|
server.setRequestHandler('resources/read', async (request, context) => {
|
|
@@ -210,6 +231,24 @@ export class GatewayServerFactory {
|
|
|
210
231
|
});
|
|
211
232
|
}
|
|
212
233
|
server.fallbackRequestHandler = async (request, context) => {
|
|
234
|
+
const listKeys = {
|
|
235
|
+
'tools/list': 'tools',
|
|
236
|
+
'prompts/list': 'prompts',
|
|
237
|
+
'resources/list': 'resources',
|
|
238
|
+
'resources/templates/list': 'resourceTemplates',
|
|
239
|
+
};
|
|
240
|
+
const emptyListKey = Object.hasOwn(listKeys, request.method)
|
|
241
|
+
? listKeys[request.method]
|
|
242
|
+
: undefined;
|
|
243
|
+
if (emptyListKey) {
|
|
244
|
+
context.mcpReq.signal.throwIfAborted();
|
|
245
|
+
const cursor = request.params?.cursor;
|
|
246
|
+
if (cursor !== undefined && typeof cursor !== 'string') {
|
|
247
|
+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid cursor');
|
|
248
|
+
}
|
|
249
|
+
const page = this.#page([], cursor, fingerprint({ [emptyListKey]: [], failedServers: [] }));
|
|
250
|
+
return { [emptyListKey]: page.items, ttlMs: 0, cacheScope: 'private' };
|
|
251
|
+
}
|
|
213
252
|
const taskMethod = canonicalTaskMethod(request.method);
|
|
214
253
|
if (taskMethod) {
|
|
215
254
|
const params = taskParamsSchema.parse(request.params);
|
|
@@ -365,7 +404,323 @@ export class GatewayServerFactory {
|
|
|
365
404
|
this.#installClientNotificationBridges(server, () => [entry]);
|
|
366
405
|
return server;
|
|
367
406
|
}
|
|
368
|
-
async
|
|
407
|
+
async catalogChanged() {
|
|
408
|
+
if (this.#options.toolMode !== 'compact')
|
|
409
|
+
return;
|
|
410
|
+
await this.#compactState.revoke((invocation) => {
|
|
411
|
+
try {
|
|
412
|
+
const target = this.#catalog().resolve(invocation.exposedTool);
|
|
413
|
+
return (target.entry.server.id === invocation.serverId &&
|
|
414
|
+
this.#compactRevision(target.entry) === invocation.revision);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}, (invocation) => this.#terminateCompact(invocation));
|
|
420
|
+
}
|
|
421
|
+
async close() {
|
|
422
|
+
await this.#compactState.revoke(() => false, (invocation) => this.#terminateCompact(invocation));
|
|
423
|
+
}
|
|
424
|
+
#catalog() {
|
|
425
|
+
return new ToolCatalog(this.#registry.store(), this.#projections, {
|
|
426
|
+
scope: this.#options.scope ?? 'host',
|
|
427
|
+
nodeId: this.#options.nodeLabel,
|
|
428
|
+
hosts: (record) => this.#upstreams.hosts(record),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
#compactProfile(server, context) {
|
|
432
|
+
return fingerprint({
|
|
433
|
+
principal: context.http?.authInfo?.clientId ?? 'local',
|
|
434
|
+
capabilities: this.#requestClientCapabilities(server, context),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
#compactRevision(entry) {
|
|
438
|
+
return fingerprint({
|
|
439
|
+
server: entry.server,
|
|
440
|
+
snapshot: entry.snapshot.fingerprint,
|
|
441
|
+
projection: this.#registry.store().getProjectionIndex().get(entry.server.id)
|
|
442
|
+
? {
|
|
443
|
+
default: this.#registry.store().getServerProjection(entry.server.id),
|
|
444
|
+
tools: this.#registry.store().listToolProjections(entry.server.id),
|
|
445
|
+
}
|
|
446
|
+
: null,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
#compactKey(profile, tool) {
|
|
450
|
+
return `${profile}/${tool}`;
|
|
451
|
+
}
|
|
452
|
+
#installCompactTools(server) {
|
|
453
|
+
server.setRequestHandler('tools/list', async () => ({
|
|
454
|
+
tools: compactTools(),
|
|
455
|
+
ttlMs: 0,
|
|
456
|
+
cacheScope: 'private',
|
|
457
|
+
}));
|
|
458
|
+
server.setRequestHandler('tools/call', { params: CallToolRequestParamsSchema, result: gatewayCallResultSchema }, async (request, context) => {
|
|
459
|
+
if (request.name === 'search') {
|
|
460
|
+
const startedAt = Date.now();
|
|
461
|
+
const catalog = this.#catalog();
|
|
462
|
+
const profile = this.#compactProfile(server, context);
|
|
463
|
+
const result = catalog.search(request.arguments, (id) => {
|
|
464
|
+
try {
|
|
465
|
+
const resolved = catalog.resolve(id);
|
|
466
|
+
const tool = this.#compactState.observed(this.#compactKey(profile, id), this.#compactRevision(resolved.entry));
|
|
467
|
+
return tool ? { serverId: resolved.entry.server.id, tool } : undefined;
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
this.#registry.store().appendEvent({
|
|
474
|
+
level: 'debug',
|
|
475
|
+
type: 'compact.search',
|
|
476
|
+
serverId: null,
|
|
477
|
+
message: 'Compact capability discovery',
|
|
478
|
+
detail: {
|
|
479
|
+
durationMs: Date.now() - startedAt,
|
|
480
|
+
bytes: Buffer.byteLength(JSON.stringify(result)),
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
if (request.name !== 'exec')
|
|
486
|
+
return compactErrorResult(new CompactError('unknown_tool', 'Use search or exec in compact mode.'));
|
|
487
|
+
return this.#compactExec(server, request, context);
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
async #compactExec(server, request, context) {
|
|
491
|
+
const startedAt = new Date();
|
|
492
|
+
let target;
|
|
493
|
+
let invocation;
|
|
494
|
+
let submitted = false;
|
|
495
|
+
let ownsInvocation = false;
|
|
496
|
+
const profile = this.#compactProfile(server, context);
|
|
497
|
+
const state = context.mcpReq.requestState();
|
|
498
|
+
try {
|
|
499
|
+
const args = parseExecArgs(request.arguments);
|
|
500
|
+
const catalog = this.#catalog();
|
|
501
|
+
const key = this.#compactKey(profile, args.tool);
|
|
502
|
+
if (!state && context.mcpReq.inputResponses !== undefined) {
|
|
503
|
+
throw new CompactError('continuation_rejected', 'Continuation state is required; the invocation will not be restarted.');
|
|
504
|
+
}
|
|
505
|
+
if (state) {
|
|
506
|
+
if (!state.compactInvocation)
|
|
507
|
+
throw new CompactError('continuation_rejected', 'This request state belongs to a different invocation.');
|
|
508
|
+
invocation = this.#compactState.invocation(state.compactInvocation);
|
|
509
|
+
if (!invocation ||
|
|
510
|
+
invocation.key !== key ||
|
|
511
|
+
invocation.exposedTool !== args.tool ||
|
|
512
|
+
state.serverId !== invocation.serverId ||
|
|
513
|
+
!state.aggregate) {
|
|
514
|
+
throw new CompactError('continuation_rejected', 'Continuation target or profile mismatch.');
|
|
515
|
+
}
|
|
516
|
+
if (invocation.busy)
|
|
517
|
+
throw new CompactError('continuation_rejected', 'Continuation is already in progress.');
|
|
518
|
+
try {
|
|
519
|
+
target = catalog.resolve(invocation.exposedTool);
|
|
520
|
+
if (target.entry.server.id !== invocation.serverId ||
|
|
521
|
+
this.#compactRevision(target.entry) !== invocation.revision) {
|
|
522
|
+
throw new Error('Invocation configuration changed.');
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
await this.#terminateCompact(invocation);
|
|
527
|
+
this.#compactState.release(invocation.id);
|
|
528
|
+
throw new CompactError('continuation_rejected', 'Invocation is no longer enabled or visible.');
|
|
529
|
+
}
|
|
530
|
+
if (invocation.upstreamRequestState === undefined) {
|
|
531
|
+
// No upstream round to resume: re-sending the bound arguments would be
|
|
532
|
+
// a new business invocation, so fail closed instead of replaying.
|
|
533
|
+
await this.#terminateCompact(invocation);
|
|
534
|
+
this.#compactState.release(invocation.id);
|
|
535
|
+
throw new CompactError('continuation_rejected', 'The original invocation cannot be resumed; it will not be restarted.', {
|
|
536
|
+
nextStep: { action: 'use_individual', guidance: this.#compactEndpoint(target.entry) },
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
if ((Object.keys(args.arguments).length > 0 &&
|
|
540
|
+
fingerprint(args.arguments) !== fingerprint(invocation.arguments)) ||
|
|
541
|
+
(args.definition !== undefined && args.definition !== invocation.definition)) {
|
|
542
|
+
throw new CompactError('continuation_rejected', 'Continuation must retain its original arguments and definition.');
|
|
543
|
+
}
|
|
544
|
+
invocation.busy = true;
|
|
545
|
+
ownsInvocation = true;
|
|
546
|
+
target.tool = invocation.tool;
|
|
547
|
+
submitted = true;
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
target = catalog.resolve(args.tool);
|
|
551
|
+
const live = (await this.#listTools(server, target.entry, context, {})).find((tool) => tool.name === target.tool.name);
|
|
552
|
+
if (!live)
|
|
553
|
+
throw new CompactError('unknown_tool', 'The target is absent from its live directory.');
|
|
554
|
+
target.tool = live;
|
|
555
|
+
if (executionBoundaryForTool(live) !== 'exec' || this.#compactAppServer(target.entry)) {
|
|
556
|
+
throw new CompactError('individual_endpoint_required', 'Use the original endpoint for this server’s App tools.', {
|
|
557
|
+
nextStep: { action: 'use_individual', guidance: this.#compactEndpoint(target.entry) },
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
const contractResult = catalog.search({ action: 'describe', tool: args.tool }, () => ({
|
|
561
|
+
serverId: target.entry.server.id,
|
|
562
|
+
tool: live,
|
|
563
|
+
}));
|
|
564
|
+
const contractText = contractResult.content.find((item) => item.type === 'text');
|
|
565
|
+
const contract = contractText?.type === 'text'
|
|
566
|
+
? JSON.parse(contractText.text)
|
|
567
|
+
: {};
|
|
568
|
+
if (contractResult.isError)
|
|
569
|
+
return gatewayCallResultSchema.parse(contractResult);
|
|
570
|
+
const definition = String(contract.definition);
|
|
571
|
+
const revision = this.#compactRevision(target.entry);
|
|
572
|
+
const saved = this.#compactState.observe(key, target.entry.server.id, revision, live);
|
|
573
|
+
if (args.definition !== undefined && args.definition !== definition) {
|
|
574
|
+
throw new CompactError(saved ? 'definition_changed' : 'definition_too_large', 'The target contract changed before invocation. Obtain its current definition.', {
|
|
575
|
+
nextStep: saved
|
|
576
|
+
? { action: 'describe', arguments: { action: 'describe', tool: args.tool } }
|
|
577
|
+
: { action: 'use_individual', guidance: this.#compactEndpoint(target.entry) },
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
const validation = await fromJsonSchema(live.inputSchema)['~standard'].validate(args.arguments);
|
|
581
|
+
if (validation.issues)
|
|
582
|
+
throw new CompactError('invalid_arguments', validation.issues
|
|
583
|
+
.map((issue) => issue.message)
|
|
584
|
+
.join('; ')
|
|
585
|
+
.slice(0, 1200));
|
|
586
|
+
const taskSupport = live.execution?.taskSupport;
|
|
587
|
+
// Task semantics are carried on the request params (`CallToolRequestParams`
|
|
588
|
+
// extends `TaskAugmentedRequestParams`), not the `_meta` envelope.
|
|
589
|
+
const taskRequested = request.task !== undefined;
|
|
590
|
+
if (taskSupport === 'required' || (taskRequested && taskSupport !== 'optional')) {
|
|
591
|
+
throw new CompactError('individual_endpoint_required', 'Use the original endpoint for this Task/client combination.', {
|
|
592
|
+
nextStep: { action: 'use_individual', guidance: this.#compactEndpoint(target.entry) },
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
invocation =
|
|
596
|
+
this.#compactState.retain({
|
|
597
|
+
key,
|
|
598
|
+
serverId: target.entry.server.id,
|
|
599
|
+
exposedTool: args.tool,
|
|
600
|
+
tool: live,
|
|
601
|
+
arguments: args.arguments,
|
|
602
|
+
definition,
|
|
603
|
+
revision,
|
|
604
|
+
}, target.entry.server.settings.maxTotalTimeoutMs, (value) => this.#terminateCompact(value)) ?? undefined;
|
|
605
|
+
if (!invocation)
|
|
606
|
+
throw new CompactError('continuation_rejected', 'Pending invocation capacity exceeded.', {
|
|
607
|
+
callEffect: 'not_started',
|
|
608
|
+
nextStep: { action: 'use_individual', guidance: this.#compactEndpoint(target.entry) },
|
|
609
|
+
});
|
|
610
|
+
invocation.busy = true;
|
|
611
|
+
ownsInvocation = true;
|
|
612
|
+
}
|
|
613
|
+
const params = this.#restoreParams({
|
|
614
|
+
...request,
|
|
615
|
+
name: invocation.tool.name,
|
|
616
|
+
arguments: invocation.arguments,
|
|
617
|
+
...(invocation.upstreamRequestState === undefined
|
|
618
|
+
? {}
|
|
619
|
+
: { requestState: invocation.upstreamRequestState }),
|
|
620
|
+
...(context.mcpReq.inputResponses === undefined
|
|
621
|
+
? {}
|
|
622
|
+
: { inputResponses: context.mcpReq.inputResponses }),
|
|
623
|
+
}, target.entry.server.slug);
|
|
624
|
+
const raw = await this.#execute(server, target.entry, { method: 'tools/call', params }, context, {
|
|
625
|
+
toolDefinition: invocation.tool,
|
|
626
|
+
onDispatch: () => {
|
|
627
|
+
submitted = true;
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
this.#recordCall(context, {
|
|
631
|
+
endpointType: 'aggregate',
|
|
632
|
+
serverId: target.entry.server.id,
|
|
633
|
+
exposedToolName: 'exec',
|
|
634
|
+
upstreamToolName: invocation.tool.name,
|
|
635
|
+
status: 'success',
|
|
636
|
+
startedAt,
|
|
637
|
+
raw,
|
|
638
|
+
});
|
|
639
|
+
if (isInputRequiredResult(raw)) {
|
|
640
|
+
invocation.upstreamRequestState = raw.requestState;
|
|
641
|
+
invocation.busy = false;
|
|
642
|
+
const rewritten = rewriteAggregateContent(raw, target.entry.server.slug);
|
|
643
|
+
return gatewayCallResultSchema.parse({
|
|
644
|
+
...rewritten,
|
|
645
|
+
requestState: await this.#stateCodec.mint({ aggregate: true, serverId: target.entry.server.id, compactInvocation: invocation.id }, context),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
this.#compactState.release(invocation.id);
|
|
649
|
+
return this.#parseToolResult(raw, context, target.entry.server.id, target.entry.server.slug);
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
if (invocation && ownsInvocation) {
|
|
653
|
+
invocation.busy = false;
|
|
654
|
+
await this.#terminateCompact(invocation);
|
|
655
|
+
this.#compactState.release(invocation.id);
|
|
656
|
+
}
|
|
657
|
+
if (target)
|
|
658
|
+
this.#recordCallError(context, {
|
|
659
|
+
endpointType: 'aggregate',
|
|
660
|
+
serverId: target.entry.server.id,
|
|
661
|
+
exposedToolName: 'exec',
|
|
662
|
+
upstreamToolName: target.tool.name,
|
|
663
|
+
startedAt,
|
|
664
|
+
error,
|
|
665
|
+
});
|
|
666
|
+
if (CompactError.isInstance(error))
|
|
667
|
+
return compactErrorResult(state && error.callEffect === 'not_started'
|
|
668
|
+
? new CompactError('continuation_rejected', error.message, {
|
|
669
|
+
callEffect: 'may_have_run',
|
|
670
|
+
nextStep: error.nextStep,
|
|
671
|
+
})
|
|
672
|
+
: error);
|
|
673
|
+
if (ProtocolError.isInstance(error) && error.code === -32020 && target) {
|
|
674
|
+
let recovered = false;
|
|
675
|
+
try {
|
|
676
|
+
const live = (await this.#listTools(server, target.entry, context, {})).find((tool) => tool.name === target.tool.name);
|
|
677
|
+
if (live)
|
|
678
|
+
recovered = this.#compactState.observe(this.#compactKey(profile, aggregateToolName(target.entry.server.slug, live.name)), target.entry.server.id, this.#compactRevision(target.entry), live);
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
/* Recovery cannot resubmit the business invocation. */
|
|
682
|
+
}
|
|
683
|
+
return compactErrorResult(new CompactError('definition_changed', error.message, {
|
|
684
|
+
source: 'upstream',
|
|
685
|
+
callEffect: 'may_have_run',
|
|
686
|
+
nextStep: {
|
|
687
|
+
action: 'check_status',
|
|
688
|
+
guidance: recovered
|
|
689
|
+
? 'Check the original invocation, then describe the target before explicitly retrying.'
|
|
690
|
+
: this.#compactEndpoint(target.entry),
|
|
691
|
+
},
|
|
692
|
+
details: { upstreamCode: error.code },
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
return compactErrorResult(new CompactError(state ? 'continuation_rejected' : 'upstream_failure', error instanceof Error ? error.message.slice(0, 1200) : 'Upstream invocation failed.', {
|
|
696
|
+
source: submitted ? 'upstream' : 'gateway',
|
|
697
|
+
callEffect: submitted || state ? 'may_have_run' : 'not_started',
|
|
698
|
+
}));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
#compactAppServer(entry) {
|
|
702
|
+
return entry.snapshot.tools.some((tool) => executionBoundaryForTool(tool) !== 'exec');
|
|
703
|
+
}
|
|
704
|
+
#compactEndpoint(entry) {
|
|
705
|
+
return this.#options.scope === 'local'
|
|
706
|
+
? `Use toolhome mcp launch ${entry.server.slug} on this machine.`
|
|
707
|
+
: `Use /mcp/${entry.server.slug}.`;
|
|
708
|
+
}
|
|
709
|
+
async #terminateCompact(invocation) {
|
|
710
|
+
if (invocation.upstreamRequestState !== undefined) {
|
|
711
|
+
const result = this.#upstreams.terminateContinuation(invocation.serverId, invocation.upstreamRequestState);
|
|
712
|
+
if (result.limitation) {
|
|
713
|
+
this.#registry.store().appendEvent({
|
|
714
|
+
level: 'warn',
|
|
715
|
+
type: 'compact.continuation.termination_limited',
|
|
716
|
+
serverId: invocation.serverId,
|
|
717
|
+
message: 'Upstream suspended state cannot be terminated by this gateway',
|
|
718
|
+
detail: { kind: result.kind },
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async #execute(server, entry, request, context, executionOptions) {
|
|
369
724
|
try {
|
|
370
725
|
return await this.#upstreams.execute(entry.server.id, request, context, this.#requestClientCapabilities(server, context), this.#aggregateServers.has(server)
|
|
371
726
|
? {
|
|
@@ -373,7 +728,7 @@ export class GatewayServerFactory {
|
|
|
373
728
|
transformNotification: (notification) => this.#aggregateNotification(notification, entry.server.slug),
|
|
374
729
|
transformRequest: (upstreamRequest) => this.#aggregateRequest(upstreamRequest, entry.server.slug),
|
|
375
730
|
}
|
|
376
|
-
: {});
|
|
731
|
+
: {}, executionOptions);
|
|
377
732
|
}
|
|
378
733
|
catch (error) {
|
|
379
734
|
if (ProtocolError.isInstance(error))
|
|
@@ -510,47 +865,67 @@ export class GatewayServerFactory {
|
|
|
510
865
|
requestState: await this.#stateCodec.mint(state, context),
|
|
511
866
|
};
|
|
512
867
|
}
|
|
868
|
+
#aggregateListMeta(listed) {
|
|
869
|
+
return listed.failedServers.length === 0
|
|
870
|
+
? {}
|
|
871
|
+
: { 'toolhome/failed-servers': listed.failedServers };
|
|
872
|
+
}
|
|
873
|
+
async #aggregateList(entries, context, list) {
|
|
874
|
+
context.mcpReq.signal.throwIfAborted();
|
|
875
|
+
const results = await Promise.allSettled(entries.map(list));
|
|
876
|
+
context.mcpReq.signal.throwIfAborted();
|
|
877
|
+
const items = [];
|
|
878
|
+
const failedServers = [];
|
|
879
|
+
for (const [index, result] of results.entries()) {
|
|
880
|
+
if (result.status === 'fulfilled')
|
|
881
|
+
items.push(...result.value);
|
|
882
|
+
else
|
|
883
|
+
failedServers.push(entries[index].server.slug);
|
|
884
|
+
}
|
|
885
|
+
failedServers.sort();
|
|
886
|
+
if (results.length > 0 && failedServers.length === results.length) {
|
|
887
|
+
throw new ProtocolError(ProtocolErrorCode.InternalError, 'Every upstream failed to list capabilities', {
|
|
888
|
+
'toolhome/failed-servers': failedServers,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
return { items, failedServers };
|
|
892
|
+
}
|
|
513
893
|
async #aggregateTools(server, entries, context, params) {
|
|
514
|
-
const
|
|
515
|
-
.filter(({ snapshot }) => snapshot.capabilities.tools)
|
|
516
|
-
.map(async (entry) => {
|
|
894
|
+
const listed = await this.#aggregateList(entries.filter(({ snapshot }) => snapshot.capabilities.tools), context, async (entry) => {
|
|
517
895
|
const tools = await this.#listTools(server, entry, context, params);
|
|
518
|
-
// Tool visibility
|
|
519
|
-
// are excluded here (and enforced again in #liveToolRoute).
|
|
896
|
+
// Tool visibility applies only to the aggregate endpoint.
|
|
520
897
|
const visible = this.#projections.apply(entry.server.id, tools);
|
|
521
898
|
return visible.map((tool) => ({
|
|
522
899
|
...rewriteAggregateTool(tool, entry.server.slug),
|
|
523
900
|
name: aggregateToolName(entry.server.slug, tool.name),
|
|
524
901
|
}));
|
|
525
|
-
})
|
|
526
|
-
|
|
902
|
+
});
|
|
903
|
+
listed.items.sort((left, right) => left.name.localeCompare(right.name));
|
|
904
|
+
return listed;
|
|
527
905
|
}
|
|
528
906
|
async #aggregatePrompts(server, entries, context, params) {
|
|
529
|
-
const
|
|
530
|
-
.filter(({ snapshot }) => snapshot.capabilities.prompts)
|
|
531
|
-
.map(async (entry) => (await this.#listPrompts(server, entry, context, params)).map((prompt) => ({
|
|
907
|
+
const listed = await this.#aggregateList(entries.filter(({ snapshot }) => snapshot.capabilities.prompts), context, async (entry) => (await this.#listPrompts(server, entry, context, params)).map((prompt) => ({
|
|
532
908
|
...prompt,
|
|
533
909
|
name: aggregateName(entry.server.slug, prompt.name),
|
|
534
|
-
})))
|
|
535
|
-
|
|
910
|
+
})));
|
|
911
|
+
listed.items.sort((left, right) => left.name.localeCompare(right.name));
|
|
912
|
+
return listed;
|
|
536
913
|
}
|
|
537
914
|
async #aggregateResources(server, entries, context, params) {
|
|
538
|
-
const
|
|
539
|
-
.filter(({ snapshot }) => snapshot.capabilities.resources)
|
|
540
|
-
.map(async (entry) => (await this.#listResources(server, entry, context, params)).map((resource) => ({
|
|
915
|
+
const listed = await this.#aggregateList(entries.filter(({ snapshot }) => snapshot.capabilities.resources), context, async (entry) => (await this.#listResources(server, entry, context, params)).map((resource) => ({
|
|
541
916
|
...resource,
|
|
542
917
|
uri: virtualResourceUri(entry.server.slug, resource.uri),
|
|
543
|
-
})))
|
|
544
|
-
|
|
918
|
+
})));
|
|
919
|
+
listed.items.sort((left, right) => left.uri.localeCompare(right.uri));
|
|
920
|
+
return listed;
|
|
545
921
|
}
|
|
546
922
|
async #aggregateResourceTemplates(server, entries, context, params) {
|
|
547
|
-
const
|
|
548
|
-
.filter(({ snapshot }) => snapshot.capabilities.resources)
|
|
549
|
-
.map(async (entry) => (await this.#listResourceTemplates(server, entry, context, params)).map((template) => ({
|
|
923
|
+
const listed = await this.#aggregateList(entries.filter(({ snapshot }) => snapshot.capabilities.resources), context, async (entry) => (await this.#listResourceTemplates(server, entry, context, params)).map((template) => ({
|
|
550
924
|
...template,
|
|
551
925
|
uriTemplate: virtualResourceTemplate(entry.server.slug, template.uriTemplate),
|
|
552
|
-
})))
|
|
553
|
-
|
|
926
|
+
})));
|
|
927
|
+
listed.items.sort((left, right) => left.uriTemplate.localeCompare(right.uriTemplate));
|
|
928
|
+
return listed;
|
|
554
929
|
}
|
|
555
930
|
async #listTools(server, entry, context, params) {
|
|
556
931
|
const tools = [];
|
|
@@ -759,7 +1134,18 @@ export class GatewayServerFactory {
|
|
|
759
1134
|
return { entry: route.entry, original: route.upstreamUri };
|
|
760
1135
|
}
|
|
761
1136
|
#page(items, cursor, key) {
|
|
762
|
-
|
|
1137
|
+
let offset = 0;
|
|
1138
|
+
if (cursor !== undefined) {
|
|
1139
|
+
try {
|
|
1140
|
+
offset = this.#cursors.decode(cursor, key).offset;
|
|
1141
|
+
}
|
|
1142
|
+
catch (error) {
|
|
1143
|
+
if (error instanceof AppError) {
|
|
1144
|
+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, error.message);
|
|
1145
|
+
}
|
|
1146
|
+
throw error;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
763
1149
|
const pageItems = items.slice(offset, offset + pageSize);
|
|
764
1150
|
const nextOffset = offset + pageItems.length;
|
|
765
1151
|
return {
|