onion-bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,26 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Onion Bridge contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ Onion Bridge is derived from GPT-Bridge:
24
+ https://github.com/dreamurl/GPT-Bridge
25
+
26
+ GPT-Bridge is identified by its project as MIT licensed.
package/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # 🧅 Onion Bridge
2
+
3
+ 양파쿵야 좋아함
4
+
5
+ Onion Bridge is a **CLI that exposes the current terminal directory as an MCP server, allowing ChatGPT to read and modify project files directly.**
6
+
7
+ ```text
8
+ ChatGPT → OpenAI Secure MCP Tunnel ← tunnel-client → Onion Bridge → Current Directory
9
+ ```
10
+
11
+ ## Derived from GPT-Bridge
12
+
13
+ Onion Bridge is derived from [GPT-Bridge](https://github.com/dreamurl/GPT-Bridge).
14
+
15
+ ## Features
16
+
17
+ | Read | Write |
18
+ |---|---|
19
+ | `get_workspace_info` | `edit_file` |
20
+ | `list_directory` | `write_file` |
21
+ | `search_text` | `create_directory` |
22
+ | `read_file` | `delete_path` |
23
+
24
+ All file operations are restricted to the directory where Onion Bridge is started.
25
+
26
+ Writes and deletions are applied directly to disk without a separate approval step.
27
+
28
+ MCP activity is shown in the terminal in real time and is not persisted to a history file.
29
+
30
+ ## Requirements
31
+
32
+ - Node.js 20 or later
33
+ - OpenAI `tunnel-client`
34
+ - OpenAI Secure MCP Tunnel ID
35
+ - OpenAI API Key
36
+
37
+ ## Installation
38
+
39
+ From the repository:
40
+
41
+ ```bash
42
+ npm install
43
+ npm pack
44
+ npm install -g ./onion-bridge-0.1.0.tgz
45
+ ```
46
+
47
+ Then run:
48
+
49
+ ```bash
50
+ onion
51
+ ```
52
+
53
+ If the command is not found, make sure the npm global bin directory is in your PATH.
54
+
55
+ ## Initial Setup
56
+
57
+ If no configuration exists when you run `onion` for the first time, onboarding starts automatically.
58
+
59
+ You will be asked for three values:
60
+
61
+ | Item | Description |
62
+ |---|---|
63
+ | tunnel-client path | Path to the `tunnel-client` executable |
64
+ | OpenAI Tunnel ID | Tunnel ID starting with `tunnel_` |
65
+ | OpenAI API key | API key starting with `sk-` |
66
+
67
+ After setup, Onion Bridge automatically configures the authentication token and the `onion` tunnel-client profile.
68
+
69
+ Configuration is stored in `.onion-bridge/config.json` in your home directory and can be reused across projects.
70
+
71
+ To change the configuration:
72
+
73
+ ```bash
74
+ onion setup
75
+ ```
76
+
77
+ Press Enter to keep an existing value.
78
+
79
+ ### Need help with setup?
80
+
81
+ Copy the prompt below and paste it into a **new ChatGPT conversation**.
82
+
83
+ ChatGPT will check the latest official OpenAI documentation and guide you step by step from creating a Secure MCP Tunnel to verifying the Onion Bridge connection.
84
+
85
+ ```text
86
+ Guide me step by step through setting up Onion Bridge with OpenAI Secure MCP Tunnel.
87
+
88
+ Use the latest official OpenAI documentation and verify the current setup process before giving me instructions.
89
+
90
+ Please guide me through these steps:
91
+ 1. Create an OpenAI Secure MCP Tunnel.
92
+ 2. Find the Tunnel ID that starts with tunnel_.
93
+ 3. Create an OpenAI API key.
94
+ 4. Run Onion Bridge setup and enter the tunnel-client executable path, Tunnel ID, and OpenAI API key.
95
+ 5. Start Onion Bridge with the onion command.
96
+ 6. Create or configure the MCP/Connector in ChatGPT using the Secure MCP Tunnel.
97
+ 7. Verify that ChatGPT can call the Onion Bridge MCP tools.
98
+
99
+ Important:
100
+ - Explain each step in Korean.
101
+ - Give me only one step at a time.
102
+ - Wait until I confirm that I completed the current step before moving to the next step.
103
+ - Use official OpenAI documentation as the source of truth because the UI and setup process may change.
104
+ - Never ask me to paste API keys, bearer tokens, or other secret values into the ChatGPT conversation.
105
+ - If I encounter an error, troubleshoot that error before continuing to the next step.
106
+ ```
107
+
108
+ ## Usage
109
+
110
+ Go to the project you want to connect:
111
+
112
+ ```bash
113
+ cd C:\workspace\my-project
114
+ onion
115
+ ```
116
+
117
+ Or explicitly:
118
+
119
+ ```bash
120
+ onion start
121
+ ```
122
+
123
+ The current directory becomes the MCP workspace.
124
+
125
+ ```text
126
+ 🧅 Onion Bridge
127
+
128
+ workspace: C:\workspace\my-project
129
+ MCP: http://127.0.0.1:3737/mcp
130
+ tunnel: onion
131
+ ```
132
+
133
+ Press `Ctrl+C` to stop Onion Bridge.
134
+
135
+ ## Activity Log
136
+
137
+ When ChatGPT calls an MCP tool, the activity is shown in the terminal in real time.
138
+
139
+ ```text
140
+ 13:02:11 INFO .
141
+ 13:02:12 LIST src
142
+ 13:02:15 SEARCH "loadConfig"
143
+ 13:02:18 READ src/server.js
144
+ 13:02:22 EDIT src/server.js
145
+ 13:02:25 WRITE src/test.js
146
+ 13:02:30 DELETE src/old.js
147
+ ```
148
+
149
+ File contents are not printed, and activity logs are not persisted to disk.
150
+
151
+ ## Connecting ChatGPT
152
+
153
+ Onion Bridge runs the MCP server locally at `127.0.0.1:3737/mcp`.
154
+
155
+ It also starts `tunnel-client` using the saved `onion` profile and connects it to the OpenAI Secure MCP Tunnel.
156
+
157
+ After registering an MCP Connector in ChatGPT that uses the same Tunnel, ChatGPT can use the tools from the project where `onion` is currently running.
158
+
159
+ Once the Connector is configured, normal usage is simply running `onion` inside the project directory.
160
+
161
+ ## Security
162
+
163
+ > ⚠️ **All Onion Bridge file operations are automatically approved.**
164
+ >
165
+ > File modifications, creations, and deletions requested by ChatGPT are written to disk immediately without an additional confirmation step. An incorrect or unexpected request can therefore modify your project immediately.
166
+
167
+ For important projects, keep your work in a state that can be restored with Git or another version-control system. Check the terminal activity log and review actual file changes while Onion Bridge is running.
168
+
169
+ Onion Bridge uses the directory where it was started as its workspace. Run it only inside the project you intend to expose. Do not run it from your home directory or an unnecessarily broad parent directory.
170
+
171
+ Basic safeguards are still applied:
172
+
173
+ - The MCP server binds only to `127.0.0.1`.
174
+ - Tunnel requests are authenticated with an automatically generated Bearer Token.
175
+ - File access is restricted to the workspace.
176
+ - Absolute paths and paths that escape the workspace are rejected.
177
+ - `.git` and `node_modules` are excluded from file traversal.
178
+ - Activity is displayed only in the terminal and is not saved to a separate log file.
179
+
180
+ The OpenAI API Key is stored in `~/.onion-bridge/config.json`. Do not share this file or commit it to Git.
181
+
182
+ ## CLI
183
+
184
+ ```bash
185
+ onion # Start MCP + Tunnel
186
+ onion start # Start MCP + Tunnel
187
+ onion setup # Create or update Tunnel configuration
188
+ onion -v # Show version
189
+ onion --version
190
+ ```
191
+
192
+ ## Differences from GPT-Bridge
193
+
194
+ GPT-Bridge is centered around a VS Code Extension. Onion Bridge uses only a CLI.
195
+
196
+ VS Code and the Extension are not required. The directory where `onion` is started becomes the workspace. Editor-specific features such as the settings UI, approval UI, and change history are removed, leaving only the MCP connection and file-operation tools.
197
+
198
+ ## Philosophy
199
+
200
+ None.
201
+
202
+ ## License
203
+
204
+ MIT
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { startBridge } from "../src/server.js";
3
+ import { runSetup } from "../src/setup.js";
4
+ import { readFile } from "node:fs/promises";
5
+
6
+ const command = process.argv[2];
7
+ if (["-v", "--version"].includes(command)) {
8
+ const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
9
+ console.log(version);
10
+ process.exit(0);
11
+ }
12
+
13
+ if (command && !["start", "setup"].includes(command)) {
14
+ console.error("Usage: onion [start|setup|-v|--version]");
15
+ process.exit(1);
16
+ }
17
+
18
+ const action = command === "setup" ? runSetup() : startBridge();
19
+
20
+ action.catch((error) => {
21
+ console.error(
22
+ `[onionBridge] ${error instanceof Error ? error.message : String(error)}`,
23
+ );
24
+ process.exitCode = 1;
25
+ });
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "onion-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Headless MCP bridge for exposing the current directory to ChatGPT.",
5
+ "type": "module",
6
+ "bin": {
7
+ "onion": "bin/onionBridge.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "start": "node ./bin/onionBridge.js",
18
+ "pack:local": "npm pack"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.30.0",
22
+ "express": "^5.2.1",
23
+ "zod": "^4.4.3"
24
+ },
25
+ "license": "MIT"
26
+ }
package/src/config.js ADDED
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { readSetup, runSetup } from "./setup.js";
5
+
6
+ const DEFAULT_PROFILE = "onion";
7
+
8
+ export function profilePath(profile = DEFAULT_PROFILE) {
9
+ const base =
10
+ process.platform === "win32"
11
+ ? (process.env.APPDATA ?? path.join(os.homedir(), ".config"))
12
+ : path.join(os.homedir(), ".config");
13
+ return path.join(base, "tunnel-client", `${profile}.yaml`);
14
+ }
15
+
16
+ export async function loadConfig() {
17
+ const setup = (await readSetup()) ?? (await runSetup());
18
+ const profile =
19
+ process.env.ONION_BRIDGE_PROFILE || setup.profile || DEFAULT_PROFILE;
20
+ const port = Number(process.env.ONION_BRIDGE_PORT || setup.port || 3737);
21
+ const token = process.env.ONION_BRIDGE_TOKEN || setup.token;
22
+
23
+ await writeTunnelProfile({ profile, tunnelId: setup.tunnelId, token, port });
24
+ process.env.OPENAI_API_KEY = setup.apiKey;
25
+
26
+ return {
27
+ profile,
28
+ port,
29
+ token,
30
+ tunnelBin: process.env.ONION_BRIDGE_TUNNEL_BIN || setup.tunnelBin,
31
+ };
32
+ }
33
+
34
+ async function writeTunnelProfile({ profile, tunnelId, token, port }) {
35
+ const file = profilePath(profile);
36
+ await fs.mkdir(path.dirname(file), { recursive: true });
37
+ const yaml = `config_version: 1
38
+
39
+ control_plane:
40
+ base_url: "https://api.openai.com"
41
+ tunnel_id: "${tunnelId}"
42
+ api_key: "env:OPENAI_API_KEY"
43
+
44
+ health:
45
+ listen_addr: "127.0.0.1:8080"
46
+
47
+ admin_ui:
48
+ open_browser: false
49
+
50
+ log:
51
+ level: error
52
+ format: json
53
+
54
+ mcp:
55
+ server_urls:
56
+ - channel: main
57
+ url: "http://127.0.0.1:${port}/mcp"
58
+ extra_headers:
59
+ Authorization: "Bearer ${token}"
60
+ `;
61
+ await fs.writeFile(file, yaml, { encoding: "utf8", mode: 0o600 });
62
+ }
package/src/server.js ADDED
@@ -0,0 +1,84 @@
1
+ import { spawn } from "node:child_process";
2
+ import express from "express";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import { loadConfig } from "./config.js";
6
+ import { registerTools } from "./tools.js";
7
+ import { workspaceRoot } from "./workspace.js";
8
+
9
+ function createMcp(root) {
10
+ const server = new McpServer(
11
+ { name: "onion-bridge", version: "0.1.0" },
12
+ {
13
+ instructions:
14
+ "Headless workspace MCP server. Changes are written directly to disk without approval.",
15
+ },
16
+ );
17
+ registerTools(server, root);
18
+ return server;
19
+ }
20
+
21
+ export async function startBridge() {
22
+ const config = await loadConfig();
23
+ const root = workspaceRoot();
24
+ const app = express();
25
+ app.disable("x-powered-by");
26
+ app.use(express.json({ limit: "5mb" }));
27
+
28
+ app.get("/health", (_req, res) => res.json({ status: "ok" }));
29
+ app.post("/mcp", async (req, res) => {
30
+ if (req.headers.authorization !== `Bearer ${config.token}`) {
31
+ res.status(401).json({ error: "Unauthorized" });
32
+ return;
33
+ }
34
+
35
+ const server = createMcp(root);
36
+ const transport = new StreamableHTTPServerTransport({});
37
+ res.on("close", () => {
38
+ void transport.close().catch(() => undefined);
39
+ void server.close().catch(() => undefined);
40
+ });
41
+
42
+ try {
43
+ await server.connect(transport);
44
+ await transport.handleRequest(req, res, req.body);
45
+ } catch (error) {
46
+ if (!res.headersSent)
47
+ res.status(500).json({ error: "MCP request failed" });
48
+ console.error("[onionBridge]", error);
49
+ }
50
+ });
51
+
52
+ const http = await new Promise((resolve, reject) => {
53
+ const server = app.listen(config.port, "127.0.0.1", () => resolve(server));
54
+ server.once("error", reject);
55
+ });
56
+
57
+ console.log(`[onionBridge] workspace: ${root}`);
58
+ console.log(`[onionBridge] MCP: http://127.0.0.1:${config.port}/mcp`);
59
+ console.warn("[onionBridge] WARNING: OAuth is not used. File operations are auto-approved. Use at your own risk.");
60
+
61
+ const tunnel = spawn(config.tunnelBin, ["run", "--profile", config.profile], {
62
+ stdio: "inherit",
63
+ windowsHide: true,
64
+ env: process.env,
65
+ });
66
+
67
+ tunnel.once("spawn", () =>
68
+ console.log(`[onionBridge] tunnel: ${config.profile}`),
69
+ );
70
+ tunnel.on("error", (error) =>
71
+ console.error(`[onionBridge] tunnel failed: ${error.message}`),
72
+ );
73
+
74
+ let stopping = false;
75
+ const stop = () => {
76
+ if (stopping) return;
77
+ stopping = true;
78
+ tunnel.kill();
79
+ http.close(() => process.exit(0));
80
+ };
81
+
82
+ process.once("SIGINT", stop);
83
+ process.once("SIGTERM", stop);
84
+ }
package/src/setup.js ADDED
@@ -0,0 +1,59 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import readline from "node:readline/promises";
6
+ import { stdin as input, stdout as output } from "node:process";
7
+
8
+ const DIR = path.join(os.homedir(), ".onion-bridge");
9
+ const FILE = path.join(DIR, "config.json");
10
+
11
+ export async function readSetup() {
12
+ try {
13
+ return JSON.parse(await fs.readFile(FILE, "utf8"));
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ export async function runSetup() {
20
+ const current = await readSetup();
21
+ const rl = readline.createInterface({ input, output });
22
+
23
+ console.log("\n🧅 Onion Bridge setup");
24
+ console.log("Press Enter to keep the current value.\n");
25
+
26
+ const tunnelBin = await ask(rl, "tunnel-client path", current?.tunnelBin);
27
+ const tunnelId = await ask(rl, "OpenAI Tunnel ID", current?.tunnelId);
28
+ const apiKey = await ask(rl, "OpenAI API key", current?.apiKey, true);
29
+
30
+ rl.close();
31
+
32
+ if (!tunnelBin || !tunnelId || !apiKey) throw new Error("All setup values are required.");
33
+ if (!tunnelId.startsWith("tunnel_")) throw new Error("Tunnel ID must start with tunnel_.");
34
+ if (!apiKey.startsWith("sk-")) throw new Error("OpenAI API key must start with sk-.");
35
+
36
+ await fs.access(tunnelBin).catch(() => {
37
+ throw new Error("tunnel-client executable does not exist.");
38
+ });
39
+
40
+ const config = {
41
+ tunnelBin,
42
+ tunnelId,
43
+ apiKey,
44
+ token: current?.token || crypto.randomBytes(32).toString("hex"),
45
+ profile: "onion",
46
+ port: current?.port || 3737
47
+ };
48
+
49
+ await fs.mkdir(DIR, { recursive: true });
50
+ await fs.writeFile(FILE, JSON.stringify(config, null, 2), { encoding: "utf8", mode: 0o600 });
51
+ console.log(`\nSaved: ${FILE}\n`);
52
+ return config;
53
+ }
54
+
55
+ async function ask(rl, label, current, secret = false) {
56
+ const hint = current ? (secret ? " [********]" : ` [${current}]`) : "";
57
+ const value = (await rl.question(`${label}${hint}: `)).trim();
58
+ return value || current;
59
+ }
package/src/tools.js ADDED
@@ -0,0 +1,229 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { readText, resolveInside, walkFiles } from "./workspace.js";
5
+
6
+ const text = (value) => ({ content: [{ type: "text", text: value }] });
7
+
8
+ function activity(action, target = "") {
9
+ const time = new Date().toLocaleTimeString("en-GB", { hour12: false });
10
+ console.log(`${time} ${action.padEnd(7)} ${target}`.trimEnd());
11
+ }
12
+
13
+ function matchesGlob(file, glob) {
14
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
15
+ const pattern = escaped
16
+ .replaceAll("**", "::DOUBLE::")
17
+ .replaceAll("*", "[^/]*")
18
+ .replaceAll("::DOUBLE::", ".*");
19
+ return new RegExp(`^${pattern}$`).test(file);
20
+ }
21
+
22
+ export function registerTools(server, root) {
23
+ server.registerTool(
24
+ "get_workspace_info",
25
+ {
26
+ description: "Return the current Onion Bridge workspace.",
27
+ annotations: { readOnlyHint: true, openWorldHint: false },
28
+ },
29
+ async () => {
30
+ activity("INFO", ".");
31
+ return text(`Workspace: ${path.basename(root)}\nRoot: ${root}`);
32
+ },
33
+ );
34
+
35
+ server.registerTool(
36
+ "list_directory",
37
+ {
38
+ description: "List files below a workspace-relative directory.",
39
+ inputSchema: {
40
+ path: z.string().optional(),
41
+ depth: z.number().int().min(1).max(3).optional(),
42
+ },
43
+ annotations: { readOnlyHint: true, openWorldHint: false },
44
+ },
45
+ async ({ path: input = ".", depth = 1 }) => {
46
+ activity("LIST", input);
47
+ const base = resolveInside(root, input);
48
+ const files = await walkFiles(root, input);
49
+ const prefix = base.relative ? `${base.relative}/` : "";
50
+ const visible = new Set();
51
+ for (const file of files) {
52
+ const local =
53
+ prefix && file.startsWith(prefix) ? file.slice(prefix.length) : file;
54
+ const parts = local.split("/");
55
+ visible.add(
56
+ parts.slice(0, depth).join("/") + (parts.length > depth ? "/" : ""),
57
+ );
58
+ }
59
+ return text([...visible].sort().join("\n") || "(empty)");
60
+ },
61
+ );
62
+
63
+ server.registerTool(
64
+ "search_text",
65
+ {
66
+ description: "Search literal text across workspace files.",
67
+ inputSchema: {
68
+ query: z.string().min(1),
69
+ include: z.string().optional(),
70
+ max_results: z.number().int().min(1).max(500).optional(),
71
+ context_lines: z.number().int().min(0).max(5).optional(),
72
+ },
73
+ annotations: { readOnlyHint: true, openWorldHint: false },
74
+ },
75
+ async ({ query, include, max_results = 50, context_lines = 0 }) => {
76
+ activity("SEARCH", JSON.stringify(query));
77
+ const files = await walkFiles(root);
78
+ const results = [];
79
+ for (const file of files) {
80
+ if (results.length >= max_results) break;
81
+ if (include && !matchesGlob(file, include)) continue;
82
+ let content;
83
+ try {
84
+ content = (await readText(root, file)).text;
85
+ } catch {
86
+ continue;
87
+ }
88
+ const lines = content.split(/\r?\n/);
89
+ for (let i = 0; i < lines.length && results.length < max_results; i++) {
90
+ if (!lines[i].includes(query)) continue;
91
+ const from = Math.max(0, i - context_lines);
92
+ const to = Math.min(lines.length, i + context_lines + 1);
93
+ results.push(
94
+ `${file}:${i + 1}\n${lines
95
+ .slice(from, to)
96
+ .map((line, n) => `${from + n + 1}│ ${line}`)
97
+ .join("\n")}`,
98
+ );
99
+ }
100
+ }
101
+ return text(results.join("\n\n") || "No matches.");
102
+ },
103
+ );
104
+
105
+ server.registerTool(
106
+ "read_file",
107
+ {
108
+ description: "Read a workspace-relative UTF-8 file.",
109
+ inputSchema: {
110
+ path: z.string(),
111
+ start_line: z.number().int().min(1).optional(),
112
+ end_line: z.number().int().min(1).optional(),
113
+ },
114
+ annotations: { readOnlyHint: true, openWorldHint: false },
115
+ },
116
+ async ({ path: input, start_line, end_line }) => {
117
+ activity("READ", input);
118
+ const file = await readText(root, input);
119
+ const lines = file.text.split(/\r?\n/);
120
+ const start = start_line ?? 1;
121
+ const end = Math.min(end_line ?? lines.length, lines.length);
122
+ const body = lines
123
+ .slice(start - 1, end)
124
+ .map((line, i) => `${start + i}│ ${line}`)
125
+ .join("\n");
126
+ return text(
127
+ `<file_content path="${file.relative}" lines="${start}-${end}" total_lines="${lines.length}">\n${body}\n</file_content>`,
128
+ );
129
+ },
130
+ );
131
+
132
+ server.registerTool(
133
+ "edit_file",
134
+ {
135
+ description:
136
+ "Replace one exact occurrence in an existing file. Writes directly to disk without approval.",
137
+ inputSchema: {
138
+ path: z.string(),
139
+ old_string: z.string(),
140
+ new_string: z.string(),
141
+ },
142
+ annotations: {
143
+ readOnlyHint: false,
144
+ destructiveHint: false,
145
+ openWorldHint: false,
146
+ },
147
+ },
148
+ async ({ path: input, old_string, new_string }) => {
149
+ activity("EDIT", input);
150
+ const file = await readText(root, input);
151
+ const first = file.text.indexOf(old_string);
152
+ if (first < 0) throw new Error("old_string was not found.");
153
+ if (file.text.indexOf(old_string, first + old_string.length) >= 0)
154
+ throw new Error("old_string appears more than once.");
155
+ await fs.writeFile(
156
+ file.absolute,
157
+ file.text.slice(0, first) +
158
+ new_string +
159
+ file.text.slice(first + old_string.length),
160
+ "utf8",
161
+ );
162
+ return text(`Updated ${file.relative}.`);
163
+ },
164
+ );
165
+
166
+ server.registerTool(
167
+ "write_file",
168
+ {
169
+ description:
170
+ "Create or fully replace a file. Writes directly to disk without approval.",
171
+ inputSchema: { path: z.string(), content: z.string() },
172
+ annotations: {
173
+ readOnlyHint: false,
174
+ destructiveHint: true,
175
+ openWorldHint: false,
176
+ },
177
+ },
178
+ async ({ path: input, content }) => {
179
+ activity("WRITE", input);
180
+ const target = resolveInside(root, input);
181
+ await fs.mkdir(path.dirname(target.absolute), { recursive: true });
182
+ await fs.writeFile(target.absolute, content, "utf8");
183
+ return text(`Wrote ${target.relative}.`);
184
+ },
185
+ );
186
+
187
+ server.registerTool(
188
+ "create_directory",
189
+ {
190
+ description: "Create a directory recursively without approval.",
191
+ inputSchema: { path: z.string() },
192
+ annotations: {
193
+ readOnlyHint: false,
194
+ destructiveHint: false,
195
+ openWorldHint: false,
196
+ },
197
+ },
198
+ async ({ path: input }) => {
199
+ activity("MKDIR", input);
200
+ const target = resolveInside(root, input);
201
+ await fs.mkdir(target.absolute, { recursive: true });
202
+ return text(`Created ${target.relative}/.`);
203
+ },
204
+ );
205
+
206
+ server.registerTool(
207
+ "delete_path",
208
+ {
209
+ description: "Delete a file or directory directly without approval.",
210
+ inputSchema: { path: z.string(), recursive: z.boolean().optional() },
211
+ annotations: {
212
+ readOnlyHint: false,
213
+ destructiveHint: true,
214
+ openWorldHint: false,
215
+ },
216
+ },
217
+ async ({ path: input, recursive = false }) => {
218
+ activity("DELETE", input);
219
+ const target = resolveInside(root, input);
220
+ if (!target.relative)
221
+ throw new Error("Workspace root cannot be deleted.");
222
+ const stat = await fs.stat(target.absolute);
223
+ if (stat.isDirectory() && !recursive)
224
+ throw new Error("recursive=true is required for directories.");
225
+ await fs.rm(target.absolute, { recursive, force: false });
226
+ return text(`Deleted ${target.relative}.`);
227
+ },
228
+ );
229
+ }
@@ -0,0 +1,46 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const EXCLUDED = new Set([".git", "node_modules"]);
5
+ const MAX_READ_BYTES = 1024 * 1024;
6
+
7
+ export function workspaceRoot() {
8
+ return path.resolve(process.cwd());
9
+ }
10
+
11
+ export function resolveInside(root, input = ".") {
12
+ if (path.isAbsolute(input)) throw new Error("Absolute paths are not allowed.");
13
+ const target = path.resolve(root, input);
14
+ const rel = path.relative(root, target);
15
+ if (rel === ".." || rel.startsWith(`..${path.sep}`)) throw new Error("Path escapes the workspace.");
16
+ return { absolute: target, relative: rel.split(path.sep).join("/") };
17
+ }
18
+
19
+ export async function readText(root, input) {
20
+ const target = resolveInside(root, input);
21
+ const stat = await fs.stat(target.absolute);
22
+ if (!stat.isFile()) throw new Error("Path is not a file.");
23
+ if (stat.size > MAX_READ_BYTES) throw new Error(`File exceeds ${MAX_READ_BYTES} bytes.`);
24
+ return { ...target, text: await fs.readFile(target.absolute, "utf8") };
25
+ }
26
+
27
+ export async function walkFiles(root, start = ".", max = 5000) {
28
+ const base = resolveInside(root, start);
29
+ const files = [];
30
+
31
+ async function walk(dir, relDir) {
32
+ if (files.length >= max) return;
33
+ const entries = await fs.readdir(dir, { withFileTypes: true });
34
+ for (const entry of entries) {
35
+ if (files.length >= max) return;
36
+ if (EXCLUDED.has(entry.name)) continue;
37
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
38
+ const abs = path.join(dir, entry.name);
39
+ if (entry.isDirectory()) await walk(abs, rel);
40
+ else if (entry.isFile()) files.push(rel);
41
+ }
42
+ }
43
+
44
+ await walk(base.absolute, base.relative);
45
+ return files;
46
+ }