agent-trellis 0.18.0 → 0.20.0

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 (100) hide show
  1. package/dist/cli.js +12 -1
  2. package/dist/commands/doctor.d.ts +8 -0
  3. package/dist/commands/doctor.js +87 -2
  4. package/dist/commands/evolve.d.ts +43 -0
  5. package/dist/commands/evolve.js +479 -0
  6. package/dist/commands/mcpGateway.d.ts +28 -0
  7. package/dist/commands/mcpGateway.js +40 -6
  8. package/dist/evolve/agentView.d.ts +32 -0
  9. package/dist/evolve/agentView.js +34 -0
  10. package/dist/evolve/apply.d.ts +71 -0
  11. package/dist/evolve/apply.js +162 -0
  12. package/dist/evolve/archive.d.ts +23 -0
  13. package/dist/evolve/archive.js +58 -0
  14. package/dist/evolve/erase.d.ts +17 -0
  15. package/dist/evolve/erase.js +65 -0
  16. package/dist/evolve/extension.d.ts +37 -0
  17. package/dist/evolve/extension.js +63 -0
  18. package/dist/evolve/gate.d.ts +95 -0
  19. package/dist/evolve/gate.js +295 -0
  20. package/dist/evolve/importHistory.d.ts +91 -0
  21. package/dist/evolve/importHistory.js +239 -0
  22. package/dist/evolve/jobMain.d.ts +22 -0
  23. package/dist/evolve/jobMain.js +52 -0
  24. package/dist/evolve/jobRun.d.ts +94 -0
  25. package/dist/evolve/jobRun.js +273 -0
  26. package/dist/evolve/jobShell.d.ts +46 -0
  27. package/dist/evolve/jobShell.js +75 -0
  28. package/dist/evolve/l2.d.ts +93 -0
  29. package/dist/evolve/l2.js +81 -0
  30. package/dist/evolve/lesson.d.ts +57 -0
  31. package/dist/evolve/lesson.js +97 -0
  32. package/dist/evolve/pending.d.ts +69 -0
  33. package/dist/evolve/pending.js +116 -0
  34. package/dist/evolve/probeWorkspace.d.ts +57 -0
  35. package/dist/evolve/probeWorkspace.js +69 -0
  36. package/dist/evolve/propose.d.ts +73 -0
  37. package/dist/evolve/propose.js +136 -0
  38. package/dist/evolve/recorder.d.ts +68 -0
  39. package/dist/evolve/recorder.js +169 -0
  40. package/dist/evolve/report.d.ts +26 -0
  41. package/dist/evolve/report.js +37 -0
  42. package/dist/evolve/reportView.d.ts +96 -0
  43. package/dist/evolve/reportView.js +311 -0
  44. package/dist/evolve/risk.d.ts +106 -0
  45. package/dist/evolve/risk.js +224 -0
  46. package/dist/evolve/sources.d.ts +19 -0
  47. package/dist/evolve/sources.js +29 -0
  48. package/dist/evolve/stateDir.d.ts +21 -0
  49. package/dist/evolve/stateDir.js +56 -0
  50. package/dist/evolve/surface.d.ts +34 -0
  51. package/dist/evolve/surface.js +72 -0
  52. package/dist/evolve/trajectory.d.ts +25 -0
  53. package/dist/evolve/trajectory.js +88 -0
  54. package/dist/evolve/treeToCanonical.d.ts +28 -0
  55. package/dist/evolve/treeToCanonical.js +79 -0
  56. package/dist/evolve/trellisSubstrate.d.ts +65 -0
  57. package/dist/evolve/trellisSubstrate.js +154 -0
  58. package/dist/evolve/trigger.d.ts +177 -0
  59. package/dist/evolve/trigger.js +400 -0
  60. package/dist/evolve/types.d.ts +126 -0
  61. package/dist/evolve/types.js +15 -0
  62. package/dist/evolve/validate.d.ts +139 -0
  63. package/dist/evolve/validate.js +315 -0
  64. package/dist/host/checks.d.ts +57 -0
  65. package/dist/host/checks.js +126 -0
  66. package/dist/host/delivery.d.ts +42 -0
  67. package/dist/host/delivery.js +59 -0
  68. package/dist/host/edits.d.ts +87 -0
  69. package/dist/host/edits.js +102 -0
  70. package/dist/host/hostEvents.d.ts +74 -0
  71. package/dist/host/hostEvents.js +45 -0
  72. package/dist/host/index.d.ts +22 -0
  73. package/dist/host/index.js +17 -0
  74. package/dist/host/levels.d.ts +106 -0
  75. package/dist/host/levels.js +206 -0
  76. package/dist/host/staging.d.ts +56 -0
  77. package/dist/host/staging.js +106 -0
  78. package/dist/host/surface.d.ts +36 -0
  79. package/dist/host/surface.js +20 -0
  80. package/dist/lib/backup.js +12 -3
  81. package/dist/lib/fsAtomic.d.ts +7 -0
  82. package/dist/lib/fsAtomic.js +14 -0
  83. package/dist/lib/gatewayBackend.d.ts +72 -2
  84. package/dist/lib/gatewayBackend.js +254 -13
  85. package/dist/lib/hostEventBus.d.ts +47 -0
  86. package/dist/lib/hostEventBus.js +94 -0
  87. package/dist/lib/hostExtensions.d.ts +75 -0
  88. package/dist/lib/hostExtensions.js +196 -0
  89. package/dist/lib/mcpRuntime.d.ts +12 -1
  90. package/dist/lib/mcpRuntime.js +34 -2
  91. package/dist/lib/mcpToolRegistry.d.ts +28 -2
  92. package/dist/lib/mcpToolRegistry.js +47 -8
  93. package/dist/lib/upstreamResources.d.ts +5 -0
  94. package/dist/lib/upstreamResources.js +10 -0
  95. package/dist/pi-bridge/bundle.js +33 -5
  96. package/docs/magpie-gateway-research.md +422 -0
  97. package/docs/roadmap.md +22 -12
  98. package/docs/runtime-infra.md +74 -0
  99. package/docs/self-evolution.md +138 -44
  100. package/package.json +7 -2
@@ -0,0 +1,196 @@
1
+ /**
2
+ * External host extension loading (host-extension-points spec, 8.6).
3
+ *
4
+ * The ONLY way an external module becomes a Trellis provider or event
5
+ * subscriber: it is declared in the canonical extension manifest at
6
+ * `~/.trellis/mcp/extensions.yaml` (entries carry `id` + a `load` module
7
+ * specifier) and its module exports `activate(ctx)` returning
8
+ * `{ providers?, subscribers? }`. There is no directory scan and no
9
+ * naming convention — a module on disk that the manifest does not name
10
+ * is never imported.
11
+ *
12
+ * Fault isolation is per extension and total: a manifest that fails to
13
+ * parse, an entry of the wrong shape, an import that throws, an
14
+ * `activate` that throws, or an activation of the wrong shape are each
15
+ * recorded as a failure (and warned), and none of them affect the core
16
+ * providers, upstream servers, or any other extension. With no manifest
17
+ * the loader returns empty everything and the gateway is byte-identical
18
+ * to the pre-extension world.
19
+ */
20
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
23
+ import { writeFileAtomicSync } from "./fsAtomic.js";
24
+ /** Manifest location relative to the home directory — canonical, not
25
+ * user-home-adjacent: extension declarations are canonical content. */
26
+ export const EXTENSIONS_MANIFEST_REL = join(".trellis", "mcp", "extensions.yaml");
27
+ export function extensionsManifestPath(homeDir) {
28
+ return join(homeDir, EXTENSIONS_MANIFEST_REL);
29
+ }
30
+ function failureMessage(err) {
31
+ return err instanceof Error ? err.message : String(err);
32
+ }
33
+ /** Minimal structural check for one provider handed back by an
34
+ * extension: it must at least be able to answer a call. */
35
+ function isProviderShape(value) {
36
+ return (typeof value === "object" && value !== null &&
37
+ typeof value.id === "string" &&
38
+ typeof value.callTool === "function");
39
+ }
40
+ function isSubscriberShape(value) {
41
+ return typeof value === "function";
42
+ }
43
+ /**
44
+ * Load every extension the canonical manifest declares. Never throws:
45
+ * the worst case is a `failures` entry per broken extension.
46
+ */
47
+ export async function loadHostExtensions(context, warn = console.error) {
48
+ const providers = [];
49
+ const subscribers = [];
50
+ const failures = [];
51
+ const report = (failure) => {
52
+ failures.push(failure);
53
+ warn(`trellis-mcp-gateway: extension "${failure.id}" failed (${failure.stage}): ${failure.message}`);
54
+ };
55
+ const manifestPath = extensionsManifestPath(context.homeDir);
56
+ if (!existsSync(manifestPath)) {
57
+ return { providers, subscribers, failures };
58
+ }
59
+ let parsed;
60
+ try {
61
+ parsed = (parseYaml(readFileSync(manifestPath, "utf-8")) ?? {});
62
+ }
63
+ catch (err) {
64
+ report({ id: "<manifest>", stage: "manifest", message: failureMessage(err) });
65
+ return { providers, subscribers, failures };
66
+ }
67
+ const rawEntries = parsed.extensions;
68
+ if (rawEntries === undefined || rawEntries === null) {
69
+ return { providers, subscribers, failures };
70
+ }
71
+ if (!Array.isArray(rawEntries)) {
72
+ report({ id: "<manifest>", stage: "manifest", message: "extensions must be a list of {id, load} entries" });
73
+ return { providers, subscribers, failures };
74
+ }
75
+ for (const raw of rawEntries) {
76
+ const entry = raw;
77
+ if (typeof entry !== "object" || entry === null ||
78
+ typeof entry.id !== "string" || entry.id === "" ||
79
+ typeof entry.load !== "string" || entry.load === "") {
80
+ report({ id: "<manifest>", stage: "invalid", message: `entry is not a {id, load} pair: ${JSON.stringify(raw)}` });
81
+ continue;
82
+ }
83
+ let mod;
84
+ try {
85
+ mod = await import(entry.load);
86
+ }
87
+ catch (err) {
88
+ report({ id: entry.id, stage: "import", message: failureMessage(err) });
89
+ continue;
90
+ }
91
+ if (typeof mod.activate !== "function") {
92
+ report({ id: entry.id, stage: "invalid", message: "module does not export an activate function" });
93
+ continue;
94
+ }
95
+ let activation;
96
+ try {
97
+ activation = (await mod.activate(context)) ?? {};
98
+ }
99
+ catch (err) {
100
+ report({ id: entry.id, stage: "activate", message: failureMessage(err) });
101
+ continue;
102
+ }
103
+ if (typeof activation !== "object") {
104
+ report({ id: entry.id, stage: "invalid", message: "activate did not return an object" });
105
+ continue;
106
+ }
107
+ const returnedProviders = Array.isArray(activation.providers) ? activation.providers : [];
108
+ for (const provider of returnedProviders) {
109
+ if (isProviderShape(provider)) {
110
+ providers.push(provider);
111
+ }
112
+ else {
113
+ report({ id: entry.id, stage: "invalid", message: "activation.providers contains a non-provider entry" });
114
+ }
115
+ }
116
+ const returnedSubscribers = Array.isArray(activation.subscribers) ? activation.subscribers : [];
117
+ for (const subscriber of returnedSubscribers) {
118
+ if (isSubscriberShape(subscriber)) {
119
+ subscribers.push(subscriber);
120
+ }
121
+ else {
122
+ report({ id: entry.id, stage: "invalid", message: "activation.subscribers contains a non-function entry" });
123
+ }
124
+ }
125
+ }
126
+ return { providers, subscribers, failures };
127
+ }
128
+ // ---------- 清单写入(声明面的唯一写路径) ----------
129
+ /**
130
+ * 写入(或原位更新)一条清单条目;幂等——同 id 旧条目被替换,其余条目
131
+ * 与未知键原样保留。清单是 canonical 内容,写入是显式管理动作(进化
132
+ * 启用命令 8.1),loader 侧永不写。
133
+ */
134
+ export function upsertExtensionEntry(homeDir, entry) {
135
+ if (typeof entry.id !== "string" || entry.id === "" || typeof entry.load !== "string" || entry.load === "") {
136
+ throw new Error(`extensions.yaml: entry must be a non-empty {id, load} pair, got ${JSON.stringify(entry)}`);
137
+ }
138
+ const manifestPath = extensionsManifestPath(homeDir);
139
+ let parsed = {};
140
+ if (existsSync(manifestPath)) {
141
+ const raw = parseYaml(readFileSync(manifestPath, "utf-8"));
142
+ if (raw !== null && raw !== undefined) {
143
+ if (typeof raw !== "object" || Array.isArray(raw)) {
144
+ throw new Error("extensions.yaml: top level must be a mapping with an `extensions` list");
145
+ }
146
+ parsed = raw;
147
+ }
148
+ }
149
+ const existing = parsed.extensions;
150
+ if (existing !== undefined && !Array.isArray(existing)) {
151
+ // 非数组的 extensions 键是手误产物:抛错而不是静默重置,避免
152
+ // 其余条目无声丢失。
153
+ throw new Error("extensions.yaml: `extensions` must be a list of {id, load} entries");
154
+ }
155
+ const entries = (existing ?? []);
156
+ const next = [];
157
+ let replaced = false;
158
+ for (const raw of entries) {
159
+ const candidate = raw;
160
+ if (candidate !== null && typeof candidate === "object" && candidate.id === entry.id) {
161
+ next.push({ id: entry.id, load: entry.load });
162
+ replaced = true;
163
+ }
164
+ else {
165
+ next.push(raw);
166
+ }
167
+ }
168
+ if (!replaced)
169
+ next.push({ id: entry.id, load: entry.load });
170
+ parsed.extensions = next;
171
+ mkdirSync(dirname(manifestPath), { recursive: true });
172
+ writeFileAtomicSync(manifestPath, stringifyYaml(parsed));
173
+ }
174
+ /**
175
+ * 移除一条清单条目;不存在时是无副作用的 no-op(返回 false)。不清空
176
+ * 文件、不动其他条目——清单还可能声明别的扩展。
177
+ */
178
+ export function removeExtensionEntry(homeDir, id) {
179
+ const manifestPath = extensionsManifestPath(homeDir);
180
+ if (!existsSync(manifestPath))
181
+ return false;
182
+ const raw = parseYaml(readFileSync(manifestPath, "utf-8"));
183
+ if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw))
184
+ return false;
185
+ const parsed = raw;
186
+ const existing = Array.isArray(parsed.extensions) ? parsed.extensions : [];
187
+ const next = existing.filter((item) => {
188
+ const candidate = item;
189
+ return !(candidate !== null && typeof candidate === "object" && candidate.id === id);
190
+ });
191
+ if (next.length === existing.length)
192
+ return false;
193
+ parsed.extensions = next;
194
+ writeFileAtomicSync(manifestPath, stringifyYaml(parsed));
195
+ return true;
196
+ }
@@ -3,6 +3,7 @@ import type { CallToolResult, GetPromptResult, Implementation, Prompt, ReadResou
3
3
  import type { AgentId } from "../core/types.js";
4
4
  import type { GatewayBackend } from "./gatewayBackend.js";
5
5
  import type { SkillEntry } from "./skillManifest.js";
6
+ import type { ToolCallInfo } from "../host/hostEvents.js";
6
7
  export interface RuntimeContext {
7
8
  agentId: AgentId;
8
9
  homeDir: string;
@@ -23,14 +24,24 @@ export interface TrellisProvider {
23
24
  export declare class BuiltinRegistry {
24
25
  private readonly providers;
25
26
  private readonly warn;
27
+ /** Event choke point (host-extension-points): called after every routed
28
+ * tool call with metadata only. Absent = pre-event behavior, unchanged. */
29
+ private readonly onToolCall?;
26
30
  private readonly toolOwners;
27
31
  private readonly originalToolOwners;
28
32
  private readonly exposedToOriginal;
29
33
  private readonly resourceOwners;
30
34
  private readonly promptOwners;
31
- constructor(providers: readonly TrellisProvider[], warn?: (message: string) => void);
35
+ constructor(providers: readonly TrellisProvider[], warn?: (message: string) => void,
36
+ /** Event choke point (host-extension-points): called after every routed
37
+ * tool call with metadata only. Absent = pre-event behavior, unchanged. */
38
+ onToolCall?: ((info: ToolCallInfo) => void) | undefined);
32
39
  listTools(context: RuntimeContext): Promise<Tool[]>;
33
40
  callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
41
+ /** Metadata-only event emission at the call choke point. Fully
42
+ * transparent: a failing observer never changes the tool call's result or
43
+ * error semantics (host-events transparency requirement). */
44
+ private emitToolCall;
34
45
  listResources(context: RuntimeContext): Promise<Resource[]>;
35
46
  readResource(uri: URL, context: RuntimeContext): Promise<ReadResourceResult>;
36
47
  listSkills(context: RuntimeContext): Promise<SkillEntry[]>;
@@ -8,17 +8,32 @@ function compactBuiltinToolName(name) {
8
8
  function providerError(provider, error) {
9
9
  return `trellis-mcp-runtime: provider "${provider}" failed: ${error instanceof Error ? error.message : String(error)}`;
10
10
  }
11
+ function resultCategory(result) {
12
+ if (!result?.isError)
13
+ return "ok";
14
+ const text = JSON.stringify(result.content ?? "");
15
+ return /timed? ?out/i.test(text) ? "timeout" : "error";
16
+ }
17
+ function thrownCategory(err) {
18
+ const message = err instanceof Error ? `${err.name} ${err.message}` : String(err);
19
+ return /timed? ?out/i.test(message) ? "timeout" : "error";
20
+ }
11
21
  export class BuiltinRegistry {
12
22
  providers;
13
23
  warn;
24
+ onToolCall;
14
25
  toolOwners = new Map();
15
26
  originalToolOwners = new Map();
16
27
  exposedToOriginal = new Map();
17
28
  resourceOwners = new Map();
18
29
  promptOwners = new Map();
19
- constructor(providers, warn = console.error) {
30
+ constructor(providers, warn = console.error,
31
+ /** Event choke point (host-extension-points): called after every routed
32
+ * tool call with metadata only. Absent = pre-event behavior, unchanged. */
33
+ onToolCall) {
20
34
  this.providers = providers;
21
35
  this.warn = warn;
36
+ this.onToolCall = onToolCall;
22
37
  }
23
38
  async listTools(context) {
24
39
  const candidates = [];
@@ -67,13 +82,30 @@ export class BuiltinRegistry {
67
82
  }
68
83
  if (!provider)
69
84
  return { content: [{ type: "text", text: `unknown Trellis tool "${name}"` }], isError: true };
85
+ const started = Date.now();
70
86
  try {
71
- return await provider.callTool(originalName, args, context);
87
+ const result = await provider.callTool(originalName, args, context);
88
+ this.emitToolCall({ provider: provider.id, tool: name, durationMs: Date.now() - started, result: resultCategory(result) });
89
+ return result;
72
90
  }
73
91
  catch (err) {
92
+ this.emitToolCall({ provider: provider.id, tool: name, durationMs: Date.now() - started, result: thrownCategory(err) });
74
93
  return { content: [{ type: "text", text: providerError(provider.id, err) }], isError: true };
75
94
  }
76
95
  }
96
+ /** Metadata-only event emission at the call choke point. Fully
97
+ * transparent: a failing observer never changes the tool call's result or
98
+ * error semantics (host-events transparency requirement). */
99
+ emitToolCall(info) {
100
+ if (!this.onToolCall)
101
+ return;
102
+ try {
103
+ this.onToolCall(info);
104
+ }
105
+ catch (err) {
106
+ this.warn(`trellis-mcp-runtime: tool-call observer failed: ${err instanceof Error ? err.message : String(err)}`);
107
+ }
108
+ }
77
109
  async listResources(context) {
78
110
  const resources = [];
79
111
  this.resourceOwners.clear();
@@ -52,6 +52,17 @@ export declare function prefixedToolName(serverName: string, toolName: string):
52
52
  export interface ToolNameCandidate {
53
53
  serverName: string;
54
54
  toolName: string;
55
+ /**
56
+ * Force the `server__tool` prefix regardless of uniqueness or length.
57
+ * Gateway aggregation sets this on every candidate so an Agent can always
58
+ * read the owning server from the exposed name (mcp-tool-name-preservation:
59
+ * "Prefix every upstream tool with its server name"). A tool whose name
60
+ * already starts with its server's name (e.g. `tanka_memo_search` on
61
+ * `tanka`) is not prefixed again — the name is already source-qualified.
62
+ * Other consumers (e.g. the built-in Runtime registry) leave it unset to
63
+ * keep their own naming contracts.
64
+ */
65
+ alwaysPrefix?: boolean;
55
66
  }
56
67
  /** Allocate all exposed names together so normalization collisions are
57
68
  * deterministic and no upstream tool is silently lost. The returned array
@@ -82,10 +93,25 @@ export declare class McpToolRegistry {
82
93
  * aggregated, with no special-casing here.
83
94
  */
84
95
  add(serverName: string, upstream: ToolUpstream, tools: readonly AggregatedTool[]): AddResult;
96
+ /**
97
+ * Swap one server's entire registration — the mid-session recovery path.
98
+ * Every candidate the server previously contributed is dropped before the
99
+ * fresh tool set is added, so a changed upstream tool set is reflected
100
+ * exactly rather than accumulated. Exposed names are re-allocated across
101
+ * the whole registry, so a name this server frees may be re-assigned
102
+ * deterministically to another server's colliding tool — accepted drift,
103
+ * pinned by the spec scenario.
104
+ */
105
+ replace(serverName: string, upstream: ToolUpstream, tools: readonly AggregatedTool[]): AddResult;
85
106
  private exposedNameFor;
86
107
  /** Every registered tool, under its prefixed name, in registration
87
- * order. Each tool's own `description`/`inputSchema` and any additional
88
- * fields the upstream sent are preserved verbatim; only `name` changes. */
108
+ * order. Each tool's own `inputSchema` and any additional fields the
109
+ * upstream sent are preserved verbatim; `name` changes, and the
110
+ * description gains a trailing `Source: <server>` line so an Agent can
111
+ * confirm the owning server without relying on the name alone
112
+ * (mcp-gateway-hosting: "Resold tool metadata identifies its upstream
113
+ * source"). The upstream's own description text is never altered or
114
+ * removed — the source line is appended. */
89
115
  listTools(): AggregatedTool[];
90
116
  /** The owning upstream and original tool name for a prefixed name, or
91
117
  * `undefined` if nothing owns it. */
@@ -81,12 +81,23 @@ export function allocateExposedToolNames(candidates) {
81
81
  // registry (for example `fixture__env` reaching the Runtime provider).
82
82
  // Do not add a second provider prefix around it.
83
83
  const sourceQualified = normalized.includes("__") || normalized === source || normalized.startsWith(`${source}_`) || normalized.startsWith(`${source}__`);
84
+ // Narrower than `sourceQualified`: for forced-prefix candidates the
85
+ // exemption applies only when the tool name already starts with ITS
86
+ // OWN server's name (a second prefix would be redundant, e.g.
87
+ // `tanka__tanka_memo_search`). A tool that merely contains `__`
88
+ // (`b__c`) or gained a `__hash` length bound still needs its server
89
+ // prefix to be identifiable.
90
+ const serverRedundant = normalized === source || normalized.startsWith(`${source}_`) || normalized.startsWith(`${source}__`);
84
91
  const unique = (counts.get(candidate.toolName) ?? 0) === 1;
85
- const base = unique && normalized.length > SHORT_EXPOSED_TOOL_NAME_LENGTH
86
- ? normalized
87
- : unique && sourceQualified
92
+ const base = candidate.alwaysPrefix
93
+ ? serverRedundant
88
94
  ? normalized
89
- : prefixedToolName(candidate.serverName, candidate.toolName);
95
+ : prefixedToolName(candidate.serverName, candidate.toolName)
96
+ : unique && normalized.length > SHORT_EXPOSED_TOOL_NAME_LENGTH
97
+ ? normalized
98
+ : unique && sourceQualified
99
+ ? normalized
100
+ : prefixedToolName(candidate.serverName, candidate.toolName);
90
101
  let exposed = base;
91
102
  let suffix = 2;
92
103
  while (used.has(exposed)) {
@@ -122,7 +133,26 @@ export class McpToolRegistry {
122
133
  */
123
134
  add(serverName, upstream, tools) {
124
135
  for (const tool of tools)
125
- this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
136
+ this.candidates.push({ serverName, toolName: tool.name, upstream, tool, alwaysPrefix: true });
137
+ this.rebuild();
138
+ const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== undefined);
139
+ return { added, skipped: [] };
140
+ }
141
+ /**
142
+ * Swap one server's entire registration — the mid-session recovery path.
143
+ * Every candidate the server previously contributed is dropped before the
144
+ * fresh tool set is added, so a changed upstream tool set is reflected
145
+ * exactly rather than accumulated. Exposed names are re-allocated across
146
+ * the whole registry, so a name this server frees may be re-assigned
147
+ * deterministically to another server's colliding tool — accepted drift,
148
+ * pinned by the spec scenario.
149
+ */
150
+ replace(serverName, upstream, tools) {
151
+ const kept = this.candidates.filter((candidate) => candidate.serverName !== serverName);
152
+ this.candidates.length = 0;
153
+ this.candidates.push(...kept);
154
+ for (const tool of tools)
155
+ this.candidates.push({ serverName, toolName: tool.name, upstream, tool, alwaysPrefix: true });
126
156
  this.rebuild();
127
157
  const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== undefined);
128
158
  return { added, skipped: [] };
@@ -135,8 +165,13 @@ export class McpToolRegistry {
135
165
  return undefined;
136
166
  }
137
167
  /** Every registered tool, under its prefixed name, in registration
138
- * order. Each tool's own `description`/`inputSchema` and any additional
139
- * fields the upstream sent are preserved verbatim; only `name` changes. */
168
+ * order. Each tool's own `inputSchema` and any additional fields the
169
+ * upstream sent are preserved verbatim; `name` changes, and the
170
+ * description gains a trailing `Source: <server>` line so an Agent can
171
+ * confirm the owning server without relying on the name alone
172
+ * (mcp-gateway-hosting: "Resold tool metadata identifies its upstream
173
+ * source"). The upstream's own description text is never altered or
174
+ * removed — the source line is appended. */
140
175
  listTools() {
141
176
  this.rebuild();
142
177
  return this.order.map((name) => {
@@ -144,7 +179,11 @@ export class McpToolRegistry {
144
179
  const title = name === entry.toolName || entry.tool.title !== undefined
145
180
  ? entry.tool.title
146
181
  : `${entry.serverName} / ${entry.toolName}`;
147
- return { ...entry.tool, name, ...(title === undefined ? {} : { title }) };
182
+ const sourceLine = `Source: ${entry.serverName}`;
183
+ const description = entry.tool.description
184
+ ? `${entry.tool.description}\n\n${sourceLine}`
185
+ : sourceLine;
186
+ return { ...entry.tool, name, description, ...(title === undefined ? {} : { title }) };
148
187
  });
149
188
  }
150
189
  /** The owning upstream and original tool name for a prefixed name, or
@@ -13,6 +13,11 @@ export declare class UpstreamResources {
13
13
  private readonly files;
14
14
  constructor(timeoutMs: number, warn: (message: string) => void);
15
15
  add(source: string, client: Client): void;
16
+ /** Forget one source entirely: its client and every file route it
17
+ * registered. Called before a source is re-added mid-session — stale
18
+ * routes would otherwise route reads at the closed previous client
19
+ * (`listResources`/`listSkills` re-register the fresh client's routes). */
20
+ removeSource(source: string): void;
16
21
  private skillsEnabled;
17
22
  private isolated;
18
23
  listResources(): Promise<Resource[]>;
@@ -54,6 +54,16 @@ export class UpstreamResources {
54
54
  this.warn = warn;
55
55
  }
56
56
  add(source, client) { this.clients.set(source, client); }
57
+ /** Forget one source entirely: its client and every file route it
58
+ * registered. Called before a source is re-added mid-session — stale
59
+ * routes would otherwise route reads at the closed previous client
60
+ * (`listResources`/`listSkills` re-register the fresh client's routes). */
61
+ removeSource(source) {
62
+ this.clients.delete(source);
63
+ for (const [uri, route] of this.files)
64
+ if (route.source === source)
65
+ this.files.delete(uri);
66
+ }
57
67
  skillsEnabled(client) {
58
68
  return Boolean(client.getServerCapabilities()?.resources && client.getDiscoverResult()?.capabilities.extensions?.[EXTENSION]);
59
69
  }
@@ -46008,8 +46008,9 @@ function allocateExposedToolNames(candidates) {
46008
46008
  const normalized = normalizedToolName(candidate.toolName);
46009
46009
  const source = compactSourceSegment(candidate.serverName);
46010
46010
  const sourceQualified = normalized.includes("__") || normalized === source || normalized.startsWith(`${source}_`) || normalized.startsWith(`${source}__`);
46011
+ const serverRedundant = normalized === source || normalized.startsWith(`${source}_`) || normalized.startsWith(`${source}__`);
46011
46012
  const unique = (counts.get(candidate.toolName) ?? 0) === 1;
46012
- const base = unique && normalized.length > SHORT_EXPOSED_TOOL_NAME_LENGTH ? normalized : unique && sourceQualified ? normalized : prefixedToolName(candidate.serverName, candidate.toolName);
46013
+ const base = candidate.alwaysPrefix ? serverRedundant ? normalized : prefixedToolName(candidate.serverName, candidate.toolName) : unique && normalized.length > SHORT_EXPOSED_TOOL_NAME_LENGTH ? normalized : unique && sourceQualified ? normalized : prefixedToolName(candidate.serverName, candidate.toolName);
46013
46014
  let exposed = base;
46014
46015
  let suffix = 2;
46015
46016
  while (used.has(exposed)) {
@@ -46042,7 +46043,25 @@ var McpToolRegistry = class {
46042
46043
  * aggregated, with no special-casing here.
46043
46044
  */
46044
46045
  add(serverName, upstream, tools) {
46045
- for (const tool of tools) this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
46046
+ for (const tool of tools) this.candidates.push({ serverName, toolName: tool.name, upstream, tool, alwaysPrefix: true });
46047
+ this.rebuild();
46048
+ const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== void 0);
46049
+ return { added, skipped: [] };
46050
+ }
46051
+ /**
46052
+ * Swap one server's entire registration — the mid-session recovery path.
46053
+ * Every candidate the server previously contributed is dropped before the
46054
+ * fresh tool set is added, so a changed upstream tool set is reflected
46055
+ * exactly rather than accumulated. Exposed names are re-allocated across
46056
+ * the whole registry, so a name this server frees may be re-assigned
46057
+ * deterministically to another server's colliding tool — accepted drift,
46058
+ * pinned by the spec scenario.
46059
+ */
46060
+ replace(serverName, upstream, tools) {
46061
+ const kept = this.candidates.filter((candidate) => candidate.serverName !== serverName);
46062
+ this.candidates.length = 0;
46063
+ this.candidates.push(...kept);
46064
+ for (const tool of tools) this.candidates.push({ serverName, toolName: tool.name, upstream, tool, alwaysPrefix: true });
46046
46065
  this.rebuild();
46047
46066
  const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== void 0);
46048
46067
  return { added, skipped: [] };
@@ -46054,14 +46073,23 @@ var McpToolRegistry = class {
46054
46073
  return void 0;
46055
46074
  }
46056
46075
  /** Every registered tool, under its prefixed name, in registration
46057
- * order. Each tool's own `description`/`inputSchema` and any additional
46058
- * fields the upstream sent are preserved verbatim; only `name` changes. */
46076
+ * order. Each tool's own `inputSchema` and any additional fields the
46077
+ * upstream sent are preserved verbatim; `name` changes, and the
46078
+ * description gains a trailing `Source: <server>` line so an Agent can
46079
+ * confirm the owning server without relying on the name alone
46080
+ * (mcp-gateway-hosting: "Resold tool metadata identifies its upstream
46081
+ * source"). The upstream's own description text is never altered or
46082
+ * removed — the source line is appended. */
46059
46083
  listTools() {
46060
46084
  this.rebuild();
46061
46085
  return this.order.map((name) => {
46062
46086
  const entry = this.entries.get(name);
46063
46087
  const title = name === entry.toolName || entry.tool.title !== void 0 ? entry.tool.title : `${entry.serverName} / ${entry.toolName}`;
46064
- return { ...entry.tool, name, ...title === void 0 ? {} : { title } };
46088
+ const sourceLine = `Source: ${entry.serverName}`;
46089
+ const description = entry.tool.description ? `${entry.tool.description}
46090
+
46091
+ ${sourceLine}` : sourceLine;
46092
+ return { ...entry.tool, name, description, ...title === void 0 ? {} : { title } };
46065
46093
  });
46066
46094
  }
46067
46095
  /** The owning upstream and original tool name for a prefixed name, or