@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 +12 -0
- package/dist/harness/claude-code.d.ts +27 -1
- package/dist/harness/claude-code.js +40 -77
- package/dist/harness/index.d.ts +2 -0
- package/dist/harness/index.js +1 -0
- package/dist/harness/tool-bridge/protocol.d.ts +91 -0
- package/dist/harness/tool-bridge/protocol.js +184 -0
- package/dist/harness/tool-bridge/server.d.ts +57 -0
- package/dist/harness/tool-bridge/server.js +189 -0
- package/dist/harness/tool-bridge/shim.d.ts +38 -0
- package/dist/harness/tool-bridge/shim.js +125 -0
- package/package.json +2 -1
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
|
-
|
|
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 =
|
|
358
|
-
|
|
359
|
-
|
|
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 (
|
|
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
|
-
|
|
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("__");
|
package/dist/harness/index.d.ts
CHANGED
|
@@ -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`.
|
package/dist/harness/index.js
CHANGED
|
@@ -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.
|
|
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",
|