@bsbofmusic/agent-reach-mcp 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.
Files changed (3) hide show
  1. package/README.md +20 -0
  2. package/index.js +263 -0
  3. package/package.json +23 -0
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # Agent-Reach MCP (stdio)
2
+
3
+ A minimal MCP stdio server for OpenCode / CC-Switch.
4
+
5
+ ## What it does
6
+
7
+ - Creates a Python venv under user cache directory
8
+ - On **every tool call**, upgrades Agent-Reach from GitHub `main.zip`
9
+ - Exposes MCP tools:
10
+ - `reach_ensure`
11
+ - `reach_doctor`
12
+ - `reach_read`
13
+
14
+ > This package prioritizes "always latest" behavior.
15
+
16
+ ## Install / run locally
17
+
18
+ ```bash
19
+ npm install
20
+ node index.js
package/index.js ADDED
@@ -0,0 +1,263 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { spawn } from "node:child_process";
6
+
7
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import {
10
+ CallToolRequestSchema,
11
+ ListToolsRequestSchema,
12
+ } from "@modelcontextprotocol/sdk/types.js";
13
+
14
+ /**
15
+ * A minimal, robust MCP stdio server that:
16
+ * - Creates a Python venv under user cache dir
17
+ * - On EVERY tool call, upgrades Agent-Reach from GitHub main.zip
18
+ * - Executes `agent-reach` via the venv (fallback to python -m ...)
19
+ *
20
+ * NOTE: This favors "latest" over strict stability, per your requirement.
21
+ */
22
+
23
+ function isWindows() {
24
+ return process.platform === "win32";
25
+ }
26
+
27
+ function userCacheDir(appName) {
28
+ if (isWindows()) {
29
+ const base =
30
+ process.env.LOCALAPPDATA ||
31
+ path.join(os.homedir(), "AppData", "Local");
32
+ return path.join(base, appName);
33
+ }
34
+ if (process.platform === "darwin") {
35
+ return path.join(os.homedir(), "Library", "Caches", appName);
36
+ }
37
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
38
+ return path.join(base, appName);
39
+ }
40
+
41
+ async function ensureDir(p) {
42
+ await fs.mkdir(p, { recursive: true });
43
+ }
44
+
45
+ function run(cmd, args, opts = {}) {
46
+ const timeoutMs = opts.timeoutMs ?? 10 * 60 * 1000;
47
+ const cwd = opts.cwd;
48
+ const env = opts.env ?? process.env;
49
+
50
+ return new Promise((resolve) => {
51
+ const child = spawn(cmd, args, {
52
+ cwd,
53
+ env,
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ windowsHide: true,
56
+ });
57
+
58
+ let stdout = "";
59
+ let stderr = "";
60
+
61
+ child.stdout.on("data", (d) => (stdout += d.toString()));
62
+ child.stderr.on("data", (d) => (stderr += d.toString()));
63
+
64
+ const t = setTimeout(() => {
65
+ try {
66
+ child.kill("SIGKILL");
67
+ } catch {}
68
+ resolve({
69
+ code: 124,
70
+ stdout,
71
+ stderr: stderr + `\n[timeout] ${cmd} ${args.join(" ")}`,
72
+ });
73
+ }, timeoutMs);
74
+
75
+ child.on("close", (code) => {
76
+ clearTimeout(t);
77
+ resolve({ code: code ?? 1, stdout, stderr });
78
+ });
79
+ });
80
+ }
81
+
82
+ function venvPaths(root) {
83
+ const venvDir = path.join(root, "venv");
84
+ if (isWindows()) {
85
+ return {
86
+ venvDir,
87
+ pythonExe: path.join(venvDir, "Scripts", "python.exe"),
88
+ agentReachExe: path.join(venvDir, "Scripts", "agent-reach.exe"),
89
+ };
90
+ }
91
+ return {
92
+ venvDir,
93
+ pythonExe: path.join(venvDir, "bin", "python"),
94
+ agentReachExe: path.join(venvDir, "bin", "agent-reach"),
95
+ };
96
+ }
97
+
98
+ async function createVenvIfMissing(root) {
99
+ await ensureDir(root);
100
+ const { venvDir, pythonExe } = venvPaths(root);
101
+
102
+ if (existsSync(pythonExe)) return;
103
+
104
+ // Try creating venv using system python.
105
+ // Windows: python, then py -3
106
+ const candidates = isWindows()
107
+ ? [
108
+ { cmd: "python", args: ["-m", "venv", venvDir] },
109
+ { cmd: "py", args: ["-3", "-m", "venv", venvDir] },
110
+ ]
111
+ : [
112
+ { cmd: "python3", args: ["-m", "venv", venvDir] },
113
+ { cmd: "python", args: ["-m", "venv", venvDir] },
114
+ ];
115
+
116
+ let last = null;
117
+ for (const c of candidates) {
118
+ last = await run(c.cmd, c.args, { timeoutMs: 2 * 60 * 1000 });
119
+ if (last.code === 0 && existsSync(pythonExe)) break;
120
+ }
121
+
122
+ if (!existsSync(pythonExe)) {
123
+ const tried = candidates.map((c) => `${c.cmd} ${c.args.join(" ")}`).join(" | ");
124
+ const err = last?.stderr || "";
125
+ throw new Error(
126
+ `Failed to create Python venv.\nTried: ${tried}\nLast stderr:\n${err}`
127
+ );
128
+ }
129
+ }
130
+
131
+ async function ensureLatestAgentReach(root) {
132
+ await createVenvIfMissing(root);
133
+ const { pythonExe, agentReachExe } = venvPaths(root);
134
+
135
+ // Upgrade pip tooling (best effort)
136
+ await run(pythonExe, ["-m", "pip", "install", "-U", "pip", "setuptools", "wheel"], {
137
+ timeoutMs: 5 * 60 * 1000,
138
+ });
139
+
140
+ // Your "always latest" requirement:
141
+ const pkgUrl = "https://github.com/Panniantong/agent-reach/archive/main.zip";
142
+ const installRes = await run(pythonExe, ["-m", "pip", "install", "-U", pkgUrl], {
143
+ timeoutMs: 10 * 60 * 1000,
144
+ });
145
+
146
+ let log = `pip install -U ${pkgUrl}\nexit=${installRes.code}\n`;
147
+ if (installRes.stdout.trim()) log += `stdout:\n${installRes.stdout}\n`;
148
+ if (installRes.stderr.trim()) log += `stderr:\n${installRes.stderr}\n`;
149
+
150
+ if (installRes.code !== 0) {
151
+ throw new Error(`Failed to install/upgrade Agent-Reach.\n\n${log}`);
152
+ }
153
+
154
+ if (!existsSync(agentReachExe)) {
155
+ log += `warning: agent-reach executable not found at ${agentReachExe}\n`;
156
+ }
157
+
158
+ return { pythonExe, agentReachExe, ensureLog: log };
159
+ }
160
+
161
+ async function runAgentReach(root, args) {
162
+ const { pythonExe, agentReachExe, ensureLog } = await ensureLatestAgentReach(root);
163
+
164
+ let execRes;
165
+ if (existsSync(agentReachExe)) {
166
+ execRes = await run(agentReachExe, args, { timeoutMs: 10 * 60 * 1000 });
167
+ } else {
168
+ // Fallback: module import. (May vary; best effort.)
169
+ execRes = await run(pythonExe, ["-m", "agent_reach", ...args], {
170
+ timeoutMs: 10 * 60 * 1000,
171
+ });
172
+ }
173
+
174
+ let out = `# ensure_latest\n${ensureLog}\n`;
175
+ out += `# agent-reach ${args.join(" ")}\nexit=${execRes.code}\n`;
176
+ if (execRes.stdout.trim()) out += `stdout:\n${execRes.stdout}\n`;
177
+ if (execRes.stderr.trim()) out += `stderr:\n${execRes.stderr}\n`;
178
+
179
+ return { out, code: execRes.code };
180
+ }
181
+
182
+ function text(content) {
183
+ return [{ type: "text", text: content }];
184
+ }
185
+
186
+ async function main() {
187
+ const server = new Server(
188
+ { name: "agent-reach-mcp", version: "0.1.0" },
189
+ { capabilities: { tools: {} } }
190
+ );
191
+
192
+ const cacheRoot = userCacheDir("agent-reach-mcp");
193
+ const runtimeRoot = path.join(cacheRoot, "runtime");
194
+ await ensureDir(runtimeRoot);
195
+
196
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
197
+ return {
198
+ tools: [
199
+ {
200
+ name: "reach_ensure",
201
+ description:
202
+ "Ensure venv exists and Agent-Reach is upgraded to latest main. Returns install logs.",
203
+ inputSchema: { type: "object", properties: {} },
204
+ },
205
+ {
206
+ name: "reach_doctor",
207
+ description: "Run `agent-reach doctor` (auto-updates Agent-Reach first).",
208
+ inputSchema: { type: "object", properties: {} },
209
+ },
210
+ {
211
+ name: "reach_read",
212
+ description: "Run `agent-reach read <url>` (auto-updates Agent-Reach first).",
213
+ inputSchema: {
214
+ type: "object",
215
+ properties: {
216
+ url: { type: "string", description: "URL to read (http/https)" },
217
+ },
218
+ required: ["url"],
219
+ },
220
+ },
221
+ ],
222
+ };
223
+ });
224
+
225
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
226
+ const name = req.params.name;
227
+ const input = req.params.arguments ?? {};
228
+
229
+ try {
230
+ if (name === "reach_ensure") {
231
+ const { ensureLog } = await ensureLatestAgentReach(runtimeRoot);
232
+ return { content: text(ensureLog) };
233
+ }
234
+
235
+ if (name === "reach_doctor") {
236
+ const { out, code } = await runAgentReach(runtimeRoot, ["doctor"]);
237
+ return { content: text(out), isError: code !== 0 };
238
+ }
239
+
240
+ if (name === "reach_read") {
241
+ const url = String(input.url ?? "");
242
+ if (!url || !/^https?:\/\//i.test(url)) {
243
+ return { content: text("Invalid url. Must start with http(s)://"), isError: true };
244
+ }
245
+ const { out, code } = await runAgentReach(runtimeRoot, ["read", url]);
246
+ return { content: text(out), isError: code !== 0 };
247
+ }
248
+
249
+ return { content: text(`Unknown tool: ${name}`), isError: true };
250
+ } catch (e) {
251
+ const msg = e?.stack || e?.message || String(e);
252
+ return { content: text(`Error:\n${msg}`), isError: true };
253
+ }
254
+ });
255
+
256
+ const transport = new StdioServerTransport();
257
+ await server.connect(transport);
258
+ }
259
+
260
+ main().catch((e) => {
261
+ console.error(e?.stack || e?.message || String(e));
262
+ process.exit(1);
263
+ });
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@bsbofmusic/agent-reach-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP stdio server that auto-installs/updates Agent-Reach and exposes reach_* tools.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "agent-reach-mcp": "index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "start": "node index.js"
16
+ },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.17.0"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ }
23
+ }