@mcpmesh/sdk 2.8.1 → 3.0.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 (84) hide show
  1. package/dist/__tests__/agent-add-tool.spec.js +106 -0
  2. package/dist/__tests__/agent-add-tool.spec.js.map +1 -1
  3. package/dist/__tests__/claim-dispatcher-resume-cursor.test.d.ts +2 -0
  4. package/dist/__tests__/claim-dispatcher-resume-cursor.test.d.ts.map +1 -0
  5. package/dist/__tests__/claim-dispatcher-resume-cursor.test.js +145 -0
  6. package/dist/__tests__/claim-dispatcher-resume-cursor.test.js.map +1 -0
  7. package/dist/__tests__/direct-invoke-required.spec.d.ts +2 -0
  8. package/dist/__tests__/direct-invoke-required.spec.d.ts.map +1 -0
  9. package/dist/__tests__/direct-invoke-required.spec.js +209 -0
  10. package/dist/__tests__/direct-invoke-required.spec.js.map +1 -0
  11. package/dist/__tests__/service-view-types.spec.d.ts +2 -0
  12. package/dist/__tests__/service-view-types.spec.d.ts.map +1 -0
  13. package/dist/__tests__/service-view-types.spec.js +66 -0
  14. package/dist/__tests__/service-view-types.spec.js.map +1 -0
  15. package/dist/__tests__/service-view.spec.d.ts +2 -0
  16. package/dist/__tests__/service-view.spec.d.ts.map +1 -0
  17. package/dist/__tests__/service-view.spec.js +902 -0
  18. package/dist/__tests__/service-view.spec.js.map +1 -0
  19. package/dist/__tests__/superseded-signal.spec.d.ts +19 -0
  20. package/dist/__tests__/superseded-signal.spec.d.ts.map +1 -0
  21. package/dist/__tests__/superseded-signal.spec.js +222 -0
  22. package/dist/__tests__/superseded-signal.spec.js.map +1 -0
  23. package/dist/__tests__/worker-error-serde.spec.d.ts +2 -0
  24. package/dist/__tests__/worker-error-serde.spec.d.ts.map +1 -0
  25. package/dist/__tests__/worker-error-serde.spec.js +122 -0
  26. package/dist/__tests__/worker-error-serde.spec.js.map +1 -0
  27. package/dist/a2a/producer/mount.d.ts.map +1 -1
  28. package/dist/a2a/producer/mount.js +4 -0
  29. package/dist/a2a/producer/mount.js.map +1 -1
  30. package/dist/agent.d.ts +51 -3
  31. package/dist/agent.d.ts.map +1 -1
  32. package/dist/agent.js +435 -33
  33. package/dist/agent.js.map +1 -1
  34. package/dist/claim-dispatcher.d.ts +25 -1
  35. package/dist/claim-dispatcher.d.ts.map +1 -1
  36. package/dist/claim-dispatcher.js +51 -2
  37. package/dist/claim-dispatcher.js.map +1 -1
  38. package/dist/inbound-job-dispatch.d.ts +1 -1
  39. package/dist/inbound-job-dispatch.d.ts.map +1 -1
  40. package/dist/inbound-job-dispatch.js +8 -2
  41. package/dist/inbound-job-dispatch.js.map +1 -1
  42. package/dist/index.d.ts +6 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +9 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/llm-provider.d.ts +14 -16
  47. package/dist/llm-provider.d.ts.map +1 -1
  48. package/dist/llm-provider.js +2 -4
  49. package/dist/llm-provider.js.map +1 -1
  50. package/dist/llm.d.ts +1 -2
  51. package/dist/llm.d.ts.map +1 -1
  52. package/dist/llm.js +1 -2
  53. package/dist/llm.js.map +1 -1
  54. package/dist/proxy.d.ts.map +1 -1
  55. package/dist/proxy.js +37 -0
  56. package/dist/proxy.js.map +1 -1
  57. package/dist/route.d.ts.map +1 -1
  58. package/dist/route.js +5 -0
  59. package/dist/route.js.map +1 -1
  60. package/dist/service-view.d.ts +245 -0
  61. package/dist/service-view.d.ts.map +1 -0
  62. package/dist/service-view.js +210 -0
  63. package/dist/service-view.js.map +1 -0
  64. package/dist/settle.d.ts +22 -0
  65. package/dist/settle.d.ts.map +1 -1
  66. package/dist/settle.js +51 -22
  67. package/dist/settle.js.map +1 -1
  68. package/dist/superseded.d.ts +70 -0
  69. package/dist/superseded.d.ts.map +1 -0
  70. package/dist/superseded.js +99 -0
  71. package/dist/superseded.js.map +1 -0
  72. package/dist/tool-worker-entry.js +1 -12
  73. package/dist/tool-worker-entry.js.map +1 -1
  74. package/dist/tool-worker-pool.d.ts.map +1 -1
  75. package/dist/tool-worker-pool.js +1 -12
  76. package/dist/tool-worker-pool.js.map +1 -1
  77. package/dist/types.d.ts +31 -1
  78. package/dist/types.d.ts.map +1 -1
  79. package/dist/types.js.map +1 -1
  80. package/dist/worker-error-serde.d.ts +40 -0
  81. package/dist/worker-error-serde.d.ts.map +1 -0
  82. package/dist/worker-error-serde.js +86 -0
  83. package/dist/worker-error-serde.js.map +1 -0
  84. package/package.json +2 -2
@@ -0,0 +1,902 @@
1
+ /**
2
+ * Service views (RFC #1280) — TypeScript runtime.
3
+ *
4
+ * Cross-runtime contract (mirrors the Java uc37 suite):
5
+ *
6
+ * - CONSUMER VIEW: a `mesh.serviceView(...)` entry in a tool's `dependencies`
7
+ * array occupies ONE positional slot but expands into N ordinary edges (one
8
+ * per method, NAME-SORTED, in-place). The framework injects a facade at that
9
+ * slot; each facade method delegates to its edge's own resolved proxy and
10
+ * rebinds independently. The flat edge array is what ships / settles /
11
+ * resolves — zero wire change; a view-free tool is byte-identical.
12
+ *
13
+ * - PRODUCER SUGAR: `agent.addService("prefix", { ... })` publishes each entry
14
+ * (name-sorted) as an ordinary tool with capability `prefix.<method>`.
15
+ *
16
+ * Covers: expansion order (explicit + view + explicit; multi-view; name-sort),
17
+ * settle keys per edge, payload carries N edges with required flags, facade
18
+ * delegation + rebinding, required refusal (direct + job flavors), the
19
+ * minAvailable floor (below/at, settle-aware wait), unresolved optional method
20
+ * error, all validation throws, producer addService, and serviceView-in-route.
21
+ */
22
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
23
+ import { z } from "zod";
24
+ import { UserError } from "fastmcp";
25
+ // Mock only makeJobController (wraps a napi handle hitting the registry). The
26
+ // REAL readJobHeaders runs so the job-dispatch classification path is exercised.
27
+ const h = vi.hoisted(() => {
28
+ const releaseLeaseMock = vi.fn(async () => { });
29
+ const failMock = vi.fn(async () => { });
30
+ const makeJobControllerMock = vi.fn(() => ({
31
+ releaseLease: releaseLeaseMock,
32
+ fail: failMock,
33
+ }));
34
+ return { releaseLeaseMock, failMock, makeJobControllerMock };
35
+ });
36
+ vi.mock("../inbound-job-dispatch.js", async (importActual) => {
37
+ const actual = await importActual();
38
+ return {
39
+ ...actual,
40
+ makeJobController: h.makeJobControllerMock,
41
+ };
42
+ });
43
+ import { MeshAgent } from "../agent.js";
44
+ import { RouteRegistry, route } from "../route.js";
45
+ import { mount as a2aMount } from "../a2a/producer/index.js";
46
+ import { ClaimDispatcher } from "../claim-dispatcher.js";
47
+ import { MeshJobSubmitter } from "../mesh-job-submitter.js";
48
+ import { normalizeSchemaWithPolicy } from "../schema-normalize.js";
49
+ import { serviceView, isServiceView, assertNoServiceViewDeps, MeshServiceUnavailableError, } from "../service-view.js";
50
+ import { getSettleState, resetSettleStateForTests, } from "../settle.js";
51
+ function makeFastMCPStub() {
52
+ return {
53
+ addTool: vi.fn(),
54
+ start: vi.fn(),
55
+ getApp: vi.fn(),
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ };
58
+ }
59
+ let autoStartSpy = null;
60
+ let warnSpy = null;
61
+ const savedEnv = {};
62
+ let agentCounter = 0;
63
+ beforeEach(() => {
64
+ h.releaseLeaseMock.mockClear();
65
+ h.makeJobControllerMock.mockClear();
66
+ autoStartSpy = vi
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ .spyOn(MeshAgent.prototype, "_autoStart")
69
+ .mockImplementation(async () => {
70
+ /* no-op */
71
+ });
72
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
73
+ /* swallow */
74
+ });
75
+ savedEnv.MCP_MESH_SETTLE_TIMEOUT = process.env.MCP_MESH_SETTLE_TIMEOUT;
76
+ savedEnv.MCP_MESH_TOOL_ISOLATION = process.env.MCP_MESH_TOOL_ISOLATION;
77
+ process.env.MCP_MESH_TOOL_ISOLATION = "false";
78
+ // Settle immediately by default so an unresolved edge is genuinely unresolved.
79
+ // Floor-wait tests override with a positive budget.
80
+ process.env.MCP_MESH_SETTLE_TIMEOUT = "0";
81
+ resetSettleStateForTests();
82
+ RouteRegistry.reset();
83
+ });
84
+ afterEach(() => {
85
+ autoStartSpy?.mockRestore();
86
+ autoStartSpy = null;
87
+ warnSpy?.mockRestore();
88
+ warnSpy = null;
89
+ for (const [key, value] of Object.entries(savedEnv)) {
90
+ if (value === undefined)
91
+ delete process.env[key];
92
+ else
93
+ process.env[key] = value;
94
+ }
95
+ resetSettleStateForTests();
96
+ RouteRegistry.reset();
97
+ });
98
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
+ function newAgent(fastmcp) {
100
+ return new MeshAgent(fastmcp, {
101
+ name: `view-agent-${agentCounter++}`,
102
+ httpPort: 0,
103
+ });
104
+ }
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ function captureExecute(fastmcp, idx = 0) {
107
+ return fastmcp.addTool.mock.calls[idx][0].execute;
108
+ }
109
+ /** Inject a fake callable proxy at a tool's flat edge slot. */
110
+ function injectEdge(agent, toolName, edgeIndex, impl) {
111
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
+ agent.resolvedDeps.set(`${toolName}:dep_${edgeIndex}`, impl);
113
+ }
114
+ function removeEdge(agent, toolName, edgeIndex) {
115
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
116
+ agent.resolvedDeps.delete(`${toolName}:dep_${edgeIndex}`);
117
+ }
118
+ /** Read the flat normalized-edge array the tool ships to the registry. */
119
+ function edgesOf(agent, toolName) {
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ return agent.tools.get(toolName).dependencies;
122
+ }
123
+ describe("serviceView() factory + brand", () => {
124
+ it("brands the returned value", () => {
125
+ const v = serviceView({ methods: { a: "cap.a" } });
126
+ expect(isServiceView(v)).toBe(true);
127
+ expect(isServiceView({ methods: {} })).toBe(false);
128
+ expect(isServiceView("cap.a")).toBe(false);
129
+ expect(isServiceView(null)).toBe(false);
130
+ });
131
+ });
132
+ describe("expansion order + slot layout", () => {
133
+ it("explicit + view + explicit: edges expand in-place, view = one facade slot", async () => {
134
+ const fastmcp = makeFastMCPStub();
135
+ const agent = newAgent(fastmcp);
136
+ const Media = serviceView({
137
+ // Declared out of order to prove name-sorting.
138
+ methods: { thumbnail: "media.thumbnail", caption: "media.caption" },
139
+ });
140
+ const seen = [];
141
+ agent.addTool({
142
+ name: "process",
143
+ parameters: z.object({}),
144
+ dependencies: ["audit_log", Media, "metrics"],
145
+ execute: async (_args, audit, media, metrics) => {
146
+ seen.push(audit, media, metrics);
147
+ return "ok";
148
+ },
149
+ });
150
+ // Flat edges: audit(0), caption(1), thumbnail(2), metrics(3) — view methods
151
+ // NAME-SORTED and expanded in-place at the view's array position.
152
+ expect(edgesOf(agent, "process").map((d) => d.capability)).toEqual([
153
+ "audit_log",
154
+ "media.caption",
155
+ "media.thumbnail",
156
+ "metrics",
157
+ ]);
158
+ // Wire up the two explicit deps + both view edges.
159
+ injectEdge(agent, "process", 0, async () => "audit-proxy");
160
+ injectEdge(agent, "process", 1, async (a) => ({ cap: "caption", a }));
161
+ injectEdge(agent, "process", 2, async (a) => ({ cap: "thumbnail", a }));
162
+ injectEdge(agent, "process", 3, async () => "metrics-proxy");
163
+ await captureExecute(fastmcp)({});
164
+ const [audit, media, metrics] = seen;
165
+ // Explicit slots receive their own proxies; the view slot is a facade.
166
+ expect(await audit()).toBe("audit-proxy");
167
+ expect(await metrics()).toBe("metrics-proxy");
168
+ expect(typeof media.caption).toBe("function");
169
+ expect(typeof media.thumbnail).toBe("function");
170
+ expect(await media.caption({ text: "hi" })).toEqual({
171
+ cap: "caption",
172
+ a: { text: "hi" },
173
+ });
174
+ expect(await media.thumbnail({ id: "x" })).toEqual({
175
+ cap: "thumbnail",
176
+ a: { id: "x" },
177
+ });
178
+ });
179
+ it("multiple views expand into disjoint contiguous edge ranges", async () => {
180
+ const fastmcp = makeFastMCPStub();
181
+ const agent = newAgent(fastmcp);
182
+ const ViewA = serviceView({ methods: { b: "svc.b", a: "svc.a" } });
183
+ const ViewB = serviceView({ methods: { c: "svc.c" } });
184
+ const seen = [];
185
+ agent.addTool({
186
+ name: "multi",
187
+ parameters: z.object({}),
188
+ dependencies: [ViewA, ViewB],
189
+ execute: async (_args, va, vb) => {
190
+ seen.push(va, vb);
191
+ return "ok";
192
+ },
193
+ });
194
+ expect(edgesOf(agent, "multi").map((d) => d.capability)).toEqual([
195
+ "svc.a", // ViewA edge 0
196
+ "svc.b", // ViewA edge 1
197
+ "svc.c", // ViewB edge 2
198
+ ]);
199
+ injectEdge(agent, "multi", 0, async () => "A.a");
200
+ injectEdge(agent, "multi", 1, async () => "A.b");
201
+ injectEdge(agent, "multi", 2, async () => "B.c");
202
+ await captureExecute(fastmcp)({});
203
+ const [va, vb] = seen;
204
+ expect(Object.keys(va).sort()).toEqual(["a", "b"]);
205
+ expect(Object.keys(vb)).toEqual(["c"]);
206
+ expect(await va.a()).toBe("A.a");
207
+ expect(await va.b()).toBe("A.b");
208
+ expect(await vb.c()).toBe("B.c");
209
+ });
210
+ it("view-free tool is byte-identical (slot index === edge index)", async () => {
211
+ const fastmcp = makeFastMCPStub();
212
+ const agent = newAgent(fastmcp);
213
+ agent.addTool({
214
+ name: "plain",
215
+ parameters: z.object({}),
216
+ dependencies: ["a", { capability: "b", required: true }],
217
+ execute: async () => "ok",
218
+ });
219
+ const edges = edgesOf(agent, "plain");
220
+ expect(edges.map((d) => d.capability)).toEqual(["a", "b"]);
221
+ expect(edges[1].required).toBe(true);
222
+ });
223
+ });
224
+ describe("wire payload + settle keys", () => {
225
+ it("payload carries N edges with per-method required flags", () => {
226
+ const fastmcp = makeFastMCPStub();
227
+ const agent = newAgent(fastmcp);
228
+ const View = serviceView({
229
+ methods: {
230
+ caption: { capability: "media.caption", required: true, tags: ["+fast"] },
231
+ thumbnail: "media.thumbnail",
232
+ },
233
+ });
234
+ agent.addTool({
235
+ name: "gen",
236
+ parameters: z.object({}),
237
+ dependencies: [View],
238
+ execute: async () => "ok",
239
+ });
240
+ const edges = edgesOf(agent, "gen");
241
+ expect(edges.map((d) => d.capability)).toEqual([
242
+ "media.caption",
243
+ "media.thumbnail",
244
+ ]);
245
+ // required=true only on the caption edge; thumbnail stays soft (undefined).
246
+ expect(edges[0].required).toBe(true);
247
+ expect(edges[1].required).toBeUndefined();
248
+ expect(edges[0].tags).toEqual(["+fast"]);
249
+ });
250
+ it("registers a settle key per edge (agent settles only when ALL resolve)", () => {
251
+ const fastmcp = makeFastMCPStub();
252
+ const agent = newAgent(fastmcp);
253
+ const View = serviceView({ methods: { a: "svc.a", b: "svc.b" } });
254
+ process.env.MCP_MESH_SETTLE_TIMEOUT = "30";
255
+ resetSettleStateForTests();
256
+ agent.addTool({
257
+ name: "svt",
258
+ parameters: z.object({}),
259
+ dependencies: [View],
260
+ execute: async () => "ok",
261
+ });
262
+ const settle = getSettleState();
263
+ expect(settle.isSettled()).toBe(false);
264
+ // Resolve edge 0 only → still unsettled (edge 1 pending).
265
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
266
+ agent.handleDependencyAvailable("svc.a", "http://localhost:19999", "fn", "prov", "svt", 0);
267
+ expect(settle.isSettled()).toBe(false);
268
+ // Resolve edge 1 → last declared key → eager latch flips.
269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
270
+ agent.handleDependencyAvailable("svc.b", "http://localhost:19999", "fn", "prov", "svt", 1);
271
+ expect(settle.isSettled()).toBe(true);
272
+ });
273
+ });
274
+ describe("facade delegation + rebinding", () => {
275
+ it("reads the CURRENT per-edge proxy at call time (rebinding-free)", async () => {
276
+ const fastmcp = makeFastMCPStub();
277
+ const agent = newAgent(fastmcp);
278
+ const View = serviceView({ methods: { chat: "llm.chat" } });
279
+ const seen = [];
280
+ agent.addTool({
281
+ name: "talk",
282
+ parameters: z.object({}),
283
+ dependencies: [View],
284
+ execute: async (_a, v) => {
285
+ seen.push(v);
286
+ return "ok";
287
+ },
288
+ });
289
+ injectEdge(agent, "talk", 0, async () => "provider-1");
290
+ await captureExecute(fastmcp)({});
291
+ const facade = seen[0];
292
+ expect(await facade.chat()).toBe("provider-1");
293
+ // Simulate a rebind: dependency_available swaps the edge proxy.
294
+ injectEdge(agent, "talk", 0, async () => "provider-2");
295
+ expect(await facade.chat()).toBe("provider-2");
296
+ // Simulate dependency_unavailable: the edge is dropped → optional call
297
+ // throws the null-proxy shape (TypeError).
298
+ removeEdge(agent, "talk", 0);
299
+ await expect(facade.chat()).rejects.toBeInstanceOf(TypeError);
300
+ });
301
+ });
302
+ describe("required=true view method refusal", () => {
303
+ it("direct tools/call refuses with dependency_unavailable", async () => {
304
+ const fastmcp = makeFastMCPStub();
305
+ const agent = newAgent(fastmcp);
306
+ const View = serviceView({
307
+ methods: { caption: { capability: "media.caption", required: true } },
308
+ });
309
+ const ran = [];
310
+ agent.addTool({
311
+ name: "req_direct",
312
+ parameters: z.object({}),
313
+ dependencies: [View],
314
+ execute: async () => {
315
+ ran.push(true);
316
+ return "ran";
317
+ },
318
+ });
319
+ let thrown;
320
+ try {
321
+ await captureExecute(fastmcp)({});
322
+ }
323
+ catch (err) {
324
+ thrown = err;
325
+ }
326
+ expect(thrown).toBeInstanceOf(UserError);
327
+ expect(JSON.parse(thrown.message)).toEqual({
328
+ error: "dependency_unavailable",
329
+ capability: "media.caption",
330
+ });
331
+ expect(ran).toHaveLength(0);
332
+ expect(h.releaseLeaseMock).not.toHaveBeenCalled();
333
+ });
334
+ it("inbound JOB dispatch releases the lease (never throws)", async () => {
335
+ const fastmcp = makeFastMCPStub();
336
+ const agent = newAgent(fastmcp);
337
+ const View = serviceView({
338
+ methods: { render: { capability: "media.render", required: true } },
339
+ });
340
+ const ran = [];
341
+ agent.addTool({
342
+ name: "req_job",
343
+ task: true,
344
+ parameters: z.object({}),
345
+ dependencies: [View],
346
+ execute: async () => {
347
+ ran.push(true);
348
+ return "ran";
349
+ },
350
+ });
351
+ const result = await captureExecute(fastmcp)({
352
+ _mesh_headers: { "x-mesh-job-id": "job-9", "x-mesh-claim-epoch": "2" },
353
+ });
354
+ expect(result).toBe("");
355
+ expect(ran).toHaveLength(0);
356
+ expect(h.makeJobControllerMock).toHaveBeenCalledTimes(1);
357
+ const ctorArgs = h.makeJobControllerMock.mock.calls[0];
358
+ expect(ctorArgs[0]).toBe("job-9");
359
+ expect(ctorArgs[3]).toBe(2);
360
+ expect(h.releaseLeaseMock).toHaveBeenCalledTimes(1);
361
+ const releaseArgs = h.releaseLeaseMock.mock.calls[0];
362
+ expect(String(releaseArgs[0])).toContain("media.render");
363
+ });
364
+ it("required view edge does not refuse once resolved", async () => {
365
+ const fastmcp = makeFastMCPStub();
366
+ const agent = newAgent(fastmcp);
367
+ const View = serviceView({
368
+ methods: { caption: { capability: "media.caption", required: true } },
369
+ });
370
+ agent.addTool({
371
+ name: "req_ok",
372
+ parameters: z.object({}),
373
+ dependencies: [View],
374
+ execute: async (_a, v) => {
375
+ const facade = v;
376
+ return (await facade.caption());
377
+ },
378
+ });
379
+ injectEdge(agent, "req_ok", 0, async () => "captioned");
380
+ // String results pass through unchanged (wrappedExecute returns strings as-is).
381
+ expect(await captureExecute(fastmcp)({})).toBe("captioned");
382
+ });
383
+ });
384
+ describe("minAvailable floor", () => {
385
+ it("at floor → delegates; below floor (settled) → MeshServiceUnavailableError", async () => {
386
+ const fastmcp = makeFastMCPStub();
387
+ const agent = newAgent(fastmcp);
388
+ const View = serviceView({
389
+ methods: { a: "svc.a", b: "svc.b" },
390
+ minAvailable: 1,
391
+ name: "MyView",
392
+ });
393
+ const seen = [];
394
+ agent.addTool({
395
+ name: "floortool",
396
+ parameters: z.object({}),
397
+ dependencies: [View],
398
+ execute: async (_a, v) => {
399
+ seen.push(v);
400
+ return "ok";
401
+ },
402
+ });
403
+ // Resolve one of two methods → floor (1) satisfied.
404
+ injectEdge(agent, "floortool", 0, async () => "A");
405
+ await captureExecute(fastmcp)({});
406
+ const facade = seen[0];
407
+ // The resolved method delegates; the unresolved-but-floor-satisfied method
408
+ // throws the null-proxy shape (optional edge) rather than the floor error.
409
+ expect(await facade.a()).toBe("A");
410
+ await expect(facade.b()).rejects.toBeInstanceOf(TypeError);
411
+ // Drop below the floor (settled → no wait) → every call throws the floor error.
412
+ removeEdge(agent, "floortool", 0);
413
+ let err;
414
+ try {
415
+ await facade.a();
416
+ }
417
+ catch (e) {
418
+ err = e;
419
+ }
420
+ expect(err).toBeInstanceOf(MeshServiceUnavailableError);
421
+ const mse = err;
422
+ expect(mse.service).toBe("MyView");
423
+ expect(mse.available).toBe(0);
424
+ expect(mse.total).toBe(2);
425
+ expect(mse.floor).toBe(1);
426
+ });
427
+ it("below floor: waking on a CROSS edge (resolve b while blocked) returns promptly", async () => {
428
+ // The old serial enforceFloor awaited each pending edge in name order, so a
429
+ // call would block on edge 'a' even after edge 'b' resolved and satisfied
430
+ // the floor. The race-based enforceFloor wakes on ANY pending edge.
431
+ const fastmcp = makeFastMCPStub();
432
+ const agent = newAgent(fastmcp);
433
+ process.env.MCP_MESH_SETTLE_TIMEOUT = "30";
434
+ resetSettleStateForTests();
435
+ const View = serviceView({
436
+ methods: { a: "svc.a", b: "svc.b" },
437
+ minAvailable: 1,
438
+ });
439
+ // Build the facade directly so the tool's own settle-wait doesn't interfere.
440
+ const { edges, slots } = (await import("../service-view.js")).expandDependencies([View], "wait_tool");
441
+ const settle = getSettleState();
442
+ slots.forEach((s) => {
443
+ if (s.kind === "view") {
444
+ for (const m of s.methods) {
445
+ settle.registerDeclared(`wait_tool:dep_${m.edgeIndex}`);
446
+ }
447
+ }
448
+ });
449
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
450
+ const depsArray = agent._buildDepSlots("wait_tool", slots, edges, undefined, settle);
451
+ const facade = depsArray[0];
452
+ expect(settle.isSettled()).toBe(false);
453
+ // Call method 'b' while BELOW the floor (0 resolved). enforceFloor races
454
+ // BOTH pending edges. Resolving 'b' (edge 1) — a DIFFERENT key than 'a'
455
+ // which sorts first — must wake the wait and delegate promptly.
456
+ const before = settle.waitCount;
457
+ const p = facade.b();
458
+ // Let enforceFloor register its waiters before we resolve.
459
+ await new Promise((r) => setImmediate(r));
460
+ injectEdge(agent, "wait_tool", 1, async () => "B");
461
+ settle.markResolved("wait_tool:dep_1");
462
+ // Floor satisfied by edge 'b' → the call delegates to 'b' without waiting
463
+ // for the still-pending 'a' or the full 30s window.
464
+ expect(await p).toBe("B");
465
+ // A bounded wait actually occurred (never a fixed sleep once settled).
466
+ expect(settle.waitCount).toBeGreaterThan(before);
467
+ });
468
+ it("flapped (ratchet-resolved then unavailable) edges do NOT busy-spin below floor", async () => {
469
+ // Regression: an edge resolved once (settle ratchet) then flapped to a null
470
+ // proxy can never re-fire its waiter. If every pending edge is in that
471
+ // state, waitForAny returns immediately — the old enforceFloor loop would
472
+ // spin hot until the window expired. The fix breaks to the final throw the
473
+ // instant no WAKEABLE edge remains.
474
+ const fastmcp = makeFastMCPStub();
475
+ const agent = newAgent(fastmcp);
476
+ process.env.MCP_MESH_SETTLE_TIMEOUT = "30";
477
+ resetSettleStateForTests();
478
+ const View = serviceView({
479
+ methods: { a: "svc.a", b: "svc.b" },
480
+ minAvailable: 1,
481
+ });
482
+ const { edges, slots } = (await import("../service-view.js")).expandDependencies([View], "flap_tool");
483
+ const settle = getSettleState();
484
+ settle.registerDeclared("flap_tool:dep_0");
485
+ settle.registerDeclared("flap_tool:dep_1");
486
+ // A third, never-resolving declared key keeps the agent UNSETTLED so the
487
+ // enforceFloor loop's `!isSettled()` guard stays true (the spin precondition).
488
+ settle.registerDeclared("other_tool:dep_0");
489
+ // Ratchet BOTH view edges (resolved-at-least-once) then flap them to null.
490
+ for (const e of [0, 1]) {
491
+ injectEdge(agent, "flap_tool", e, async () => "x");
492
+ settle.markResolved(`flap_tool:dep_${e}`);
493
+ removeEdge(agent, "flap_tool", e);
494
+ }
495
+ expect(settle.isResolved("flap_tool:dep_0")).toBe(true);
496
+ expect(settle.isResolved("flap_tool:dep_1")).toBe(true);
497
+ expect(settle.isSettled()).toBe(false); // window still open
498
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
499
+ const depsArray = agent._buildDepSlots("flap_tool", slots, edges, undefined, settle);
500
+ const facade = depsArray[0];
501
+ const waitAnySpy = vi.spyOn(settle, "waitForAny");
502
+ let err;
503
+ try {
504
+ await facade.a();
505
+ }
506
+ catch (e) {
507
+ err = e;
508
+ }
509
+ // Threw promptly with the floor error — no wakeable edge, so waitForAny was
510
+ // never entered and the window is still open (proof it did NOT spin to
511
+ // expiry; a spin would have latched isSettled() true).
512
+ expect(err).toBeInstanceOf(MeshServiceUnavailableError);
513
+ expect(err.available).toBe(0);
514
+ expect(err.total).toBe(2);
515
+ expect(err.floor).toBe(1);
516
+ expect(waitAnySpy).not.toHaveBeenCalled();
517
+ expect(settle.isSettled()).toBe(false);
518
+ waitAnySpy.mockRestore();
519
+ });
520
+ });
521
+ describe("serviceView validation (throws at construction)", () => {
522
+ it("empty methods map", () => {
523
+ expect(() => serviceView({ methods: {} })).toThrow(/at least one method/);
524
+ });
525
+ it("methods array (numeric keys) is rejected as a non-object map", () => {
526
+ expect(() =>
527
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
528
+ serviceView({ methods: [] })).toThrow(/must be an object mapping method names/);
529
+ expect(() =>
530
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
531
+ serviceView({ methods: ["svc.a"] })).toThrow(/must be an object mapping method names/);
532
+ });
533
+ it("blank capability", () => {
534
+ expect(() => serviceView({ methods: { a: "" } })).toThrow(/blank capability/);
535
+ expect(() => serviceView({ methods: { a: { capability: " " } } })).toThrow(/blank capability/);
536
+ });
537
+ it("minAvailable < 0", () => {
538
+ expect(() => serviceView({ methods: { a: "svc.a" }, minAvailable: -1 })).toThrow(/minAvailable must be an integer >= 0/);
539
+ });
540
+ it("minAvailable > method count", () => {
541
+ expect(() => serviceView({ methods: { a: "svc.a" }, minAvailable: 2 })).toThrow(/exceeds the number of methods/);
542
+ });
543
+ });
544
+ describe("addTool validation (views)", () => {
545
+ it("meshJobDepIndex pointing at a view slot throws", () => {
546
+ const fastmcp = makeFastMCPStub();
547
+ const agent = newAgent(fastmcp);
548
+ const View = serviceView({ methods: { a: "svc.a" } });
549
+ expect(() => agent.addTool({
550
+ name: "bad_mj",
551
+ parameters: z.object({}),
552
+ dependencies: [View],
553
+ meshJobDepIndex: 0,
554
+ execute: async () => "ok",
555
+ })).toThrow(/points at a mesh.serviceView slot/);
556
+ });
557
+ it("a producer-shaped object (no capability) in dependencies throws", () => {
558
+ const fastmcp = makeFastMCPStub();
559
+ const agent = newAgent(fastmcp);
560
+ expect(() => agent.addTool({
561
+ name: "bad_dep",
562
+ parameters: z.object({}),
563
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
564
+ dependencies: [{ execute: async () => "x" }],
565
+ execute: async () => "ok",
566
+ })).toThrow(/without a 'capability'/);
567
+ });
568
+ });
569
+ describe("serviceView rejected on non-facade surfaces", () => {
570
+ it("shared guard rejects a view for any surface label", () => {
571
+ const View = serviceView({ methods: { a: "svc.a" } });
572
+ expect(() => assertNoServiceViewDeps([View], "mesh.route")).toThrow(/mesh.route does not support mesh.serviceView/);
573
+ expect(() => assertNoServiceViewDeps(["ok", View], "mesh.a2a.mount")).toThrow(/mesh.a2a.mount does not support mesh.serviceView/);
574
+ // No views → no throw.
575
+ expect(() => assertNoServiceViewDeps(["a", { capability: "b" }], "x")).not.toThrow();
576
+ });
577
+ it("mesh.route rejects a view in route deps", () => {
578
+ const View = serviceView({ methods: { a: "svc.a" } });
579
+ expect(() => route(
580
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
581
+ [View], async () => {
582
+ /* noop */
583
+ })).toThrow(/does not support mesh.serviceView/);
584
+ });
585
+ it("mesh.a2a.mount rejects a view in surface deps", () => {
586
+ const View = serviceView({ methods: { a: "svc.a" } });
587
+ expect(() => a2aMount(
588
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
589
+ {},
590
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
591
+ { path: "/a2a", skillId: "s", dependencies: [View] },
592
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
593
+ (async () => ({})))).toThrow(/mesh.a2a.mount does not support mesh.serviceView/);
594
+ });
595
+ });
596
+ describe("view-bound worker isolation", () => {
597
+ it("warns at registration that isolation is force-disabled when the env is set", () => {
598
+ process.env.MCP_MESH_TOOL_ISOLATION = "true"; // override the beforeEach "false"
599
+ const fastmcp = makeFastMCPStub();
600
+ const agent = newAgent(fastmcp);
601
+ const View = serviceView({ methods: { a: "svc.a" } });
602
+ agent.addTool({
603
+ name: "isotool",
604
+ parameters: z.object({}),
605
+ dependencies: [View],
606
+ execute: async () => "ok",
607
+ });
608
+ const warned = warnSpy.mock.calls
609
+ .map((c) => String(c[0]))
610
+ .some((m) => m.includes("isotool") &&
611
+ m.includes("serviceView facade") &&
612
+ m.includes("isolation is disabled"));
613
+ expect(warned).toBe(true);
614
+ });
615
+ it("does NOT warn when isolation is off (the default here)", () => {
616
+ // beforeEach pins MCP_MESH_TOOL_ISOLATION="false".
617
+ const fastmcp = makeFastMCPStub();
618
+ const agent = newAgent(fastmcp);
619
+ const View = serviceView({ methods: { a: "svc.a" } });
620
+ agent.addTool({
621
+ name: "noiso",
622
+ parameters: z.object({}),
623
+ dependencies: [View],
624
+ execute: async () => "ok",
625
+ });
626
+ const warned = warnSpy.mock.calls
627
+ .map((c) => String(c[0]))
628
+ .some((m) => m.includes("noiso") && m.includes("serviceView facade"));
629
+ expect(warned).toBe(false);
630
+ });
631
+ });
632
+ describe("dependencyKwargs slot→edge remap", () => {
633
+ it("a slot AFTER a view shifts correctly (view kwargs applied to each edge)", () => {
634
+ const fastmcp = makeFastMCPStub();
635
+ const agent = newAgent(fastmcp);
636
+ const View = serviceView({ methods: { a: "svc.a", b: "svc.b" } });
637
+ agent.addTool({
638
+ name: "kwt",
639
+ parameters: z.object({}),
640
+ dependencies: ["dep", View, "dep2"],
641
+ dependencyKwargs: [{ timeout: 10 }, { timeout: 20 }, { timeout: 30 }],
642
+ execute: async () => "ok",
643
+ });
644
+ // Slots: dep(k0), View(k1 → both edges), dep2(k2). Edge kwargs:
645
+ // [dep=10, svc.a=20, svc.b=20, dep2=30].
646
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
647
+ const kwargs = agent.tools.get("kwt").dependencyKwargs;
648
+ expect(kwargs.map((k) => k.timeout)).toEqual([
649
+ 10, 20, 20, 30,
650
+ ]);
651
+ });
652
+ it("view-free tools pass dependencyKwargs through unchanged", () => {
653
+ const fastmcp = makeFastMCPStub();
654
+ const agent = newAgent(fastmcp);
655
+ const kw = [{ timeout: 5 }, { timeout: 6 }];
656
+ agent.addTool({
657
+ name: "kwplain",
658
+ parameters: z.object({}),
659
+ dependencies: ["a", "b"],
660
+ dependencyKwargs: kw,
661
+ execute: async () => "ok",
662
+ });
663
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
664
+ expect(agent.tools.get("kwplain").dependencyKwargs).toBe(kw);
665
+ });
666
+ });
667
+ describe("claim path with a required view edge (#1268 gate)", () => {
668
+ it("requiredProbe reports the unresolved required view method; released via dispatcher", async () => {
669
+ const fastmcp = makeFastMCPStub();
670
+ const agent = newAgent(fastmcp);
671
+ const View = serviceView({
672
+ methods: { render: { capability: "media.render", required: true } },
673
+ });
674
+ const ran = [];
675
+ agent.addTool({
676
+ name: "renderjob",
677
+ capability: "renderjob",
678
+ task: true,
679
+ parameters: z.object({}),
680
+ dependencies: [View],
681
+ execute: async () => {
682
+ ran.push(true);
683
+ return "ran";
684
+ },
685
+ });
686
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
687
+ const th = agent._taskHandlers.get("renderjob");
688
+ expect(th.requiredProbe).toBeDefined();
689
+ // The required VIEW method edge feeds the claim gate.
690
+ expect(th.requiredProbe()).toBe("media.render");
691
+ // Drive the dispatcher's pre-invoke guard: an unresolved required edge
692
+ // releases the lease and never runs the handler.
693
+ const dispatcher = new ClaimDispatcher("renderjob", "inst-1", "http://registry:8000", th.handler, undefined, th.requiredProbe);
694
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
695
+ await dispatcher._dispatch({ id: "job-1", submitted_payload: {} });
696
+ expect(h.releaseLeaseMock).toHaveBeenCalledTimes(1);
697
+ expect(h.failMock).not.toHaveBeenCalled();
698
+ expect(ran).toHaveLength(0);
699
+ // Once the required view edge resolves, the probe clears.
700
+ injectEdge(agent, "renderjob", 0, async () => "rendered");
701
+ expect(th.requiredProbe()).toBeNull();
702
+ });
703
+ });
704
+ describe("view + meshJobDepIndex (job slot AFTER the view)", () => {
705
+ it("edge-shift math: submitter at the job slot, facade at the view slot", async () => {
706
+ const fastmcp = makeFastMCPStub();
707
+ const agent = newAgent(fastmcp);
708
+ process.env.MCP_MESH_SETTLE_TIMEOUT = "30";
709
+ resetSettleStateForTests();
710
+ const View = serviceView({ methods: { a: "svc.a", b: "svc.b" } });
711
+ const seen = [];
712
+ agent.addTool({
713
+ name: "mjv",
714
+ parameters: z.object({}),
715
+ // Authored slots: [View(slot 0), "jobcap"(slot 1)].
716
+ dependencies: [View, "jobcap"],
717
+ meshJobDepIndex: 1, // the jobcap slot (edge index 2 after the view's 2 edges)
718
+ execute: async (_a, view, job) => {
719
+ seen.push(view, job);
720
+ return "ok";
721
+ },
722
+ });
723
+ // Flat edges: svc.a(0), svc.b(1), jobcap(2).
724
+ expect(edgesOf(agent, "mjv").map((d) => d.capability)).toEqual([
725
+ "svc.a",
726
+ "svc.b",
727
+ "jobcap",
728
+ ]);
729
+ // Settle keys skip the MeshJob EDGE (index 2): resolving the two view edges
730
+ // is sufficient to settle (proving meshJobEdgeIndex shifted past the view).
731
+ const settle = getSettleState();
732
+ expect(settle.isSettled()).toBe(false);
733
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
734
+ agent.handleDependencyAvailable("svc.a", "http://x", "fn", "p", "mjv", 0);
735
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
736
+ agent.handleDependencyAvailable("svc.b", "http://x", "fn", "p", "mjv", 1);
737
+ expect(settle.isSettled()).toBe(true);
738
+ await captureExecute(fastmcp)({});
739
+ const [view, job] = seen;
740
+ expect(Object.keys(view).sort()).toEqual(["a", "b"]);
741
+ expect(job).toBeInstanceOf(MeshJobSubmitter);
742
+ // The submitter targets the capability at the shifted edge (jobcap).
743
+ expect(job.capability).toBe("jobcap");
744
+ });
745
+ });
746
+ describe("#1231 unwired-slot warning for view edges", () => {
747
+ it("warns once when a view method edge is null after settling", async () => {
748
+ process.env.MCP_MESH_SETTLE_TIMEOUT = "0"; // settled immediately
749
+ resetSettleStateForTests();
750
+ const fastmcp = makeFastMCPStub();
751
+ const agent = newAgent(fastmcp);
752
+ const View = serviceView({ methods: { caption: "media.caption" } });
753
+ agent.addTool({
754
+ name: "unwired",
755
+ parameters: z.object({}),
756
+ dependencies: [View],
757
+ execute: async () => "ok",
758
+ });
759
+ // Build the facade (unresolved edge, settled) → warn once.
760
+ await captureExecute(fastmcp)({});
761
+ const warned = warnSpy.mock.calls
762
+ .map((c) => String(c[0]))
763
+ .filter((m) => m.includes("service view") &&
764
+ m.includes("caption") &&
765
+ m.includes("media.caption") &&
766
+ m.includes("still null after settling"));
767
+ expect(warned.length).toBeGreaterThanOrEqual(1);
768
+ });
769
+ });
770
+ describe("view method schema matching (#547 parity)", () => {
771
+ it("expectedSchema on a view method lands on the edge and yields canonical/hash", () => {
772
+ const fastmcp = makeFastMCPStub();
773
+ const agent = newAgent(fastmcp);
774
+ const View = serviceView({
775
+ methods: {
776
+ caption: {
777
+ capability: "media.caption",
778
+ expectedSchema: z.object({ caption: z.string() }),
779
+ matchMode: "strict",
780
+ },
781
+ },
782
+ });
783
+ agent.addTool({
784
+ name: "schematool",
785
+ parameters: z.object({}),
786
+ dependencies: [View],
787
+ execute: async () => "ok",
788
+ });
789
+ const edge = edgesOf(agent, "schematool")[0];
790
+ expect(edge.capability).toBe("media.caption");
791
+ expect(edge.expectedSchemaRaw).toBeDefined();
792
+ expect(edge.matchMode).toBe("strict");
793
+ // The payload builder feeds expectedSchemaRaw through this exact call to
794
+ // derive expectedSchemaCanonical/hash (agent.ts startHeartbeat).
795
+ const r = normalizeSchemaWithPolicy(edge.expectedSchemaRaw, "dependency on 'media.caption'", false, true);
796
+ expect(r.canonicalJson).toBeTruthy();
797
+ expect(r.hash).toBeTruthy();
798
+ });
799
+ });
800
+ describe("addService producer sugar", () => {
801
+ it("publishes N tools name-sorted with capability prefix.method", () => {
802
+ const fastmcp = makeFastMCPStub();
803
+ const agent = newAgent(fastmcp);
804
+ agent.addService("media", {
805
+ // Out of order to prove name-sorting.
806
+ thumbnail: async () => ({ url: "u" }),
807
+ caption: async (args) => ({ caption: args.text }),
808
+ });
809
+ // Registered in NAME-SORTED order: caption, then thumbnail.
810
+ const names = fastmcp.addTool.mock.calls.map(
811
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
812
+ (c) => c[0].name);
813
+ expect(names).toEqual(["media.caption", "media.thumbnail"]);
814
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
815
+ expect(agent.tools.get("media.caption").capability).toBe("media.caption");
816
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
817
+ expect(agent.tools.get("media.thumbnail").capability).toBe("media.thumbnail");
818
+ });
819
+ it("object form carries addTool passthrough (tags/version/description)", () => {
820
+ const fastmcp = makeFastMCPStub();
821
+ const agent = newAgent(fastmcp);
822
+ agent.addService("svc", {
823
+ m: {
824
+ execute: async () => "ok",
825
+ tags: ["fast"],
826
+ version: "2.1.0",
827
+ description: "does m",
828
+ parameters: z.object({ x: z.number() }),
829
+ },
830
+ });
831
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
832
+ const meta = agent.tools.get("svc.m");
833
+ expect(meta.tags).toEqual(["fast"]);
834
+ expect(meta.version).toBe("2.1.0");
835
+ expect(meta.description).toBe("does m");
836
+ });
837
+ it("function shorthand works and runs through the wrapped execute", async () => {
838
+ const fastmcp = makeFastMCPStub();
839
+ const agent = newAgent(fastmcp);
840
+ agent.addService("svc", {
841
+ echo: async (args) => ({ echoed: args.v }),
842
+ });
843
+ const execute = captureExecute(fastmcp);
844
+ expect(await execute({ v: "hi" })).toBe(JSON.stringify({ echoed: "hi" }));
845
+ });
846
+ it("rejects an invalid prefix", () => {
847
+ const fastmcp = makeFastMCPStub();
848
+ const agent = newAgent(fastmcp);
849
+ expect(() => agent.addService("1bad", { m: async () => "x" })).toThrow(/not a valid capability name/);
850
+ expect(() => agent.addService("", { m: async () => "x" })).toThrow(/non-empty capability prefix/);
851
+ });
852
+ it("rejects a method whose derived capability is invalid", () => {
853
+ const fastmcp = makeFastMCPStub();
854
+ const agent = newAgent(fastmcp);
855
+ expect(() => agent.addService("svc", { "bad name": async () => "x" })).toThrow(/is not a valid capability name/);
856
+ });
857
+ it("rejects a serviceView passed as a producer method", () => {
858
+ const fastmcp = makeFastMCPStub();
859
+ const agent = newAgent(fastmcp);
860
+ const View = serviceView({ methods: { a: "svc.a" } });
861
+ expect(() =>
862
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
863
+ agent.addService("svc", { a: View })).toThrow(/is a mesh.serviceView/);
864
+ });
865
+ it("rejects a method that is neither a function nor an execute object", () => {
866
+ const fastmcp = makeFastMCPStub();
867
+ const agent = newAgent(fastmcp);
868
+ expect(() =>
869
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
870
+ agent.addService("svc", { a: { tags: ["x"] } })).toThrow(/must be an execute function/);
871
+ });
872
+ it("accepts dotted prefixes (segment-wise grammar)", () => {
873
+ const fastmcp = makeFastMCPStub();
874
+ const agent = newAgent(fastmcp);
875
+ agent.addService("acme.media", { caption: async () => "c" });
876
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
877
+ expect(agent.tools.get("acme.media.caption").capability).toBe("acme.media.caption");
878
+ });
879
+ it("is atomic: an invalid method mid-map registers NOTHING (item 7)", () => {
880
+ const fastmcp = makeFastMCPStub();
881
+ const agent = newAgent(fastmcp);
882
+ expect(() => agent.addService("svc", {
883
+ good: async () => "ok",
884
+ "bad name": async () => "x", // invalid derived capability
885
+ })).toThrow(/is not a valid capability name/);
886
+ // 'svc.good' must NOT have been registered — validation runs before any addTool.
887
+ expect(fastmcp.addTool).not.toHaveBeenCalled();
888
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
889
+ expect(agent.tools.has("svc.good")).toBe(false);
890
+ });
891
+ it("throws when a derived capability collides with an existing tool (item 9)", () => {
892
+ const fastmcp = makeFastMCPStub();
893
+ const agent = newAgent(fastmcp);
894
+ agent.addTool({
895
+ name: "svc.caption",
896
+ parameters: z.object({}),
897
+ execute: async () => "existing",
898
+ });
899
+ expect(() => agent.addService("svc", { caption: async () => "new" })).toThrow(/already registered/);
900
+ });
901
+ });
902
+ //# sourceMappingURL=service-view.spec.js.map