@nebutra/agent-runtime 0.2.0 → 0.2.2

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 (137) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +2 -0
  3. package/dist/adapters/dispatcher-sse.js +1 -0
  4. package/dist/adapters/index.d.ts +24 -5
  5. package/dist/adapters/index.js +28 -0
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/adapters/mcp-catalog.js +1 -0
  8. package/dist/adapters/prisma-rollout.js +1 -0
  9. package/dist/chunk-424PT5DM.js +23 -0
  10. package/dist/chunk-424PT5DM.js.map +1 -0
  11. package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
  12. package/dist/chunk-4Y25ZTKI.js.map +1 -0
  13. package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
  14. package/dist/chunk-D4YAPLOW.js.map +1 -0
  15. package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
  16. package/dist/chunk-GQZKYWFT.js.map +1 -0
  17. package/dist/chunk-KCNN4QUQ.js +255 -0
  18. package/dist/chunk-KCNN4QUQ.js.map +1 -0
  19. package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
  20. package/dist/chunk-NI4EDT4T.js.map +1 -0
  21. package/dist/chunk-Q62VKHIT.js +178 -0
  22. package/dist/chunk-Q62VKHIT.js.map +1 -0
  23. package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
  24. package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
  25. package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +52 -0
  28. package/dist/cli.js.map +1 -0
  29. package/dist/commands.js +1 -0
  30. package/dist/definitions.js +1 -0
  31. package/dist/dispatcher.js +1 -0
  32. package/dist/durable-turn.js +3 -2
  33. package/dist/hook-pipeline.js +1 -0
  34. package/dist/index.d.ts +8 -85
  35. package/dist/index.js +249 -134
  36. package/dist/index.js.map +1 -1
  37. package/dist/loop.d.ts +2 -2
  38. package/dist/loop.js +3 -2
  39. package/dist/mcp-bridge.d.ts +3 -3
  40. package/dist/mcp-bridge.js +3 -2
  41. package/dist/model.js +1 -0
  42. package/dist/orchestration.d.ts +84 -0
  43. package/dist/orchestration.js +16 -0
  44. package/dist/orchestration.js.map +1 -0
  45. package/dist/policy.js +1 -0
  46. package/dist/protocol.js +1 -0
  47. package/dist/pulsar.d.ts +78 -0
  48. package/dist/pulsar.js +17 -0
  49. package/dist/pulsar.js.map +1 -0
  50. package/dist/rollout-store-persistent.js +1 -0
  51. package/dist/rollout.js +1 -0
  52. package/dist/sandbox.js +2 -1
  53. package/dist/skills.js +2 -1
  54. package/dist/subagents.js +1 -0
  55. package/dist/tools.d.ts +4 -3
  56. package/dist/tools.js +5 -3
  57. package/package.json +84 -27
  58. package/.turbo/turbo-build.log +0 -115
  59. package/.turbo/turbo-test.log +0 -44
  60. package/.turbo/turbo-typecheck.log +0 -4
  61. package/CHANGELOG.md +0 -253
  62. package/dist/chunk-BJBBR3QA.js.map +0 -1
  63. package/dist/chunk-NN7DATXA.js.map +0 -1
  64. package/dist/chunk-PGGWSUTM.js.map +0 -1
  65. package/dist/chunk-ZMYX5VBU.js.map +0 -1
  66. package/src/adapters/dispatcher-sse.test.ts +0 -218
  67. package/src/adapters/dispatcher-sse.ts +0 -222
  68. package/src/adapters/index.ts +0 -18
  69. package/src/adapters/mcp-catalog.test.ts +0 -213
  70. package/src/adapters/mcp-catalog.ts +0 -188
  71. package/src/adapters/prisma-rollout.test.ts +0 -153
  72. package/src/adapters/prisma-rollout.ts +0 -104
  73. package/src/agent-runtime.test.ts +0 -176
  74. package/src/artifact-stream.test.ts +0 -330
  75. package/src/artifact-stream.ts +0 -453
  76. package/src/channel-gateway.test.ts +0 -432
  77. package/src/channel-gateway.ts +0 -357
  78. package/src/code-review.test.ts +0 -501
  79. package/src/code-review.ts +0 -495
  80. package/src/command-suggestions.test.ts +0 -251
  81. package/src/command-suggestions.ts +0 -338
  82. package/src/commands.test.ts +0 -184
  83. package/src/commands.ts +0 -140
  84. package/src/commit-message.test.ts +0 -249
  85. package/src/commit-message.ts +0 -180
  86. package/src/context-compaction.test.ts +0 -522
  87. package/src/context-compaction.ts +0 -434
  88. package/src/definitions.test.ts +0 -78
  89. package/src/definitions.ts +0 -190
  90. package/src/deployment-status.test.ts +0 -215
  91. package/src/deployment-status.ts +0 -227
  92. package/src/design-context.test.ts +0 -195
  93. package/src/design-context.ts +0 -198
  94. package/src/dispatcher.test.ts +0 -234
  95. package/src/dispatcher.ts +0 -189
  96. package/src/durable-turn.test.ts +0 -209
  97. package/src/durable-turn.ts +0 -135
  98. package/src/edit-planner.test.ts +0 -204
  99. package/src/edit-planner.ts +0 -325
  100. package/src/fuzzy-match.test.ts +0 -311
  101. package/src/fuzzy-match.ts +0 -444
  102. package/src/hook-pipeline.test.ts +0 -279
  103. package/src/hook-pipeline.ts +0 -373
  104. package/src/inbound-admission.test.ts +0 -394
  105. package/src/inbound-admission.ts +0 -246
  106. package/src/index.ts +0 -47
  107. package/src/loop.test.ts +0 -161
  108. package/src/loop.ts +0 -211
  109. package/src/mcp-bridge.test.ts +0 -165
  110. package/src/mcp-bridge.ts +0 -76
  111. package/src/memory-provider.test.ts +0 -232
  112. package/src/memory-provider.ts +0 -257
  113. package/src/model.ts +0 -168
  114. package/src/permission-ruleset.test.ts +0 -301
  115. package/src/permission-ruleset.ts +0 -200
  116. package/src/policy.ts +0 -151
  117. package/src/project-repo.test.ts +0 -232
  118. package/src/project-repo.ts +0 -311
  119. package/src/protocol.ts +0 -159
  120. package/src/rollout-store-persistent.test.ts +0 -217
  121. package/src/rollout-store-persistent.ts +0 -166
  122. package/src/rollout.ts +0 -150
  123. package/src/sandbox.ts +0 -113
  124. package/src/session-share.test.ts +0 -360
  125. package/src/session-share.ts +0 -310
  126. package/src/skill-distillation.test.ts +0 -177
  127. package/src/skill-distillation.ts +0 -369
  128. package/src/skills.test.ts +0 -277
  129. package/src/skills.ts +0 -255
  130. package/src/subagents.test.ts +0 -290
  131. package/src/subagents.ts +0 -332
  132. package/src/tools.ts +0 -126
  133. package/src/workbench.test.ts +0 -0
  134. package/src/workbench.ts +0 -0
  135. package/tsconfig.json +0 -12
  136. package/tsup.config.ts +0 -33
  137. /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.js.map} +0 -0
@@ -1,222 +0,0 @@
1
- /**
2
- * Runtime-agnostic SSE / HTTP transport binding for `ProtocolDispatcher`.
3
- *
4
- * Built on Web-standard primitives ONLY — `Request`, `Response`,
5
- * `ReadableStream`, `TextEncoder`, `AbortSignal`. There is no Hono / Express /
6
- * `node:http` dependency, so the same code runs unchanged on edge runtimes,
7
- * Cloudflare Workers, Deno, Bun, and Node (>=18, where these globals exist).
8
- *
9
- * Design goals:
10
- * - Pure functions returning Web objects; nothing is mutated in place.
11
- * - The dispatcher already fails closed on a missing `tenantId` (its
12
- * `requestEnvelopeSchema` makes `tenantId` mandatory). This binding does
13
- * not re-implement that policy — it faithfully relays the JSON-RPC error.
14
- * - Never throw across the transport boundary and never leak a stack trace:
15
- * a malformed body becomes a well-formed JSON-RPC parse error.
16
- *
17
- * Wire framing (SSE): each event is emitted as
18
- * `event: <ThreadEvent.type>\n` + `data: <JSON(event)>\n\n`
19
- */
20
-
21
- import type { ProtocolDispatcher } from "../dispatcher";
22
- import type { ThreadEvent } from "../model";
23
- import type { NotificationName } from "../protocol";
24
-
25
- // ── JSON-RPC constants ───────────────────────────────────────────────────────
26
-
27
- /** Spec code for "Parse error" — the request body was not valid JSON. */
28
- const ERR_PARSE = -32700;
29
-
30
- const JSON_HEADERS: Readonly<Record<string, string>> = {
31
- "content-type": "application/json; charset=utf-8",
32
- };
33
-
34
- /** A self-contained JSON-RPC failure envelope (used only for parse errors;
35
- * every other failure path is produced by the dispatcher itself). */
36
- function parseErrorEnvelope(message: string): {
37
- readonly jsonrpc: "2.0";
38
- readonly id: null;
39
- readonly error: { readonly code: number; readonly message: string };
40
- } {
41
- return { jsonrpc: "2.0", id: null, error: { code: ERR_PARSE, message } };
42
- }
43
-
44
- function jsonResponse(payload: unknown, status: number): Response {
45
- return new Response(JSON.stringify(payload), { status, headers: { ...JSON_HEADERS } });
46
- }
47
-
48
- // ── HTTP / JSON-RPC handler ──────────────────────────────────────────────────
49
-
50
- /**
51
- * Build a Web-standard request handler bound to a dispatcher.
52
- *
53
- * Contract:
54
- * - Unparseable body → `400` with a JSON-RPC parse-error envelope.
55
- * - Any parseable body (incl. missing tenant / unknown method / handler
56
- * failure) → `200` with the dispatcher's own JSON-RPC envelope. The HTTP
57
- * layer stays `200` for business-level JSON-RPC errors per JSON-RPC-over-
58
- * HTTP convention; the error lives in the envelope, not the status line.
59
- * - The returned function never throws and never emits a 500-with-stack.
60
- */
61
- export function createRpcHandler(
62
- dispatcher: ProtocolDispatcher,
63
- ): (request: Request) => Promise<Response> {
64
- return async (request: Request): Promise<Response> => {
65
- let raw: unknown;
66
- try {
67
- raw = await request.json();
68
- } catch {
69
- return jsonResponse(parseErrorEnvelope("Request body is not valid JSON"), 400);
70
- }
71
-
72
- // `dispatch` is documented to never throw; the try/catch is defence in
73
- // depth so the transport boundary is total under any future change.
74
- try {
75
- const envelope = await dispatcher.dispatch(raw);
76
- return jsonResponse(envelope, 200);
77
- } catch {
78
- return jsonResponse(parseErrorEnvelope("Dispatcher failure"), 400);
79
- }
80
- };
81
- }
82
-
83
- // ── SSE streaming ────────────────────────────────────────────────────────────
84
-
85
- export interface SseOptions {
86
- /** Cancels the stream cleanly; closing the consumer also cancels it. */
87
- readonly signal?: AbortSignal;
88
- }
89
-
90
- /** Encode one `ThreadEvent` as a single SSE frame. */
91
- function frame(event: ThreadEvent): string {
92
- return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
93
- }
94
-
95
- /** Encode a terminal transport-level error as an `event: error` SSE frame. */
96
- function errorFrame(message: string): string {
97
- return `event: error\ndata: ${JSON.stringify({ type: "error", message })}\n\n`;
98
- }
99
-
100
- /**
101
- * Adapt an async `ThreadEvent` stream into a Web-standard
102
- * `text/event-stream` `Response`.
103
- *
104
- * Guarantees:
105
- * - Incremental flush: each event is enqueued as soon as it is produced.
106
- * - Clean termination: the stream closes when the iterable is exhausted.
107
- * - Error surfacing: an iterable error is emitted as a final
108
- * `event: error` frame, then the stream closes (it never hangs).
109
- * - Cancellation: an aborted `signal` (or a cancelled reader) stops
110
- * pulling from the source and releases its iterator.
111
- */
112
- export function sseResponse(stream: AsyncIterable<ThreadEvent>, opts?: SseOptions): Response {
113
- const encoder = new TextEncoder();
114
- const signal = opts?.signal;
115
-
116
- const body = new ReadableStream<Uint8Array>({
117
- async start(controller) {
118
- const iterator = stream[Symbol.asyncIterator]();
119
-
120
- const onAbort = () => {
121
- void iterator.return?.(undefined);
122
- };
123
- if (signal) {
124
- if (signal.aborted) {
125
- await iterator.return?.(undefined);
126
- controller.close();
127
- return;
128
- }
129
- signal.addEventListener("abort", onAbort, { once: true });
130
- }
131
-
132
- try {
133
- for (;;) {
134
- if (signal?.aborted) break;
135
- const next = await iterator.next();
136
- if (next.done) break;
137
- controller.enqueue(encoder.encode(frame(next.value)));
138
- }
139
- } catch (error) {
140
- const message = error instanceof Error ? error.message : "Stream error";
141
- controller.enqueue(encoder.encode(errorFrame(message)));
142
- } finally {
143
- signal?.removeEventListener("abort", onAbort);
144
- controller.close();
145
- }
146
- },
147
- cancel() {
148
- // Consumer hung up: best-effort release of the upstream iterator.
149
- void stream[Symbol.asyncIterator]().return?.(undefined);
150
- },
151
- });
152
-
153
- return new Response(body, {
154
- status: 200,
155
- headers: {
156
- "content-type": "text/event-stream; charset=utf-8",
157
- "cache-control": "no-cache, no-transform",
158
- connection: "keep-alive",
159
- },
160
- });
161
- }
162
-
163
- // ── Notification → async-iterable bridge ─────────────────────────────────────
164
-
165
- export interface NotificationFrame {
166
- readonly name: NotificationName;
167
- readonly payload: unknown;
168
- }
169
-
170
- /**
171
- * Bridge `dispatcher.onNotification` (a push listener API) into a pull-based
172
- * `AsyncIterable`. A bounded backlog buffers events that arrive between
173
- * `next()` calls; pending readers are resolved immediately when an event
174
- * arrives. Calling `return()` (or breaking a `for await`) unsubscribes every
175
- * underlying listener — no leaks, fail-closed teardown.
176
- */
177
- export function subscribeNotifications(
178
- dispatcher: ProtocolDispatcher,
179
- names: readonly NotificationName[],
180
- ): AsyncIterable<NotificationFrame> {
181
- return {
182
- [Symbol.asyncIterator](): AsyncIterator<NotificationFrame> {
183
- const backlog: NotificationFrame[] = [];
184
- const waiters: Array<(r: IteratorResult<NotificationFrame>) => void> = [];
185
- let closed = false;
186
-
187
- const unsubscribers = names.map((name) =>
188
- dispatcher.onNotification(name, (payload) => {
189
- if (closed) return;
190
- const value: NotificationFrame = { name, payload };
191
- const waiter = waiters.shift();
192
- if (waiter) waiter({ value, done: false });
193
- else backlog.push(value);
194
- }),
195
- );
196
-
197
- const teardown = (): void => {
198
- if (closed) return;
199
- closed = true;
200
- for (const off of unsubscribers) off();
201
- let waiter = waiters.shift();
202
- while (waiter) {
203
- waiter({ value: undefined, done: true });
204
- waiter = waiters.shift();
205
- }
206
- };
207
-
208
- return {
209
- next(): Promise<IteratorResult<NotificationFrame>> {
210
- const buffered = backlog.shift();
211
- if (buffered) return Promise.resolve({ value: buffered, done: false });
212
- if (closed) return Promise.resolve({ value: undefined, done: true });
213
- return new Promise((resolve) => waiters.push(resolve));
214
- },
215
- return(): Promise<IteratorResult<NotificationFrame>> {
216
- teardown();
217
- return Promise.resolve({ value: undefined, done: true });
218
- },
219
- };
220
- },
221
- };
222
- }
@@ -1,18 +0,0 @@
1
- /**
2
- * @nebutra/agent-runtime/adapters — concrete, reusable port adapters.
3
- *
4
- * The root runtime keeps its pure grammar and injectable ports. These subpath
5
- * exports supply concrete wirings so apps/backends reuse them instead of
6
- * re-implementing glue:
7
- *
8
- * - `./mcp-catalog` — McpServerCatalogPort + McpClientLike over @nebutra/mcp
9
- * - `./dispatcher-sse` — runtime-agnostic SSE transport for ProtocolDispatcher
10
- *
11
- * The durable rollout-store backend adapter is pending a governance decision
12
- * (a correct system-of-record needs a fail-loud datastore; @nebutra/audit's
13
- * log() swallows transient failures and is not suitable as source-of-truth).
14
- */
15
-
16
- export * from "./dispatcher-sse.js";
17
- export * from "./mcp-catalog.js";
18
- export * from "./prisma-rollout.js";
@@ -1,213 +0,0 @@
1
- import type { MCPContext, ToolExecutionResult } from "@nebutra/mcp";
2
- import { MCPServerRegistry } from "@nebutra/mcp";
3
- import { describe, expect, it, vi } from "vitest";
4
- import type { z } from "zod";
5
- import { activateMcpTools, type McpServerCatalogPort } from "../mcp-bridge";
6
- import { ToolRegistry } from "../tools";
7
-
8
- import { createMcpClientPort, createMcpServerCatalog } from "./mcp-catalog.js";
9
-
10
- /**
11
- * Build a fresh, isolated registry seeded with two servers gated by plan and
12
- * tenant. No network — `local` transport with in-process handlers.
13
- */
14
- function seedRegistry(): MCPServerRegistry {
15
- const registry = new MCPServerRegistry();
16
-
17
- registry.register({
18
- id: "weather",
19
- name: "Weather",
20
- description: "Weather tools",
21
- endpoint: "local://weather",
22
- transport: "local",
23
- tools: [
24
- {
25
- name: "forecast",
26
- description: "Get a forecast",
27
- parameters: {
28
- city: { type: "string", description: "City name", required: true },
29
- days: { type: "number", description: "Day count" },
30
- },
31
- },
32
- {
33
- name: "alerts",
34
- description: "Severe weather alerts (pro only)",
35
- allowedPlans: ["pro", "enterprise"],
36
- },
37
- ],
38
- handlers: {
39
- forecast: (args) => ({ ok: true, echo: args }),
40
- alerts: () => ({ ok: true, alerts: [] }),
41
- },
42
- });
43
-
44
- registry.register({
45
- id: "acme-private",
46
- name: "Acme Private",
47
- description: "Tenant-scoped server",
48
- endpoint: "local://acme",
49
- transport: "local",
50
- allowedTenants: ["tenant-acme"],
51
- tools: [{ name: "acme_secret", description: "Acme-only tool" }],
52
- handlers: { acme_secret: () => ({ ok: true }) },
53
- });
54
-
55
- return registry;
56
- }
57
-
58
- describe("createMcpServerCatalog", () => {
59
- it("fails closed on empty tenantId before touching the registry", async () => {
60
- const registry = new MCPServerRegistry();
61
- const spy = vi.spyOn(registry, "getAccessibleTools");
62
- const catalog = createMcpServerCatalog(registry);
63
-
64
- await expect(catalog.listTools({ tenantId: "" })).rejects.toThrow();
65
- await expect(catalog.listTools({ tenantId: " " })).rejects.toThrow();
66
- expect(spy).not.toHaveBeenCalled();
67
- });
68
-
69
- it("plan-gates: a free tenant sees fewer tools than a pro tenant", async () => {
70
- const catalog = createMcpServerCatalog(seedRegistry());
71
-
72
- const free = await catalog.listTools({ tenantId: "tenant-1", plan: "free" });
73
- const pro = await catalog.listTools({ tenantId: "tenant-1", plan: "pro" });
74
-
75
- const freeNames = free.map((e) => e.definition.name).sort();
76
- const proNames = pro.map((e) => e.definition.name).sort();
77
-
78
- expect(freeNames).toEqual(["forecast"]);
79
- expect(proNames).toEqual(["alerts", "forecast"]);
80
- expect(pro.length).toBeGreaterThan(free.length);
81
- });
82
-
83
- it("isolates tenants: only the allowed tenant sees its private server", async () => {
84
- const catalog = createMcpServerCatalog(seedRegistry());
85
-
86
- const other = await catalog.listTools({ tenantId: "tenant-other", plan: "enterprise" });
87
- const acme = await catalog.listTools({ tenantId: "tenant-acme", plan: "enterprise" });
88
-
89
- expect(other.map((e) => e.definition.name)).not.toContain("acme_secret");
90
- expect(acme.map((e) => e.definition.name)).toContain("acme_secret");
91
- });
92
-
93
- it("produces a usable ToolDefinition even for a schema-less MCP tool", async () => {
94
- const catalog = createMcpServerCatalog(seedRegistry());
95
- const pro = await catalog.listTools({ tenantId: "tenant-1", plan: "pro" });
96
-
97
- const alerts = pro.find((e) => e.definition.name === "alerts");
98
- expect(alerts).toBeDefined();
99
- // No `parameters` → permissive object schema, never throws on parse.
100
- expect(() => alerts?.definition.inputSchema.parse({})).not.toThrow();
101
- expect(() => alerts?.definition.inputSchema.parse({ anything: 1 })).not.toThrow();
102
- expect(alerts?.server).toBe("weather");
103
- });
104
-
105
- it("derives a Zod schema from MCP `parameters`", async () => {
106
- const catalog = createMcpServerCatalog(seedRegistry());
107
- const free = await catalog.listTools({ tenantId: "tenant-1", plan: "free" });
108
- const forecast = free.find((e) => e.definition.name === "forecast");
109
-
110
- expect(forecast).toBeDefined();
111
- if (!forecast) throw new Error("forecast tool missing");
112
- const schema = forecast.definition.inputSchema as z.ZodType;
113
- // Required `city` enforced.
114
- expect(() => schema.parse({})).toThrow();
115
- expect(schema.parse({ city: "NYC" })).toMatchObject({ city: "NYC" });
116
- expect(schema.parse({ city: "NYC", days: 3 })).toMatchObject({ days: 3 });
117
- });
118
- });
119
-
120
- describe("createMcpClientPort", () => {
121
- it("forwards tenantId into the MCPContext and unwraps the result envelope", async () => {
122
- let seen: MCPContext | undefined;
123
- const fake = {
124
- async executeTool(
125
- _name: string,
126
- _args: Record<string, unknown>,
127
- ctx: MCPContext,
128
- ): Promise<ToolExecutionResult> {
129
- seen = ctx;
130
- return { success: true, result: { value: 42 }, duration: 1 };
131
- },
132
- };
133
-
134
- const port = createMcpClientPort(fake);
135
- const out = await port.executeTool(
136
- "forecast",
137
- { city: "NYC" },
138
- {
139
- requestId: "req-1",
140
- tenantId: "tenant-xyz",
141
- },
142
- );
143
-
144
- expect(out).toEqual({ value: 42 });
145
- expect(seen?.tenantId).toBe("tenant-xyz");
146
- expect(seen?.requestId).toBe("req-1");
147
- });
148
-
149
- it("fails closed when the MCP client reports failure", async () => {
150
- const fake = {
151
- async executeTool(): Promise<ToolExecutionResult> {
152
- return { success: false, error: "Access denied", duration: 1 };
153
- },
154
- };
155
- const port = createMcpClientPort(fake);
156
-
157
- await expect(port.executeTool("x", {}, { requestId: "r", tenantId: "t" })).rejects.toThrow(
158
- /Access denied/,
159
- );
160
- });
161
-
162
- it("rejects an empty tenantId at the client boundary (fail-closed)", async () => {
163
- const fake = {
164
- executeTool: vi.fn(
165
- async (): Promise<ToolExecutionResult> => ({
166
- success: true,
167
- result: null,
168
- duration: 0,
169
- }),
170
- ),
171
- };
172
- const port = createMcpClientPort(fake);
173
-
174
- await expect(port.executeTool("x", {}, { requestId: "r", tenantId: "" })).rejects.toThrow();
175
- expect(fake.executeTool).not.toHaveBeenCalled();
176
- });
177
- });
178
-
179
- describe("composition with agent-runtime activateMcpTools", () => {
180
- it("registers catalog tools into a ToolRegistry and dispatches through the client port", async () => {
181
- const catalog: McpServerCatalogPort = createMcpServerCatalog(seedRegistry());
182
- const calls: { name: string; ctx: MCPContext }[] = [];
183
- const client = createMcpClientPort({
184
- async executeTool(
185
- name: string,
186
- args: Record<string, unknown>,
187
- ctx: MCPContext,
188
- ): Promise<ToolExecutionResult> {
189
- calls.push({ name, ctx });
190
- return { success: true, result: { echoed: args }, duration: 1 };
191
- },
192
- });
193
-
194
- const registry = new ToolRegistry();
195
- const result = await activateMcpTools(registry, catalog, client, {
196
- tenantId: "tenant-1",
197
- plan: "pro",
198
- });
199
-
200
- expect([...result.registered].sort()).toEqual(["alerts", "forecast"]);
201
- expect(result.skipped).toEqual([]);
202
-
203
- const out = await registry.dispatch(
204
- "forecast",
205
- { city: "NYC" },
206
- { tenantId: "tenant-1", threadId: "thread-1" },
207
- );
208
-
209
- expect(out).toEqual({ echoed: { city: "NYC" } });
210
- expect(calls).toHaveLength(1);
211
- expect(calls[0]?.ctx.tenantId).toBe("tenant-1");
212
- });
213
- });
@@ -1,188 +0,0 @@
1
- /**
2
- * MCP catalog adapter — concrete wiring of `@nebutra/mcp` into
3
- * `@nebutra/agent-runtime`'s injectable ports.
4
- *
5
- * `@nebutra/agent-runtime` is intentionally dependency-free: it declares the
6
- * `McpServerCatalogPort` / `McpClientLike` ports and an `activateMcpTools`
7
- * composer, but never imports `@nebutra/mcp`. This module supplies the only
8
- * concrete bridge so apps/backends reuse it instead of re-implementing glue.
9
- *
10
- * Invariants:
11
- * - tenantId is mandatory and validated *before* any registry/client call
12
- * (fail-closed): an empty/blank tenant can never enumerate or execute.
13
- * - Plan/tenant gating is delegated to `MCPServerRegistry` — a tenant only
14
- * ever sees the tools its plan/tenant policy permits.
15
- * - MCP `parameters` are translated into a Zod `inputSchema`; a tool with no
16
- * usable schema still yields a permissive, never-crashing definition.
17
- * - The MCP `ToolExecutionResult` envelope is unwrapped: a non-success
18
- * result throws (fail-closed) rather than leaking a falsy value upstream.
19
- *
20
- * Both factories accept an injected dependency (registry / client) and default
21
- * to the `@nebutra/mcp` singletons, keeping the module free of hidden globals
22
- * and fully unit-testable.
23
- */
24
-
25
- import type { MCPContext, MCPServerRegistry, ToolExecutionResult } from "@nebutra/mcp";
26
- import { mcpClient, serverRegistry } from "@nebutra/mcp";
27
- import { z } from "zod";
28
- import type { McpServerCatalogPort } from "../mcp-bridge";
29
- import type { McpClientLike, ToolDefinition } from "../tools";
30
-
31
- /** Narrow structural shapes — we depend on behaviour, not concrete classes. */
32
-
33
- /** The slice of `MCPServerRegistry` the catalog port needs. */
34
- export type McpRegistryLike = Pick<MCPServerRegistry, "getAccessibleTools">;
35
-
36
- /** The slice of `mcpClient` the client port needs. */
37
- export interface McpExecutorLike {
38
- executeTool(
39
- name: string,
40
- args: Record<string, unknown>,
41
- ctx: MCPContext,
42
- ): Promise<ToolExecutionResult>;
43
- }
44
-
45
- type McpParameter = {
46
- type?: "string" | "number" | "boolean" | "object" | "array";
47
- description?: string;
48
- required?: boolean;
49
- default?: unknown;
50
- };
51
-
52
- type McpToolShape = {
53
- name: string;
54
- description?: string;
55
- parameters?: Record<string, McpParameter> | undefined;
56
- serverId: string;
57
- };
58
-
59
- /** Fail-closed tenant guard — the single chokepoint for both ports. */
60
- const tenantSchema = z
61
- .string({ message: "tenantId is required (fail-closed)" })
62
- .trim()
63
- .min(1, "tenantId is required (fail-closed)");
64
-
65
- const catalogCtxSchema = z.object({
66
- tenantId: tenantSchema,
67
- plan: z.string().min(1).optional(),
68
- });
69
-
70
- const clientCtxSchema = z.object({
71
- requestId: z.string().min(1, "requestId is required"),
72
- tenantId: tenantSchema,
73
- });
74
-
75
- /** Map one MCP parameter to its Zod leaf type. Unknown → permissive. */
76
- function paramToZod(param: McpParameter): z.ZodTypeAny {
77
- switch (param.type) {
78
- case "string":
79
- return z.string();
80
- case "number":
81
- return z.number();
82
- case "boolean":
83
- return z.boolean();
84
- case "array":
85
- return z.array(z.unknown());
86
- case "object":
87
- return z.record(z.string(), z.unknown());
88
- default:
89
- return z.unknown();
90
- }
91
- }
92
-
93
- /**
94
- * Build a Zod `inputSchema` from an MCP tool's `parameters`. A tool with no
95
- * declared parameters becomes a permissive passthrough object (never throws on
96
- * unknown keys) so schema-less MCP tools remain dispatchable.
97
- */
98
- function toInputSchema(parameters: Record<string, McpParameter> | undefined): z.ZodType {
99
- const entries = parameters ? Object.entries(parameters) : [];
100
- if (entries.length === 0) {
101
- return z.record(z.string(), z.unknown());
102
- }
103
-
104
- const shape: Record<string, z.ZodTypeAny> = {};
105
- for (const [key, param] of entries) {
106
- let leaf = paramToZod(param);
107
- if (param.required !== true) {
108
- leaf = leaf.optional();
109
- }
110
- shape[key] = leaf;
111
- }
112
- // `passthrough` keeps unknown keys: MCP schemas are advisory, not strict.
113
- return z.object(shape).passthrough();
114
- }
115
-
116
- /** Adapt one registry tool entry into an agent-runtime `ToolDefinition`. */
117
- function toToolDefinition(tool: McpToolShape): ToolDefinition {
118
- return {
119
- name: tool.name,
120
- description: tool.description ?? tool.name,
121
- inputSchema: toInputSchema(tool.parameters),
122
- };
123
- }
124
-
125
- /**
126
- * Adapt `MCPServerRegistry`'s plan/tenant-gated tool listing into the
127
- * agent-runtime `McpServerCatalogPort`.
128
- *
129
- * @param registry Defaults to `@nebutra/mcp`'s `serverRegistry` singleton;
130
- * inject a fresh `MCPServerRegistry` for tests/multi-tenant
131
- * isolation.
132
- */
133
- export function createMcpServerCatalog(
134
- registry: McpRegistryLike = serverRegistry,
135
- ): McpServerCatalogPort {
136
- return {
137
- async listTools(ctx) {
138
- const scope = catalogCtxSchema.parse(ctx); // fail-closed first
139
-
140
- const mcpCtx: MCPContext = {
141
- requestId: "catalog-list",
142
- tenantId: scope.tenantId,
143
- ...(scope.plan === undefined ? {} : { plan: scope.plan }),
144
- };
145
-
146
- const tools = registry.getAccessibleTools(mcpCtx) as McpToolShape[];
147
-
148
- return tools.map((tool) => ({
149
- server: tool.serverId,
150
- definition: toToolDefinition(tool),
151
- }));
152
- },
153
- };
154
- }
155
-
156
- /**
157
- * Adapt `@nebutra/mcp`'s `mcpClient` into agent-runtime's `McpClientLike`.
158
- *
159
- * Maps `{ requestId, tenantId }` → the richer `MCPContext`, and unwraps the
160
- * `ToolExecutionResult` envelope: a non-success result throws so a failed MCP
161
- * call never silently surfaces as a falsy tool output.
162
- *
163
- * @param client Defaults to `@nebutra/mcp`'s `mcpClient` singleton;
164
- * inject a fake for tests.
165
- */
166
- export function createMcpClientPort(client: McpExecutorLike = mcpClient): McpClientLike {
167
- return {
168
- async executeTool(name, args, ctx) {
169
- const scope = clientCtxSchema.parse(ctx); // fail-closed first
170
-
171
- const mcpCtx: MCPContext = {
172
- requestId: scope.requestId,
173
- tenantId: scope.tenantId,
174
- };
175
-
176
- const result = await client.executeTool(
177
- name,
178
- (args ?? {}) as Record<string, unknown>,
179
- mcpCtx,
180
- );
181
-
182
- if (!result.success) {
183
- throw new Error(`MCP tool "${name}" failed: ${result.error ?? "unknown error"}`);
184
- }
185
- return result.result;
186
- },
187
- };
188
- }