@mcpmesh/sdk 2.8.2 → 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 (80) 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__/service-view-types.spec.d.ts +2 -0
  8. package/dist/__tests__/service-view-types.spec.d.ts.map +1 -0
  9. package/dist/__tests__/service-view-types.spec.js +66 -0
  10. package/dist/__tests__/service-view-types.spec.js.map +1 -0
  11. package/dist/__tests__/service-view.spec.d.ts +2 -0
  12. package/dist/__tests__/service-view.spec.d.ts.map +1 -0
  13. package/dist/__tests__/service-view.spec.js +902 -0
  14. package/dist/__tests__/service-view.spec.js.map +1 -0
  15. package/dist/__tests__/superseded-signal.spec.d.ts +19 -0
  16. package/dist/__tests__/superseded-signal.spec.d.ts.map +1 -0
  17. package/dist/__tests__/superseded-signal.spec.js +222 -0
  18. package/dist/__tests__/superseded-signal.spec.js.map +1 -0
  19. package/dist/__tests__/worker-error-serde.spec.d.ts +2 -0
  20. package/dist/__tests__/worker-error-serde.spec.d.ts.map +1 -0
  21. package/dist/__tests__/worker-error-serde.spec.js +122 -0
  22. package/dist/__tests__/worker-error-serde.spec.js.map +1 -0
  23. package/dist/a2a/producer/mount.d.ts.map +1 -1
  24. package/dist/a2a/producer/mount.js +4 -0
  25. package/dist/a2a/producer/mount.js.map +1 -1
  26. package/dist/agent.d.ts +51 -3
  27. package/dist/agent.d.ts.map +1 -1
  28. package/dist/agent.js +356 -33
  29. package/dist/agent.js.map +1 -1
  30. package/dist/claim-dispatcher.d.ts +25 -1
  31. package/dist/claim-dispatcher.d.ts.map +1 -1
  32. package/dist/claim-dispatcher.js +51 -2
  33. package/dist/claim-dispatcher.js.map +1 -1
  34. package/dist/inbound-job-dispatch.d.ts +1 -1
  35. package/dist/inbound-job-dispatch.d.ts.map +1 -1
  36. package/dist/inbound-job-dispatch.js +8 -2
  37. package/dist/inbound-job-dispatch.js.map +1 -1
  38. package/dist/index.d.ts +6 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +9 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/llm-provider.d.ts +14 -16
  43. package/dist/llm-provider.d.ts.map +1 -1
  44. package/dist/llm-provider.js +2 -4
  45. package/dist/llm-provider.js.map +1 -1
  46. package/dist/llm.d.ts +1 -2
  47. package/dist/llm.d.ts.map +1 -1
  48. package/dist/llm.js +1 -2
  49. package/dist/llm.js.map +1 -1
  50. package/dist/proxy.d.ts.map +1 -1
  51. package/dist/proxy.js +37 -0
  52. package/dist/proxy.js.map +1 -1
  53. package/dist/route.d.ts.map +1 -1
  54. package/dist/route.js +5 -0
  55. package/dist/route.js.map +1 -1
  56. package/dist/service-view.d.ts +245 -0
  57. package/dist/service-view.d.ts.map +1 -0
  58. package/dist/service-view.js +210 -0
  59. package/dist/service-view.js.map +1 -0
  60. package/dist/settle.d.ts +22 -0
  61. package/dist/settle.d.ts.map +1 -1
  62. package/dist/settle.js +51 -22
  63. package/dist/settle.js.map +1 -1
  64. package/dist/superseded.d.ts +70 -0
  65. package/dist/superseded.d.ts.map +1 -0
  66. package/dist/superseded.js +99 -0
  67. package/dist/superseded.js.map +1 -0
  68. package/dist/tool-worker-entry.js +1 -12
  69. package/dist/tool-worker-entry.js.map +1 -1
  70. package/dist/tool-worker-pool.d.ts.map +1 -1
  71. package/dist/tool-worker-pool.js +1 -12
  72. package/dist/tool-worker-pool.js.map +1 -1
  73. package/dist/types.d.ts +31 -1
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js.map +1 -1
  76. package/dist/worker-error-serde.d.ts +40 -0
  77. package/dist/worker-error-serde.d.ts.map +1 -0
  78. package/dist/worker-error-serde.js +86 -0
  79. package/dist/worker-error-serde.js.map +1 -0
  80. 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";
@@ -276,6 +277,22 @@ export class MeshAgent {
276
277
  addTool(def) {
277
278
  const toolName = def.name;
278
279
  const execute = def.execute;
280
+ // Issue #1293 item 6: validate the PRODUCED capability name — the value
281
+ // published to the registry (def.capability ?? toolName) — against the
282
+ // segment-wise dotted grammar, closing the parity gap with the Go registry
283
+ // (src/core/registry/validation.go) and the Python runtime
284
+ // (support_types.py validate_capability_name). Only the produced capability
285
+ // is checked; dependency capability names are deliberately left unvalidated
286
+ // (they are unvalidated in the Go registry and every runtime), so checking
287
+ // them here would be stricter than the contract, not parity. Reuses the
288
+ // same CAPABILITY_NAME_PATTERN that addService applies to synthesized names.
289
+ const producedCapability = def.capability ?? toolName;
290
+ if (!CAPABILITY_NAME_PATTERN.test(producedCapability)) {
291
+ throw new Error(`addTool: capability '${producedCapability}' is not a valid capability ` +
292
+ `name — each dot-separated segment must start with a letter and ` +
293
+ `contain only letters, digits, underscores, and hyphens ` +
294
+ `(cross-ref src/core/registry/validation.go).`);
295
+ }
279
296
  // Phase 1 MeshJob substrate: validate `task: true` requires an
280
297
  // async function. Long-running tools need a Promise-based control
281
298
  // flow so the dispatch wrapper (Phase B) can await
@@ -388,6 +405,15 @@ export class MeshAgent {
388
405
  }
389
406
  }
390
407
  }
408
+ // Issue #1277: validate resumeCursor at registration so misuse fails loud
409
+ // before the agent talks to the registry. Mirrors the retryOn check:
410
+ // resumeCursor requires task: true — only job-bound handlers have a
411
+ // controller (and a per-filter recvEvent cursor) to resume, so the flag
412
+ // is meaningless for synchronous tools.
413
+ if (def.resumeCursor === true && def.task !== true) {
414
+ throw new Error(`addTool({ resumeCursor }) for tool '${toolName}': resumeCursor is ` +
415
+ `only valid with task: true; remove resumeCursor or set task: true.`);
416
+ }
391
417
  // Issue #917: validate a2aConfig at registration time so misuse fails
392
418
  // loud BEFORE the agent talks to the registry. Match the Python
393
419
  // `mesh.a2a_consumer` and Java `@A2AConsumer` startup-time checks.
@@ -446,16 +472,42 @@ export class MeshAgent {
446
472
  _workerToolMap.set(toolName, execute);
447
473
  return this;
448
474
  }
449
- // Normalize dependencies
450
- const normalizedDeps = (def.dependencies ?? []).map(normalizeDependency);
475
+ // Normalize dependencies. RFC #1280: a `mesh.serviceView(...)` entry
476
+ // occupies ONE positional slot but expands into N ordinary edges (name-
477
+ // sorted, in-place). `normalizedDeps` is the FLAT edge array — the wire
478
+ // payload, settle keys, resolution events, and resolvedDeps all index it
479
+ // exactly as before; `depSlots` maps each positional execute slot to its
480
+ // edge (a `dep`) or edge-range (a `view`). For a view-free tool the edges
481
+ // are byte-identical to `deps.map(normalizeDependency)` and
482
+ // `depSlots[i].edgeIndex === i` — zero behavior change.
483
+ const { edges: normalizedDeps, slots: depSlots } = expandDependencies(def.dependencies ?? [], toolName);
451
484
  const depEndpoints = normalizedDeps.map((d) => d.capability);
485
+ // Phase 1 MeshJob substrate: meshJobDepIndex is an index into the AUTHORED
486
+ // dependencies array (i.e. a positional slot). It must point at an ordinary
487
+ // dependency slot, never a service-view slot (a view has no single edge to
488
+ // swap for a submitter).
489
+ if (def.meshJobDepIndex !== undefined) {
490
+ const slot = depSlots[def.meshJobDepIndex];
491
+ if (slot && slot.kind === "view") {
492
+ throw new Error(`addTool({ meshJobDepIndex: ${def.meshJobDepIndex} }) for tool ` +
493
+ `'${toolName}' points at a mesh.serviceView slot — a MeshJob ` +
494
+ `submitter can only replace an ordinary dependency slot.`);
495
+ }
496
+ }
497
+ // The flat edge index of the MeshJob submitter slot (if any). Used by the
498
+ // edge-indexed skip logic below (settle declaration/wait, required guard).
499
+ // For a view-free tool this equals `def.meshJobDepIndex` (slot index ===
500
+ // edge index), so those loops are byte-identical to before.
501
+ const meshJobEdgeIndex = def.meshJobDepIndex === undefined
502
+ ? undefined
503
+ : depSlots[def.meshJobDepIndex].edgeIndex;
452
504
  // Settling-window grace (#1193): declare this tool's proxy deps with the
453
505
  // process-wide settle state so the agent-level "all declared deps
454
506
  // resolved" latch can flip eagerly. The MeshJob slot is excluded — its
455
507
  // submitter is constructed locally, not resolved by an event.
456
508
  const settleState = getSettleState();
457
509
  normalizedDeps.forEach((_dep, depIndex) => {
458
- if (depIndex !== def.meshJobDepIndex) {
510
+ if (depIndex !== meshJobEdgeIndex) {
459
511
  settleState.registerDeclared(`${toolName}:dep_${depIndex}`);
460
512
  }
461
513
  });
@@ -469,6 +521,10 @@ export class MeshAgent {
469
521
  // registered in this.taskHandlers). Captured here so the closure
470
522
  // sees a stable reference even if def is mutated post-registration.
471
523
  const retryOn = def.retryOn;
524
+ // Issue #1277: per-tool durable-cursor resume opt-in. Captured here so
525
+ // the ClaimHandler registration below carries it into the dispatcher
526
+ // (which seeds the reclaimed controller from the claim's recv_cursor).
527
+ const resumeCursor = def.resumeCursor === true;
472
528
  // Phase 1 MeshJob substrate: when a job-bound tool exists AND the
473
529
  // user explicitly opted into worker isolation via env, log a single
474
530
  // warning at registration time. The wrapper force-disables
@@ -487,6 +543,18 @@ export class MeshAgent {
487
543
  `(controllers/AsyncLocalStorage don't cross worker boundaries). ` +
488
544
  `Set 'task: true' explicitly if you intend a producer.`);
489
545
  }
546
+ // RFC #1280: same force-disable + non-silent warning for view-bound tools.
547
+ // A serviceView facade wraps live closures over resolvedDeps and has no
548
+ // PROXY_DISPATCH_META, so it can't cross the worker_threads boundary — the
549
+ // wrapper runs view-bound tools inline. Warn once (like the job-bound case)
550
+ // so users who opted into MCP_MESH_TOOL_ISOLATION know it does not apply.
551
+ const isViewBoundForLog = depSlots.some((s) => s.kind === "view");
552
+ if (isViewBoundForLog && isolationEnvSet) {
553
+ console.warn(`[mesh-tool] '${toolName}' injects a mesh.serviceView facade; worker ` +
554
+ `isolation is disabled for view-bound tools (facades wrap live ` +
555
+ `closures over resolved dependencies that don't cross worker ` +
556
+ `boundaries).`);
557
+ }
490
558
  // Create wrapper that injects dependencies positionally and handles tracing
491
559
  const wrappedExecute = async (args) => {
492
560
  // Settling-window grace (#1193): while the agent is still settling,
@@ -498,7 +566,7 @@ export class MeshAgent {
498
566
  const pendingSettle = [];
499
567
  normalizedDeps.forEach((dep, depIndex) => {
500
568
  const depKey = `${toolName}:dep_${depIndex}`;
501
- if (depIndex !== meshJobDepIndex &&
569
+ if (depIndex !== meshJobEdgeIndex &&
502
570
  !this.resolvedDeps.has(depKey)) {
503
571
  pendingSettle.push({ depKey, capability: dep.capability });
504
572
  }
@@ -507,14 +575,25 @@ export class MeshAgent {
507
575
  await settleState.awaitPending(pendingSettle);
508
576
  }
509
577
  }
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;
578
+ // Build positional deps array (one entry per authored slot). A view slot
579
+ // yields a facade; the MeshJob slot yields a MeshJobSubmitter; every other
580
+ // slot yields resolvedDeps.get(...) ?? null.
581
+ const depsArray = this._buildDepSlots(toolName, depSlots, normalizedDeps, meshJobDepIndex, settleState);
582
+ // Trace metric: count resolved EDGES, not slots a view slot always
583
+ // yields a non-null facade, so counting slots would inflate the numerator
584
+ // against the edge-based dependency total. Excludes the MeshJob submitter
585
+ // edge (built locally, never a resolved proxy) and adds it back as one
586
+ // injected slot so a view-free tool's count is byte-identical to before.
587
+ let injectedCount = 0;
588
+ for (let e = 0; e < normalizedDeps.length; e++) {
589
+ if (e === meshJobEdgeIndex)
590
+ continue;
591
+ if ((this.resolvedDeps.get(`${toolName}:dep_${e}`) ?? null) !== null) {
592
+ injectedCount++;
593
+ }
594
+ }
595
+ if (meshJobDepIndex !== undefined)
596
+ injectedCount++;
518
597
  // Issue #1273: detect a required dependency slot that is still
519
598
  // unresolved AFTER the settle wait above (depsArray was built
520
599
  // post-settle). The ACTION is deferred until the invocation flavor is
@@ -522,12 +601,16 @@ export class MeshAgent {
522
601
  // direct tools/call refuses, an inbound JOB dispatch releases the
523
602
  // lease. Optional deps keep null-passthrough; the MeshJob submitter
524
603
  // slot is skipped (built locally, never a resolved proxy).
604
+ // Evaluate per-EDGE against resolvedDeps (not per-slot against depsArray):
605
+ // a view slot collapses N edges into one non-null facade, so a required
606
+ // view-method edge must be checked on the flat edge state. For a view-free
607
+ // tool this is equivalent to the old `depsArray[depIndex] == null` test.
525
608
  let missingRequiredCap = null;
526
609
  for (let depIndex = 0; depIndex < normalizedDeps.length; depIndex++) {
527
610
  const dep = normalizedDeps[depIndex];
528
611
  if (dep.required === true &&
529
- depIndex !== meshJobDepIndex &&
530
- depsArray[depIndex] == null) {
612
+ depIndex !== meshJobEdgeIndex &&
613
+ (this.resolvedDeps.get(`${toolName}:dep_${depIndex}`) ?? null) == null) {
531
614
  missingRequiredCap = dep.capability;
532
615
  break;
533
616
  }
@@ -655,8 +738,14 @@ export class MeshAgent {
655
738
  // cached client + connection pool intact across calls.
656
739
  const isA2aBound = a2aClient !== null;
657
740
  const isJobBound = isTaskTool || meshJobDepIndex !== undefined;
741
+ // RFC #1280: a service-view facade wraps live closures over resolvedDeps
742
+ // and cannot be serialised across the worker_threads boundary (it has no
743
+ // PROXY_DISPATCH_META). Force inline execution for view-bound tools, like
744
+ // job-bound and A2A-bound tools.
745
+ const isViewBound = depSlots.some((s) => s.kind === "view");
658
746
  const isolationEnabled = !isJobBound &&
659
747
  !isA2aBound &&
748
+ !isViewBound &&
660
749
  (process.env.MCP_MESH_TOOL_ISOLATION ?? "true").toLowerCase() !== "false";
661
750
  try {
662
751
  let result;
@@ -832,7 +921,7 @@ export class MeshAgent {
832
921
  const pendingSettle = [];
833
922
  normalizedDeps.forEach((dep, depIndex) => {
834
923
  const depKey = `${toolName}:dep_${depIndex}`;
835
- if (depIndex !== meshJobDepIndex &&
924
+ if (depIndex !== meshJobEdgeIndex &&
836
925
  !this.resolvedDeps.has(depKey)) {
837
926
  pendingSettle.push({ depKey, capability: dep.capability });
838
927
  }
@@ -841,7 +930,7 @@ export class MeshAgent {
841
930
  await settleState.awaitPending(pendingSettle);
842
931
  }
843
932
  }
844
- const liveDeps = this._buildDepSlots(toolName, normalizedDeps, meshJobDepIndex, settleState);
933
+ const liveDeps = this._buildDepSlots(toolName, depSlots, normalizedDeps, meshJobDepIndex, settleState);
845
934
  const callArgs = spliceJobController(payload, liveDeps, controller, meshJobParamIndex);
846
935
  // Issue #917: A2A consumer tools dispatched via the claim
847
936
  // path get the same trailing A2AClient argument as the
@@ -858,7 +947,7 @@ export class MeshAgent {
858
947
  // covers both an absent key and an explicit null slot.
859
948
  const requiredSlots = [];
860
949
  normalizedDeps.forEach((dep, depIndex) => {
861
- if (dep.required && depIndex !== meshJobDepIndex) {
950
+ if (dep.required && depIndex !== meshJobEdgeIndex) {
862
951
  requiredSlots.push([depIndex, dep.capability]);
863
952
  }
864
953
  });
@@ -873,7 +962,12 @@ export class MeshAgent {
873
962
  return null;
874
963
  }
875
964
  : undefined;
876
- this._taskHandlers.set(capability, { handler, retryOn, requiredProbe });
965
+ this._taskHandlers.set(capability, {
966
+ handler,
967
+ retryOn,
968
+ requiredProbe,
969
+ resumeCursor,
970
+ });
877
971
  }
878
972
  // Store mesh metadata with JSON Schema for LLM tool resolution
879
973
  const inputSchema = this.convertZodToJsonSchema(def.parameters);
@@ -884,6 +978,28 @@ export class MeshAgent {
884
978
  if (def.outputSchema) {
885
979
  outputSchemaRaw = this.convertZodToJsonSchema(def.outputSchema);
886
980
  }
981
+ // dependencyKwargs is authored per positional SLOT but consumed per flat
982
+ // EDGE (handleDependencyAvailable reads meta.dependencyKwargs[depIndex] with
983
+ // the event's edge index). For a view-free tool slot index === edge index,
984
+ // so pass it through unchanged (byte-identical). When a view slot is present,
985
+ // remap each slot's kwargs onto its edge(s) — a view's single kwargs entry
986
+ // applies to each of its method edges.
987
+ let edgeDependencyKwargs = def.dependencyKwargs;
988
+ if (def.dependencyKwargs && depSlots.some((s) => s.kind === "view")) {
989
+ const remapped = new Array(normalizedDeps.length);
990
+ depSlots.forEach((slot, slotIndex) => {
991
+ const kw = def.dependencyKwargs?.[slotIndex];
992
+ if (slot.kind === "dep") {
993
+ remapped[slot.edgeIndex] = kw;
994
+ }
995
+ else {
996
+ for (const m of slot.methods) {
997
+ remapped[m.edgeIndex] = kw;
998
+ }
999
+ }
1000
+ });
1001
+ edgeDependencyKwargs = remapped;
1002
+ }
887
1003
  this.tools.set(toolName, {
888
1004
  capability: def.capability ?? toolName,
889
1005
  version: def.version ?? "1.0.0",
@@ -894,7 +1010,7 @@ export class MeshAgent {
894
1010
  // Issue #547 Phase 4: per-tool override (default true = current behavior).
895
1011
  outputSchemaStrict: def.outputSchemaStrict !== false,
896
1012
  dependencies: normalizedDeps,
897
- dependencyKwargs: def.dependencyKwargs,
1013
+ dependencyKwargs: edgeDependencyKwargs,
898
1014
  // Phase 1 MeshJob substrate: stamp producer's long-running flag
899
1015
  // so the heartbeat pipeline ships it to the registry. Consumers
900
1016
  // read this to decide between job semantics and a regular
@@ -911,6 +1027,108 @@ export class MeshAgent {
911
1027
  });
912
1028
  return this;
913
1029
  }
1030
+ /**
1031
+ * RFC #1280 producer sugar: publish each entry of `methods` as an ordinary
1032
+ * mesh tool with capability `prefix.<method>`, routed through the SAME
1033
+ * `addTool` machinery (schemas, heartbeat, DI, duplicate handling all fall
1034
+ * out). Entries are registered NAME-SORTED for deterministic registration.
1035
+ *
1036
+ * Each entry is either a bare execute function (shorthand) or an object
1037
+ * `{ execute, parameters?, tags?, version?, description?, dependencies?, ... }`
1038
+ * carrying any `addTool` passthrough (but NOT `name`/`capability` — those are
1039
+ * derived). A method with no `parameters` gets a permissive passthrough schema.
1040
+ *
1041
+ * The prefix AND every derived capability are validated against the segment-
1042
+ * wise dotted grammar (kept in lockstep with the Go registry validator —
1043
+ * src/core/registry/validation.go). addTool applies the same grammar to its
1044
+ * produced capability (#1293 item 6), so both surfaces validate a PRODUCED
1045
+ * capability name before it reaches the registry; dependency capability names
1046
+ * remain the registry's authority.
1047
+ *
1048
+ * @example
1049
+ * ```typescript
1050
+ * agent.addService("media", {
1051
+ * caption: async (args) => ({ caption: `...${args.text}` }),
1052
+ * thumbnail: { execute: async (args) => ({ url: "..." }), tags: ["fast"] },
1053
+ * });
1054
+ * // → tools/capabilities "media.caption" and "media.thumbnail"
1055
+ * ```
1056
+ */
1057
+ addService(prefix, methods) {
1058
+ if (typeof prefix !== "string" || prefix.trim() === "") {
1059
+ throw new Error("addService: a non-empty capability prefix is required " +
1060
+ '(e.g. addService("media", { ... })).');
1061
+ }
1062
+ if (!CAPABILITY_NAME_PATTERN.test(prefix)) {
1063
+ throw new Error(`addService: prefix '${prefix}' is not a valid capability name — each ` +
1064
+ `dot-separated segment must start with a letter and contain only ` +
1065
+ `letters, digits, '_' or '-' (cross-ref src/core/registry/validation.go).`);
1066
+ }
1067
+ if (methods == null || typeof methods !== "object") {
1068
+ throw new Error(`addService("${prefix}", ...): methods must be an object mapping ` +
1069
+ `method names to producer functions/definitions.`);
1070
+ }
1071
+ const names = Object.keys(methods).sort();
1072
+ if (names.length === 0) {
1073
+ throw new Error(`addService("${prefix}", ...): at least one method is required.`);
1074
+ }
1075
+ // Pass 1 — validate EVERY derived name/entry before registering any tool so
1076
+ // an invalid method mid-map is atomic (registers nothing). Also rejects a
1077
+ // derived capability that collides with an already-registered tool (scoped
1078
+ // to addService: addTool's own last-wins behavior for hand-written tools is
1079
+ // unchanged).
1080
+ const prepared = [];
1081
+ for (const method of names) {
1082
+ const capability = `${prefix}.${method}`;
1083
+ // Validate the FULL derived capability, not just the prefix: a method
1084
+ // name may contain characters ('$', unicode, ...) the grammar rejects.
1085
+ if (!CAPABILITY_NAME_PATTERN.test(capability)) {
1086
+ throw new Error(`addService("${prefix}", ...): method '${method}' derives capability ` +
1087
+ `'${capability}', which is not a valid capability name (each ` +
1088
+ `dot-separated segment must start with a letter and contain only ` +
1089
+ `letters, digits, '_' or '-'). Rename the method.`);
1090
+ }
1091
+ if (this.tools.has(capability)) {
1092
+ throw new Error(`addService("${prefix}", ...): method '${method}' derives tool/` +
1093
+ `capability '${capability}', which is already registered. Rename ` +
1094
+ `the method or the conflicting tool.`);
1095
+ }
1096
+ const entry = methods[method];
1097
+ // Guard: a service-view value here is the consumer surface used in the
1098
+ // wrong role (producer). Fail loud.
1099
+ if (isServiceView(entry)) {
1100
+ throw new Error(`addService("${prefix}", ...): method '${method}' is a ` +
1101
+ `mesh.serviceView(...) — that is a CONSUMER view, not a producer ` +
1102
+ `method. Provide an execute function or { execute, ... } object.`);
1103
+ }
1104
+ let def;
1105
+ if (typeof entry === "function") {
1106
+ def = { execute: entry };
1107
+ }
1108
+ else if (entry != null &&
1109
+ typeof entry === "object" &&
1110
+ typeof entry.execute === "function") {
1111
+ def = entry;
1112
+ }
1113
+ else {
1114
+ throw new Error(`addService("${prefix}", ...): method '${method}' must be an execute ` +
1115
+ `function or an object with an execute function.`);
1116
+ }
1117
+ prepared.push({ capability, def });
1118
+ }
1119
+ // Pass 2 — register through the existing addTool machinery. Derived name ===
1120
+ // derived capability for deterministic uniqueness.
1121
+ for (const { capability, def } of prepared) {
1122
+ this.addTool({
1123
+ ...def,
1124
+ name: capability,
1125
+ capability,
1126
+ parameters: def.parameters ?? defaultProducerParams(),
1127
+ execute: def.execute,
1128
+ });
1129
+ }
1130
+ return this;
1131
+ }
914
1132
  /**
915
1133
  * Add an LLM provider to the agent.
916
1134
  *
@@ -1014,27 +1232,36 @@ export class MeshAgent {
1014
1232
  * Build the positional dependency-slot array for one tool call, shared by
1015
1233
  * BOTH the inbound HTTP wrapper (wrappedExecute) and the claim-dispatch
1016
1234
  * path (the ClaimHandler for task:true tools). Centralising this keeps the
1017
- * two slot-assembly sites identical:
1235
+ * two slot-assembly sites identical. One entry per AUTHORED slot:
1236
+ * - a service-view slot (RFC #1280) → a facade over its method edges
1018
1237
  * - the MeshJob slot (meshJobDepIndex) → a freshly-bound MeshJobSubmitter
1019
- * - every other slot → resolvedDeps.get(`${toolName}:dep_${index}`) ?? null
1238
+ * - every other slot → resolvedDeps.get(`${toolName}:dep_${edgeIndex}`) ?? null
1239
+ *
1240
+ * `depSlots` carries the slot→edge mapping (a `dep` slot owns one edge; a
1241
+ * `view` slot owns a contiguous edge range). `edges` is the flat edge array;
1242
+ * a slot's proxy/capability is read via its edge index.
1020
1243
  *
1021
1244
  * #1231: a typed dep slot can resolve at the registry yet never receive an
1022
1245
  * injected proxy, leaving the parameter null. Once the settle latch has
1023
1246
  * flipped (during settling the proxy may still land), warn ONCE per
1024
1247
  * 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
1248
+ * spam. The warn-once dedupe keys on `${toolName}:dep_${edgeIndex}` via the
1026
1249
  * module-level `_unwiredSlotWarned` Set, so a tool exercised via BOTH the
1027
1250
  * inbound and claim paths warns at most once total.
1028
1251
  */
1029
- _buildDepSlots(toolName, normalizedDeps, meshJobDepIndex, settleState) {
1030
- return normalizedDeps.map((dep, depIndex) => {
1031
- if (depIndex === meshJobDepIndex) {
1252
+ _buildDepSlots(toolName, depSlots, edges, meshJobDepIndex, settleState) {
1253
+ return depSlots.map((slot, slotIndex) => {
1254
+ if (slot.kind === "view") {
1255
+ return this._buildServiceViewFacade(toolName, slot, edges, settleState);
1256
+ }
1257
+ const edgeIndex = slot.edgeIndex;
1258
+ if (slotIndex === meshJobDepIndex) {
1032
1259
  // Build the submitter lazily per call so we always pick up the
1033
1260
  // current registryUrl (test harnesses sometimes mutate it between
1034
1261
  // calls).
1035
- return new MeshJobSubmitter(dep.capability, this.agentId, this.config.registryUrl);
1262
+ return new MeshJobSubmitter(edges[edgeIndex].capability, this.agentId, this.config.registryUrl);
1036
1263
  }
1037
- const slotKey = `${toolName}:dep_${depIndex}`;
1264
+ const slotKey = `${toolName}:dep_${edgeIndex}`;
1038
1265
  const proxy = this.resolvedDeps.get(slotKey) ?? null;
1039
1266
  // #1231: a declared typed dep slot that is still null AFTER settling
1040
1267
  // never received an injected proxy. Warn ONCE per tool+slot. Note this
@@ -1043,15 +1270,111 @@ export class MeshAgent {
1043
1270
  if (proxy === null && settleState.isSettled()) {
1044
1271
  if (!_unwiredSlotWarned.has(slotKey)) {
1045
1272
  _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.`);
1273
+ console.warn(`[mesh-tool] dependency '${edges[edgeIndex].capability}' on ` +
1274
+ `'${toolName}' is still null after settling — no proxy was ` +
1275
+ `injected into positional slot ${slotIndex}. Fix: ensure the ` +
1276
+ `provider for '${edges[edgeIndex].capability}' is registered ` +
1277
+ `and reachable.`);
1050
1278
  }
1051
1279
  }
1052
1280
  return proxy;
1053
1281
  });
1054
1282
  }
1283
+ /**
1284
+ * RFC #1280: build the facade injected at a service-view slot. Each facade
1285
+ * method reads its edge's CURRENT proxy from `resolvedDeps` at call time —
1286
+ * rebinding-free: a `dependency_available`/`unavailable` event swapping the
1287
+ * per-edge proxy is observed on the next call without rebuilding the facade.
1288
+ *
1289
+ * The `minAvailable` floor is enforced BEFORE delegation on every call: below
1290
+ * the floor during the settling window (#1193) the method performs the same
1291
+ * bounded, capability-keyed wait the injected-proxy path uses, recounts, then
1292
+ * throws {@link MeshServiceUnavailableError}. An unresolved OPTIONAL method
1293
+ * edge throws a `TypeError` — the same failure shape a directly-injected
1294
+ * unresolved `McpMeshTool` (a null proxy) produces when called.
1295
+ */
1296
+ _buildServiceViewFacade(toolName, slot, edges, settleState) {
1297
+ const total = slot.methods.length;
1298
+ const floor = slot.minAvailable;
1299
+ const viewName = slot.name;
1300
+ const countAvailable = () => {
1301
+ let n = 0;
1302
+ for (const m of slot.methods) {
1303
+ if ((this.resolvedDeps.get(`${toolName}:dep_${m.edgeIndex}`) ?? null) !== null) {
1304
+ n++;
1305
+ }
1306
+ }
1307
+ return n;
1308
+ };
1309
+ const enforceFloor = async () => {
1310
+ if (floor <= 0)
1311
+ return;
1312
+ let available = countAvailable();
1313
+ // Below the floor during the settling window: perform a bounded wait that
1314
+ // RACES all of the view's still-pending edges (wake on ANY resolution),
1315
+ // recounts, and breaks the moment the floor is met — so a call gated on
1316
+ // the floor wakes when ANY qualifying edge lands, never sleeping the full
1317
+ // window on a still-pending sibling key. Once settled the waits are
1318
+ // no-ops and the floor fails fast.
1319
+ while (available < floor &&
1320
+ !settleState.isSettled() &&
1321
+ settleState.remainingMs() > 0) {
1322
+ // Only edges that can still fire a settle WAKE are worth waiting on: a
1323
+ // key already in the settle ratchet (resolved at least once) will never
1324
+ // re-fire its waiter, so if such an edge has since flapped to a null
1325
+ // proxy `waitForAny` would filter it out and return immediately — a hot
1326
+ // busy-loop until the window expires. Excluding ratchet-resolved keys
1327
+ // here means `pending` empties and we break to the final recount/throw
1328
+ // the instant no wakeable edge remains (no spin).
1329
+ const pending = [];
1330
+ for (const m of slot.methods) {
1331
+ const key = `${toolName}:dep_${m.edgeIndex}`;
1332
+ if ((this.resolvedDeps.get(key) ?? null) === null &&
1333
+ !settleState.isResolved(key)) {
1334
+ pending.push({ depKey: key, capability: edges[m.edgeIndex].capability });
1335
+ }
1336
+ }
1337
+ if (pending.length === 0)
1338
+ break;
1339
+ await settleState.waitForAny(pending);
1340
+ available = countAvailable();
1341
+ }
1342
+ if (available < floor) {
1343
+ throw new MeshServiceUnavailableError(viewName, available, total, floor);
1344
+ }
1345
+ };
1346
+ const facade = {};
1347
+ for (const m of slot.methods) {
1348
+ const key = `${toolName}:dep_${m.edgeIndex}`;
1349
+ const capability = edges[m.edgeIndex].capability;
1350
+ // #1231 parity for view edges: a method edge that resolved at the registry
1351
+ // yet never received an injected proxy stays null after settling. Warn
1352
+ // ONCE per tool+edge (facade is rebuilt per call), naming the view method
1353
+ // and capability so the diagnostic matches the slot-dep warning.
1354
+ if ((this.resolvedDeps.get(key) ?? null) === null && settleState.isSettled()) {
1355
+ if (!_unwiredSlotWarned.has(key)) {
1356
+ _unwiredSlotWarned.add(key);
1357
+ console.warn(`[mesh-tool] service view '${viewName}' method '${m.method}' ` +
1358
+ `(capability '${capability}') on '${toolName}' is still null ` +
1359
+ `after settling — no proxy was injected. Fix: ensure the provider ` +
1360
+ `for '${capability}' is registered and reachable.`);
1361
+ }
1362
+ }
1363
+ facade[m.method] = async (args, options) => {
1364
+ await enforceFloor();
1365
+ const proxy = this.resolvedDeps.get(key) ?? null;
1366
+ if (proxy === null) {
1367
+ // Mirror the failure shape of calling an unresolved McpMeshTool
1368
+ // (a null proxy) directly — a TypeError.
1369
+ throw new TypeError(`service view '${viewName}' method '${m.method}' (capability ` +
1370
+ `'${capability}') is unavailable — the dependency did not ` +
1371
+ `resolve (unresolved optional edge).`);
1372
+ }
1373
+ return proxy(args, options);
1374
+ };
1375
+ }
1376
+ return facade;
1377
+ }
1055
1378
  _getOrBuildA2AClient(config) {
1056
1379
  const key = [
1057
1380
  config.url,
@@ -1259,7 +1582,7 @@ export class MeshAgent {
1259
1582
  if (this._taskHandlers.size === 0)
1260
1583
  return;
1261
1584
  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);
1585
+ const dispatcher = new ClaimDispatcher(capability, this.agentId, this.config.registryUrl, entry.handler, entry.retryOn, entry.requiredProbe, entry.resumeCursor);
1263
1586
  dispatcher.start();
1264
1587
  this._claimDispatchers.push(dispatcher);
1265
1588
  }