@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.
Files changed (92) 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__/api-runtime-apply-idempotency.spec.d.ts +2 -0
  4. package/dist/__tests__/api-runtime-apply-idempotency.spec.d.ts.map +1 -0
  5. package/dist/__tests__/api-runtime-apply-idempotency.spec.js +89 -0
  6. package/dist/__tests__/api-runtime-apply-idempotency.spec.js.map +1 -0
  7. package/dist/__tests__/claim-dispatcher-resume-cursor.test.d.ts +2 -0
  8. package/dist/__tests__/claim-dispatcher-resume-cursor.test.d.ts.map +1 -0
  9. package/dist/__tests__/claim-dispatcher-resume-cursor.test.js +145 -0
  10. package/dist/__tests__/claim-dispatcher-resume-cursor.test.js.map +1 -0
  11. package/dist/__tests__/dependency-apply-idempotency.spec.d.ts +2 -0
  12. package/dist/__tests__/dependency-apply-idempotency.spec.d.ts.map +1 -0
  13. package/dist/__tests__/dependency-apply-idempotency.spec.js +114 -0
  14. package/dist/__tests__/dependency-apply-idempotency.spec.js.map +1 -0
  15. package/dist/__tests__/service-view-types.spec.d.ts +2 -0
  16. package/dist/__tests__/service-view-types.spec.d.ts.map +1 -0
  17. package/dist/__tests__/service-view-types.spec.js +66 -0
  18. package/dist/__tests__/service-view-types.spec.js.map +1 -0
  19. package/dist/__tests__/service-view.spec.d.ts +2 -0
  20. package/dist/__tests__/service-view.spec.d.ts.map +1 -0
  21. package/dist/__tests__/service-view.spec.js +902 -0
  22. package/dist/__tests__/service-view.spec.js.map +1 -0
  23. package/dist/__tests__/superseded-signal.spec.d.ts +19 -0
  24. package/dist/__tests__/superseded-signal.spec.d.ts.map +1 -0
  25. package/dist/__tests__/superseded-signal.spec.js +222 -0
  26. package/dist/__tests__/superseded-signal.spec.js.map +1 -0
  27. package/dist/__tests__/worker-error-serde.spec.d.ts +2 -0
  28. package/dist/__tests__/worker-error-serde.spec.d.ts.map +1 -0
  29. package/dist/__tests__/worker-error-serde.spec.js +122 -0
  30. package/dist/__tests__/worker-error-serde.spec.js.map +1 -0
  31. package/dist/a2a/producer/mount.d.ts.map +1 -1
  32. package/dist/a2a/producer/mount.js +4 -0
  33. package/dist/a2a/producer/mount.js.map +1 -1
  34. package/dist/agent.d.ts +53 -3
  35. package/dist/agent.d.ts.map +1 -1
  36. package/dist/agent.js +406 -33
  37. package/dist/agent.js.map +1 -1
  38. package/dist/api-runtime.d.ts +9 -0
  39. package/dist/api-runtime.d.ts.map +1 -1
  40. package/dist/api-runtime.js +29 -0
  41. package/dist/api-runtime.js.map +1 -1
  42. package/dist/claim-dispatcher.d.ts +25 -1
  43. package/dist/claim-dispatcher.d.ts.map +1 -1
  44. package/dist/claim-dispatcher.js +51 -2
  45. package/dist/claim-dispatcher.js.map +1 -1
  46. package/dist/inbound-job-dispatch.d.ts +1 -1
  47. package/dist/inbound-job-dispatch.d.ts.map +1 -1
  48. package/dist/inbound-job-dispatch.js +8 -2
  49. package/dist/inbound-job-dispatch.js.map +1 -1
  50. package/dist/index.d.ts +6 -2
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +9 -2
  53. package/dist/index.js.map +1 -1
  54. package/dist/llm-provider.d.ts +14 -16
  55. package/dist/llm-provider.d.ts.map +1 -1
  56. package/dist/llm-provider.js +2 -4
  57. package/dist/llm-provider.js.map +1 -1
  58. package/dist/llm.d.ts +1 -2
  59. package/dist/llm.d.ts.map +1 -1
  60. package/dist/llm.js +1 -2
  61. package/dist/llm.js.map +1 -1
  62. package/dist/proxy.d.ts.map +1 -1
  63. package/dist/proxy.js +37 -0
  64. package/dist/proxy.js.map +1 -1
  65. package/dist/route.d.ts.map +1 -1
  66. package/dist/route.js +5 -0
  67. package/dist/route.js.map +1 -1
  68. package/dist/service-view.d.ts +245 -0
  69. package/dist/service-view.d.ts.map +1 -0
  70. package/dist/service-view.js +210 -0
  71. package/dist/service-view.js.map +1 -0
  72. package/dist/settle.d.ts +22 -0
  73. package/dist/settle.d.ts.map +1 -1
  74. package/dist/settle.js +51 -22
  75. package/dist/settle.js.map +1 -1
  76. package/dist/superseded.d.ts +70 -0
  77. package/dist/superseded.d.ts.map +1 -0
  78. package/dist/superseded.js +99 -0
  79. package/dist/superseded.js.map +1 -0
  80. package/dist/tool-worker-entry.js +1 -12
  81. package/dist/tool-worker-entry.js.map +1 -1
  82. package/dist/tool-worker-pool.d.ts.map +1 -1
  83. package/dist/tool-worker-pool.js +1 -12
  84. package/dist/tool-worker-pool.js.map +1 -1
  85. package/dist/types.d.ts +31 -1
  86. package/dist/types.d.ts.map +1 -1
  87. package/dist/types.js.map +1 -1
  88. package/dist/worker-error-serde.d.ts +40 -0
  89. package/dist/worker-error-serde.d.ts.map +1 -0
  90. package/dist/worker-error-serde.js +86 -0
  91. package/dist/worker-error-serde.js.map +1 -0
  92. package/package.json +2 -2
package/dist/agent.js CHANGED
@@ -12,9 +12,10 @@ import { UserError } from "fastmcp";
12
12
  import { zodToJsonSchema } from "zod-to-json-schema";
13
13
  import { isMainThread } from "node:worker_threads";
14
14
  import { startAgent, } from "@mcpmesh/core";
15
+ import { expandDependencies, isServiceView, MeshServiceUnavailableError, CAPABILITY_NAME_PATTERN, defaultProducerParams, } from "./service-view.js";
15
16
  import { resolveConfig, generateAgentIdSuffix, findAvailablePort, resolveStartupBindPort, MAX_CONSECUTIVE_NEXT_EVENT_FAILURES, NEXT_EVENT_BACKOFF_CAP_MS, } from "./config.js";
16
17
  import { enrichSchemaWithMediaTypes } from "./media-param.js";
17
- import { createProxy, normalizeDependency, runWithTraceContext, runWithPropagatedHeaders, PROXY_DISPATCH_META } from "./proxy.js";
18
+ import { createProxy, runWithTraceContext, runWithPropagatedHeaders, PROXY_DISPATCH_META } from "./proxy.js";
18
19
  import { readJobHeaders, runWithJobContext, makeJobController, spliceJobController, } from "./inbound-job-dispatch.js";
19
20
  import { MeshJobSubmitter } from "./mesh-job-submitter.js";
20
21
  import { getSettleState, } from "./settle.js";
@@ -115,6 +116,30 @@ function scheduleAutoStart() {
115
116
  }
116
117
  });
117
118
  }
119
+ // #1314: canonical JSON so that value-equal-but-reference-distinct objects
120
+ // (e.g. re-parsed kwargs on a reconcile re-emit) hash identically. Object keys
121
+ // are emitted in sorted order recursively; arrays keep their order.
122
+ function stableStringify(value) {
123
+ if (value === null || typeof value !== "object") {
124
+ return JSON.stringify(value) ?? "null";
125
+ }
126
+ if (Array.isArray(value)) {
127
+ return `[${value.map(stableStringify).join(",")}]`;
128
+ }
129
+ const obj = value;
130
+ const keys = Object.keys(obj).sort();
131
+ return `{${keys
132
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
133
+ .join(",")}}`;
134
+ }
135
+ // #1314: the resolution signature actually wired for a dependency edge. Two
136
+ // applies with the same signature produce an identical proxy, so the second is
137
+ // a no-op we can skip. `agentId` is included so this composes with #1315: an
138
+ // agent_id-only change still rebuilds, while an unchanged reconcile re-emit
139
+ // (same agentId) is skipped.
140
+ export function depSignature(endpoint, functionName, kwargs, agentId) {
141
+ return stableStringify([endpoint, functionName, kwargs, agentId]);
142
+ }
118
143
  /**
119
144
  * MeshAgent wraps a FastMCP server with MCP Mesh capabilities.
120
145
  *
@@ -173,6 +198,14 @@ export class MeshAgent {
173
198
  * different tags/settings without overwriting each other.
174
199
  */
175
200
  resolvedDeps = new Map();
201
+ // #1314: last-applied resolution signature per depKey. The Rust core
202
+ // re-emits `dependency_available` for every believed-delivered edge on an
203
+ // independent ~10s wall-clock tick (self-heals dropped applies). Without a
204
+ // guard the SDK would rebuild every proxy on each tick. We record the
205
+ // signature (endpoint, functionName, kwargs, agentId) actually wired for a
206
+ // depKey and skip the rebuild when an incoming apply carries the same one.
207
+ // Cleared on the unavailable/removal path so a later re-add rebuilds.
208
+ appliedDepSignatures = new Map();
176
209
  // True when this MeshAgent is constructed inside a worker_threads Worker.
177
210
  // In worker mode addTool() only stashes execute fns and skips all FastMCP /
178
211
  // registry / Rust core wiring (no Express port conflict, no double-register).
@@ -276,6 +309,22 @@ export class MeshAgent {
276
309
  addTool(def) {
277
310
  const toolName = def.name;
278
311
  const execute = def.execute;
312
+ // Issue #1293 item 6: validate the PRODUCED capability name — the value
313
+ // published to the registry (def.capability ?? toolName) — against the
314
+ // segment-wise dotted grammar, closing the parity gap with the Go registry
315
+ // (src/core/registry/validation.go) and the Python runtime
316
+ // (support_types.py validate_capability_name). Only the produced capability
317
+ // is checked; dependency capability names are deliberately left unvalidated
318
+ // (they are unvalidated in the Go registry and every runtime), so checking
319
+ // them here would be stricter than the contract, not parity. Reuses the
320
+ // same CAPABILITY_NAME_PATTERN that addService applies to synthesized names.
321
+ const producedCapability = def.capability ?? toolName;
322
+ if (!CAPABILITY_NAME_PATTERN.test(producedCapability)) {
323
+ throw new Error(`addTool: capability '${producedCapability}' is not a valid capability ` +
324
+ `name — each dot-separated segment must start with a letter and ` +
325
+ `contain only letters, digits, underscores, and hyphens ` +
326
+ `(cross-ref src/core/registry/validation.go).`);
327
+ }
279
328
  // Phase 1 MeshJob substrate: validate `task: true` requires an
280
329
  // async function. Long-running tools need a Promise-based control
281
330
  // flow so the dispatch wrapper (Phase B) can await
@@ -388,6 +437,15 @@ export class MeshAgent {
388
437
  }
389
438
  }
390
439
  }
440
+ // Issue #1277: validate resumeCursor at registration so misuse fails loud
441
+ // before the agent talks to the registry. Mirrors the retryOn check:
442
+ // resumeCursor requires task: true — only job-bound handlers have a
443
+ // controller (and a per-filter recvEvent cursor) to resume, so the flag
444
+ // is meaningless for synchronous tools.
445
+ if (def.resumeCursor === true && def.task !== true) {
446
+ throw new Error(`addTool({ resumeCursor }) for tool '${toolName}': resumeCursor is ` +
447
+ `only valid with task: true; remove resumeCursor or set task: true.`);
448
+ }
391
449
  // Issue #917: validate a2aConfig at registration time so misuse fails
392
450
  // loud BEFORE the agent talks to the registry. Match the Python
393
451
  // `mesh.a2a_consumer` and Java `@A2AConsumer` startup-time checks.
@@ -446,16 +504,42 @@ export class MeshAgent {
446
504
  _workerToolMap.set(toolName, execute);
447
505
  return this;
448
506
  }
449
- // Normalize dependencies
450
- const normalizedDeps = (def.dependencies ?? []).map(normalizeDependency);
507
+ // Normalize dependencies. RFC #1280: a `mesh.serviceView(...)` entry
508
+ // occupies ONE positional slot but expands into N ordinary edges (name-
509
+ // sorted, in-place). `normalizedDeps` is the FLAT edge array — the wire
510
+ // payload, settle keys, resolution events, and resolvedDeps all index it
511
+ // exactly as before; `depSlots` maps each positional execute slot to its
512
+ // edge (a `dep`) or edge-range (a `view`). For a view-free tool the edges
513
+ // are byte-identical to `deps.map(normalizeDependency)` and
514
+ // `depSlots[i].edgeIndex === i` — zero behavior change.
515
+ const { edges: normalizedDeps, slots: depSlots } = expandDependencies(def.dependencies ?? [], toolName);
451
516
  const depEndpoints = normalizedDeps.map((d) => d.capability);
517
+ // Phase 1 MeshJob substrate: meshJobDepIndex is an index into the AUTHORED
518
+ // dependencies array (i.e. a positional slot). It must point at an ordinary
519
+ // dependency slot, never a service-view slot (a view has no single edge to
520
+ // swap for a submitter).
521
+ if (def.meshJobDepIndex !== undefined) {
522
+ const slot = depSlots[def.meshJobDepIndex];
523
+ if (slot && slot.kind === "view") {
524
+ throw new Error(`addTool({ meshJobDepIndex: ${def.meshJobDepIndex} }) for tool ` +
525
+ `'${toolName}' points at a mesh.serviceView slot — a MeshJob ` +
526
+ `submitter can only replace an ordinary dependency slot.`);
527
+ }
528
+ }
529
+ // The flat edge index of the MeshJob submitter slot (if any). Used by the
530
+ // edge-indexed skip logic below (settle declaration/wait, required guard).
531
+ // For a view-free tool this equals `def.meshJobDepIndex` (slot index ===
532
+ // edge index), so those loops are byte-identical to before.
533
+ const meshJobEdgeIndex = def.meshJobDepIndex === undefined
534
+ ? undefined
535
+ : depSlots[def.meshJobDepIndex].edgeIndex;
452
536
  // Settling-window grace (#1193): declare this tool's proxy deps with the
453
537
  // process-wide settle state so the agent-level "all declared deps
454
538
  // resolved" latch can flip eagerly. The MeshJob slot is excluded — its
455
539
  // submitter is constructed locally, not resolved by an event.
456
540
  const settleState = getSettleState();
457
541
  normalizedDeps.forEach((_dep, depIndex) => {
458
- if (depIndex !== def.meshJobDepIndex) {
542
+ if (depIndex !== meshJobEdgeIndex) {
459
543
  settleState.registerDeclared(`${toolName}:dep_${depIndex}`);
460
544
  }
461
545
  });
@@ -469,6 +553,10 @@ export class MeshAgent {
469
553
  // registered in this.taskHandlers). Captured here so the closure
470
554
  // sees a stable reference even if def is mutated post-registration.
471
555
  const retryOn = def.retryOn;
556
+ // Issue #1277: per-tool durable-cursor resume opt-in. Captured here so
557
+ // the ClaimHandler registration below carries it into the dispatcher
558
+ // (which seeds the reclaimed controller from the claim's recv_cursor).
559
+ const resumeCursor = def.resumeCursor === true;
472
560
  // Phase 1 MeshJob substrate: when a job-bound tool exists AND the
473
561
  // user explicitly opted into worker isolation via env, log a single
474
562
  // warning at registration time. The wrapper force-disables
@@ -487,6 +575,18 @@ export class MeshAgent {
487
575
  `(controllers/AsyncLocalStorage don't cross worker boundaries). ` +
488
576
  `Set 'task: true' explicitly if you intend a producer.`);
489
577
  }
578
+ // RFC #1280: same force-disable + non-silent warning for view-bound tools.
579
+ // A serviceView facade wraps live closures over resolvedDeps and has no
580
+ // PROXY_DISPATCH_META, so it can't cross the worker_threads boundary — the
581
+ // wrapper runs view-bound tools inline. Warn once (like the job-bound case)
582
+ // so users who opted into MCP_MESH_TOOL_ISOLATION know it does not apply.
583
+ const isViewBoundForLog = depSlots.some((s) => s.kind === "view");
584
+ if (isViewBoundForLog && isolationEnvSet) {
585
+ console.warn(`[mesh-tool] '${toolName}' injects a mesh.serviceView facade; worker ` +
586
+ `isolation is disabled for view-bound tools (facades wrap live ` +
587
+ `closures over resolved dependencies that don't cross worker ` +
588
+ `boundaries).`);
589
+ }
490
590
  // Create wrapper that injects dependencies positionally and handles tracing
491
591
  const wrappedExecute = async (args) => {
492
592
  // Settling-window grace (#1193): while the agent is still settling,
@@ -498,7 +598,7 @@ export class MeshAgent {
498
598
  const pendingSettle = [];
499
599
  normalizedDeps.forEach((dep, depIndex) => {
500
600
  const depKey = `${toolName}:dep_${depIndex}`;
501
- if (depIndex !== meshJobDepIndex &&
601
+ if (depIndex !== meshJobEdgeIndex &&
502
602
  !this.resolvedDeps.has(depKey)) {
503
603
  pendingSettle.push({ depKey, capability: dep.capability });
504
604
  }
@@ -507,14 +607,25 @@ export class MeshAgent {
507
607
  await settleState.awaitPending(pendingSettle);
508
608
  }
509
609
  }
510
- // Build positional deps array using composite keys (toolName:dep_index)
511
- // Phase 1 MeshJob substrate (consumer-side): if meshJobDepIndex is
512
- // set, swap the McpMeshTool proxy at that slot for a
513
- // MeshJobSubmitter targeting that dep's capability. We bind the
514
- // submitter to the live registryUrl/agentId so it can submit
515
- // jobs without needing access to the agent instance.
516
- const depsArray = this._buildDepSlots(toolName, normalizedDeps, meshJobDepIndex, settleState);
517
- const injectedCount = depsArray.filter((d) => d !== null).length;
610
+ // Build positional deps array (one entry per authored slot). A view slot
611
+ // yields a facade; the MeshJob slot yields a MeshJobSubmitter; every other
612
+ // slot yields resolvedDeps.get(...) ?? null.
613
+ const depsArray = this._buildDepSlots(toolName, depSlots, normalizedDeps, meshJobDepIndex, settleState);
614
+ // Trace metric: count resolved EDGES, not slots a view slot always
615
+ // yields a non-null facade, so counting slots would inflate the numerator
616
+ // against the edge-based dependency total. Excludes the MeshJob submitter
617
+ // edge (built locally, never a resolved proxy) and adds it back as one
618
+ // injected slot so a view-free tool's count is byte-identical to before.
619
+ let injectedCount = 0;
620
+ for (let e = 0; e < normalizedDeps.length; e++) {
621
+ if (e === meshJobEdgeIndex)
622
+ continue;
623
+ if ((this.resolvedDeps.get(`${toolName}:dep_${e}`) ?? null) !== null) {
624
+ injectedCount++;
625
+ }
626
+ }
627
+ if (meshJobDepIndex !== undefined)
628
+ injectedCount++;
518
629
  // Issue #1273: detect a required dependency slot that is still
519
630
  // unresolved AFTER the settle wait above (depsArray was built
520
631
  // post-settle). The ACTION is deferred until the invocation flavor is
@@ -522,12 +633,16 @@ export class MeshAgent {
522
633
  // direct tools/call refuses, an inbound JOB dispatch releases the
523
634
  // lease. Optional deps keep null-passthrough; the MeshJob submitter
524
635
  // slot is skipped (built locally, never a resolved proxy).
636
+ // Evaluate per-EDGE against resolvedDeps (not per-slot against depsArray):
637
+ // a view slot collapses N edges into one non-null facade, so a required
638
+ // view-method edge must be checked on the flat edge state. For a view-free
639
+ // tool this is equivalent to the old `depsArray[depIndex] == null` test.
525
640
  let missingRequiredCap = null;
526
641
  for (let depIndex = 0; depIndex < normalizedDeps.length; depIndex++) {
527
642
  const dep = normalizedDeps[depIndex];
528
643
  if (dep.required === true &&
529
- depIndex !== meshJobDepIndex &&
530
- depsArray[depIndex] == null) {
644
+ depIndex !== meshJobEdgeIndex &&
645
+ (this.resolvedDeps.get(`${toolName}:dep_${depIndex}`) ?? null) == null) {
531
646
  missingRequiredCap = dep.capability;
532
647
  break;
533
648
  }
@@ -655,8 +770,14 @@ export class MeshAgent {
655
770
  // cached client + connection pool intact across calls.
656
771
  const isA2aBound = a2aClient !== null;
657
772
  const isJobBound = isTaskTool || meshJobDepIndex !== undefined;
773
+ // RFC #1280: a service-view facade wraps live closures over resolvedDeps
774
+ // and cannot be serialised across the worker_threads boundary (it has no
775
+ // PROXY_DISPATCH_META). Force inline execution for view-bound tools, like
776
+ // job-bound and A2A-bound tools.
777
+ const isViewBound = depSlots.some((s) => s.kind === "view");
658
778
  const isolationEnabled = !isJobBound &&
659
779
  !isA2aBound &&
780
+ !isViewBound &&
660
781
  (process.env.MCP_MESH_TOOL_ISOLATION ?? "true").toLowerCase() !== "false";
661
782
  try {
662
783
  let result;
@@ -832,7 +953,7 @@ export class MeshAgent {
832
953
  const pendingSettle = [];
833
954
  normalizedDeps.forEach((dep, depIndex) => {
834
955
  const depKey = `${toolName}:dep_${depIndex}`;
835
- if (depIndex !== meshJobDepIndex &&
956
+ if (depIndex !== meshJobEdgeIndex &&
836
957
  !this.resolvedDeps.has(depKey)) {
837
958
  pendingSettle.push({ depKey, capability: dep.capability });
838
959
  }
@@ -841,7 +962,7 @@ export class MeshAgent {
841
962
  await settleState.awaitPending(pendingSettle);
842
963
  }
843
964
  }
844
- const liveDeps = this._buildDepSlots(toolName, normalizedDeps, meshJobDepIndex, settleState);
965
+ const liveDeps = this._buildDepSlots(toolName, depSlots, normalizedDeps, meshJobDepIndex, settleState);
845
966
  const callArgs = spliceJobController(payload, liveDeps, controller, meshJobParamIndex);
846
967
  // Issue #917: A2A consumer tools dispatched via the claim
847
968
  // path get the same trailing A2AClient argument as the
@@ -858,7 +979,7 @@ export class MeshAgent {
858
979
  // covers both an absent key and an explicit null slot.
859
980
  const requiredSlots = [];
860
981
  normalizedDeps.forEach((dep, depIndex) => {
861
- if (dep.required && depIndex !== meshJobDepIndex) {
982
+ if (dep.required && depIndex !== meshJobEdgeIndex) {
862
983
  requiredSlots.push([depIndex, dep.capability]);
863
984
  }
864
985
  });
@@ -873,7 +994,12 @@ export class MeshAgent {
873
994
  return null;
874
995
  }
875
996
  : undefined;
876
- this._taskHandlers.set(capability, { handler, retryOn, requiredProbe });
997
+ this._taskHandlers.set(capability, {
998
+ handler,
999
+ retryOn,
1000
+ requiredProbe,
1001
+ resumeCursor,
1002
+ });
877
1003
  }
878
1004
  // Store mesh metadata with JSON Schema for LLM tool resolution
879
1005
  const inputSchema = this.convertZodToJsonSchema(def.parameters);
@@ -884,6 +1010,28 @@ export class MeshAgent {
884
1010
  if (def.outputSchema) {
885
1011
  outputSchemaRaw = this.convertZodToJsonSchema(def.outputSchema);
886
1012
  }
1013
+ // dependencyKwargs is authored per positional SLOT but consumed per flat
1014
+ // EDGE (handleDependencyAvailable reads meta.dependencyKwargs[depIndex] with
1015
+ // the event's edge index). For a view-free tool slot index === edge index,
1016
+ // so pass it through unchanged (byte-identical). When a view slot is present,
1017
+ // remap each slot's kwargs onto its edge(s) — a view's single kwargs entry
1018
+ // applies to each of its method edges.
1019
+ let edgeDependencyKwargs = def.dependencyKwargs;
1020
+ if (def.dependencyKwargs && depSlots.some((s) => s.kind === "view")) {
1021
+ const remapped = new Array(normalizedDeps.length);
1022
+ depSlots.forEach((slot, slotIndex) => {
1023
+ const kw = def.dependencyKwargs?.[slotIndex];
1024
+ if (slot.kind === "dep") {
1025
+ remapped[slot.edgeIndex] = kw;
1026
+ }
1027
+ else {
1028
+ for (const m of slot.methods) {
1029
+ remapped[m.edgeIndex] = kw;
1030
+ }
1031
+ }
1032
+ });
1033
+ edgeDependencyKwargs = remapped;
1034
+ }
887
1035
  this.tools.set(toolName, {
888
1036
  capability: def.capability ?? toolName,
889
1037
  version: def.version ?? "1.0.0",
@@ -894,7 +1042,7 @@ export class MeshAgent {
894
1042
  // Issue #547 Phase 4: per-tool override (default true = current behavior).
895
1043
  outputSchemaStrict: def.outputSchemaStrict !== false,
896
1044
  dependencies: normalizedDeps,
897
- dependencyKwargs: def.dependencyKwargs,
1045
+ dependencyKwargs: edgeDependencyKwargs,
898
1046
  // Phase 1 MeshJob substrate: stamp producer's long-running flag
899
1047
  // so the heartbeat pipeline ships it to the registry. Consumers
900
1048
  // read this to decide between job semantics and a regular
@@ -911,6 +1059,108 @@ export class MeshAgent {
911
1059
  });
912
1060
  return this;
913
1061
  }
1062
+ /**
1063
+ * RFC #1280 producer sugar: publish each entry of `methods` as an ordinary
1064
+ * mesh tool with capability `prefix.<method>`, routed through the SAME
1065
+ * `addTool` machinery (schemas, heartbeat, DI, duplicate handling all fall
1066
+ * out). Entries are registered NAME-SORTED for deterministic registration.
1067
+ *
1068
+ * Each entry is either a bare execute function (shorthand) or an object
1069
+ * `{ execute, parameters?, tags?, version?, description?, dependencies?, ... }`
1070
+ * carrying any `addTool` passthrough (but NOT `name`/`capability` — those are
1071
+ * derived). A method with no `parameters` gets a permissive passthrough schema.
1072
+ *
1073
+ * The prefix AND every derived capability are validated against the segment-
1074
+ * wise dotted grammar (kept in lockstep with the Go registry validator —
1075
+ * src/core/registry/validation.go). addTool applies the same grammar to its
1076
+ * produced capability (#1293 item 6), so both surfaces validate a PRODUCED
1077
+ * capability name before it reaches the registry; dependency capability names
1078
+ * remain the registry's authority.
1079
+ *
1080
+ * @example
1081
+ * ```typescript
1082
+ * agent.addService("media", {
1083
+ * caption: async (args) => ({ caption: `...${args.text}` }),
1084
+ * thumbnail: { execute: async (args) => ({ url: "..." }), tags: ["fast"] },
1085
+ * });
1086
+ * // → tools/capabilities "media.caption" and "media.thumbnail"
1087
+ * ```
1088
+ */
1089
+ addService(prefix, methods) {
1090
+ if (typeof prefix !== "string" || prefix.trim() === "") {
1091
+ throw new Error("addService: a non-empty capability prefix is required " +
1092
+ '(e.g. addService("media", { ... })).');
1093
+ }
1094
+ if (!CAPABILITY_NAME_PATTERN.test(prefix)) {
1095
+ throw new Error(`addService: prefix '${prefix}' is not a valid capability name — each ` +
1096
+ `dot-separated segment must start with a letter and contain only ` +
1097
+ `letters, digits, '_' or '-' (cross-ref src/core/registry/validation.go).`);
1098
+ }
1099
+ if (methods == null || typeof methods !== "object") {
1100
+ throw new Error(`addService("${prefix}", ...): methods must be an object mapping ` +
1101
+ `method names to producer functions/definitions.`);
1102
+ }
1103
+ const names = Object.keys(methods).sort();
1104
+ if (names.length === 0) {
1105
+ throw new Error(`addService("${prefix}", ...): at least one method is required.`);
1106
+ }
1107
+ // Pass 1 — validate EVERY derived name/entry before registering any tool so
1108
+ // an invalid method mid-map is atomic (registers nothing). Also rejects a
1109
+ // derived capability that collides with an already-registered tool (scoped
1110
+ // to addService: addTool's own last-wins behavior for hand-written tools is
1111
+ // unchanged).
1112
+ const prepared = [];
1113
+ for (const method of names) {
1114
+ const capability = `${prefix}.${method}`;
1115
+ // Validate the FULL derived capability, not just the prefix: a method
1116
+ // name may contain characters ('$', unicode, ...) the grammar rejects.
1117
+ if (!CAPABILITY_NAME_PATTERN.test(capability)) {
1118
+ throw new Error(`addService("${prefix}", ...): method '${method}' derives capability ` +
1119
+ `'${capability}', which is not a valid capability name (each ` +
1120
+ `dot-separated segment must start with a letter and contain only ` +
1121
+ `letters, digits, '_' or '-'). Rename the method.`);
1122
+ }
1123
+ if (this.tools.has(capability)) {
1124
+ throw new Error(`addService("${prefix}", ...): method '${method}' derives tool/` +
1125
+ `capability '${capability}', which is already registered. Rename ` +
1126
+ `the method or the conflicting tool.`);
1127
+ }
1128
+ const entry = methods[method];
1129
+ // Guard: a service-view value here is the consumer surface used in the
1130
+ // wrong role (producer). Fail loud.
1131
+ if (isServiceView(entry)) {
1132
+ throw new Error(`addService("${prefix}", ...): method '${method}' is a ` +
1133
+ `mesh.serviceView(...) — that is a CONSUMER view, not a producer ` +
1134
+ `method. Provide an execute function or { execute, ... } object.`);
1135
+ }
1136
+ let def;
1137
+ if (typeof entry === "function") {
1138
+ def = { execute: entry };
1139
+ }
1140
+ else if (entry != null &&
1141
+ typeof entry === "object" &&
1142
+ typeof entry.execute === "function") {
1143
+ def = entry;
1144
+ }
1145
+ else {
1146
+ throw new Error(`addService("${prefix}", ...): method '${method}' must be an execute ` +
1147
+ `function or an object with an execute function.`);
1148
+ }
1149
+ prepared.push({ capability, def });
1150
+ }
1151
+ // Pass 2 — register through the existing addTool machinery. Derived name ===
1152
+ // derived capability for deterministic uniqueness.
1153
+ for (const { capability, def } of prepared) {
1154
+ this.addTool({
1155
+ ...def,
1156
+ name: capability,
1157
+ capability,
1158
+ parameters: def.parameters ?? defaultProducerParams(),
1159
+ execute: def.execute,
1160
+ });
1161
+ }
1162
+ return this;
1163
+ }
914
1164
  /**
915
1165
  * Add an LLM provider to the agent.
916
1166
  *
@@ -1014,27 +1264,36 @@ export class MeshAgent {
1014
1264
  * Build the positional dependency-slot array for one tool call, shared by
1015
1265
  * BOTH the inbound HTTP wrapper (wrappedExecute) and the claim-dispatch
1016
1266
  * path (the ClaimHandler for task:true tools). Centralising this keeps the
1017
- * two slot-assembly sites identical:
1267
+ * two slot-assembly sites identical. One entry per AUTHORED slot:
1268
+ * - a service-view slot (RFC #1280) → a facade over its method edges
1018
1269
  * - the MeshJob slot (meshJobDepIndex) → a freshly-bound MeshJobSubmitter
1019
- * - every other slot → resolvedDeps.get(`${toolName}:dep_${index}`) ?? null
1270
+ * - every other slot → resolvedDeps.get(`${toolName}:dep_${edgeIndex}`) ?? null
1271
+ *
1272
+ * `depSlots` carries the slot→edge mapping (a `dep` slot owns one edge; a
1273
+ * `view` slot owns a contiguous edge range). `edges` is the flat edge array;
1274
+ * a slot's proxy/capability is read via its edge index.
1020
1275
  *
1021
1276
  * #1231: a typed dep slot can resolve at the registry yet never receive an
1022
1277
  * injected proxy, leaving the parameter null. Once the settle latch has
1023
1278
  * flipped (during settling the proxy may still land), warn ONCE per
1024
1279
  * tool+slot — the array is rebuilt per call, so a per-call warning would
1025
- * spam. The warn-once dedupe keys on `${toolName}:dep_${index}` via the
1280
+ * spam. The warn-once dedupe keys on `${toolName}:dep_${edgeIndex}` via the
1026
1281
  * module-level `_unwiredSlotWarned` Set, so a tool exercised via BOTH the
1027
1282
  * inbound and claim paths warns at most once total.
1028
1283
  */
1029
- _buildDepSlots(toolName, normalizedDeps, meshJobDepIndex, settleState) {
1030
- return normalizedDeps.map((dep, depIndex) => {
1031
- if (depIndex === meshJobDepIndex) {
1284
+ _buildDepSlots(toolName, depSlots, edges, meshJobDepIndex, settleState) {
1285
+ return depSlots.map((slot, slotIndex) => {
1286
+ if (slot.kind === "view") {
1287
+ return this._buildServiceViewFacade(toolName, slot, edges, settleState);
1288
+ }
1289
+ const edgeIndex = slot.edgeIndex;
1290
+ if (slotIndex === meshJobDepIndex) {
1032
1291
  // Build the submitter lazily per call so we always pick up the
1033
1292
  // current registryUrl (test harnesses sometimes mutate it between
1034
1293
  // calls).
1035
- return new MeshJobSubmitter(dep.capability, this.agentId, this.config.registryUrl);
1294
+ return new MeshJobSubmitter(edges[edgeIndex].capability, this.agentId, this.config.registryUrl);
1036
1295
  }
1037
- const slotKey = `${toolName}:dep_${depIndex}`;
1296
+ const slotKey = `${toolName}:dep_${edgeIndex}`;
1038
1297
  const proxy = this.resolvedDeps.get(slotKey) ?? null;
1039
1298
  // #1231: a declared typed dep slot that is still null AFTER settling
1040
1299
  // never received an injected proxy. Warn ONCE per tool+slot. Note this
@@ -1043,15 +1302,111 @@ export class MeshAgent {
1043
1302
  if (proxy === null && settleState.isSettled()) {
1044
1303
  if (!_unwiredSlotWarned.has(slotKey)) {
1045
1304
  _unwiredSlotWarned.add(slotKey);
1046
- console.warn(`[mesh-tool] dependency '${dep.capability}' on '${toolName}' ` +
1047
- `is still null after settling — no proxy was injected into ` +
1048
- `positional slot ${depIndex}. Fix: ensure the provider for ` +
1049
- `'${dep.capability}' is registered and reachable.`);
1305
+ console.warn(`[mesh-tool] dependency '${edges[edgeIndex].capability}' on ` +
1306
+ `'${toolName}' is still null after settling — no proxy was ` +
1307
+ `injected into positional slot ${slotIndex}. Fix: ensure the ` +
1308
+ `provider for '${edges[edgeIndex].capability}' is registered ` +
1309
+ `and reachable.`);
1050
1310
  }
1051
1311
  }
1052
1312
  return proxy;
1053
1313
  });
1054
1314
  }
1315
+ /**
1316
+ * RFC #1280: build the facade injected at a service-view slot. Each facade
1317
+ * method reads its edge's CURRENT proxy from `resolvedDeps` at call time —
1318
+ * rebinding-free: a `dependency_available`/`unavailable` event swapping the
1319
+ * per-edge proxy is observed on the next call without rebuilding the facade.
1320
+ *
1321
+ * The `minAvailable` floor is enforced BEFORE delegation on every call: below
1322
+ * the floor during the settling window (#1193) the method performs the same
1323
+ * bounded, capability-keyed wait the injected-proxy path uses, recounts, then
1324
+ * throws {@link MeshServiceUnavailableError}. An unresolved OPTIONAL method
1325
+ * edge throws a `TypeError` — the same failure shape a directly-injected
1326
+ * unresolved `McpMeshTool` (a null proxy) produces when called.
1327
+ */
1328
+ _buildServiceViewFacade(toolName, slot, edges, settleState) {
1329
+ const total = slot.methods.length;
1330
+ const floor = slot.minAvailable;
1331
+ const viewName = slot.name;
1332
+ const countAvailable = () => {
1333
+ let n = 0;
1334
+ for (const m of slot.methods) {
1335
+ if ((this.resolvedDeps.get(`${toolName}:dep_${m.edgeIndex}`) ?? null) !== null) {
1336
+ n++;
1337
+ }
1338
+ }
1339
+ return n;
1340
+ };
1341
+ const enforceFloor = async () => {
1342
+ if (floor <= 0)
1343
+ return;
1344
+ let available = countAvailable();
1345
+ // Below the floor during the settling window: perform a bounded wait that
1346
+ // RACES all of the view's still-pending edges (wake on ANY resolution),
1347
+ // recounts, and breaks the moment the floor is met — so a call gated on
1348
+ // the floor wakes when ANY qualifying edge lands, never sleeping the full
1349
+ // window on a still-pending sibling key. Once settled the waits are
1350
+ // no-ops and the floor fails fast.
1351
+ while (available < floor &&
1352
+ !settleState.isSettled() &&
1353
+ settleState.remainingMs() > 0) {
1354
+ // Only edges that can still fire a settle WAKE are worth waiting on: a
1355
+ // key already in the settle ratchet (resolved at least once) will never
1356
+ // re-fire its waiter, so if such an edge has since flapped to a null
1357
+ // proxy `waitForAny` would filter it out and return immediately — a hot
1358
+ // busy-loop until the window expires. Excluding ratchet-resolved keys
1359
+ // here means `pending` empties and we break to the final recount/throw
1360
+ // the instant no wakeable edge remains (no spin).
1361
+ const pending = [];
1362
+ for (const m of slot.methods) {
1363
+ const key = `${toolName}:dep_${m.edgeIndex}`;
1364
+ if ((this.resolvedDeps.get(key) ?? null) === null &&
1365
+ !settleState.isResolved(key)) {
1366
+ pending.push({ depKey: key, capability: edges[m.edgeIndex].capability });
1367
+ }
1368
+ }
1369
+ if (pending.length === 0)
1370
+ break;
1371
+ await settleState.waitForAny(pending);
1372
+ available = countAvailable();
1373
+ }
1374
+ if (available < floor) {
1375
+ throw new MeshServiceUnavailableError(viewName, available, total, floor);
1376
+ }
1377
+ };
1378
+ const facade = {};
1379
+ for (const m of slot.methods) {
1380
+ const key = `${toolName}:dep_${m.edgeIndex}`;
1381
+ const capability = edges[m.edgeIndex].capability;
1382
+ // #1231 parity for view edges: a method edge that resolved at the registry
1383
+ // yet never received an injected proxy stays null after settling. Warn
1384
+ // ONCE per tool+edge (facade is rebuilt per call), naming the view method
1385
+ // and capability so the diagnostic matches the slot-dep warning.
1386
+ if ((this.resolvedDeps.get(key) ?? null) === null && settleState.isSettled()) {
1387
+ if (!_unwiredSlotWarned.has(key)) {
1388
+ _unwiredSlotWarned.add(key);
1389
+ console.warn(`[mesh-tool] service view '${viewName}' method '${m.method}' ` +
1390
+ `(capability '${capability}') on '${toolName}' is still null ` +
1391
+ `after settling — no proxy was injected. Fix: ensure the provider ` +
1392
+ `for '${capability}' is registered and reachable.`);
1393
+ }
1394
+ }
1395
+ facade[m.method] = async (args, options) => {
1396
+ await enforceFloor();
1397
+ const proxy = this.resolvedDeps.get(key) ?? null;
1398
+ if (proxy === null) {
1399
+ // Mirror the failure shape of calling an unresolved McpMeshTool
1400
+ // (a null proxy) directly — a TypeError.
1401
+ throw new TypeError(`service view '${viewName}' method '${m.method}' (capability ` +
1402
+ `'${capability}') is unavailable — the dependency did not ` +
1403
+ `resolve (unresolved optional edge).`);
1404
+ }
1405
+ return proxy(args, options);
1406
+ };
1407
+ }
1408
+ return facade;
1409
+ }
1055
1410
  _getOrBuildA2AClient(config) {
1056
1411
  const key = [
1057
1412
  config.url,
@@ -1259,7 +1614,7 @@ export class MeshAgent {
1259
1614
  if (this._taskHandlers.size === 0)
1260
1615
  return;
1261
1616
  for (const [capability, entry] of this._taskHandlers.entries()) {
1262
- const dispatcher = new ClaimDispatcher(capability, this.agentId, this.config.registryUrl, entry.handler, entry.retryOn, entry.requiredProbe);
1617
+ const dispatcher = new ClaimDispatcher(capability, this.agentId, this.config.registryUrl, entry.handler, entry.retryOn, entry.requiredProbe, entry.resumeCursor);
1263
1618
  dispatcher.start();
1264
1619
  this._claimDispatchers.push(dispatcher);
1265
1620
  }
@@ -1657,8 +2012,16 @@ export class MeshAgent {
1657
2012
  const meta = this.tools.get(requestingFunction);
1658
2013
  const kwargs = meta?.dependencyKwargs?.[depIndex];
1659
2014
  const depKey = `${requestingFunction}:dep_${depIndex}`;
2015
+ // #1314 idempotency guard: the Rust core re-emits dependency_available
2016
+ // for believed-delivered edges on a ~10s tick. If the incoming
2017
+ // resolution matches what is already wired, skip the rebuild entirely.
2018
+ const signature = depSignature(endpoint, functionName, kwargs, agentId);
2019
+ if (this.appliedDepSignatures.get(depKey) === signature) {
2020
+ return;
2021
+ }
1660
2022
  const proxy = createProxy(endpoint, capability, functionName, kwargs);
1661
2023
  this.resolvedDeps.set(depKey, proxy);
2024
+ this.appliedDepSignatures.set(depKey, signature);
1662
2025
  // Settling-window grace (#1193): wake any settling call waiting on
1663
2026
  // this dependency AFTER the proxy is stored so the woken call
1664
2027
  // re-reads a real proxy.
@@ -1676,8 +2039,14 @@ export class MeshAgent {
1676
2039
  if (dep.capability === capability) {
1677
2040
  const kwargs = meta.dependencyKwargs?.[idx];
1678
2041
  const depKey = `${toolName}:dep_${idx}`;
2042
+ // #1314 idempotency guard (see position-info path above).
2043
+ const signature = depSignature(endpoint, functionName, kwargs, agentId);
2044
+ if (this.appliedDepSignatures.get(depKey) === signature) {
2045
+ return;
2046
+ }
1679
2047
  const proxy = createProxy(endpoint, capability, functionName, kwargs);
1680
2048
  this.resolvedDeps.set(depKey, proxy);
2049
+ this.appliedDepSignatures.set(depKey, signature);
1681
2050
  getSettleState().markResolved(depKey);
1682
2051
  matchCount++;
1683
2052
  }
@@ -1694,6 +2063,8 @@ export class MeshAgent {
1694
2063
  if (requestingFunction !== undefined && depIndex !== undefined) {
1695
2064
  const depKey = `${requestingFunction}:dep_${depIndex}`;
1696
2065
  this.resolvedDeps.delete(depKey);
2066
+ // #1314: drop the applied signature so a later re-add rebuilds.
2067
+ this.appliedDepSignatures.delete(depKey);
1697
2068
  console.log(`Dependency unavailable: ${capability} (tool: ${requestingFunction}, index: ${depIndex})`);
1698
2069
  return;
1699
2070
  }
@@ -1706,6 +2077,8 @@ export class MeshAgent {
1706
2077
  if (dep.capability === capability) {
1707
2078
  const depKey = `${toolName}:dep_${idx}`;
1708
2079
  this.resolvedDeps.delete(depKey);
2080
+ // #1314: drop the applied signature so a later re-add rebuilds.
2081
+ this.appliedDepSignatures.delete(depKey);
1709
2082
  removeCount++;
1710
2083
  }
1711
2084
  });