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.
Files changed (51) hide show
  1. package/README.md +27 -0
  2. package/README.zh.md +27 -0
  3. package/dist/server/app.d.ts.map +1 -1
  4. package/dist/server/app.js +1 -1
  5. package/dist/server/app.js.map +1 -1
  6. package/dist/server/cli/main.js +7 -0
  7. package/dist/server/cli/main.js.map +1 -1
  8. package/dist/server/config.d.ts +12 -0
  9. package/dist/server/config.d.ts.map +1 -1
  10. package/dist/server/config.js +4 -0
  11. package/dist/server/config.js.map +1 -1
  12. package/dist/server/data-plane/compact-protocol.d.ts +111 -0
  13. package/dist/server/data-plane/compact-protocol.d.ts.map +1 -0
  14. package/dist/server/data-plane/compact-protocol.js +416 -0
  15. package/dist/server/data-plane/compact-protocol.js.map +1 -0
  16. package/dist/server/data-plane/compact-state.d.ts +28 -0
  17. package/dist/server/data-plane/compact-state.d.ts.map +1 -0
  18. package/dist/server/data-plane/compact-state.js +97 -0
  19. package/dist/server/data-plane/compact-state.js.map +1 -0
  20. package/dist/server/data-plane/data-plane.d.ts.map +1 -1
  21. package/dist/server/data-plane/data-plane.js +4 -0
  22. package/dist/server/data-plane/data-plane.js.map +1 -1
  23. package/dist/server/data-plane/gateway-server.d.ts +7 -1
  24. package/dist/server/data-plane/gateway-server.d.ts.map +1 -1
  25. package/dist/server/data-plane/gateway-server.js +424 -38
  26. package/dist/server/data-plane/gateway-server.js.map +1 -1
  27. package/dist/server/data-plane/registry.d.ts +1 -0
  28. package/dist/server/data-plane/registry.d.ts.map +1 -1
  29. package/dist/server/data-plane/registry.js +3 -0
  30. package/dist/server/data-plane/registry.js.map +1 -1
  31. package/dist/server/data-plane/tool-catalog.d.ts +71 -0
  32. package/dist/server/data-plane/tool-catalog.d.ts.map +1 -0
  33. package/dist/server/data-plane/tool-catalog.js +667 -0
  34. package/dist/server/data-plane/tool-catalog.js.map +1 -0
  35. package/dist/server/node/local-gateway.d.ts +20 -0
  36. package/dist/server/node/local-gateway.d.ts.map +1 -1
  37. package/dist/server/node/local-gateway.js +85 -1
  38. package/dist/server/node/local-gateway.js.map +1 -1
  39. package/dist/server/upstream/adapter.d.ts +39 -2
  40. package/dist/server/upstream/adapter.d.ts.map +1 -1
  41. package/dist/server/upstream/adapter.js +37 -6
  42. package/dist/server/upstream/adapter.js.map +1 -1
  43. package/dist/server/upstream/manager.d.ts +9 -2
  44. package/dist/server/upstream/manager.d.ts.map +1 -1
  45. package/dist/server/upstream/manager.js +24 -4
  46. package/dist/server/upstream/manager.js.map +1 -1
  47. package/package.json +1 -1
  48. package/skills/toolhome/SKILL.md +23 -0
  49. package/skills/toolhome/references/cli-reference.md +19 -0
  50. package/skills/toolhome/references/deployment.md +3 -0
  51. 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
- constructor(registry, upstreams, cursors, masterKey, projections, recorder) {
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: aggregate.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 (aggregate.capabilities.tools) {
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 tools = await this.#aggregateTools(server, entries, context, request.params);
75
- const page = this.#page(tools, request.params?.cursor, fingerprint({ tools }));
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: { 'toolhome/server-count': entries.length },
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 prompts = await this.#aggregatePrompts(server, this.#registry.entries(), context, request.params);
118
- const page = this.#page(prompts, request.params?.cursor, fingerprint({ prompts }));
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 resources = await this.#aggregateResources(server, this.#registry.entries(), context, request.params);
139
- const page = this.#page(resources, request.params?.cursor, fingerprint({ resources }));
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 resourceTemplates = await this.#aggregateResourceTemplates(server, this.#registry.entries(), context, request.params);
149
- const page = this.#page(resourceTemplates, request.params?.cursor, fingerprint({ resourceTemplates }));
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 #execute(server, entry, request, context) {
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 groups = await Promise.all(entries
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 is an aggregate-endpoint projection: hidden tools
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
- return groups.flat().sort((left, right) => left.name.localeCompare(right.name));
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 groups = await Promise.all(entries
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
- return groups.flat().sort((left, right) => left.name.localeCompare(right.name));
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 groups = await Promise.all(entries
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
- return groups.flat().sort((left, right) => left.uri.localeCompare(right.uri));
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 groups = await Promise.all(entries
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
- return groups.flat().sort((left, right) => left.uriTemplate.localeCompare(right.uriTemplate));
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
- const offset = cursor === undefined ? 0 : this.#cursors.decode(cursor, key).offset;
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 {