@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
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 { ToolDispatchContext, ToolRegistry } 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: ToolRegistry;
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,76 +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 { adaptMcpTool, type McpClientLike, type ToolDefinition, type ToolRegistry } from "./tools";
17
-
18
- /**
19
- * Port over an MCP server catalog (satisfied by `@nebutra/mcp`'s
20
- * `serverRegistry` + plan middleware). Returns only the tools visible to the
21
- * given tenant/plan — visibility/plan-gating is the port's responsibility.
22
- */
23
- export interface McpServerCatalogPort {
24
- listTools(ctx: {
25
- tenantId: string;
26
- plan?: string;
27
- }): Promise<{ server: string; definition: ToolDefinition }[]>;
28
- }
29
-
30
- const ctxSchema = z.object({
31
- tenantId: z.string().trim().min(1, "tenantId is required (fail-closed)"),
32
- plan: z.string().min(1).optional(),
33
- });
34
-
35
- export interface ActivateMcpToolsResult {
36
- /** Tool names newly registered into the registry, in catalog order. */
37
- readonly registered: readonly string[];
38
- /** Tool names skipped because the name was already registered. */
39
- readonly skipped: readonly string[];
40
- }
41
-
42
- /**
43
- * List the tenant/plan-visible MCP tools, adapt each via {@link adaptMcpTool},
44
- * and register them into the {@link ToolRegistry}. A tool whose name is already
45
- * registered is skipped (reported, never thrown). Empty/blank tenantId fails
46
- * closed before any catalog call.
47
- */
48
- export async function activateMcpTools(
49
- registry: ToolRegistry,
50
- catalog: McpServerCatalogPort,
51
- client: McpClientLike,
52
- ctx: { tenantId: string; plan?: string },
53
- ): Promise<ActivateMcpToolsResult> {
54
- const scope = ctxSchema.parse(ctx);
55
-
56
- const entries = await catalog.listTools(
57
- scope.plan === undefined
58
- ? { tenantId: scope.tenantId }
59
- : { tenantId: scope.tenantId, plan: scope.plan },
60
- );
61
-
62
- const registered: string[] = [];
63
- const skipped: string[] = [];
64
-
65
- for (const { server, definition } of entries) {
66
- if (registry.list().some((r) => r.definition.name === definition.name)) {
67
- skipped.push(definition.name);
68
- continue;
69
- }
70
- const adapted = adaptMcpTool(server, definition, client);
71
- registry.register(adapted.definition, adapted.handler, adapted.origin);
72
- registered.push(definition.name);
73
- }
74
-
75
- return { registered, skipped };
76
- }