@nebutra/agent-runtime 0.2.1 → 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 (101) hide show
  1. package/LICENSE +21 -676
  2. package/dist/adapters/index.d.ts +24 -5
  3. package/dist/adapters/index.js +27 -0
  4. package/dist/adapters/index.js.map +1 -1
  5. package/dist/chunk-Q62VKHIT.js +178 -0
  6. package/dist/chunk-Q62VKHIT.js.map +1 -0
  7. package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
  8. package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +14 -17
  11. package/dist/index.js.map +1 -1
  12. package/dist/orchestration.d.ts +42 -1
  13. package/dist/orchestration.js +7 -3
  14. package/dist/sandbox.js +1 -1
  15. package/package.json +80 -30
  16. package/.turbo/turbo-build.log +0 -130
  17. package/.turbo/turbo-test.log +0 -47
  18. package/.turbo/turbo-typecheck.log +0 -4
  19. package/CHANGELOG.md +0 -250
  20. package/dist/chunk-MX2WL43P.js +0 -90
  21. package/dist/chunk-MX2WL43P.js.map +0 -1
  22. package/examples/pulsar-quickstart.ts +0 -35
  23. package/examples/resume-branch.ts +0 -45
  24. package/examples/subagent-fanout.ts +0 -20
  25. package/src/adapters/dispatcher-sse.test.ts +0 -218
  26. package/src/adapters/dispatcher-sse.ts +0 -222
  27. package/src/adapters/index.ts +0 -18
  28. package/src/adapters/mcp-catalog.test.ts +0 -213
  29. package/src/adapters/mcp-catalog.ts +0 -188
  30. package/src/adapters/prisma-rollout.test.ts +0 -153
  31. package/src/adapters/prisma-rollout.ts +0 -104
  32. package/src/agent-runtime.test.ts +0 -176
  33. package/src/artifact-stream.test.ts +0 -330
  34. package/src/artifact-stream.ts +0 -453
  35. package/src/channel-gateway.test.ts +0 -432
  36. package/src/channel-gateway.ts +0 -357
  37. package/src/cli.ts +0 -49
  38. package/src/code-review.test.ts +0 -501
  39. package/src/code-review.ts +0 -495
  40. package/src/command-suggestions.test.ts +0 -251
  41. package/src/command-suggestions.ts +0 -338
  42. package/src/commands.test.ts +0 -184
  43. package/src/commands.ts +0 -140
  44. package/src/commit-message.test.ts +0 -249
  45. package/src/commit-message.ts +0 -180
  46. package/src/context-compaction.test.ts +0 -522
  47. package/src/context-compaction.ts +0 -438
  48. package/src/definitions.test.ts +0 -78
  49. package/src/definitions.ts +0 -190
  50. package/src/deployment-status.test.ts +0 -215
  51. package/src/deployment-status.ts +0 -227
  52. package/src/design-context.test.ts +0 -195
  53. package/src/design-context.ts +0 -198
  54. package/src/dispatcher.test.ts +0 -234
  55. package/src/dispatcher.ts +0 -189
  56. package/src/durable-turn.test.ts +0 -209
  57. package/src/durable-turn.ts +0 -135
  58. package/src/edit-planner.test.ts +0 -204
  59. package/src/edit-planner.ts +0 -325
  60. package/src/fuzzy-match.test.ts +0 -311
  61. package/src/fuzzy-match.ts +0 -444
  62. package/src/hook-pipeline.test.ts +0 -279
  63. package/src/hook-pipeline.ts +0 -373
  64. package/src/inbound-admission.test.ts +0 -394
  65. package/src/inbound-admission.ts +0 -246
  66. package/src/index.ts +0 -49
  67. package/src/loop.test.ts +0 -161
  68. package/src/loop.ts +0 -211
  69. package/src/mcp-bridge.test.ts +0 -165
  70. package/src/mcp-bridge.ts +0 -81
  71. package/src/memory-provider.test.ts +0 -232
  72. package/src/memory-provider.ts +0 -257
  73. package/src/model.ts +0 -168
  74. package/src/orchestration.test.ts +0 -53
  75. package/src/orchestration.ts +0 -146
  76. package/src/permission-ruleset.test.ts +0 -301
  77. package/src/permission-ruleset.ts +0 -1
  78. package/src/policy.ts +0 -151
  79. package/src/project-repo.test.ts +0 -232
  80. package/src/project-repo.ts +0 -311
  81. package/src/protocol.ts +0 -159
  82. package/src/pulsar.test.ts +0 -156
  83. package/src/pulsar.ts +0 -322
  84. package/src/rollout-store-persistent.test.ts +0 -217
  85. package/src/rollout-store-persistent.ts +0 -166
  86. package/src/rollout.ts +0 -150
  87. package/src/sandbox.ts +0 -113
  88. package/src/session-share.test.ts +0 -360
  89. package/src/session-share.ts +0 -310
  90. package/src/skill-distillation.test.ts +0 -177
  91. package/src/skill-distillation.ts +0 -369
  92. package/src/skills.test.ts +0 -277
  93. package/src/skills.ts +0 -277
  94. package/src/subagents.test.ts +0 -290
  95. package/src/subagents.ts +0 -332
  96. package/src/tools.test.ts +0 -12
  97. package/src/tools.ts +0 -129
  98. package/src/workbench.test.ts +0 -0
  99. package/src/workbench.ts +0 -0
  100. package/tsconfig.json +0 -12
  101. package/tsup.config.ts +0 -36
package/src/loop.test.ts DELETED
@@ -1,161 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { z } from "zod";
3
- import { type ApprovalGate, type ModelInvoker, type ModelRoundResult, runTurn } from "./loop";
4
- import type { ThreadEvent, TurnConfig } from "./model";
5
- import type { ReviewDecision } from "./policy";
6
- import { InMemoryRolloutStore } from "./rollout";
7
- import { ToolRegistry } from "./tools";
8
-
9
- const config: TurnConfig = {
10
- model: "m",
11
- provider: "p",
12
- approvalPolicy: "on_request",
13
- capabilityPolicy: "external_sandbox",
14
- };
15
-
16
- const approveAll: ApprovalGate = {
17
- async request() {
18
- return { kind: "approved" } as ReviewDecision;
19
- },
20
- };
21
- const denyAll: ApprovalGate = {
22
- async request() {
23
- return { kind: "denied" } as ReviewDecision;
24
- },
25
- };
26
-
27
- /** Model that calls `echo` once, then finishes with text. */
28
- function scriptedModel(): ModelInvoker {
29
- let round = 0;
30
- return {
31
- async invoke(): Promise<ModelRoundResult> {
32
- round += 1;
33
- if (round === 1) {
34
- return {
35
- emissions: [{ kind: "tool_call", id: "tc_1", name: "echo", args: { v: "hi" } }],
36
- usage: { outputTokens: 5 },
37
- };
38
- }
39
- return { emissions: [{ kind: "text", text: "done" }] };
40
- },
41
- };
42
- }
43
-
44
- function registry(): ToolRegistry {
45
- const reg = new ToolRegistry();
46
- reg.register(
47
- { name: "echo", description: "echo", inputSchema: z.object({ v: z.string() }) },
48
- async (input: { v: string }, ctx) => `${ctx.tenantId}:${input.v}`,
49
- );
50
- return reg;
51
- }
52
-
53
- async function collect(gen: AsyncGenerator<ThreadEvent>): Promise<ThreadEvent[]> {
54
- const out: ThreadEvent[] = [];
55
- for await (const e of gen) out.push(e);
56
- return out;
57
- }
58
-
59
- describe("loop runner", () => {
60
- it("drives model → tool → result → completion and persists the rollout", async () => {
61
- const store = new InMemoryRolloutStore();
62
- const events = await collect(
63
- runTurn("do it", {
64
- tenantId: "org_a",
65
- threadId: "th_1",
66
- config,
67
- approvalPolicy: { kind: "on_request" },
68
- model: scriptedModel(),
69
- tools: registry(),
70
- store,
71
- approvalGate: approveAll,
72
- ruleEvaluator: () => "allow",
73
- }),
74
- );
75
- const types = events.map((e) => e.type);
76
- expect(types[0]).toBe("turn.started");
77
- expect(types).toContain("item.completed");
78
- expect(types[types.length - 1]).toBe("turn.completed");
79
-
80
- const lines = await store.read("org_a", "th_1");
81
- expect(lines.length).toBe(events.length); // every event persisted, tenant-scoped
82
- expect(lines.every((l) => l.tenantId === "org_a")).toBe(true);
83
- });
84
-
85
- it("fails closed when a tool is not approved — never dispatches it", async () => {
86
- let dispatched = false;
87
- const reg = new ToolRegistry();
88
- reg.register(
89
- { name: "echo", description: "echo", inputSchema: z.object({ v: z.string() }) },
90
- async () => {
91
- dispatched = true;
92
- return "ran";
93
- },
94
- );
95
- const events = await collect(
96
- runTurn("do it", {
97
- tenantId: "org_a",
98
- threadId: "th_1",
99
- config,
100
- approvalPolicy: { kind: "on_request" },
101
- model: scriptedModel(),
102
- tools: reg,
103
- store: new InMemoryRolloutStore(),
104
- approvalGate: denyAll,
105
- ruleEvaluator: () => "prompt",
106
- }),
107
- );
108
- expect(dispatched).toBe(false);
109
- expect(events.some((e) => e.type === "item.completed" && e.item.type === "error")).toBe(true);
110
- expect(events[events.length - 1]?.type).toBe("turn.completed");
111
- });
112
-
113
- it("surfaces an internal failure as turn.failed, never throws to caller", async () => {
114
- const brokenModel: ModelInvoker = {
115
- async invoke() {
116
- throw new Error("model exploded");
117
- },
118
- };
119
- const events = await collect(
120
- runTurn("x", {
121
- tenantId: "t",
122
- threadId: "th",
123
- config,
124
- approvalPolicy: { kind: "on_request" },
125
- model: brokenModel,
126
- tools: new ToolRegistry(),
127
- store: new InMemoryRolloutStore(),
128
- approvalGate: approveAll,
129
- }),
130
- );
131
- expect(events[events.length - 1]).toEqual({
132
- type: "turn.failed",
133
- error: { message: "model exploded" },
134
- });
135
- });
136
-
137
- it("respects the bounded step ceiling", async () => {
138
- const loopingModel: ModelInvoker = {
139
- async invoke() {
140
- return { emissions: [{ kind: "tool_call", id: "x", name: "echo", args: { v: "1" } }] };
141
- },
142
- };
143
- const events = await collect(
144
- runTurn("x", {
145
- tenantId: "t",
146
- threadId: "th",
147
- config,
148
- approvalPolicy: { kind: "on_request" },
149
- model: loopingModel,
150
- tools: registry(),
151
- store: new InMemoryRolloutStore(),
152
- approvalGate: approveAll,
153
- ruleEvaluator: () => "allow",
154
- maxSteps: 3,
155
- }),
156
- );
157
- // 3 steps × 1 tool item + turn.started + turn.completed
158
- expect(events.filter((e) => e.type === "item.completed")).toHaveLength(3);
159
- expect(events[events.length - 1]?.type).toBe("turn.completed");
160
- });
161
- });
package/src/loop.ts DELETED
@@ -1,211 +0,0 @@
1
- /**
2
- * Agent loop runner (WRAP — the turn engine).
3
- *
4
- * Faithful re-expression of the upstream loop: a turn is
5
- * `loop { model_call → emit items → execute tools → feed results back }`
6
- * until the model stops requesting tools or a bounded step ceiling is hit.
7
- * Single-threaded (Cognition teaching: shared context, no conflicting
8
- * sub-agent decisions). Every item is appended to the tenant-scoped rollout
9
- * as it reaches a terminal state, so the turn is resumable by replay.
10
- *
11
- * The model call is abstracted behind {@link ModelInvoker} so this WRAPs an
12
- * existing model stack (e.g. `@nebutra/agents`) rather than re-porting
13
- * provider/routing/fallback. No untrusted code runs here — command items are
14
- * dispatched through the tool registry / external-sandbox seam.
15
- */
16
-
17
- import type { AgentMessageItem, ThreadEvent, ThreadItem, TurnConfig, TurnUsage } from "./model";
18
- import {
19
- type ApprovalPolicy,
20
- DENIED,
21
- isApproval,
22
- type ReviewDecision,
23
- type RuleDecision,
24
- resolveRuleDecision,
25
- } from "./policy";
26
- import type { ServerRequest } from "./protocol";
27
- import { type RolloutLine, type RolloutStore, sanitizeForPersist } from "./rollout";
28
- import type { RuntimeToolRegistry, ToolDispatchContext } from "./tools";
29
-
30
- /** A single thing the model emitted in one round. */
31
- export type ModelEmission =
32
- | { readonly kind: "text"; readonly text: string }
33
- | {
34
- readonly kind: "tool_call";
35
- readonly id: string;
36
- readonly name: string;
37
- readonly args: unknown;
38
- };
39
-
40
- export interface ModelRoundResult {
41
- readonly emissions: readonly ModelEmission[];
42
- readonly usage?: Partial<TurnUsage>;
43
- }
44
-
45
- export interface ModelRoundRequest {
46
- readonly config: TurnConfig;
47
- /** Running transcript: user input, agent text, and tool results. */
48
- readonly history: readonly { readonly role: string; readonly content: string }[];
49
- readonly toolNames: readonly string[];
50
- }
51
-
52
- /** Abstracts the model stack — implement over `@nebutra/agents`, etc. */
53
- export interface ModelInvoker {
54
- invoke(request: ModelRoundRequest): Promise<ModelRoundResult>;
55
- }
56
-
57
- /** Server-initiated approval transport (see {@link ServerRequest}). */
58
- export interface ApprovalGate {
59
- request(serverRequest: ServerRequest): Promise<ReviewDecision>;
60
- }
61
-
62
- /** Classifies a tool call into a static rule decision before approval. */
63
- export type RuleEvaluator = (toolName: string, args: unknown) => RuleDecision;
64
-
65
- export interface RunTurnDeps {
66
- readonly tenantId: string;
67
- readonly threadId: string;
68
- readonly config: TurnConfig;
69
- readonly approvalPolicy: ApprovalPolicy;
70
- readonly model: ModelInvoker;
71
- readonly tools: RuntimeToolRegistry;
72
- readonly store: RolloutStore;
73
- readonly approvalGate: ApprovalGate;
74
- /** Defaults to: everything requires a prompt (safe). */
75
- readonly ruleEvaluator?: RuleEvaluator;
76
- /** Bounded steps (parity with upstream step ceiling). Default 20. */
77
- readonly maxSteps?: number;
78
- }
79
-
80
- const DEFAULT_MAX_STEPS = 20;
81
- const requirePrompt: RuleEvaluator = () => "prompt";
82
-
83
- function newId(prefix: string): string {
84
- return `${prefix}_${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`;
85
- }
86
-
87
- /**
88
- * Drive one turn to completion. Yields the {@link ThreadEvent} stream and
89
- * appends each terminal item + the turn outcome to the rollout store.
90
- * Never throws to the caller — failures surface as a `turn.failed` event.
91
- */
92
- export async function* runTurn(userInput: string, deps: RunTurnDeps): AsyncGenerator<ThreadEvent> {
93
- const at = () => new Date().toISOString();
94
- const ruleEvaluator = deps.ruleEvaluator ?? requirePrompt;
95
- const maxSteps = deps.maxSteps ?? DEFAULT_MAX_STEPS;
96
- const ctx: ToolDispatchContext = { tenantId: deps.tenantId, threadId: deps.threadId };
97
-
98
- const append = async (line: RolloutLine): Promise<void> => deps.store.append(line);
99
- const event = async (e: ThreadEvent): Promise<ThreadEvent> => {
100
- await append({
101
- tenantId: deps.tenantId,
102
- threadId: deps.threadId,
103
- type: "event",
104
- event:
105
- e.type === "item.completed"
106
- ? { type: "item.completed", item: sanitizeForPersist(e.item) }
107
- : e,
108
- at: at(),
109
- });
110
- return e;
111
- };
112
-
113
- yield await event({ type: "turn.started" });
114
-
115
- const history: { role: string; content: string }[] = [{ role: "user", content: userInput }];
116
- let usage: TurnUsage = {
117
- inputTokens: 0,
118
- cachedInputTokens: 0,
119
- outputTokens: 0,
120
- reasoningOutputTokens: 0,
121
- };
122
-
123
- try {
124
- for (let step = 0; step < maxSteps; step++) {
125
- const round = await deps.model.invoke({
126
- config: deps.config,
127
- history,
128
- toolNames: deps.tools.list().map((t) => t.definition.name),
129
- });
130
- if (round.usage) {
131
- const u = round.usage;
132
- usage = {
133
- inputTokens: usage.inputTokens + (u.inputTokens ?? 0),
134
- cachedInputTokens: usage.cachedInputTokens + (u.cachedInputTokens ?? 0),
135
- outputTokens: usage.outputTokens + (u.outputTokens ?? 0),
136
- reasoningOutputTokens: usage.reasoningOutputTokens + (u.reasoningOutputTokens ?? 0),
137
- };
138
- }
139
-
140
- const toolCalls = round.emissions.filter(
141
- (e): e is Extract<ModelEmission, { kind: "tool_call" }> => e.kind === "tool_call",
142
- );
143
-
144
- for (const e of round.emissions) {
145
- if (e.kind !== "text") continue;
146
- const item: AgentMessageItem = { id: newId("msg"), type: "agent_message", text: e.text };
147
- history.push({ role: "assistant", content: e.text });
148
- yield await event({ type: "item.completed", item });
149
- }
150
-
151
- if (toolCalls.length === 0) break; // model is done
152
-
153
- for (const call of toolCalls) {
154
- const decision = await gateToolCall(call.name, call.args, deps, ruleEvaluator);
155
- if (!isApproval(decision)) {
156
- const failed: ThreadItem = {
157
- id: call.id,
158
- type: "error",
159
- message: `tool '${call.name}' not approved (${decision.kind})`,
160
- };
161
- history.push({ role: "tool", content: `DENIED: ${call.name}` });
162
- yield await event({ type: "item.completed", item: failed });
163
- continue;
164
- }
165
- try {
166
- const output = await deps.tools.dispatch(call.name, call.args, ctx);
167
- const item: ThreadItem = {
168
- id: call.id,
169
- type: "mcp_tool_call",
170
- server: "native",
171
- tool: call.name,
172
- arguments: call.args,
173
- result: { content: output },
174
- status: "completed",
175
- };
176
- history.push({ role: "tool", content: JSON.stringify(output) });
177
- yield await event({ type: "item.completed", item });
178
- } catch (err) {
179
- const message = err instanceof Error ? err.message : String(err);
180
- history.push({ role: "tool", content: `ERROR: ${message}` });
181
- yield await event({
182
- type: "item.completed",
183
- item: { id: call.id, type: "error", message },
184
- });
185
- }
186
- }
187
- }
188
-
189
- yield await event({ type: "turn.completed", usage });
190
- } catch (err) {
191
- const message = err instanceof Error ? err.message : String(err);
192
- yield await event({ type: "turn.failed", error: { message } });
193
- }
194
- }
195
-
196
- /** Resolve a tool call's approval, raising a server-initiated request if asked. */
197
- async function gateToolCall(
198
- name: string,
199
- args: unknown,
200
- deps: RunTurnDeps,
201
- ruleEvaluator: RuleEvaluator,
202
- ): Promise<ReviewDecision> {
203
- const outcome = resolveRuleDecision(ruleEvaluator(name, args), deps.approvalPolicy);
204
- if (outcome === "auto_allow") return { kind: "approved" };
205
- if (outcome === "auto_reject") return DENIED;
206
- return deps.approvalGate.request({
207
- type: "permissions.request_approval",
208
- requestId: newId("appr"),
209
- summary: `tool '${name}'`,
210
- });
211
- }
@@ -1,165 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { z } from "zod";
3
-
4
- import { activateMcpTools, type McpServerCatalogPort } from "./mcp-bridge";
5
- import { type McpClientLike, type ToolDefinition, ToolRegistry } from "./tools";
6
-
7
- function def(name: string): ToolDefinition {
8
- return {
9
- name,
10
- description: `tool ${name}`,
11
- inputSchema: z.object({ q: z.string() }),
12
- };
13
- }
14
-
15
- /** Catalog fake: tenant- and plan-scoped tool listings. */
16
- function fakeCatalog(
17
- byTenant: Record<
18
- string,
19
- {
20
- free: { server: string; definition: ToolDefinition }[];
21
- pro: { server: string; definition: ToolDefinition }[];
22
- }
23
- >,
24
- ): McpServerCatalogPort {
25
- return {
26
- async listTools(ctx) {
27
- const t = byTenant[ctx.tenantId];
28
- if (!t) return [];
29
- return ctx.plan === "pro" ? t.pro : t.free;
30
- },
31
- };
32
- }
33
-
34
- function fakeClient(): McpClientLike & {
35
- calls: { name: string; args: unknown; tenantId: string }[];
36
- } {
37
- const calls: { name: string; args: unknown; tenantId: string }[] = [];
38
- return {
39
- calls,
40
- async executeTool(name, args, ctx) {
41
- calls.push({ name, args, tenantId: ctx.tenantId });
42
- return { ok: true, name };
43
- },
44
- };
45
- }
46
-
47
- describe("activateMcpTools", () => {
48
- it("registers tenant-scoped tools dispatchable through the registry with the right tenantId", async () => {
49
- const registry = new ToolRegistry();
50
- const catalog = fakeCatalog({
51
- org_a: {
52
- free: [{ server: "weather", definition: def("get_weather") }],
53
- pro: [],
54
- },
55
- });
56
- const client = fakeClient();
57
-
58
- const res = await activateMcpTools(registry, catalog, client, { tenantId: "org_a" });
59
-
60
- expect(res.registered).toEqual(["get_weather"]);
61
- expect(res.skipped).toEqual([]);
62
- expect(registry.list().map((r) => r.definition.name)).toEqual(["get_weather"]);
63
-
64
- const out = await registry.dispatch(
65
- "get_weather",
66
- { q: "NYC" },
67
- {
68
- tenantId: "org_a",
69
- threadId: "t1",
70
- },
71
- );
72
- expect(out).toEqual({ ok: true, name: "get_weather" });
73
- expect(client.calls).toHaveLength(1);
74
- expect(client.calls[0]).toMatchObject({
75
- name: "get_weather",
76
- tenantId: "org_a",
77
- args: { q: "NYC" },
78
- });
79
- });
80
-
81
- it("gates by plan — low-plan tenant gets fewer tools", async () => {
82
- const catalog = fakeCatalog({
83
- org_a: {
84
- free: [{ server: "s", definition: def("basic") }],
85
- pro: [
86
- { server: "s", definition: def("basic") },
87
- { server: "s", definition: def("premium") },
88
- ],
89
- },
90
- });
91
- const client = fakeClient();
92
-
93
- const freeReg = new ToolRegistry();
94
- const free = await activateMcpTools(freeReg, catalog, client, {
95
- tenantId: "org_a",
96
- plan: "free",
97
- });
98
- expect(free.registered).toEqual(["basic"]);
99
-
100
- const proReg = new ToolRegistry();
101
- const pro = await activateMcpTools(proReg, catalog, client, { tenantId: "org_a", plan: "pro" });
102
- expect(pro.registered).toEqual(["basic", "premium"]);
103
- });
104
-
105
- it("skips a duplicate-name tool instead of throwing", async () => {
106
- const registry = new ToolRegistry();
107
- registry.register(def("get_weather"), async () => ({ native: true }));
108
-
109
- const catalog = fakeCatalog({
110
- org_a: {
111
- free: [
112
- { server: "weather", definition: def("get_weather") },
113
- { server: "weather", definition: def("get_forecast") },
114
- ],
115
- pro: [],
116
- },
117
- });
118
-
119
- const res = await activateMcpTools(registry, catalog, fakeClient(), { tenantId: "org_a" });
120
-
121
- expect(res.registered).toEqual(["get_forecast"]);
122
- expect(res.skipped).toEqual(["get_weather"]);
123
- expect(registry.list()).toHaveLength(2);
124
- });
125
-
126
- it("fails closed on empty tenantId before touching the catalog", async () => {
127
- const catalog = fakeCatalog({});
128
- const spy = vi.spyOn(catalog, "listTools");
129
- await expect(
130
- activateMcpTools(new ToolRegistry(), catalog, fakeClient(), { tenantId: "" }),
131
- ).rejects.toThrow(/tenantId/i);
132
- expect(spy).not.toHaveBeenCalled();
133
- });
134
-
135
- it("fails closed on whitespace-only tenantId", async () => {
136
- await expect(
137
- activateMcpTools(new ToolRegistry(), fakeCatalog({}), fakeClient(), { tenantId: " " }),
138
- ).rejects.toThrow(/tenantId/i);
139
- });
140
-
141
- it("isolates tenants — tenant A never sees tenant B's catalog", async () => {
142
- const catalog = fakeCatalog({
143
- org_a: { free: [{ server: "s", definition: def("a_tool") }], pro: [] },
144
- org_b: { free: [{ server: "s", definition: def("b_tool") }], pro: [] },
145
- });
146
- const client = fakeClient();
147
-
148
- const regA = new ToolRegistry();
149
- const a = await activateMcpTools(regA, catalog, client, { tenantId: "org_a" });
150
- expect(a.registered).toEqual(["a_tool"]);
151
- expect(regA.list().map((r) => r.definition.name)).not.toContain("b_tool");
152
-
153
- await regA.dispatch("a_tool", { q: "x" }, { tenantId: "org_a", threadId: "t1" });
154
- expect(client.calls.every((c) => c.tenantId === "org_a")).toBe(true);
155
- });
156
-
157
- it("returns mcp origin so adapted tools are provenance-tagged", async () => {
158
- const registry = new ToolRegistry();
159
- const catalog = fakeCatalog({
160
- org_a: { free: [{ server: "weather", definition: def("get_weather") }], pro: [] },
161
- });
162
- await activateMcpTools(registry, catalog, fakeClient(), { tenantId: "org_a" });
163
- expect(registry.list()[0]?.origin).toEqual({ kind: "mcp", server: "weather" });
164
- });
165
- });
package/src/mcp-bridge.ts DELETED
@@ -1,81 +0,0 @@
1
- /**
2
- * MCP activation bridge (WRAP — capability #9, activation seam).
3
- *
4
- * Concrete bridge that registers external MCP-server tools into the runtime's
5
- * uniform tool model. It does NOT import `@nebutra/mcp` (WIP / do-not-import):
6
- * instead it defines minimal injectable ports so callers wire `@nebutra/mcp`
7
- * (`serverRegistry` + `mcpClient`) without this package taking a hard dep.
8
- *
9
- * Tenant-scoped by construction: the catalog port is always queried with the
10
- * caller's `{ tenantId, plan }`, so a tenant only ever sees its own MCP
11
- * servers/tools. Fail-closed on missing tenant.
12
- */
13
-
14
- import { z } from "zod";
15
-
16
- import {
17
- adaptMcpTool,
18
- type McpClientLike,
19
- type RuntimeToolRegistry,
20
- type ToolDefinition,
21
- } from "./tools";
22
-
23
- /**
24
- * Port over an MCP server catalog (satisfied by `@nebutra/mcp`'s
25
- * `serverRegistry` + plan middleware). Returns only the tools visible to the
26
- * given tenant/plan — visibility/plan-gating is the port's responsibility.
27
- */
28
- export interface McpServerCatalogPort {
29
- listTools(ctx: {
30
- tenantId: string;
31
- plan?: string;
32
- }): Promise<{ server: string; definition: ToolDefinition }[]>;
33
- }
34
-
35
- const ctxSchema = z.object({
36
- tenantId: z.string().trim().min(1, "tenantId is required (fail-closed)"),
37
- plan: z.string().min(1).optional(),
38
- });
39
-
40
- export interface ActivateMcpToolsResult {
41
- /** Tool names newly registered into the registry, in catalog order. */
42
- readonly registered: readonly string[];
43
- /** Tool names skipped because the name was already registered. */
44
- readonly skipped: readonly string[];
45
- }
46
-
47
- /**
48
- * List the tenant/plan-visible MCP tools, adapt each via {@link adaptMcpTool},
49
- * and register them into the {@link RuntimeToolRegistry}. A tool whose name is already
50
- * registered is skipped (reported, never thrown). Empty/blank tenantId fails
51
- * closed before any catalog call.
52
- */
53
- export async function activateMcpTools(
54
- registry: RuntimeToolRegistry,
55
- catalog: McpServerCatalogPort,
56
- client: McpClientLike,
57
- ctx: { tenantId: string; plan?: string },
58
- ): Promise<ActivateMcpToolsResult> {
59
- const scope = ctxSchema.parse(ctx);
60
-
61
- const entries = await catalog.listTools(
62
- scope.plan === undefined
63
- ? { tenantId: scope.tenantId }
64
- : { tenantId: scope.tenantId, plan: scope.plan },
65
- );
66
-
67
- const registered: string[] = [];
68
- const skipped: string[] = [];
69
-
70
- for (const { server, definition } of entries) {
71
- if (registry.list().some((r) => r.definition.name === definition.name)) {
72
- skipped.push(definition.name);
73
- continue;
74
- }
75
- const adapted = adaptMcpTool(server, definition, client);
76
- registry.register(adapted.definition, adapted.handler, adapted.origin);
77
- registered.push(definition.name);
78
- }
79
-
80
- return { registered, skipped };
81
- }