@mcpmesh/sdk 2.8.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/__tests__/agent-add-tool.spec.js +106 -0
  2. package/dist/__tests__/agent-add-tool.spec.js.map +1 -1
  3. package/dist/__tests__/claim-dispatcher-resume-cursor.test.d.ts +2 -0
  4. package/dist/__tests__/claim-dispatcher-resume-cursor.test.d.ts.map +1 -0
  5. package/dist/__tests__/claim-dispatcher-resume-cursor.test.js +145 -0
  6. package/dist/__tests__/claim-dispatcher-resume-cursor.test.js.map +1 -0
  7. package/dist/__tests__/direct-invoke-required.spec.d.ts +2 -0
  8. package/dist/__tests__/direct-invoke-required.spec.d.ts.map +1 -0
  9. package/dist/__tests__/direct-invoke-required.spec.js +209 -0
  10. package/dist/__tests__/direct-invoke-required.spec.js.map +1 -0
  11. package/dist/__tests__/service-view-types.spec.d.ts +2 -0
  12. package/dist/__tests__/service-view-types.spec.d.ts.map +1 -0
  13. package/dist/__tests__/service-view-types.spec.js +66 -0
  14. package/dist/__tests__/service-view-types.spec.js.map +1 -0
  15. package/dist/__tests__/service-view.spec.d.ts +2 -0
  16. package/dist/__tests__/service-view.spec.d.ts.map +1 -0
  17. package/dist/__tests__/service-view.spec.js +902 -0
  18. package/dist/__tests__/service-view.spec.js.map +1 -0
  19. package/dist/__tests__/superseded-signal.spec.d.ts +19 -0
  20. package/dist/__tests__/superseded-signal.spec.d.ts.map +1 -0
  21. package/dist/__tests__/superseded-signal.spec.js +222 -0
  22. package/dist/__tests__/superseded-signal.spec.js.map +1 -0
  23. package/dist/__tests__/worker-error-serde.spec.d.ts +2 -0
  24. package/dist/__tests__/worker-error-serde.spec.d.ts.map +1 -0
  25. package/dist/__tests__/worker-error-serde.spec.js +122 -0
  26. package/dist/__tests__/worker-error-serde.spec.js.map +1 -0
  27. package/dist/a2a/producer/mount.d.ts.map +1 -1
  28. package/dist/a2a/producer/mount.js +4 -0
  29. package/dist/a2a/producer/mount.js.map +1 -1
  30. package/dist/agent.d.ts +51 -3
  31. package/dist/agent.d.ts.map +1 -1
  32. package/dist/agent.js +435 -33
  33. package/dist/agent.js.map +1 -1
  34. package/dist/claim-dispatcher.d.ts +25 -1
  35. package/dist/claim-dispatcher.d.ts.map +1 -1
  36. package/dist/claim-dispatcher.js +51 -2
  37. package/dist/claim-dispatcher.js.map +1 -1
  38. package/dist/inbound-job-dispatch.d.ts +1 -1
  39. package/dist/inbound-job-dispatch.d.ts.map +1 -1
  40. package/dist/inbound-job-dispatch.js +8 -2
  41. package/dist/inbound-job-dispatch.js.map +1 -1
  42. package/dist/index.d.ts +6 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +9 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/llm-provider.d.ts +14 -16
  47. package/dist/llm-provider.d.ts.map +1 -1
  48. package/dist/llm-provider.js +2 -4
  49. package/dist/llm-provider.js.map +1 -1
  50. package/dist/llm.d.ts +1 -2
  51. package/dist/llm.d.ts.map +1 -1
  52. package/dist/llm.js +1 -2
  53. package/dist/llm.js.map +1 -1
  54. package/dist/proxy.d.ts.map +1 -1
  55. package/dist/proxy.js +37 -0
  56. package/dist/proxy.js.map +1 -1
  57. package/dist/route.d.ts.map +1 -1
  58. package/dist/route.js +5 -0
  59. package/dist/route.js.map +1 -1
  60. package/dist/service-view.d.ts +245 -0
  61. package/dist/service-view.d.ts.map +1 -0
  62. package/dist/service-view.js +210 -0
  63. package/dist/service-view.js.map +1 -0
  64. package/dist/settle.d.ts +22 -0
  65. package/dist/settle.d.ts.map +1 -1
  66. package/dist/settle.js +51 -22
  67. package/dist/settle.js.map +1 -1
  68. package/dist/superseded.d.ts +70 -0
  69. package/dist/superseded.d.ts.map +1 -0
  70. package/dist/superseded.js +99 -0
  71. package/dist/superseded.js.map +1 -0
  72. package/dist/tool-worker-entry.js +1 -12
  73. package/dist/tool-worker-entry.js.map +1 -1
  74. package/dist/tool-worker-pool.d.ts.map +1 -1
  75. package/dist/tool-worker-pool.js +1 -12
  76. package/dist/tool-worker-pool.js.map +1 -1
  77. package/dist/types.d.ts +31 -1
  78. package/dist/types.d.ts.map +1 -1
  79. package/dist/types.js.map +1 -1
  80. package/dist/worker-error-serde.d.ts +40 -0
  81. package/dist/worker-error-serde.d.ts.map +1 -0
  82. package/dist/worker-error-serde.js +86 -0
  83. package/dist/worker-error-serde.js.map +1 -0
  84. package/package.json +2 -2
package/dist/agent.js CHANGED
@@ -8,12 +8,14 @@
8
8
  * - Dependency injection for tool functions
9
9
  * - Graceful shutdown
10
10
  */
11
+ import { UserError } from "fastmcp";
11
12
  import { zodToJsonSchema } from "zod-to-json-schema";
12
13
  import { isMainThread } from "node:worker_threads";
13
14
  import { startAgent, } from "@mcpmesh/core";
15
+ import { expandDependencies, isServiceView, MeshServiceUnavailableError, CAPABILITY_NAME_PATTERN, defaultProducerParams, } from "./service-view.js";
14
16
  import { resolveConfig, generateAgentIdSuffix, findAvailablePort, resolveStartupBindPort, MAX_CONSECUTIVE_NEXT_EVENT_FAILURES, NEXT_EVENT_BACKOFF_CAP_MS, } from "./config.js";
15
17
  import { enrichSchemaWithMediaTypes } from "./media-param.js";
16
- import { createProxy, normalizeDependency, runWithTraceContext, runWithPropagatedHeaders, PROXY_DISPATCH_META } from "./proxy.js";
18
+ import { createProxy, runWithTraceContext, runWithPropagatedHeaders, PROXY_DISPATCH_META } from "./proxy.js";
17
19
  import { readJobHeaders, runWithJobContext, makeJobController, spliceJobController, } from "./inbound-job-dispatch.js";
18
20
  import { MeshJobSubmitter } from "./mesh-job-submitter.js";
19
21
  import { getSettleState, } from "./settle.js";
@@ -275,6 +277,22 @@ export class MeshAgent {
275
277
  addTool(def) {
276
278
  const toolName = def.name;
277
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
+ }
278
296
  // Phase 1 MeshJob substrate: validate `task: true` requires an
279
297
  // async function. Long-running tools need a Promise-based control
280
298
  // flow so the dispatch wrapper (Phase B) can await
@@ -387,6 +405,15 @@ export class MeshAgent {
387
405
  }
388
406
  }
389
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
+ }
390
417
  // Issue #917: validate a2aConfig at registration time so misuse fails
391
418
  // loud BEFORE the agent talks to the registry. Match the Python
392
419
  // `mesh.a2a_consumer` and Java `@A2AConsumer` startup-time checks.
@@ -445,16 +472,42 @@ export class MeshAgent {
445
472
  _workerToolMap.set(toolName, execute);
446
473
  return this;
447
474
  }
448
- // Normalize dependencies
449
- 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);
450
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;
451
504
  // Settling-window grace (#1193): declare this tool's proxy deps with the
452
505
  // process-wide settle state so the agent-level "all declared deps
453
506
  // resolved" latch can flip eagerly. The MeshJob slot is excluded — its
454
507
  // submitter is constructed locally, not resolved by an event.
455
508
  const settleState = getSettleState();
456
509
  normalizedDeps.forEach((_dep, depIndex) => {
457
- if (depIndex !== def.meshJobDepIndex) {
510
+ if (depIndex !== meshJobEdgeIndex) {
458
511
  settleState.registerDeclared(`${toolName}:dep_${depIndex}`);
459
512
  }
460
513
  });
@@ -468,6 +521,10 @@ export class MeshAgent {
468
521
  // registered in this.taskHandlers). Captured here so the closure
469
522
  // sees a stable reference even if def is mutated post-registration.
470
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;
471
528
  // Phase 1 MeshJob substrate: when a job-bound tool exists AND the
472
529
  // user explicitly opted into worker isolation via env, log a single
473
530
  // warning at registration time. The wrapper force-disables
@@ -486,6 +543,18 @@ export class MeshAgent {
486
543
  `(controllers/AsyncLocalStorage don't cross worker boundaries). ` +
487
544
  `Set 'task: true' explicitly if you intend a producer.`);
488
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
+ }
489
558
  // Create wrapper that injects dependencies positionally and handles tracing
490
559
  const wrappedExecute = async (args) => {
491
560
  // Settling-window grace (#1193): while the agent is still settling,
@@ -497,7 +566,7 @@ export class MeshAgent {
497
566
  const pendingSettle = [];
498
567
  normalizedDeps.forEach((dep, depIndex) => {
499
568
  const depKey = `${toolName}:dep_${depIndex}`;
500
- if (depIndex !== meshJobDepIndex &&
569
+ if (depIndex !== meshJobEdgeIndex &&
501
570
  !this.resolvedDeps.has(depKey)) {
502
571
  pendingSettle.push({ depKey, capability: dep.capability });
503
572
  }
@@ -506,14 +575,46 @@ export class MeshAgent {
506
575
  await settleState.awaitPending(pendingSettle);
507
576
  }
508
577
  }
509
- // Build positional deps array using composite keys (toolName:dep_index)
510
- // Phase 1 MeshJob substrate (consumer-side): if meshJobDepIndex is
511
- // set, swap the McpMeshTool proxy at that slot for a
512
- // MeshJobSubmitter targeting that dep's capability. We bind the
513
- // submitter to the live registryUrl/agentId so it can submit
514
- // jobs without needing access to the agent instance.
515
- const depsArray = this._buildDepSlots(toolName, normalizedDeps, meshJobDepIndex, settleState);
516
- 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++;
597
+ // Issue #1273: detect a required dependency slot that is still
598
+ // unresolved AFTER the settle wait above (depsArray was built
599
+ // post-settle). The ACTION is deferred until the invocation flavor is
600
+ // known (see the flavor-aware guard below the header extraction) —
601
+ // direct tools/call refuses, an inbound JOB dispatch releases the
602
+ // lease. Optional deps keep null-passthrough; the MeshJob submitter
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.
608
+ let missingRequiredCap = null;
609
+ for (let depIndex = 0; depIndex < normalizedDeps.length; depIndex++) {
610
+ const dep = normalizedDeps[depIndex];
611
+ if (dep.required === true &&
612
+ depIndex !== meshJobEdgeIndex &&
613
+ (this.resolvedDeps.get(`${toolName}:dep_${depIndex}`) ?? null) == null) {
614
+ missingRequiredCap = dep.capability;
615
+ break;
616
+ }
617
+ }
517
618
  // Extract trace context from arguments (injected by upstream proxy)
518
619
  // This is the fallback mechanism since fastmcp doesn't expose HTTP headers
519
620
  let incomingTraceId = null;
@@ -521,6 +622,14 @@ export class MeshAgent {
521
622
  let cleanArgs = args;
522
623
  // Extract _mesh_headers from args for header propagation
523
624
  let propagatedHeaders = {};
625
+ // Issue #1273: the RAW inbound _mesh_headers, lowercased but NOT
626
+ // allowlist-filtered. Job-dispatch infra headers (x-mesh-job-id /
627
+ // x-mesh-claim-epoch) are the DISPATCH DISCRIMINATOR, deliberately kept
628
+ // OUT of the propagate allowlist (so a nested outbound call doesn't look
629
+ // like a job dispatch to every downstream). They must therefore be read
630
+ // from the raw map — reading them off `propagatedHeaders` would drop them
631
+ // and misclassify a genuine inbound job dispatch as a plain tools/call.
632
+ let rawMeshHeaders = null;
524
633
  if (args && typeof args === "object") {
525
634
  const argsObj = args;
526
635
  if (typeof argsObj._trace_id === "string") {
@@ -531,9 +640,14 @@ export class MeshAgent {
531
640
  }
532
641
  if (argsObj._mesh_headers && typeof argsObj._mesh_headers === "object") {
533
642
  const meshHeaders = argsObj._mesh_headers;
534
- // Filter against allowlist
643
+ rawMeshHeaders = {};
535
644
  for (const [key, value] of Object.entries(meshHeaders)) {
536
- if (typeof value === "string" && matchesPropagateHeader(key)) {
645
+ if (typeof value !== "string")
646
+ continue;
647
+ // Raw copy for job-dispatch detection (bypasses the allowlist).
648
+ rawMeshHeaders[key.toLowerCase()] = value;
649
+ // Allowlist-filtered copy for onward propagation.
650
+ if (matchesPropagateHeader(key)) {
537
651
  propagatedHeaders[key.toLowerCase()] = value;
538
652
  }
539
653
  }
@@ -549,6 +663,54 @@ export class MeshAgent {
549
663
  const spanId = generateSpanId();
550
664
  const parentSpanId = incomingParentSpan ?? null;
551
665
  const traceContext = { traceId, parentSpanId: spanId };
666
+ // Issue #1273: flavor-aware required-dependency guard. The claim path
667
+ // (requiredProbe, #1268) and the @mesh.route perimeter (503) already
668
+ // refuse before a handler can observe a null REQUIRED dep; the plain
669
+ // tool-dispatch path did not. A required dep flapping DOWN→UP flips the
670
+ // registry view available before THIS callee's own heartbeat refills
671
+ // its slot; a call landing in that window would invoke with a null
672
+ // required proxy. Now that the propagated headers are extracted we can
673
+ // tell the flavor apart:
674
+ // - inbound JOB dispatch (task tool + X-Mesh-Job-Id) → RELEASE the
675
+ // lease so the claimed row re-queues (retryable), mirroring the
676
+ // claim path — NEVER a bare throw (which would strand the row
677
+ // `working` until lease expiry) and never a terminal fail.
678
+ // - plain tools/call → structured dependency_unavailable refusal
679
+ // (UserError → isError result) so the caller classifies it as
680
+ // retryable topology, not application failure.
681
+ if (missingRequiredCap !== null) {
682
+ // Read job headers from the RAW inbound map (not the allowlist-filtered
683
+ // propagatedHeaders), so a genuine inbound job dispatch is classified as
684
+ // one — and released, not refused — even though x-mesh-job-id is not in
685
+ // the propagate allowlist.
686
+ const [guardJobId, , guardClaimEpoch] = readJobHeaders(rawMeshHeaders);
687
+ const isJobDispatch = isTaskTool &&
688
+ guardJobId !== null &&
689
+ !!this.config.registryUrl &&
690
+ !!this.agentId;
691
+ if (isJobDispatch) {
692
+ console.warn(`[mesh-jobs] releasing job=${guardJobId} for tool '${toolName}' — ` +
693
+ `required dependency '${missingRequiredCap}' unavailable at ` +
694
+ `invocation time; releasing lease for retry (not invoking, not failing)`);
695
+ try {
696
+ const controller = makeJobController(guardJobId, this.agentId, this.config.registryUrl, guardClaimEpoch);
697
+ await controller.releaseLease(`required dependency '${missingRequiredCap}' unavailable at ` +
698
+ `job-dispatch (issue #1273)`);
699
+ }
700
+ catch (err) {
701
+ console.debug(`[mesh-jobs] release-lease for job=${guardJobId} raised:`, err);
702
+ }
703
+ // The row is released and will be re-claimed; nothing to return.
704
+ return "";
705
+ }
706
+ console.warn(`🚫 Tool '${toolName}': required dependency '${missingRequiredCap}' ` +
707
+ `unavailable at invocation time — refusing with dependency_unavailable ` +
708
+ `(not invoking the handler with a null required proxy)`);
709
+ throw new UserError(JSON.stringify({
710
+ error: "dependency_unavailable",
711
+ capability: missingRequiredCap,
712
+ }));
713
+ }
552
714
  const startTime = Date.now() / 1000;
553
715
  let success = true;
554
716
  let error = null;
@@ -576,8 +738,14 @@ export class MeshAgent {
576
738
  // cached client + connection pool intact across calls.
577
739
  const isA2aBound = a2aClient !== null;
578
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");
579
746
  const isolationEnabled = !isJobBound &&
580
747
  !isA2aBound &&
748
+ !isViewBound &&
581
749
  (process.env.MCP_MESH_TOOL_ISOLATION ?? "true").toLowerCase() !== "false";
582
750
  try {
583
751
  let result;
@@ -753,7 +921,7 @@ export class MeshAgent {
753
921
  const pendingSettle = [];
754
922
  normalizedDeps.forEach((dep, depIndex) => {
755
923
  const depKey = `${toolName}:dep_${depIndex}`;
756
- if (depIndex !== meshJobDepIndex &&
924
+ if (depIndex !== meshJobEdgeIndex &&
757
925
  !this.resolvedDeps.has(depKey)) {
758
926
  pendingSettle.push({ depKey, capability: dep.capability });
759
927
  }
@@ -762,7 +930,7 @@ export class MeshAgent {
762
930
  await settleState.awaitPending(pendingSettle);
763
931
  }
764
932
  }
765
- const liveDeps = this._buildDepSlots(toolName, normalizedDeps, meshJobDepIndex, settleState);
933
+ const liveDeps = this._buildDepSlots(toolName, depSlots, normalizedDeps, meshJobDepIndex, settleState);
766
934
  const callArgs = spliceJobController(payload, liveDeps, controller, meshJobParamIndex);
767
935
  // Issue #917: A2A consumer tools dispatched via the claim
768
936
  // path get the same trailing A2AClient argument as the
@@ -779,7 +947,7 @@ export class MeshAgent {
779
947
  // covers both an absent key and an explicit null slot.
780
948
  const requiredSlots = [];
781
949
  normalizedDeps.forEach((dep, depIndex) => {
782
- if (dep.required && depIndex !== meshJobDepIndex) {
950
+ if (dep.required && depIndex !== meshJobEdgeIndex) {
783
951
  requiredSlots.push([depIndex, dep.capability]);
784
952
  }
785
953
  });
@@ -794,7 +962,12 @@ export class MeshAgent {
794
962
  return null;
795
963
  }
796
964
  : undefined;
797
- this._taskHandlers.set(capability, { handler, retryOn, requiredProbe });
965
+ this._taskHandlers.set(capability, {
966
+ handler,
967
+ retryOn,
968
+ requiredProbe,
969
+ resumeCursor,
970
+ });
798
971
  }
799
972
  // Store mesh metadata with JSON Schema for LLM tool resolution
800
973
  const inputSchema = this.convertZodToJsonSchema(def.parameters);
@@ -805,6 +978,28 @@ export class MeshAgent {
805
978
  if (def.outputSchema) {
806
979
  outputSchemaRaw = this.convertZodToJsonSchema(def.outputSchema);
807
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
+ }
808
1003
  this.tools.set(toolName, {
809
1004
  capability: def.capability ?? toolName,
810
1005
  version: def.version ?? "1.0.0",
@@ -815,7 +1010,7 @@ export class MeshAgent {
815
1010
  // Issue #547 Phase 4: per-tool override (default true = current behavior).
816
1011
  outputSchemaStrict: def.outputSchemaStrict !== false,
817
1012
  dependencies: normalizedDeps,
818
- dependencyKwargs: def.dependencyKwargs,
1013
+ dependencyKwargs: edgeDependencyKwargs,
819
1014
  // Phase 1 MeshJob substrate: stamp producer's long-running flag
820
1015
  // so the heartbeat pipeline ships it to the registry. Consumers
821
1016
  // read this to decide between job semantics and a regular
@@ -832,6 +1027,108 @@ export class MeshAgent {
832
1027
  });
833
1028
  return this;
834
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
+ }
835
1132
  /**
836
1133
  * Add an LLM provider to the agent.
837
1134
  *
@@ -935,27 +1232,36 @@ export class MeshAgent {
935
1232
  * Build the positional dependency-slot array for one tool call, shared by
936
1233
  * BOTH the inbound HTTP wrapper (wrappedExecute) and the claim-dispatch
937
1234
  * path (the ClaimHandler for task:true tools). Centralising this keeps the
938
- * 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
939
1237
  * - the MeshJob slot (meshJobDepIndex) → a freshly-bound MeshJobSubmitter
940
- * - 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.
941
1243
  *
942
1244
  * #1231: a typed dep slot can resolve at the registry yet never receive an
943
1245
  * injected proxy, leaving the parameter null. Once the settle latch has
944
1246
  * flipped (during settling the proxy may still land), warn ONCE per
945
1247
  * tool+slot — the array is rebuilt per call, so a per-call warning would
946
- * 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
947
1249
  * module-level `_unwiredSlotWarned` Set, so a tool exercised via BOTH the
948
1250
  * inbound and claim paths warns at most once total.
949
1251
  */
950
- _buildDepSlots(toolName, normalizedDeps, meshJobDepIndex, settleState) {
951
- return normalizedDeps.map((dep, depIndex) => {
952
- 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) {
953
1259
  // Build the submitter lazily per call so we always pick up the
954
1260
  // current registryUrl (test harnesses sometimes mutate it between
955
1261
  // calls).
956
- return new MeshJobSubmitter(dep.capability, this.agentId, this.config.registryUrl);
1262
+ return new MeshJobSubmitter(edges[edgeIndex].capability, this.agentId, this.config.registryUrl);
957
1263
  }
958
- const slotKey = `${toolName}:dep_${depIndex}`;
1264
+ const slotKey = `${toolName}:dep_${edgeIndex}`;
959
1265
  const proxy = this.resolvedDeps.get(slotKey) ?? null;
960
1266
  // #1231: a declared typed dep slot that is still null AFTER settling
961
1267
  // never received an injected proxy. Warn ONCE per tool+slot. Note this
@@ -964,15 +1270,111 @@ export class MeshAgent {
964
1270
  if (proxy === null && settleState.isSettled()) {
965
1271
  if (!_unwiredSlotWarned.has(slotKey)) {
966
1272
  _unwiredSlotWarned.add(slotKey);
967
- console.warn(`[mesh-tool] dependency '${dep.capability}' on '${toolName}' ` +
968
- `is still null after settling — no proxy was injected into ` +
969
- `positional slot ${depIndex}. Fix: ensure the provider for ` +
970
- `'${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.`);
971
1278
  }
972
1279
  }
973
1280
  return proxy;
974
1281
  });
975
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
+ }
976
1378
  _getOrBuildA2AClient(config) {
977
1379
  const key = [
978
1380
  config.url,
@@ -1180,7 +1582,7 @@ export class MeshAgent {
1180
1582
  if (this._taskHandlers.size === 0)
1181
1583
  return;
1182
1584
  for (const [capability, entry] of this._taskHandlers.entries()) {
1183
- 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);
1184
1586
  dispatcher.start();
1185
1587
  this._claimDispatchers.push(dispatcher);
1186
1588
  }