@nebutra/agent-runtime 0.2.1 → 2.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 (114) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +33 -10
  3. package/dist/adapters/index.d.ts +24 -5
  4. package/dist/adapters/index.js +27 -0
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/{chunk-KCNN4QUQ.js → chunk-5UGIUYJR.js} +4 -4
  7. package/dist/chunk-7ELA4SWE.js +73 -0
  8. package/dist/chunk-7ELA4SWE.js.map +1 -0
  9. package/dist/chunk-Q62VKHIT.js +178 -0
  10. package/dist/chunk-Q62VKHIT.js.map +1 -0
  11. package/dist/chunk-VXILZAXK.js +541 -0
  12. package/dist/chunk-VXILZAXK.js.map +1 -0
  13. package/dist/command-exec.d.ts +45 -0
  14. package/dist/command-exec.js +15 -0
  15. package/dist/command-exec.js.map +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +81 -28
  18. package/dist/index.js.map +1 -1
  19. package/dist/orchestration.d.ts +42 -1
  20. package/dist/orchestration.js +7 -3
  21. package/dist/pulsar.js +2 -2
  22. package/dist/sandbox-DfcRttXt.d.ts +219 -0
  23. package/dist/sandbox.d.ts +2 -64
  24. package/dist/sandbox.js +25 -3
  25. package/package.json +81 -30
  26. package/.turbo/turbo-build.log +0 -130
  27. package/.turbo/turbo-test.log +0 -47
  28. package/.turbo/turbo-typecheck.log +0 -4
  29. package/CHANGELOG.md +0 -250
  30. package/dist/chunk-MUF7ZZTO.js +0 -57
  31. package/dist/chunk-MUF7ZZTO.js.map +0 -1
  32. package/dist/chunk-MX2WL43P.js +0 -90
  33. package/dist/chunk-MX2WL43P.js.map +0 -1
  34. package/examples/pulsar-quickstart.ts +0 -35
  35. package/examples/resume-branch.ts +0 -45
  36. package/examples/subagent-fanout.ts +0 -20
  37. package/src/adapters/dispatcher-sse.test.ts +0 -218
  38. package/src/adapters/dispatcher-sse.ts +0 -222
  39. package/src/adapters/index.ts +0 -18
  40. package/src/adapters/mcp-catalog.test.ts +0 -213
  41. package/src/adapters/mcp-catalog.ts +0 -188
  42. package/src/adapters/prisma-rollout.test.ts +0 -153
  43. package/src/adapters/prisma-rollout.ts +0 -104
  44. package/src/agent-runtime.test.ts +0 -176
  45. package/src/artifact-stream.test.ts +0 -330
  46. package/src/artifact-stream.ts +0 -453
  47. package/src/channel-gateway.test.ts +0 -432
  48. package/src/channel-gateway.ts +0 -357
  49. package/src/cli.ts +0 -49
  50. package/src/code-review.test.ts +0 -501
  51. package/src/code-review.ts +0 -495
  52. package/src/command-suggestions.test.ts +0 -251
  53. package/src/command-suggestions.ts +0 -338
  54. package/src/commands.test.ts +0 -184
  55. package/src/commands.ts +0 -140
  56. package/src/commit-message.test.ts +0 -249
  57. package/src/commit-message.ts +0 -180
  58. package/src/context-compaction.test.ts +0 -522
  59. package/src/context-compaction.ts +0 -438
  60. package/src/definitions.test.ts +0 -78
  61. package/src/definitions.ts +0 -190
  62. package/src/deployment-status.test.ts +0 -215
  63. package/src/deployment-status.ts +0 -227
  64. package/src/design-context.test.ts +0 -195
  65. package/src/design-context.ts +0 -198
  66. package/src/dispatcher.test.ts +0 -234
  67. package/src/dispatcher.ts +0 -189
  68. package/src/durable-turn.test.ts +0 -209
  69. package/src/durable-turn.ts +0 -135
  70. package/src/edit-planner.test.ts +0 -204
  71. package/src/edit-planner.ts +0 -325
  72. package/src/fuzzy-match.test.ts +0 -311
  73. package/src/fuzzy-match.ts +0 -444
  74. package/src/hook-pipeline.test.ts +0 -279
  75. package/src/hook-pipeline.ts +0 -373
  76. package/src/inbound-admission.test.ts +0 -394
  77. package/src/inbound-admission.ts +0 -246
  78. package/src/index.ts +0 -49
  79. package/src/loop.test.ts +0 -161
  80. package/src/loop.ts +0 -211
  81. package/src/mcp-bridge.test.ts +0 -165
  82. package/src/mcp-bridge.ts +0 -81
  83. package/src/memory-provider.test.ts +0 -232
  84. package/src/memory-provider.ts +0 -257
  85. package/src/model.ts +0 -168
  86. package/src/orchestration.test.ts +0 -53
  87. package/src/orchestration.ts +0 -146
  88. package/src/permission-ruleset.test.ts +0 -301
  89. package/src/permission-ruleset.ts +0 -1
  90. package/src/policy.ts +0 -151
  91. package/src/project-repo.test.ts +0 -232
  92. package/src/project-repo.ts +0 -311
  93. package/src/protocol.ts +0 -159
  94. package/src/pulsar.test.ts +0 -156
  95. package/src/pulsar.ts +0 -322
  96. package/src/rollout-store-persistent.test.ts +0 -217
  97. package/src/rollout-store-persistent.ts +0 -166
  98. package/src/rollout.ts +0 -150
  99. package/src/sandbox.ts +0 -113
  100. package/src/session-share.test.ts +0 -360
  101. package/src/session-share.ts +0 -310
  102. package/src/skill-distillation.test.ts +0 -177
  103. package/src/skill-distillation.ts +0 -369
  104. package/src/skills.test.ts +0 -277
  105. package/src/skills.ts +0 -277
  106. package/src/subagents.test.ts +0 -290
  107. package/src/subagents.ts +0 -332
  108. package/src/tools.test.ts +0 -12
  109. package/src/tools.ts +0 -129
  110. package/src/workbench.test.ts +0 -0
  111. package/src/workbench.ts +0 -0
  112. package/tsconfig.json +0 -12
  113. package/tsup.config.ts +0 -36
  114. /package/dist/{chunk-KCNN4QUQ.js.map → chunk-5UGIUYJR.js.map} +0 -0
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
- }