buddy-mcp-proxy 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 +105 -0
  2. package/dist/index.js +515 -0
  3. package/package.json +53 -0
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # buddy-mcp-proxy
2
+
3
+ MCP proxy server for buddy.nvim sessions. This proxy allows OpenCode (or any MCP client) to connect to multiple buddy.nvim instances and switch between them dynamically.
4
+
5
+ ## How it works
6
+
7
+ 1. **Sessions Registry**: Reads buddy.nvim session info from `~/.local/state/buddy/sessions.json` (or `$XDG_STATE_HOME/buddy/sessions.json`)
8
+ 2. **Meta-tools**: Exposes tools to list, select, and manage sessions
9
+ 3. **Tool Proxying**: Once a session is selected, proxies all its tools to the MCP client
10
+
11
+ ## Meta-tools
12
+
13
+ - `buddy_sessions_list` - List all available buddy.nvim sessions
14
+ - `buddy_session_select` - Select a session by ID to connect to
15
+ - `buddy_session_info` - Get info about the currently selected session
16
+ - `buddy_refresh` - Refresh sessions list and reconnect if needed
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install -g buddy-mcp-proxy
22
+ ```
23
+
24
+ Or use it directly with `npx` — no install needed (see Usage below).
25
+
26
+ ## Usage
27
+
28
+ ### With OpenCode
29
+
30
+ Add to `~/.config/opencode/opencode.jsonc`:
31
+
32
+ ```jsonc
33
+ {
34
+ "mcp": {
35
+ "vim": {
36
+ "type": "local",
37
+ "command": ["npx", "buddy-mcp-proxy"]
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ ### With Claude Desktop
44
+
45
+ Add to your Claude Desktop MCP config:
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "vim": {
51
+ "command": "npx",
52
+ "args": ["buddy-mcp-proxy"]
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ ### From Source
59
+
60
+ ```bash
61
+ cd mcp/buddy-mcp-proxy
62
+ npm install
63
+ npm run build
64
+ node dist/index.js
65
+ ```
66
+
67
+ ### Development
68
+
69
+ ```bash
70
+ # Run directly with bun (no build needed)
71
+ bun run dev
72
+
73
+ # Build for production
74
+ npm run build
75
+
76
+ # Type check
77
+ npm run typecheck
78
+ ```
79
+
80
+ ## Sessions Registry Format
81
+
82
+ The proxy understands buddy.nvim's format (recommended) and a legacy array format.
83
+
84
+ Recommended (buddy.nvim):
85
+
86
+ ```json
87
+ {
88
+ "version": 1,
89
+ "sessions": {
90
+ "<session_id>": {
91
+ "host": "127.0.0.1",
92
+ "port": 52341,
93
+ "cwd": "/home/user/project-a",
94
+ "label": "project-a",
95
+ "pid": 12345,
96
+ "started_at": 1730000000,
97
+ "last_seen": 1730000030
98
+ }
99
+ }
100
+ }
101
+ ```
102
+
103
+ ## Logs
104
+
105
+ All logs are written to stderr only (as required by MCP stdio transport).
package/dist/index.js ADDED
@@ -0,0 +1,515 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
7
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
+ import {
9
+ CallToolRequestSchema,
10
+ ListToolsRequestSchema
11
+ } from "@modelcontextprotocol/sdk/types.js";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import * as os from "node:os";
15
+ function log(level, msg, data) {
16
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
17
+ const line = `[${timestamp}] [${level.toUpperCase()}] ${msg}`;
18
+ if (data !== void 0) {
19
+ console.error(line, data);
20
+ } else {
21
+ console.error(line);
22
+ }
23
+ }
24
+ function getSessionsPath() {
25
+ const xdgState = process.env.XDG_STATE_HOME;
26
+ if (xdgState) {
27
+ return path.join(xdgState, "nvim", "buddy", "sessions.json");
28
+ }
29
+ return path.join(os.homedir(), ".local", "state", "nvim", "buddy", "sessions.json");
30
+ }
31
+ function normalizeSessions(raw) {
32
+ if (!raw || typeof raw !== "object")
33
+ return [];
34
+ const maybeArray = raw;
35
+ if (Array.isArray(maybeArray.sessions)) {
36
+ return maybeArray.sessions.filter((s) => !!s && typeof s.id === "string").map((s) => ({
37
+ ...s,
38
+ host: s.host || "127.0.0.1"
39
+ }));
40
+ }
41
+ const maybeV1 = raw;
42
+ if (maybeV1.sessions && typeof maybeV1.sessions === "object") {
43
+ return Object.entries(maybeV1.sessions).flatMap(([id, s]) => {
44
+ const host = s.host || "127.0.0.1";
45
+ const port = Number(s.port);
46
+ const cwd = String(s.cwd || "");
47
+ if (!id || !Number.isFinite(port) || !cwd)
48
+ return [];
49
+ return [
50
+ {
51
+ id,
52
+ host,
53
+ port,
54
+ cwd,
55
+ label: s.label ?? null,
56
+ pid: typeof s.pid === "number" ? s.pid : void 0,
57
+ started_at: s.started_at,
58
+ last_seen: typeof s.last_seen === "number" ? s.last_seen : void 0
59
+ }
60
+ ];
61
+ });
62
+ }
63
+ return [];
64
+ }
65
+ function loadSessions() {
66
+ const sessionsPath = getSessionsPath();
67
+ try {
68
+ if (!fs.existsSync(sessionsPath)) {
69
+ log("info", `Sessions file not found: ${sessionsPath}`);
70
+ return [];
71
+ }
72
+ const content = fs.readFileSync(sessionsPath, "utf-8");
73
+ const raw = JSON.parse(content);
74
+ return normalizeSessions(raw);
75
+ } catch (err) {
76
+ log("error", `Failed to load sessions from ${sessionsPath}`, err);
77
+ return [];
78
+ }
79
+ }
80
+ function fuzzyIncludes(haystack, needle) {
81
+ return haystack.toLowerCase().includes(needle.toLowerCase());
82
+ }
83
+ function resolveSession(sessions, selector) {
84
+ if (selector.id) {
85
+ return sessions.find((s) => s.id === selector.id) ?? null;
86
+ }
87
+ if (typeof selector.index === "number") {
88
+ const sorted = [...sessions].sort((a, b) => {
89
+ const aSeen = a.last_seen ?? 0;
90
+ const bSeen = b.last_seen ?? 0;
91
+ return bSeen - aSeen;
92
+ });
93
+ const idx = selector.index - 1;
94
+ return sorted[idx] ?? null;
95
+ }
96
+ if (selector.label) {
97
+ const matches = sessions.filter(
98
+ (s) => s.label ? fuzzyIncludes(String(s.label), selector.label) : false
99
+ );
100
+ if (matches.length === 1)
101
+ return matches[0];
102
+ if (matches.length > 1) {
103
+ matches.sort((a, b) => (b.last_seen ?? 0) - (a.last_seen ?? 0));
104
+ return matches[0];
105
+ }
106
+ }
107
+ if (selector.cwd) {
108
+ const matches = sessions.filter((s) => fuzzyIncludes(s.cwd, selector.cwd));
109
+ if (matches.length === 1)
110
+ return matches[0];
111
+ if (matches.length > 1) {
112
+ matches.sort((a, b) => (b.last_seen ?? 0) - (a.last_seen ?? 0));
113
+ return matches[0];
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+ var META_TOOLS = [
119
+ {
120
+ name: "buddy_sessions_list",
121
+ description: "List all available buddy.nvim sessions from the sessions registry",
122
+ inputSchema: {
123
+ type: "object",
124
+ properties: {},
125
+ required: []
126
+ }
127
+ },
128
+ {
129
+ name: "buddy_session_select",
130
+ description: "Select a buddy.nvim session by ID to connect to. This will expose that session's tools.",
131
+ inputSchema: {
132
+ type: "object",
133
+ properties: {
134
+ session_id: {
135
+ type: "string",
136
+ description: "(Deprecated) Session ID to select"
137
+ },
138
+ id: {
139
+ type: "string",
140
+ description: "Session ID to select"
141
+ },
142
+ label: {
143
+ type: "string",
144
+ description: "Fuzzy match session label"
145
+ },
146
+ cwd: {
147
+ type: "string",
148
+ description: "Fuzzy match session cwd"
149
+ },
150
+ index: {
151
+ type: "number",
152
+ description: "1-based index (sorted by last_seen desc)"
153
+ }
154
+ },
155
+ required: []
156
+ }
157
+ },
158
+ {
159
+ name: "buddy_session_info",
160
+ description: "Get information about the currently selected buddy.nvim session",
161
+ inputSchema: {
162
+ type: "object",
163
+ properties: {},
164
+ required: []
165
+ }
166
+ },
167
+ {
168
+ name: "buddy_refresh",
169
+ description: "Refresh the sessions list and reconnect to the current session if still available",
170
+ inputSchema: {
171
+ type: "object",
172
+ properties: {},
173
+ required: []
174
+ }
175
+ }
176
+ ];
177
+ var DownstreamManager = class {
178
+ client = null;
179
+ transport = null;
180
+ currentSession = null;
181
+ cachedTools = [];
182
+ async connect(session) {
183
+ await this.disconnect();
184
+ const url = `http://${session.host || "127.0.0.1"}:${session.port}/sse`;
185
+ log("info", `Connecting to downstream: ${url}`);
186
+ try {
187
+ this.transport = new SSEClientTransport(new URL(url));
188
+ this.client = new Client(
189
+ { name: "buddy-mcp-proxy", version: "0.1.0" },
190
+ { capabilities: {} }
191
+ );
192
+ await this.client.connect(this.transport);
193
+ this.currentSession = session;
194
+ await this.refreshTools();
195
+ log("info", `Connected to session ${session.id} on port ${session.port}`);
196
+ } catch (err) {
197
+ log("error", `Failed to connect to ${url}`, err);
198
+ await this.disconnect();
199
+ throw err;
200
+ }
201
+ }
202
+ async disconnect() {
203
+ if (this.transport) {
204
+ try {
205
+ await this.transport.close();
206
+ } catch {
207
+ }
208
+ }
209
+ this.client = null;
210
+ this.transport = null;
211
+ this.currentSession = null;
212
+ this.cachedTools = [];
213
+ }
214
+ async refreshTools() {
215
+ if (!this.client) {
216
+ this.cachedTools = [];
217
+ return;
218
+ }
219
+ try {
220
+ const result = await this.client.listTools();
221
+ this.cachedTools = result.tools || [];
222
+ log("info", `Fetched ${this.cachedTools.length} tools from downstream`);
223
+ } catch (err) {
224
+ log("error", "Failed to fetch downstream tools", err);
225
+ this.cachedTools = [];
226
+ }
227
+ }
228
+ async callTool(name, args) {
229
+ if (!this.client) {
230
+ throw new Error("No downstream session connected");
231
+ }
232
+ try {
233
+ const result = await this.client.callTool({ name, arguments: args });
234
+ if ("content" in result) {
235
+ return result;
236
+ }
237
+ if ("toolResult" in result) {
238
+ const toolResult = result.toolResult;
239
+ return {
240
+ content: [
241
+ {
242
+ type: "text",
243
+ text: typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult, null, 2)
244
+ }
245
+ ]
246
+ };
247
+ }
248
+ return {
249
+ content: [
250
+ { type: "text", text: JSON.stringify(result, null, 2) }
251
+ ]
252
+ };
253
+ } catch (err) {
254
+ log("error", `Failed to call tool ${name}`, err);
255
+ await this.checkHealth();
256
+ throw err;
257
+ }
258
+ }
259
+ async checkHealth() {
260
+ if (!this.client || !this.currentSession) {
261
+ return false;
262
+ }
263
+ try {
264
+ await this.client.listTools();
265
+ return true;
266
+ } catch {
267
+ log("warn", "Downstream connection is dead, clearing selection");
268
+ await this.disconnect();
269
+ return false;
270
+ }
271
+ }
272
+ getTools() {
273
+ return this.cachedTools;
274
+ }
275
+ getCurrentSession() {
276
+ return this.currentSession;
277
+ }
278
+ isConnected() {
279
+ return this.client !== null && this.currentSession !== null;
280
+ }
281
+ };
282
+ async function main() {
283
+ log("info", "Starting buddy-mcp-proxy");
284
+ const downstream = new DownstreamManager();
285
+ const server = new Server(
286
+ { name: "buddy-mcp-proxy", version: "0.1.0" },
287
+ { capabilities: { tools: { listChanged: true } } }
288
+ );
289
+ const notifyToolsChanged = async () => {
290
+ try {
291
+ await server.notification({
292
+ method: "notifications/tools/list_changed"
293
+ });
294
+ log("info", "Sent tools/list_changed notification");
295
+ } catch (err) {
296
+ log("error", "Failed to send tools/list_changed notification", err);
297
+ }
298
+ };
299
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
300
+ if (downstream.isConnected()) {
301
+ await downstream.checkHealth();
302
+ }
303
+ const downstreamTools = downstream.getTools();
304
+ const allTools = [...META_TOOLS, ...downstreamTools];
305
+ log(
306
+ "info",
307
+ `Returning ${allTools.length} tools (${META_TOOLS.length} meta + ${downstreamTools.length} downstream)`
308
+ );
309
+ return { tools: allTools };
310
+ });
311
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
312
+ const { name, arguments: args = {} } = request.params;
313
+ log("info", `Tool call: ${name}`);
314
+ if (name === "buddy_sessions_list") {
315
+ const sessions = loadSessions();
316
+ const currentId = downstream.getCurrentSession()?.id;
317
+ return {
318
+ content: [
319
+ {
320
+ type: "text",
321
+ text: JSON.stringify(
322
+ {
323
+ sessions: sessions.sort((a, b) => (b.last_seen ?? 0) - (a.last_seen ?? 0)).map((s) => ({
324
+ id: s.id,
325
+ label: s.label ?? null,
326
+ host: s.host,
327
+ port: s.port,
328
+ cwd: s.cwd,
329
+ pid: s.pid,
330
+ started_at: s.started_at,
331
+ last_seen: s.last_seen,
332
+ url: `http://${s.host}:${s.port}/sse`,
333
+ selected: s.id === currentId
334
+ })),
335
+ current_session_id: currentId || null
336
+ },
337
+ null,
338
+ 2
339
+ )
340
+ }
341
+ ]
342
+ };
343
+ }
344
+ if (name === "buddy_session_select") {
345
+ const selector = {
346
+ id: args.session_id || args.id || void 0,
347
+ label: args.label || void 0,
348
+ cwd: args.cwd || void 0,
349
+ index: typeof args.index === "number" ? args.index : void 0
350
+ };
351
+ if (!selector.id && !selector.label && !selector.cwd && !selector.index) {
352
+ return {
353
+ content: [
354
+ {
355
+ type: "text",
356
+ text: "Error: provide one of: id, session_id, label, cwd, index"
357
+ }
358
+ ],
359
+ isError: true
360
+ };
361
+ }
362
+ const sessions = loadSessions();
363
+ const session = resolveSession(sessions, selector);
364
+ if (!session) {
365
+ return {
366
+ content: [
367
+ {
368
+ type: "text",
369
+ text: `Error: Session not found for selector ${JSON.stringify(selector)}`
370
+ }
371
+ ],
372
+ isError: true
373
+ };
374
+ }
375
+ try {
376
+ await downstream.connect(session);
377
+ await notifyToolsChanged();
378
+ return {
379
+ content: [
380
+ {
381
+ type: "text",
382
+ text: `Connected to session '${session.id}' at http://${session.host}:${session.port}. ${downstream.getTools().length} tools now available.`
383
+ }
384
+ ]
385
+ };
386
+ } catch (err) {
387
+ return {
388
+ content: [
389
+ {
390
+ type: "text",
391
+ text: `Error connecting to session '${session.id}': ${err instanceof Error ? err.message : String(err)}`
392
+ }
393
+ ],
394
+ isError: true
395
+ };
396
+ }
397
+ }
398
+ if (name === "buddy_session_info") {
399
+ const session = downstream.getCurrentSession();
400
+ if (!session) {
401
+ return {
402
+ content: [
403
+ { type: "text", text: "No session currently selected" }
404
+ ]
405
+ };
406
+ }
407
+ const tools = downstream.getTools();
408
+ return {
409
+ content: [
410
+ {
411
+ type: "text",
412
+ text: JSON.stringify(
413
+ {
414
+ id: session.id,
415
+ port: session.port,
416
+ cwd: session.cwd,
417
+ pid: session.pid,
418
+ started_at: session.started_at,
419
+ tools_count: tools.length,
420
+ tools: tools.map((t) => t.name)
421
+ },
422
+ null,
423
+ 2
424
+ )
425
+ }
426
+ ]
427
+ };
428
+ }
429
+ if (name === "buddy_refresh") {
430
+ const currentSession = downstream.getCurrentSession();
431
+ const sessions = loadSessions();
432
+ if (currentSession) {
433
+ const stillExists = sessions.find((s) => s.id === currentSession.id);
434
+ if (stillExists) {
435
+ const healthy = await downstream.checkHealth();
436
+ if (!healthy) {
437
+ try {
438
+ await downstream.connect(stillExists);
439
+ } catch {
440
+ }
441
+ } else {
442
+ await downstream.refreshTools();
443
+ }
444
+ } else {
445
+ await downstream.disconnect();
446
+ }
447
+ await notifyToolsChanged();
448
+ }
449
+ return {
450
+ content: [
451
+ {
452
+ type: "text",
453
+ text: JSON.stringify(
454
+ {
455
+ sessions_count: sessions.length,
456
+ current_session: downstream.getCurrentSession()?.id || null,
457
+ connected: downstream.isConnected(),
458
+ tools_count: downstream.getTools().length
459
+ },
460
+ null,
461
+ 2
462
+ )
463
+ }
464
+ ]
465
+ };
466
+ }
467
+ if (!downstream.isConnected()) {
468
+ return {
469
+ content: [
470
+ {
471
+ type: "text",
472
+ text: `Error: No buddy.nvim session selected. Use buddy_sessions_list and buddy_session_select first.`
473
+ }
474
+ ],
475
+ isError: true
476
+ };
477
+ }
478
+ try {
479
+ const result = await downstream.callTool(name, args);
480
+ return result;
481
+ } catch (err) {
482
+ if (!downstream.isConnected()) {
483
+ await notifyToolsChanged();
484
+ }
485
+ return {
486
+ content: [
487
+ {
488
+ type: "text",
489
+ text: `Error calling tool '${name}': ${err instanceof Error ? err.message : String(err)}`
490
+ }
491
+ ],
492
+ isError: true
493
+ };
494
+ }
495
+ });
496
+ const transport = new StdioServerTransport();
497
+ await server.connect(transport);
498
+ log("info", "buddy-mcp-proxy running on stdio");
499
+ process.on("SIGINT", async () => {
500
+ log("info", "Shutting down...");
501
+ await downstream.disconnect();
502
+ await server.close();
503
+ process.exit(0);
504
+ });
505
+ process.on("SIGTERM", async () => {
506
+ log("info", "Shutting down...");
507
+ await downstream.disconnect();
508
+ await server.close();
509
+ process.exit(0);
510
+ });
511
+ }
512
+ main().catch((err) => {
513
+ log("error", "Fatal error", err);
514
+ process.exit(1);
515
+ });
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "buddy-mcp-proxy",
3
+ "version": "0.1.0",
4
+ "description": "MCP proxy server for buddy.nvim — connect AI agents to Neovim sessions",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "buddy-mcp-proxy": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "start": "node dist/index.js",
16
+ "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/index.js --banner:js='#!/usr/bin/env node' --packages=external",
17
+ "prepublishOnly": "npm run build",
18
+ "dev": "bun run src/index.ts",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "neovim",
24
+ "nvim",
25
+ "buddy",
26
+ "ai",
27
+ "model-context-protocol",
28
+ "editor",
29
+ "proxy"
30
+ ],
31
+ "author": "arismoko",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/arismoko/buddy.nvim.git",
36
+ "directory": "mcp/buddy-mcp-proxy"
37
+ },
38
+ "homepage": "https://github.com/arismoko/buddy.nvim/tree/main/mcp/buddy-mcp-proxy",
39
+ "bugs": {
40
+ "url": "https://github.com/arismoko/buddy.nvim/issues"
41
+ },
42
+ "engines": {
43
+ "node": ">=20.0.0"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.0.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^20.0.0",
50
+ "esbuild": "^0.20.0",
51
+ "typescript": "^5.4.0"
52
+ }
53
+ }