@mcpmesh/sdk 2.8.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/agent-add-tool.spec.js +106 -0
- package/dist/__tests__/agent-add-tool.spec.js.map +1 -1
- package/dist/__tests__/api-runtime-apply-idempotency.spec.d.ts +2 -0
- package/dist/__tests__/api-runtime-apply-idempotency.spec.d.ts.map +1 -0
- package/dist/__tests__/api-runtime-apply-idempotency.spec.js +89 -0
- package/dist/__tests__/api-runtime-apply-idempotency.spec.js.map +1 -0
- package/dist/__tests__/claim-dispatcher-resume-cursor.test.d.ts +2 -0
- package/dist/__tests__/claim-dispatcher-resume-cursor.test.d.ts.map +1 -0
- package/dist/__tests__/claim-dispatcher-resume-cursor.test.js +145 -0
- package/dist/__tests__/claim-dispatcher-resume-cursor.test.js.map +1 -0
- package/dist/__tests__/dependency-apply-idempotency.spec.d.ts +2 -0
- package/dist/__tests__/dependency-apply-idempotency.spec.d.ts.map +1 -0
- package/dist/__tests__/dependency-apply-idempotency.spec.js +114 -0
- package/dist/__tests__/dependency-apply-idempotency.spec.js.map +1 -0
- package/dist/__tests__/service-view-types.spec.d.ts +2 -0
- package/dist/__tests__/service-view-types.spec.d.ts.map +1 -0
- package/dist/__tests__/service-view-types.spec.js +66 -0
- package/dist/__tests__/service-view-types.spec.js.map +1 -0
- package/dist/__tests__/service-view.spec.d.ts +2 -0
- package/dist/__tests__/service-view.spec.d.ts.map +1 -0
- package/dist/__tests__/service-view.spec.js +902 -0
- package/dist/__tests__/service-view.spec.js.map +1 -0
- package/dist/__tests__/superseded-signal.spec.d.ts +19 -0
- package/dist/__tests__/superseded-signal.spec.d.ts.map +1 -0
- package/dist/__tests__/superseded-signal.spec.js +222 -0
- package/dist/__tests__/superseded-signal.spec.js.map +1 -0
- package/dist/__tests__/worker-error-serde.spec.d.ts +2 -0
- package/dist/__tests__/worker-error-serde.spec.d.ts.map +1 -0
- package/dist/__tests__/worker-error-serde.spec.js +122 -0
- package/dist/__tests__/worker-error-serde.spec.js.map +1 -0
- package/dist/a2a/producer/mount.d.ts.map +1 -1
- package/dist/a2a/producer/mount.js +4 -0
- package/dist/a2a/producer/mount.js.map +1 -1
- package/dist/agent.d.ts +53 -3
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +406 -33
- package/dist/agent.js.map +1 -1
- package/dist/api-runtime.d.ts +9 -0
- package/dist/api-runtime.d.ts.map +1 -1
- package/dist/api-runtime.js +29 -0
- package/dist/api-runtime.js.map +1 -1
- package/dist/claim-dispatcher.d.ts +25 -1
- package/dist/claim-dispatcher.d.ts.map +1 -1
- package/dist/claim-dispatcher.js +51 -2
- package/dist/claim-dispatcher.js.map +1 -1
- package/dist/inbound-job-dispatch.d.ts +1 -1
- package/dist/inbound-job-dispatch.d.ts.map +1 -1
- package/dist/inbound-job-dispatch.js +8 -2
- package/dist/inbound-job-dispatch.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/llm-provider.d.ts +14 -16
- package/dist/llm-provider.d.ts.map +1 -1
- package/dist/llm-provider.js +2 -4
- package/dist/llm-provider.js.map +1 -1
- package/dist/llm.d.ts +1 -2
- package/dist/llm.d.ts.map +1 -1
- package/dist/llm.js +1 -2
- package/dist/llm.js.map +1 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +37 -0
- package/dist/proxy.js.map +1 -1
- package/dist/route.d.ts.map +1 -1
- package/dist/route.js +5 -0
- package/dist/route.js.map +1 -1
- package/dist/service-view.d.ts +245 -0
- package/dist/service-view.d.ts.map +1 -0
- package/dist/service-view.js +210 -0
- package/dist/service-view.js.map +1 -0
- package/dist/settle.d.ts +22 -0
- package/dist/settle.d.ts.map +1 -1
- package/dist/settle.js +51 -22
- package/dist/settle.js.map +1 -1
- package/dist/superseded.d.ts +70 -0
- package/dist/superseded.d.ts.map +1 -0
- package/dist/superseded.js +99 -0
- package/dist/superseded.js.map +1 -0
- package/dist/tool-worker-entry.js +1 -12
- package/dist/tool-worker-entry.js.map +1 -1
- package/dist/tool-worker-pool.d.ts.map +1 -1
- package/dist/tool-worker-pool.js +1 -12
- package/dist/tool-worker-pool.js.map +1 -1
- package/dist/types.d.ts +31 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/worker-error-serde.d.ts +40 -0
- package/dist/worker-error-serde.d.ts.map +1 -0
- package/dist/worker-error-serde.js +86 -0
- package/dist/worker-error-serde.js.map +1 -0
- 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
|