@sweny-ai/core 0.1.136 → 0.1.137

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.
package/dist/cli/main.js CHANGED
@@ -1244,6 +1244,18 @@ workflowCmd
1244
1244
  }
1245
1245
  console.log();
1246
1246
  });
1247
+ // ── sweny tool-bridge (hidden, #414) ──────────────────────────────────
1248
+ // stdio MCP shim a harness starts to reach this run's skill tools over the
1249
+ // per-run unix socket. Not for humans; started by harness/tool-bridge/server.ts.
1250
+ program
1251
+ .command("tool-bridge", { hidden: true })
1252
+ .description("Internal: stdio MCP shim for sweny skill tools")
1253
+ .requiredOption("--socket <path>", "Per-run bridge socket")
1254
+ .option("--token <token>", "Per-run token (default: SWENY_TOOL_BRIDGE_TOKEN)")
1255
+ .action(async (options) => {
1256
+ const { runToolBridgeShim } = await import("../harness/tool-bridge/shim.js");
1257
+ await runToolBridgeShim({ socket: options.socket, token: options.token, version });
1258
+ });
1247
1259
  // ── sweny upgrade / update ────────────────────────────────────────────
1248
1260
  // Self-update the globally-installed @sweny-ai/core. Mirrors the UX of
1249
1261
  // `bun upgrade`, `deno upgrade`, `rustup update`, etc.
@@ -8,7 +8,7 @@
8
8
  * This is the ONLY supported LLM backend. The whole point of sweny
9
9
  * is to use headless Claude Code - never the raw Anthropic API.
10
10
  */
11
- import type { Claude, ToolContext, Logger, McpServerConfig } from "../types.js";
11
+ import type { Claude, Tool, ToolContext, Logger, McpServerConfig } from "../types.js";
12
12
  import { type SandboxMode } from "../agent-env.js";
13
13
  import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult } from "./types.js";
14
14
  import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
@@ -107,6 +107,18 @@ export interface ClaudeCodeHarnessOptions {
107
107
  sandboxAllowedDomains?: string[];
108
108
  /** Sandbox preflight probe (test seam). Returns a reason when the sandbox cannot run. */
109
109
  sandboxProbe?: () => string | undefined;
110
+ /**
111
+ * Serve skill tools through the SwenyToolBridge (a stdio MCP shim over a
112
+ * per-run unix socket, #414) instead of the in-process SDK MCP server.
113
+ * Same tools, same results; used to run the harness contract suite both
114
+ * ways. Default: off, unless `SWENY_TOOL_BRIDGE=1`.
115
+ */
116
+ toolBridge?: boolean;
117
+ /** Shim command override for the bridge (test seam). Default: this package's `sweny tool-bridge`. */
118
+ toolBridgeShim?: {
119
+ command: string;
120
+ args: string[];
121
+ };
110
122
  }
111
123
  /**
112
124
  * Wire an optional timeout + caller signal onto a single AbortController.
@@ -139,6 +151,8 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
139
151
  private sandboxProbe;
140
152
  private sandboxWarned;
141
153
  private envScope;
154
+ private toolBridge;
155
+ private toolBridgeShim;
142
156
  constructor(opts?: ClaudeCodeHarnessOptions);
143
157
  /**
144
158
  * Build env for the Claude Code subprocess (#360). Applies auth precedence
@@ -201,6 +215,18 @@ export type ClaudeClientOptions = ClaudeCodeHarnessOptions;
201
215
  export declare const ClaudeClient: typeof ClaudeCodeHarness;
202
216
  /** @deprecated Use {@link ClaudeCodeHarness}. */
203
217
  export type ClaudeClient = ClaudeCodeHarness;
218
+ /**
219
+ * Convert a core Tool to an SDK MCP tool definition.
220
+ *
221
+ * The wrapper runs the user's handler and returns the MCP `CallToolResult`.
222
+ * Output bookkeeping happens on the stream side (see the tool_result
223
+ * handler in `run()`), keyed by `tool_use_id` - never from inside the
224
+ * wrapper. Keeping the wrapper stateless means parallel calls to the same
225
+ * tool cannot mis-pair outputs.
226
+ *
227
+ * @internal Exported for the tool bridge parity test (#414).
228
+ */
229
+ export declare function coreToolToSdkTool(coreTool: Tool, defaultCtx: ToolContext): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<Record<string, import("zod").ZodType<any, import("zod").ZodTypeDef, any>>>;
204
230
  /**
205
231
  * Best-effort recovery of typed output from a tool_result's `content`.
206
232
  *
@@ -10,13 +10,14 @@
10
10
  */
11
11
  import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude-agent-sdk";
12
12
  import { createRequire } from "node:module";
13
- import { z } from "zod";
14
13
  import { consoleLogger } from "../types.js";
15
14
  import { reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, resolveAgentSandbox, } from "../agent-env.js";
16
15
  import { fenceUntrustedJson } from "../untrusted.js";
17
16
  import { policyGate } from "./policy.js";
18
17
  import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
19
18
  import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt } from "./prompts.js";
19
+ import { jsonSchemaToZodShape, toolErrorToMcpResult, toolOutputToMcpResult } from "./tool-bridge/protocol.js";
20
+ import { startToolBridge } from "./tool-bridge/server.js";
20
21
  export { buildEvaluatePrompt, CLAUDE_CODE_CAPABILITIES };
21
22
  let cachedSdkVersion;
22
23
  /** Version of the Claude Agent SDK this adapter drives; "unknown" when it cannot be read. */
@@ -241,6 +242,8 @@ export class ClaudeCodeHarness {
241
242
  sandboxProbe;
242
243
  sandboxWarned = false;
243
244
  envScope;
245
+ toolBridge;
246
+ toolBridgeShim;
244
247
  constructor(opts = {}) {
245
248
  this.model = opts.model;
246
249
  this.maxTurns = opts.maxTurns ?? 20;
@@ -254,6 +257,8 @@ export class ClaudeCodeHarness {
254
257
  this.sandboxMode = opts.sandbox;
255
258
  this.sandboxAllowedDomains = opts.sandboxAllowedDomains;
256
259
  this.sandboxProbe = opts.sandboxProbe;
260
+ this.toolBridge = opts.toolBridge ?? process.env.SWENY_TOOL_BRIDGE === "1";
261
+ this.toolBridgeShim = opts.toolBridgeShim;
257
262
  }
258
263
  /**
259
264
  * Build env for the Claude Code subprocess (#360). Applies auth precedence
@@ -352,12 +357,18 @@ export class ClaudeCodeHarness {
352
357
  // handler and returns `content` via the MCP transport. We do not push
353
358
  // to toolCalls from the wrapper - the stream's tool_result is the sole
354
359
  // signal of completion, which keeps pairing correct under parallelism.
355
- const sdkTools = tools.map((t) => coreToolToSdkTool(t, this.defaultContext));
360
+ // #414: with the bridge on, the same tools reach Claude Code through the
361
+ // `sweny tool-bridge` stdio shim instead, so no in-process server is built.
362
+ const useBridge = this.toolBridge && tools.length > 0;
363
+ const sdkTools = useBridge ? [] : tools.map((t) => coreToolToSdkTool(t, this.defaultContext));
356
364
  // Create in-process MCP server
357
- const mcpServer = createSdkMcpServer({
358
- name: "sweny-core",
359
- tools: sdkTools,
360
- });
365
+ const mcpServer = useBridge
366
+ ? undefined
367
+ : createSdkMcpServer({
368
+ name: "sweny-core",
369
+ tools: sdkTools,
370
+ });
371
+ let bridge;
361
372
  // Build prompt. Context (workflow input such as issues/alerts, plus prior
362
373
  // node outputs) is fenced as untrusted data (#360).
363
374
  const prompt = [
@@ -394,8 +405,21 @@ export class ClaudeCodeHarness {
394
405
  const allMcpServers = readOnly
395
406
  ? {}
396
407
  : { ...this.defaultMcpServers, ...opts.mcpServers, ...this.mcpServers };
397
- if (sdkTools.length > 0)
408
+ if (useBridge) {
409
+ // Only `tools` (already allow/deny and dry-run filtered by the
410
+ // executor) are exposed. Under readOnly it is still the only MCP
411
+ // server, and strictMcpConfig below keeps it that way.
412
+ bridge = await startToolBridge({
413
+ tools,
414
+ context: this.defaultContext,
415
+ logger: this.logger,
416
+ ...(this.toolBridgeShim ? { shimCommand: this.toolBridgeShim } : {}),
417
+ });
418
+ allMcpServers["sweny-core"] = bridge.mcpServer;
419
+ }
420
+ else if (sdkTools.length > 0) {
398
421
  allMcpServers["sweny-core"] = mcpServer;
422
+ }
399
423
  stream = query({
400
424
  prompt,
401
425
  options: {
@@ -567,6 +591,8 @@ export class ClaudeCodeHarness {
567
591
  // it cannot fire after we are done.
568
592
  abort?.clear();
569
593
  await interruptStream(stream);
594
+ // #414: the socket and its directory go away with the run.
595
+ await bridge?.close();
570
596
  }
571
597
  if (!sawResult) {
572
598
  const msg = "agent stream ended without a result message";
@@ -703,8 +729,10 @@ export const ClaudeClient = ClaudeCodeHarness;
703
729
  * handler in `run()`), keyed by `tool_use_id` - never from inside the
704
730
  * wrapper. Keeping the wrapper stateless means parallel calls to the same
705
731
  * tool cannot mis-pair outputs.
732
+ *
733
+ * @internal Exported for the tool bridge parity test (#414).
706
734
  */
707
- function coreToolToSdkTool(coreTool, defaultCtx) {
735
+ export function coreToolToSdkTool(coreTool, defaultCtx) {
708
736
  const zodShape = jsonSchemaToZodShape(coreTool.input_schema);
709
737
  return sdkTool(coreTool.name, coreTool.description, zodShape, async (args) => {
710
738
  try {
@@ -712,16 +740,12 @@ function coreToolToSdkTool(coreTool, defaultCtx) {
712
740
  // When used standalone, defaultCtx is the fallback.
713
741
  const output = await coreTool.handler(args, defaultCtx);
714
742
  // JSON-stringify structured output so the tool_result handler can
715
- // recover it via parseToolResultContent. Strings pass through.
716
- return {
717
- content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }],
718
- };
743
+ // recover it via parseToolResultContent. Strings pass through. Shared
744
+ // with the tool bridge so both paths return the same content.
745
+ return toolOutputToMcpResult(output);
719
746
  }
720
747
  catch (err) {
721
- return {
722
- content: [{ type: "text", text: `Error: ${err.message}` }],
723
- isError: true,
724
- };
748
+ return toolErrorToMcpResult(err);
725
749
  }
726
750
  });
727
751
  }
@@ -831,67 +855,6 @@ export function summarizeToolError(parsed) {
831
855
  const collapsed = raw.replace(/\s+/g, " ").trim();
832
856
  return collapsed.length > 300 ? collapsed.slice(0, 297) + "..." : collapsed;
833
857
  }
834
- // ─── JSON Schema → Zod conversion ───────────────────────────────
835
- /**
836
- * Convert a JSON Schema object to a Zod raw shape for the agent SDK.
837
- * Preserves property names, types, and descriptions so Claude sees
838
- * accurate tool parameters through the MCP protocol.
839
- */
840
- function jsonSchemaToZodShape(schema) {
841
- const props = schema?.properties ?? {};
842
- const required = new Set(schema?.required ?? []);
843
- const shape = {};
844
- for (const [key, prop] of Object.entries(props)) {
845
- let zodType = jsonPropertyToZod(prop);
846
- if (!required.has(key)) {
847
- zodType = zodType.optional();
848
- }
849
- shape[key] = zodType;
850
- }
851
- return shape;
852
- }
853
- function jsonPropertyToZod(prop) {
854
- if (!prop || typeof prop !== "object")
855
- return z.unknown();
856
- const desc = typeof prop.description === "string" ? prop.description : undefined;
857
- switch (prop.type) {
858
- case "string": {
859
- if (prop.enum && Array.isArray(prop.enum)) {
860
- const e = z.enum(prop.enum);
861
- return desc ? e.describe(desc) : e;
862
- }
863
- const s = z.string();
864
- return desc ? s.describe(desc) : s;
865
- }
866
- case "number":
867
- case "integer": {
868
- const n = z.number();
869
- return desc ? n.describe(desc) : n;
870
- }
871
- case "boolean": {
872
- const b = z.boolean();
873
- return desc ? b.describe(desc) : b;
874
- }
875
- case "array": {
876
- const items = prop.items ? jsonPropertyToZod(prop.items) : z.unknown();
877
- const a = z.array(items);
878
- return desc ? a.describe(desc) : a;
879
- }
880
- case "object": {
881
- if (prop.properties && typeof prop.properties === "object") {
882
- const nested = jsonSchemaToZodShape(prop);
883
- const o = z.object(nested);
884
- return desc ? o.describe(desc) : o;
885
- }
886
- const r = z.record(z.string(), z.unknown());
887
- return desc ? r.describe(desc) : r;
888
- }
889
- default: {
890
- const u = z.unknown();
891
- return desc ? u.describe(desc) : u;
892
- }
893
- }
894
- }
895
858
  /** Strip MCP server prefix: "mcp__server__tool" → "tool" */
896
859
  function stripMcpPrefix(name) {
897
860
  const parts = name.split("__");
@@ -9,6 +9,8 @@ export { policyGate } from "./policy.js";
9
9
  export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt } from "./prompts.js";
10
10
  export { ClaudeCodeHarness, CLAUDE_CODE_CAPABILITIES } from "./claude-code.js";
11
11
  export type { ClaudeCodeHarnessOptions } from "./claude-code.js";
12
+ export { startToolBridge } from "./tool-bridge/server.js";
13
+ export type { ToolBridge, ToolBridgeOptions } from "./tool-bridge/server.js";
12
14
  /**
13
15
  * Build the harness for an agent id. `claude` is the historical `--agent` /
14
16
  * `coding-agent-provider` value and means the same as `claude-code`.
@@ -7,6 +7,7 @@ export { claudeCompat, asClaude } from "./compat.js";
7
7
  export { policyGate } from "./policy.js";
8
8
  export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt } from "./prompts.js";
9
9
  export { ClaudeCodeHarness, CLAUDE_CODE_CAPABILITIES } from "./claude-code.js";
10
+ export { startToolBridge } from "./tool-bridge/server.js";
10
11
  /**
11
12
  * Build the harness for an agent id. `claude` is the historical `--agent` /
12
13
  * `coding-agent-provider` value and means the same as `claude-code`.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * SwenyToolBridge wire protocol (#414).
3
+ *
4
+ * The bridge moves skill tool calls from a harness's stdio MCP client to the
5
+ * sweny process that owns the tool handlers:
6
+ *
7
+ * harness --stdio MCP--> shim (`sweny tool-bridge`) --unix socket--> server (sweny run)
8
+ *
9
+ * The socket carries newline-delimited JSON frames. Every request carries the
10
+ * per-run token; the server rejects a frame with a wrong or missing token and
11
+ * drops the connection.
12
+ *
13
+ * This module also owns the one function that turns a core `Tool` handler
14
+ * outcome into an MCP `CallToolResult`, so the in-process Claude path
15
+ * (`createSdkMcpServer`) and the bridge return byte-identical content and the
16
+ * same `isError` status.
17
+ */
18
+ import { z } from "zod";
19
+ import type { JSONSchema, Tool, ToolContext } from "../../types.js";
20
+ export declare const BRIDGE_PROTOCOL_VERSION = 1;
21
+ /** Frames longer than this are refused and the connection is dropped. */
22
+ export declare const MAX_FRAME_BYTES: number;
23
+ /** Env var the shim reads the per-run token from (kept out of argv by default). */
24
+ export declare const TOKEN_ENV = "SWENY_TOOL_BRIDGE_TOKEN";
25
+ export type BridgeMethod = "tools/list" | "tools/call";
26
+ export interface BridgeRequest {
27
+ v: typeof BRIDGE_PROTOCOL_VERSION;
28
+ id: number;
29
+ token: string;
30
+ method: BridgeMethod;
31
+ params?: {
32
+ name?: string;
33
+ arguments?: Record<string, unknown>;
34
+ };
35
+ }
36
+ export interface BridgeToolDescriptor {
37
+ name: string;
38
+ description: string;
39
+ inputSchema: JSONSchema;
40
+ }
41
+ export type McpTextContent = {
42
+ type: "text";
43
+ text: string;
44
+ };
45
+ /** The MCP `CallToolResult` subset sweny produces for skill tools. */
46
+ export type McpCallToolResult = {
47
+ content: McpTextContent[];
48
+ isError?: true;
49
+ };
50
+ export type BridgeResponse = {
51
+ id: number;
52
+ ok: true;
53
+ result: {
54
+ tools: BridgeToolDescriptor[];
55
+ } | McpCallToolResult;
56
+ } | {
57
+ id: number;
58
+ ok: false;
59
+ error: {
60
+ code: BridgeErrorCode;
61
+ message: string;
62
+ };
63
+ };
64
+ export type BridgeErrorCode = "unauthorized" | "bad_request" | "unknown_tool" | "internal";
65
+ export declare function encodeFrame(frame: BridgeRequest | BridgeResponse): string;
66
+ /**
67
+ * Split a byte stream into newline-delimited JSON frames. Calls `onFrame` with
68
+ * each parsed value (or `undefined` for a line that is not JSON) and
69
+ * `onOverflow` once when a single frame exceeds {@link MAX_FRAME_BYTES}.
70
+ */
71
+ export declare function createFrameReader(onFrame: (value: unknown) => void, onOverflow: () => void, maxBytes?: number): (chunk: Buffer | string) => void;
72
+ /** Constant-time token comparison. False for any non-string or length mismatch. */
73
+ export declare function tokenMatches(expected: string, given: unknown): boolean;
74
+ /** Structural check of an incoming request frame (token checked separately). */
75
+ export declare function parseRequest(value: unknown): BridgeRequest | undefined;
76
+ /** A handler's return value as MCP content. Strings pass through; anything else is JSON. */
77
+ export declare function toolOutputToMcpResult(output: unknown): McpCallToolResult;
78
+ /** A handler's thrown error as an MCP error result. */
79
+ export declare function toolErrorToMcpResult(err: unknown): McpCallToolResult;
80
+ /**
81
+ * Validate args against the tool's input schema (the same Zod shape the
82
+ * in-process SDK server validates with), run the handler, and return the MCP
83
+ * result. Invalid args come back as an error result, never reach the handler.
84
+ */
85
+ export declare function callToolAsMcp(tool: Tool, args: unknown, ctx: ToolContext): Promise<McpCallToolResult>;
86
+ /**
87
+ * Convert a JSON Schema object to a Zod raw shape. The in-process SDK server
88
+ * and the bridge both validate tool args with it, so both paths accept and
89
+ * reject the same inputs and preserve property names, types and descriptions.
90
+ */
91
+ export declare function jsonSchemaToZodShape(schema: JSONSchema): Record<string, z.ZodType>;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * SwenyToolBridge wire protocol (#414).
3
+ *
4
+ * The bridge moves skill tool calls from a harness's stdio MCP client to the
5
+ * sweny process that owns the tool handlers:
6
+ *
7
+ * harness --stdio MCP--> shim (`sweny tool-bridge`) --unix socket--> server (sweny run)
8
+ *
9
+ * The socket carries newline-delimited JSON frames. Every request carries the
10
+ * per-run token; the server rejects a frame with a wrong or missing token and
11
+ * drops the connection.
12
+ *
13
+ * This module also owns the one function that turns a core `Tool` handler
14
+ * outcome into an MCP `CallToolResult`, so the in-process Claude path
15
+ * (`createSdkMcpServer`) and the bridge return byte-identical content and the
16
+ * same `isError` status.
17
+ */
18
+ import { timingSafeEqual } from "node:crypto";
19
+ import { z } from "zod";
20
+ export const BRIDGE_PROTOCOL_VERSION = 1;
21
+ /** Frames longer than this are refused and the connection is dropped. */
22
+ export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
23
+ /** Env var the shim reads the per-run token from (kept out of argv by default). */
24
+ export const TOKEN_ENV = "SWENY_TOOL_BRIDGE_TOKEN";
25
+ export function encodeFrame(frame) {
26
+ return JSON.stringify(frame) + "\n";
27
+ }
28
+ /**
29
+ * Split a byte stream into newline-delimited JSON frames. Calls `onFrame` with
30
+ * each parsed value (or `undefined` for a line that is not JSON) and
31
+ * `onOverflow` once when a single frame exceeds {@link MAX_FRAME_BYTES}.
32
+ */
33
+ export function createFrameReader(onFrame, onOverflow, maxBytes = MAX_FRAME_BYTES) {
34
+ let buf = "";
35
+ let overflowed = false;
36
+ return (chunk) => {
37
+ if (overflowed)
38
+ return;
39
+ buf += typeof chunk === "string" ? chunk : chunk.toString("utf8");
40
+ let nl;
41
+ while ((nl = buf.indexOf("\n")) !== -1) {
42
+ const line = buf.slice(0, nl);
43
+ buf = buf.slice(nl + 1);
44
+ if (line.trim() === "")
45
+ continue;
46
+ let value;
47
+ try {
48
+ value = JSON.parse(line);
49
+ }
50
+ catch {
51
+ value = undefined;
52
+ }
53
+ onFrame(value);
54
+ }
55
+ if (Buffer.byteLength(buf, "utf8") > maxBytes) {
56
+ overflowed = true;
57
+ buf = "";
58
+ onOverflow();
59
+ }
60
+ };
61
+ }
62
+ /** Constant-time token comparison. False for any non-string or length mismatch. */
63
+ export function tokenMatches(expected, given) {
64
+ if (typeof given !== "string")
65
+ return false;
66
+ const a = Buffer.from(expected, "utf8");
67
+ const b = Buffer.from(given, "utf8");
68
+ if (a.length !== b.length)
69
+ return false;
70
+ return timingSafeEqual(a, b);
71
+ }
72
+ /** Structural check of an incoming request frame (token checked separately). */
73
+ export function parseRequest(value) {
74
+ if (!value || typeof value !== "object" || Array.isArray(value))
75
+ return undefined;
76
+ const r = value;
77
+ if (r.v !== BRIDGE_PROTOCOL_VERSION)
78
+ return undefined;
79
+ if (typeof r.id !== "number" || !Number.isInteger(r.id))
80
+ return undefined;
81
+ if (r.method !== "tools/list" && r.method !== "tools/call")
82
+ return undefined;
83
+ if (r.params !== undefined && (typeof r.params !== "object" || r.params === null || Array.isArray(r.params))) {
84
+ return undefined;
85
+ }
86
+ return value;
87
+ }
88
+ // ─── Tool result shape (shared with the in-process Claude path) ─────
89
+ /** A handler's return value as MCP content. Strings pass through; anything else is JSON. */
90
+ export function toolOutputToMcpResult(output) {
91
+ return {
92
+ content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }],
93
+ };
94
+ }
95
+ /** A handler's thrown error as an MCP error result. */
96
+ export function toolErrorToMcpResult(err) {
97
+ const message = err instanceof Error ? err.message : err?.message;
98
+ return {
99
+ content: [{ type: "text", text: `Error: ${message}` }],
100
+ isError: true,
101
+ };
102
+ }
103
+ /**
104
+ * Validate args against the tool's input schema (the same Zod shape the
105
+ * in-process SDK server validates with), run the handler, and return the MCP
106
+ * result. Invalid args come back as an error result, never reach the handler.
107
+ */
108
+ export async function callToolAsMcp(tool, args, ctx) {
109
+ const parsed = z.object(jsonSchemaToZodShape(tool.input_schema)).safeParse(args ?? {});
110
+ if (!parsed.success) {
111
+ const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
112
+ return {
113
+ content: [{ type: "text", text: `Input validation error: invalid arguments for tool ${tool.name}: ${detail}` }],
114
+ isError: true,
115
+ };
116
+ }
117
+ try {
118
+ return toolOutputToMcpResult(await tool.handler(parsed.data, ctx));
119
+ }
120
+ catch (err) {
121
+ return toolErrorToMcpResult(err);
122
+ }
123
+ }
124
+ // ─── JSON Schema → Zod conversion ───────────────────────────────
125
+ /**
126
+ * Convert a JSON Schema object to a Zod raw shape. The in-process SDK server
127
+ * and the bridge both validate tool args with it, so both paths accept and
128
+ * reject the same inputs and preserve property names, types and descriptions.
129
+ */
130
+ export function jsonSchemaToZodShape(schema) {
131
+ const props = schema?.properties ?? {};
132
+ const required = new Set(schema?.required ?? []);
133
+ const shape = {};
134
+ for (const [key, prop] of Object.entries(props)) {
135
+ let zodType = jsonPropertyToZod(prop);
136
+ if (!required.has(key)) {
137
+ zodType = zodType.optional();
138
+ }
139
+ shape[key] = zodType;
140
+ }
141
+ return shape;
142
+ }
143
+ function jsonPropertyToZod(prop) {
144
+ if (!prop || typeof prop !== "object")
145
+ return z.unknown();
146
+ const desc = typeof prop.description === "string" ? prop.description : undefined;
147
+ switch (prop.type) {
148
+ case "string": {
149
+ if (prop.enum && Array.isArray(prop.enum)) {
150
+ const e = z.enum(prop.enum);
151
+ return desc ? e.describe(desc) : e;
152
+ }
153
+ const s = z.string();
154
+ return desc ? s.describe(desc) : s;
155
+ }
156
+ case "number":
157
+ case "integer": {
158
+ const n = z.number();
159
+ return desc ? n.describe(desc) : n;
160
+ }
161
+ case "boolean": {
162
+ const b = z.boolean();
163
+ return desc ? b.describe(desc) : b;
164
+ }
165
+ case "array": {
166
+ const items = prop.items ? jsonPropertyToZod(prop.items) : z.unknown();
167
+ const a = z.array(items);
168
+ return desc ? a.describe(desc) : a;
169
+ }
170
+ case "object": {
171
+ if (prop.properties && typeof prop.properties === "object") {
172
+ const nested = jsonSchemaToZodShape(prop);
173
+ const o = z.object(nested);
174
+ return desc ? o.describe(desc) : o;
175
+ }
176
+ const r = z.record(z.string(), z.unknown());
177
+ return desc ? r.describe(desc) : r;
178
+ }
179
+ default: {
180
+ const u = z.unknown();
181
+ return desc ? u.describe(desc) : u;
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * SwenyToolBridge server (#414): the sweny side of the bridge.
3
+ *
4
+ * Holds a node's skill `Tool[]` in the sweny process and serves them over a
5
+ * per-run unix socket to the stdio MCP shim (`sweny tool-bridge`), so any
6
+ * harness that speaks stdio MCP gets skill tools while the handlers, and the
7
+ * executor's tool:call / tool:result observation, stay in sweny.
8
+ *
9
+ * Security properties (this is an injection boundary):
10
+ * - The socket lives in a fresh `mkdtemp` directory (mode 0700) and is itself
11
+ * chmod 0600, so other users cannot connect.
12
+ * - A random 256-bit token is required on every frame. A wrong or missing
13
+ * token gets an `unauthorized` error and the connection is dropped.
14
+ * - Only the tools passed in are listed or callable. The executor has already
15
+ * applied the node's allow/deny filter and the dry-run read-only filter, so
16
+ * the bridge can never widen what the node sees.
17
+ * - Tool results use the same MCP content and `isError` status as the
18
+ * in-process path (`callToolAsMcp`).
19
+ * - `close()` removes the socket and its directory; a process `exit` hook
20
+ * and SIGINT/SIGTERM handlers do the same if the run crashes first.
21
+ * - Nothing here logs the tool list, tool arguments or results.
22
+ */
23
+ import type { Logger, McpServerConfig, Tool, ToolContext } from "../../types.js";
24
+ export interface ToolBridgeOptions {
25
+ /** The node's skill tools, already allow/deny and read-only filtered by core. */
26
+ tools: Tool[];
27
+ /** Context passed to handlers (the executor's tracked handlers ignore it and inject their own). */
28
+ context: ToolContext;
29
+ logger?: Pick<Logger, "debug" | "warn">;
30
+ /**
31
+ * Command that starts the shim. Default: this node binary running the
32
+ * sweny CLI next to this module (`dist/cli/main.js tool-bridge`).
33
+ */
34
+ shimCommand?: {
35
+ command: string;
36
+ args: string[];
37
+ };
38
+ /** Parent directory for the private socket dir. Default: `os.tmpdir()`. */
39
+ tmpDir?: string;
40
+ }
41
+ export interface ToolBridge {
42
+ /** Absolute path of the unix socket. */
43
+ readonly socketPath: string;
44
+ /** Per-run token. Never log it. */
45
+ readonly token: string;
46
+ /** stdio MCP server config a harness uses to start the shim. The token rides in `env`, not argv. */
47
+ readonly mcpServer: McpServerConfig;
48
+ /** Stop serving, drop connections, remove the socket and its directory. Idempotent. */
49
+ close(): Promise<void>;
50
+ }
51
+ /** Default shim command: the sweny CLI that ships next to this module. */
52
+ export declare function defaultShimCommand(): {
53
+ command: string;
54
+ args: string[];
55
+ };
56
+ /** Start a bridge for one node run. Call `close()` when the run ends. */
57
+ export declare function startToolBridge(opts: ToolBridgeOptions): Promise<ToolBridge>;
@@ -0,0 +1,189 @@
1
+ /**
2
+ * SwenyToolBridge server (#414): the sweny side of the bridge.
3
+ *
4
+ * Holds a node's skill `Tool[]` in the sweny process and serves them over a
5
+ * per-run unix socket to the stdio MCP shim (`sweny tool-bridge`), so any
6
+ * harness that speaks stdio MCP gets skill tools while the handlers, and the
7
+ * executor's tool:call / tool:result observation, stay in sweny.
8
+ *
9
+ * Security properties (this is an injection boundary):
10
+ * - The socket lives in a fresh `mkdtemp` directory (mode 0700) and is itself
11
+ * chmod 0600, so other users cannot connect.
12
+ * - A random 256-bit token is required on every frame. A wrong or missing
13
+ * token gets an `unauthorized` error and the connection is dropped.
14
+ * - Only the tools passed in are listed or callable. The executor has already
15
+ * applied the node's allow/deny filter and the dry-run read-only filter, so
16
+ * the bridge can never widen what the node sees.
17
+ * - Tool results use the same MCP content and `isError` status as the
18
+ * in-process path (`callToolAsMcp`).
19
+ * - `close()` removes the socket and its directory; a process `exit` hook
20
+ * and SIGINT/SIGTERM handlers do the same if the run crashes first.
21
+ * - Nothing here logs the tool list, tool arguments or results.
22
+ */
23
+ import * as fs from "node:fs";
24
+ import * as net from "node:net";
25
+ import * as os from "node:os";
26
+ import * as path from "node:path";
27
+ import { randomBytes } from "node:crypto";
28
+ import { fileURLToPath } from "node:url";
29
+ import { TOKEN_ENV, callToolAsMcp, createFrameReader, encodeFrame, parseRequest, tokenMatches, } from "./protocol.js";
30
+ // ─── Crash cleanup ───────────────────────────────────────────────
31
+ /** Directories of bridges that are still open, removed synchronously on exit or signal. */
32
+ const liveDirs = new Set();
33
+ let hooksInstalled = false;
34
+ function removeDirSync(dir) {
35
+ try {
36
+ fs.rmSync(dir, { recursive: true, force: true });
37
+ }
38
+ catch {
39
+ // Best effort: the process is going down.
40
+ }
41
+ }
42
+ function cleanupAllSync() {
43
+ for (const dir of liveDirs)
44
+ removeDirSync(dir);
45
+ liveDirs.clear();
46
+ }
47
+ function onSignal(signal) {
48
+ cleanupAllSync();
49
+ // Keep default signal semantics: if nobody else handles it, re-raise so the
50
+ // process still dies with the signal.
51
+ process.removeListener("exit", cleanupAllSync);
52
+ process.removeListener("SIGINT", onSignal);
53
+ process.removeListener("SIGTERM", onSignal);
54
+ hooksInstalled = false;
55
+ if (process.listenerCount(signal) === 0)
56
+ process.kill(process.pid, signal);
57
+ }
58
+ function installCrashHooks() {
59
+ if (hooksInstalled)
60
+ return;
61
+ hooksInstalled = true;
62
+ // `exit` also fires after an uncaught exception, before the process ends.
63
+ process.on("exit", cleanupAllSync);
64
+ process.on("SIGINT", onSignal);
65
+ process.on("SIGTERM", onSignal);
66
+ }
67
+ function uninstallCrashHooksIfIdle() {
68
+ if (!hooksInstalled || liveDirs.size > 0)
69
+ return;
70
+ hooksInstalled = false;
71
+ process.removeListener("exit", cleanupAllSync);
72
+ process.removeListener("SIGINT", onSignal);
73
+ process.removeListener("SIGTERM", onSignal);
74
+ }
75
+ // ─── Server ──────────────────────────────────────────────────────
76
+ /** Default shim command: the sweny CLI that ships next to this module. */
77
+ export function defaultShimCommand() {
78
+ const cli = fileURLToPath(new URL("../../cli/main.js", import.meta.url));
79
+ return { command: process.execPath, args: [cli, "tool-bridge"] };
80
+ }
81
+ function describeTool(t) {
82
+ return {
83
+ name: t.name,
84
+ description: t.description,
85
+ inputSchema: { type: "object", ...t.input_schema },
86
+ };
87
+ }
88
+ /** Start a bridge for one node run. Call `close()` when the run ends. */
89
+ export async function startToolBridge(opts) {
90
+ const { tools, context, logger } = opts;
91
+ const byName = new Map(tools.map((t) => [t.name, t]));
92
+ const descriptors = tools.map(describeTool);
93
+ // mkdtemp creates the directory with mode 0700.
94
+ const dir = fs.mkdtempSync(path.join(opts.tmpDir ?? os.tmpdir(), "sweny-tb-"));
95
+ fs.chmodSync(dir, 0o700);
96
+ const socketPath = path.join(dir, "b.sock");
97
+ const token = randomBytes(32).toString("hex");
98
+ liveDirs.add(dir);
99
+ installCrashHooks();
100
+ const sockets = new Set();
101
+ const server = net.createServer((sock) => {
102
+ sockets.add(sock);
103
+ sock.on("close", () => sockets.delete(sock));
104
+ sock.on("error", () => sock.destroy());
105
+ // Set on the first bad token: later frames on this connection, even ones
106
+ // already buffered in the same chunk, are ignored.
107
+ let rejected = false;
108
+ const send = (res) => {
109
+ if (!sock.destroyed && sock.writable)
110
+ sock.write(encodeFrame(res));
111
+ };
112
+ const read = createFrameReader((value) => {
113
+ if (rejected)
114
+ return;
115
+ const req = parseRequest(value);
116
+ const id = req?.id ?? -1;
117
+ // Token first: an unauthenticated peer learns nothing else.
118
+ if (!tokenMatches(token, value?.token)) {
119
+ logger?.warn("tool bridge: rejected a request with a bad token");
120
+ send({ id, ok: false, error: { code: "unauthorized", message: "unauthorized" } });
121
+ rejected = true;
122
+ sock.end();
123
+ setTimeout(() => sock.destroy(), 1000).unref();
124
+ return;
125
+ }
126
+ if (!req) {
127
+ send({ id, ok: false, error: { code: "bad_request", message: "malformed request" } });
128
+ return;
129
+ }
130
+ if (req.method === "tools/list") {
131
+ send({ id, ok: true, result: { tools: descriptors } });
132
+ return;
133
+ }
134
+ const name = req.params?.name;
135
+ const tool = typeof name === "string" ? byName.get(name) : undefined;
136
+ if (!tool) {
137
+ send({ id, ok: false, error: { code: "unknown_tool", message: `unknown tool: ${String(name)}` } });
138
+ return;
139
+ }
140
+ callToolAsMcp(tool, req.params?.arguments, context).then((result) => send({ id, ok: true, result }), () => send({ id, ok: false, error: { code: "internal", message: "tool call failed" } }));
141
+ }, () => {
142
+ send({ id: -1, ok: false, error: { code: "bad_request", message: "frame too large" } });
143
+ sock.destroy();
144
+ });
145
+ sock.on("data", read);
146
+ });
147
+ try {
148
+ await new Promise((resolve, reject) => {
149
+ server.once("error", reject);
150
+ server.listen(socketPath, () => {
151
+ server.removeListener("error", reject);
152
+ resolve();
153
+ });
154
+ });
155
+ fs.chmodSync(socketPath, 0o600);
156
+ }
157
+ catch (err) {
158
+ server.close();
159
+ liveDirs.delete(dir);
160
+ removeDirSync(dir);
161
+ uninstallCrashHooksIfIdle();
162
+ throw err;
163
+ }
164
+ logger?.debug("tool bridge: listening");
165
+ const shim = opts.shimCommand ?? defaultShimCommand();
166
+ let closed = false;
167
+ return {
168
+ socketPath,
169
+ token,
170
+ mcpServer: {
171
+ type: "stdio",
172
+ command: shim.command,
173
+ args: [...shim.args, "--socket", socketPath],
174
+ // No update-check nudge in the shim: its stdio belongs to MCP.
175
+ env: { [TOKEN_ENV]: token, SWENY_NO_UPDATE_CHECK: "1" },
176
+ },
177
+ async close() {
178
+ if (closed)
179
+ return;
180
+ closed = true;
181
+ for (const s of sockets)
182
+ s.destroy();
183
+ await new Promise((resolve) => server.close(() => resolve()));
184
+ liveDirs.delete(dir);
185
+ removeDirSync(dir);
186
+ uninstallCrashHooksIfIdle();
187
+ },
188
+ };
189
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path>`.
3
+ *
4
+ * A stdio MCP server a harness starts as a child process. It holds no tools
5
+ * of its own: `tools/list` and `tools/call` are forwarded over the per-run
6
+ * unix socket to the sweny process (server.ts), which runs the real handlers.
7
+ *
8
+ * stdout carries MCP only; diagnostics go to stderr. The token comes from
9
+ * `--token` or, by default, the `SWENY_TOOL_BRIDGE_TOKEN` env var the server
10
+ * puts in the MCP server config, so it never shows up in `ps` output.
11
+ */
12
+ import { type BridgeMethod, type BridgeResponse } from "./protocol.js";
13
+ /** Socket client that multiplexes requests by id over one connection. */
14
+ export declare class BridgeClient {
15
+ private readonly socketPath;
16
+ private readonly token;
17
+ private sock;
18
+ private connecting;
19
+ private nextId;
20
+ private pending;
21
+ private closedError;
22
+ constructor(socketPath: string, token: string);
23
+ private connect;
24
+ private failAll;
25
+ request(method: BridgeMethod, params?: {
26
+ name: string;
27
+ arguments?: Record<string, unknown>;
28
+ }): Promise<BridgeResponse>;
29
+ close(): void;
30
+ }
31
+ export interface ShimOptions {
32
+ socket: string;
33
+ /** Falls back to `SWENY_TOOL_BRIDGE_TOKEN`. */
34
+ token?: string;
35
+ version?: string;
36
+ }
37
+ /** Run the stdio MCP shim until stdin closes or the bridge goes away. */
38
+ export declare function runToolBridgeShim(opts: ShimOptions): Promise<void>;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * SwenyToolBridge shim (#414): `sweny tool-bridge --socket <path>`.
3
+ *
4
+ * A stdio MCP server a harness starts as a child process. It holds no tools
5
+ * of its own: `tools/list` and `tools/call` are forwarded over the per-run
6
+ * unix socket to the sweny process (server.ts), which runs the real handlers.
7
+ *
8
+ * stdout carries MCP only; diagnostics go to stderr. The token comes from
9
+ * `--token` or, by default, the `SWENY_TOOL_BRIDGE_TOKEN` env var the server
10
+ * puts in the MCP server config, so it never shows up in `ps` output.
11
+ */
12
+ import * as net from "node:net";
13
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
16
+ import { BRIDGE_PROTOCOL_VERSION, TOKEN_ENV, createFrameReader, encodeFrame, } from "./protocol.js";
17
+ /** Socket client that multiplexes requests by id over one connection. */
18
+ export class BridgeClient {
19
+ socketPath;
20
+ token;
21
+ sock;
22
+ connecting;
23
+ nextId = 1;
24
+ pending = new Map();
25
+ closedError;
26
+ constructor(socketPath, token) {
27
+ this.socketPath = socketPath;
28
+ this.token = token;
29
+ }
30
+ connect() {
31
+ // Once the connection is gone (bad token, server closed) every later call fails fast.
32
+ if (this.closedError)
33
+ return Promise.reject(this.closedError);
34
+ if (this.sock)
35
+ return Promise.resolve(this.sock);
36
+ this.connecting ??= new Promise((resolve, reject) => {
37
+ const sock = net.createConnection(this.socketPath);
38
+ const read = createFrameReader((value) => {
39
+ const res = value;
40
+ if (!res || typeof res.id !== "number")
41
+ return;
42
+ const waiter = this.pending.get(res.id);
43
+ if (waiter) {
44
+ this.pending.delete(res.id);
45
+ waiter.resolve(res);
46
+ }
47
+ else if (res.ok === false) {
48
+ // Unsolicited error (for example a frame-size rejection): fail everything.
49
+ this.failAll(new Error(`tool bridge: ${res.error.message}`));
50
+ }
51
+ }, () => this.failAll(new Error("tool bridge: response frame too large")));
52
+ sock.on("data", read);
53
+ sock.once("connect", () => {
54
+ this.sock = sock;
55
+ resolve(sock);
56
+ });
57
+ sock.once("error", (err) => {
58
+ reject(err);
59
+ this.failAll(err);
60
+ });
61
+ sock.once("close", () => this.failAll(new Error("tool bridge: connection closed")));
62
+ });
63
+ return this.connecting;
64
+ }
65
+ failAll(err) {
66
+ this.closedError ??= err;
67
+ for (const w of this.pending.values())
68
+ w.reject(err);
69
+ this.pending.clear();
70
+ this.sock?.destroy();
71
+ this.sock = undefined;
72
+ }
73
+ async request(method, params) {
74
+ const sock = await this.connect();
75
+ const id = this.nextId++;
76
+ return new Promise((resolve, reject) => {
77
+ if (this.closedError || sock.destroyed) {
78
+ reject(this.closedError ?? new Error("tool bridge: connection closed"));
79
+ return;
80
+ }
81
+ this.pending.set(id, { resolve, reject });
82
+ sock.write(encodeFrame({ v: BRIDGE_PROTOCOL_VERSION, id, token: this.token, method, params }));
83
+ });
84
+ }
85
+ close() {
86
+ this.sock?.destroy();
87
+ }
88
+ }
89
+ /** Run the stdio MCP shim until stdin closes or the bridge goes away. */
90
+ export async function runToolBridgeShim(opts) {
91
+ const token = opts.token ?? process.env[TOKEN_ENV];
92
+ if (!opts.socket || !token) {
93
+ process.stderr.write(`sweny tool-bridge: --socket and a token (--token or ${TOKEN_ENV}) are required\n`);
94
+ process.exit(2);
95
+ }
96
+ // Keep the token out of anything the shim might spawn or report.
97
+ delete process.env[TOKEN_ENV];
98
+ const client = new BridgeClient(opts.socket, token);
99
+ const server = new Server({ name: "sweny-core", version: opts.version ?? "0.0.0" }, { capabilities: { tools: {} } });
100
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
101
+ const res = await client.request("tools/list");
102
+ if (!res.ok)
103
+ throw new Error(`tool bridge: ${res.error.message}`);
104
+ return { tools: res.result.tools };
105
+ });
106
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
107
+ const res = await client.request("tools/call", {
108
+ name: req.params.name,
109
+ arguments: req.params.arguments,
110
+ });
111
+ if (!res.ok) {
112
+ // Unknown tool and friends surface as a tool error result, the same way
113
+ // the in-process SDK server reports a call it cannot serve.
114
+ return { content: [{ type: "text", text: `Error: ${res.error.message}` }], isError: true };
115
+ }
116
+ return res.result;
117
+ });
118
+ const shutdown = () => {
119
+ client.close();
120
+ void server.close().finally(() => process.exit(0));
121
+ };
122
+ process.stdin.once("end", shutdown);
123
+ process.stdin.once("close", shutdown);
124
+ await server.connect(new StdioServerTransport());
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.136",
3
+ "version": "0.1.137",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"
@@ -59,6 +59,7 @@
59
59
  "dependencies": {
60
60
  "@anthropic-ai/claude-agent-sdk": "^0.3.220",
61
61
  "@clack/prompts": "^1.7.0",
62
+ "@modelcontextprotocol/sdk": "^1.29.0",
62
63
  "chalk": "^5",
63
64
  "commander": "^13",
64
65
  "yaml": "^2.9.0",