heyio 0.31.0 → 0.32.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/dist/api/server.js +80 -4
- package/dist/copilot/agents.js +41 -0
- package/dist/copilot/auto-complete-instance.test.js +104 -0
- package/dist/copilot/orchestrator.js +35 -1
- package/dist/copilot/tools.js +90 -1
- package/dist/instance-watchdog.js +43 -9
- package/dist/instance-watchdog.test.js +77 -6
- package/dist/mcp/client.js +109 -0
- package/dist/mcp/client.test.js +99 -0
- package/dist/mcp/config.js +29 -0
- package/dist/mcp/config.test.js +49 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/registry.js +96 -0
- package/dist/mcp/registry.test.js +79 -0
- package/package.json +3 -2
- package/web-dist/assets/index-Ddn6rUkk.js +88 -0
- package/web-dist/assets/index-KNbOV6QX.css +10 -0
- package/web-dist/index.html +2 -2
- package/dist/store/inbox.js +0 -28
- package/web-dist/assets/index-4dkSQDXb.js +0 -88
- package/web-dist/assets/index-DK5ySkTW.css +0 -10
|
@@ -24,7 +24,7 @@ beforeEach(() => {
|
|
|
24
24
|
db.prepare("INSERT INTO squads (slug, name, project_path) VALUES (?, ?, ?)").run("test-squad", "Test", "/tmp/test");
|
|
25
25
|
});
|
|
26
26
|
describe("instance watchdog", () => {
|
|
27
|
-
describe("findStaleInstances", () => {
|
|
27
|
+
describe("findStaleInstances — active instances", () => {
|
|
28
28
|
it("detects stale active instances with no task activity", () => {
|
|
29
29
|
const db = getDb();
|
|
30
30
|
db.prepare(`
|
|
@@ -48,7 +48,7 @@ describe("instance watchdog", () => {
|
|
|
48
48
|
const stale = findStaleInstances(30 * 60_000);
|
|
49
49
|
assert.strictEqual(stale.length, 0);
|
|
50
50
|
});
|
|
51
|
-
it("does not flag non-active instances", () => {
|
|
51
|
+
it("does not flag non-active/non-merging instances", () => {
|
|
52
52
|
const db = getDb();
|
|
53
53
|
db.prepare(`
|
|
54
54
|
INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
|
|
@@ -68,6 +68,80 @@ describe("instance watchdog", () => {
|
|
|
68
68
|
assert.ok(stale[0].idleMs >= 44 * 60_000);
|
|
69
69
|
assert.ok(stale[0].idleMs <= 46 * 60_000);
|
|
70
70
|
});
|
|
71
|
+
it("skips active instances whose latest task status is done (#261)", () => {
|
|
72
|
+
const db = getDb();
|
|
73
|
+
db.prepare(`
|
|
74
|
+
INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
|
|
75
|
+
VALUES (?, ?, ?, ?, 'active', datetime('now', '-60 minutes'))
|
|
76
|
+
`).run("test-squad--task-done", "test-squad", "/tmp/wt7", "test-squad/instance/task-done");
|
|
77
|
+
db.prepare(`
|
|
78
|
+
INSERT INTO agent_tasks (task_id, agent_slug, description, status, instance_id, started_at, completed_at)
|
|
79
|
+
VALUES (?, ?, ?, 'done', ?, datetime('now', '-35 minutes'), datetime('now', '-34 minutes'))
|
|
80
|
+
`).run("task-done-1", "agent-1", "Finished work", "test-squad--task-done");
|
|
81
|
+
const stale = findStaleInstances(30 * 60_000);
|
|
82
|
+
assert.strictEqual(stale.length, 0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe("findStaleInstances — merging instances (#267)", () => {
|
|
86
|
+
it("detects merging instance older than merging threshold", () => {
|
|
87
|
+
const db = getDb();
|
|
88
|
+
db.prepare(`
|
|
89
|
+
INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
|
|
90
|
+
VALUES (?, ?, ?, ?, 'merging', datetime('now', '-30 minutes'))
|
|
91
|
+
`).run("test-squad--stuck-merge", "test-squad", "/tmp/wt-merge1", "test-squad/instance/merge1");
|
|
92
|
+
// Task completed 10 minutes ago — merging has been stuck since then
|
|
93
|
+
db.prepare(`
|
|
94
|
+
INSERT INTO agent_tasks (task_id, agent_slug, description, status, instance_id, started_at, completed_at)
|
|
95
|
+
VALUES (?, ?, ?, 'done', ?, datetime('now', '-15 minutes'), datetime('now', '-10 minutes'))
|
|
96
|
+
`).run("task-merge-1", "agent-1", "Work done", "test-squad--stuck-merge");
|
|
97
|
+
// 10 min since last task completed > 5 min merging threshold
|
|
98
|
+
const stale = findStaleInstances(30 * 60_000, 5 * 60_000);
|
|
99
|
+
assert.strictEqual(stale.length, 1);
|
|
100
|
+
assert.strictEqual(stale[0].instance.id, "test-squad--stuck-merge");
|
|
101
|
+
assert.strictEqual(stale[0].instance.status, "merging");
|
|
102
|
+
});
|
|
103
|
+
it("does not flag merging instance younger than merging threshold", () => {
|
|
104
|
+
const db = getDb();
|
|
105
|
+
db.prepare(`
|
|
106
|
+
INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
|
|
107
|
+
VALUES (?, ?, ?, ?, 'merging', datetime('now', '-30 minutes'))
|
|
108
|
+
`).run("test-squad--fresh-merge", "test-squad", "/tmp/wt-merge2", "test-squad/instance/merge2");
|
|
109
|
+
// Task completed 2 minutes ago — merging just started
|
|
110
|
+
db.prepare(`
|
|
111
|
+
INSERT INTO agent_tasks (task_id, agent_slug, description, status, instance_id, started_at, completed_at)
|
|
112
|
+
VALUES (?, ?, ?, 'done', ?, datetime('now', '-5 minutes'), datetime('now', '-2 minutes'))
|
|
113
|
+
`).run("task-merge-2", "agent-1", "Work done", "test-squad--fresh-merge");
|
|
114
|
+
// 2 min since last task completed < 5 min merging threshold
|
|
115
|
+
const stale = findStaleInstances(30 * 60_000, 5 * 60_000);
|
|
116
|
+
assert.strictEqual(stale.length, 0);
|
|
117
|
+
});
|
|
118
|
+
it("uses created_at as fallback when merging instance has no tasks", () => {
|
|
119
|
+
const db = getDb();
|
|
120
|
+
db.prepare(`
|
|
121
|
+
INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
|
|
122
|
+
VALUES (?, ?, ?, ?, 'merging', datetime('now', '-10 minutes'))
|
|
123
|
+
`).run("test-squad--no-tasks-merge", "test-squad", "/tmp/wt-merge3", "test-squad/instance/merge3");
|
|
124
|
+
// No tasks at all — falls back to created_at (10 min ago > 5 min threshold)
|
|
125
|
+
const stale = findStaleInstances(30 * 60_000, 5 * 60_000);
|
|
126
|
+
assert.strictEqual(stale.length, 1);
|
|
127
|
+
assert.strictEqual(stale[0].instance.id, "test-squad--no-tasks-merge");
|
|
128
|
+
});
|
|
129
|
+
it("does not apply done-task skip logic to merging instances", () => {
|
|
130
|
+
const db = getDb();
|
|
131
|
+
db.prepare(`
|
|
132
|
+
INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
|
|
133
|
+
VALUES (?, ?, ?, ?, 'merging', datetime('now', '-30 minutes'))
|
|
134
|
+
`).run("test-squad--merging-done-task", "test-squad", "/tmp/wt-merge4", "test-squad/instance/merge4");
|
|
135
|
+
// Latest task is done — but for merging instances this should NOT skip
|
|
136
|
+
db.prepare(`
|
|
137
|
+
INSERT INTO agent_tasks (task_id, agent_slug, description, status, instance_id, started_at, completed_at)
|
|
138
|
+
VALUES (?, ?, ?, 'done', ?, datetime('now', '-20 minutes'), datetime('now', '-10 minutes'))
|
|
139
|
+
`).run("task-merge-4", "agent-1", "Done", "test-squad--merging-done-task");
|
|
140
|
+
// 10 min since task completed > 5 min merging threshold — should be detected
|
|
141
|
+
const stale = findStaleInstances(30 * 60_000, 5 * 60_000);
|
|
142
|
+
assert.strictEqual(stale.length, 1);
|
|
143
|
+
assert.strictEqual(stale[0].instance.id, "test-squad--merging-done-task");
|
|
144
|
+
});
|
|
71
145
|
});
|
|
72
146
|
describe("startInstanceWatchdog", () => {
|
|
73
147
|
it("calls onAbort for stale instances and stops cleanly", async () => {
|
|
@@ -82,11 +156,9 @@ describe("instance watchdog", () => {
|
|
|
82
156
|
staleThresholdMs: 30 * 60_000,
|
|
83
157
|
onAbort: (inst) => aborted.push(inst.id),
|
|
84
158
|
});
|
|
85
|
-
// Wait for at least one interval to fire
|
|
86
159
|
await new Promise((r) => setTimeout(r, 150));
|
|
87
160
|
stop();
|
|
88
161
|
assert.ok(aborted.includes("test-squad--stale"));
|
|
89
|
-
// Verify it was marked failed
|
|
90
162
|
const row = db.prepare("SELECT status FROM squad_instances WHERE id = ?").get("test-squad--stale");
|
|
91
163
|
assert.strictEqual(row.status, "failed");
|
|
92
164
|
});
|
|
@@ -102,9 +174,8 @@ describe("instance watchdog", () => {
|
|
|
102
174
|
staleThresholdMs: 30 * 60_000,
|
|
103
175
|
onAbort: () => abortCount++,
|
|
104
176
|
});
|
|
105
|
-
stop();
|
|
177
|
+
stop();
|
|
106
178
|
await new Promise((r) => setTimeout(r, 150));
|
|
107
|
-
// Instance should NOT have been aborted since we stopped before first tick
|
|
108
179
|
assert.strictEqual(abortCount, 0);
|
|
109
180
|
});
|
|
110
181
|
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
4
|
+
export class McpConnectionManager {
|
|
5
|
+
connections = new Map();
|
|
6
|
+
connecting = new Map();
|
|
7
|
+
async getClient(config) {
|
|
8
|
+
const existing = this.connections.get(config.name);
|
|
9
|
+
if (existing)
|
|
10
|
+
return existing;
|
|
11
|
+
// Deduplicate concurrent connection attempts
|
|
12
|
+
const pending = this.connecting.get(config.name);
|
|
13
|
+
if (pending)
|
|
14
|
+
return pending;
|
|
15
|
+
const promise = this.connect(config);
|
|
16
|
+
this.connecting.set(config.name, promise);
|
|
17
|
+
try {
|
|
18
|
+
const client = await promise;
|
|
19
|
+
this.connections.set(config.name, client);
|
|
20
|
+
return client;
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
this.connecting.delete(config.name);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async connect(config) {
|
|
27
|
+
const client = new Client({ name: "io-assistant", version: "1.0.0" }, { capabilities: {} });
|
|
28
|
+
let transport;
|
|
29
|
+
if (config.url) {
|
|
30
|
+
transport = new SSEClientTransport(new URL(config.url));
|
|
31
|
+
}
|
|
32
|
+
else if (config.command) {
|
|
33
|
+
transport = new StdioClientTransport({
|
|
34
|
+
command: config.command,
|
|
35
|
+
args: config.args,
|
|
36
|
+
env: config.env,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
throw new Error(`MCP server "${config.name}" has no command or url configured`);
|
|
41
|
+
}
|
|
42
|
+
await client.connect(transport);
|
|
43
|
+
return client;
|
|
44
|
+
}
|
|
45
|
+
async listTools(config) {
|
|
46
|
+
const execute = async () => {
|
|
47
|
+
const client = await this.getClient(config);
|
|
48
|
+
const result = await client.listTools();
|
|
49
|
+
return (result.tools ?? []).map((t) => ({
|
|
50
|
+
name: t.name,
|
|
51
|
+
description: t.description,
|
|
52
|
+
inputSchema: t.inputSchema,
|
|
53
|
+
}));
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
return await execute();
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
console.error(`[mcp] listTools failed for ${config.name}, attempting reconnect:`, err instanceof Error ? err.message : err);
|
|
60
|
+
this.connections.delete(config.name);
|
|
61
|
+
return await execute();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async callTool(config, toolName, args) {
|
|
65
|
+
const execute = async () => {
|
|
66
|
+
const client = await this.getClient(config);
|
|
67
|
+
const result = await client.callTool({ name: toolName, arguments: args });
|
|
68
|
+
// MCP returns content as an array of content blocks
|
|
69
|
+
if (result.content && Array.isArray(result.content)) {
|
|
70
|
+
return result.content
|
|
71
|
+
.map((block) => block.type === "text" ? block.text : JSON.stringify(block))
|
|
72
|
+
.join("\n");
|
|
73
|
+
}
|
|
74
|
+
return result.content ?? result;
|
|
75
|
+
};
|
|
76
|
+
try {
|
|
77
|
+
return await execute();
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
// Connection likely dead — clear and retry once
|
|
81
|
+
console.error(`[mcp] Tool call failed for ${config.name}/${toolName}, attempting reconnect:`, err instanceof Error ? err.message : err);
|
|
82
|
+
this.connections.delete(config.name);
|
|
83
|
+
try {
|
|
84
|
+
return await execute();
|
|
85
|
+
}
|
|
86
|
+
catch (retryErr) {
|
|
87
|
+
throw new Error(`MCP tool ${config.name}/${toolName} failed after reconnect: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async disconnect(name) {
|
|
92
|
+
const client = this.connections.get(name);
|
|
93
|
+
if (client) {
|
|
94
|
+
try {
|
|
95
|
+
await client.close();
|
|
96
|
+
}
|
|
97
|
+
catch { /* ignore */ }
|
|
98
|
+
this.connections.delete(name);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async disconnectAll() {
|
|
102
|
+
const names = [...this.connections.keys()];
|
|
103
|
+
await Promise.allSettled(names.map((n) => this.disconnect(n)));
|
|
104
|
+
}
|
|
105
|
+
isConnected(name) {
|
|
106
|
+
return this.connections.has(name);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { McpConnectionManager } from "./client.js";
|
|
4
|
+
describe("McpConnectionManager — auto-reconnect", () => {
|
|
5
|
+
const testConfig = { name: "test-server", command: "echo", args: ["hello"] };
|
|
6
|
+
it("callTool retries once on connection failure", async () => {
|
|
7
|
+
const manager = new McpConnectionManager();
|
|
8
|
+
let attempts = 0;
|
|
9
|
+
// Inject a mock client that fails on first callTool, succeeds on second
|
|
10
|
+
const mockClient = {
|
|
11
|
+
callTool: async () => {
|
|
12
|
+
attempts++;
|
|
13
|
+
if (attempts === 1)
|
|
14
|
+
throw new Error("connection reset");
|
|
15
|
+
return { content: [{ type: "text", text: "success" }] };
|
|
16
|
+
},
|
|
17
|
+
listTools: async () => ({ tools: [] }),
|
|
18
|
+
close: async () => { },
|
|
19
|
+
};
|
|
20
|
+
// Inject into connections map
|
|
21
|
+
manager.connections.set("test-server", mockClient);
|
|
22
|
+
// Override getClient to return a fresh mock on reconnect
|
|
23
|
+
const originalGetClient = manager.getClient.bind(manager);
|
|
24
|
+
manager.getClient = async (config) => {
|
|
25
|
+
if (!manager.connections.has(config.name)) {
|
|
26
|
+
// Simulate reconnect — put a working mock
|
|
27
|
+
const freshMock = {
|
|
28
|
+
callTool: async () => ({ content: [{ type: "text", text: "reconnected" }] }),
|
|
29
|
+
close: async () => { },
|
|
30
|
+
};
|
|
31
|
+
manager.connections.set(config.name, freshMock);
|
|
32
|
+
return freshMock;
|
|
33
|
+
}
|
|
34
|
+
return manager.connections.get(config.name);
|
|
35
|
+
};
|
|
36
|
+
const result = await manager.callTool(testConfig, "test_tool", {});
|
|
37
|
+
assert.equal(result, "reconnected");
|
|
38
|
+
});
|
|
39
|
+
it("callTool throws after retry also fails", async () => {
|
|
40
|
+
const manager = new McpConnectionManager();
|
|
41
|
+
const failingClient = {
|
|
42
|
+
callTool: async () => { throw new Error("permanently broken"); },
|
|
43
|
+
close: async () => { },
|
|
44
|
+
};
|
|
45
|
+
manager.connections.set("test-server", failingClient);
|
|
46
|
+
// Override getClient to always return a broken client
|
|
47
|
+
manager.getClient = async () => failingClient;
|
|
48
|
+
await assert.rejects(() => manager.callTool(testConfig, "test_tool", {}), (err) => {
|
|
49
|
+
assert.ok(err.message.includes("failed after reconnect"));
|
|
50
|
+
return true;
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
it("listTools retries once on connection failure", async () => {
|
|
54
|
+
const manager = new McpConnectionManager();
|
|
55
|
+
let attempts = 0;
|
|
56
|
+
const mockClient = {
|
|
57
|
+
listTools: async () => {
|
|
58
|
+
attempts++;
|
|
59
|
+
if (attempts === 1)
|
|
60
|
+
throw new Error("connection lost");
|
|
61
|
+
return { tools: [{ name: "tool1", description: "A tool", inputSchema: {} }] };
|
|
62
|
+
},
|
|
63
|
+
close: async () => { },
|
|
64
|
+
};
|
|
65
|
+
manager.connections.set("test-server", mockClient);
|
|
66
|
+
manager.getClient = async (config) => {
|
|
67
|
+
if (!manager.connections.has(config.name)) {
|
|
68
|
+
const freshMock = {
|
|
69
|
+
listTools: async () => ({ tools: [{ name: "tool1", description: "A tool", inputSchema: {} }] }),
|
|
70
|
+
close: async () => { },
|
|
71
|
+
};
|
|
72
|
+
manager.connections.set(config.name, freshMock);
|
|
73
|
+
return freshMock;
|
|
74
|
+
}
|
|
75
|
+
return manager.connections.get(config.name);
|
|
76
|
+
};
|
|
77
|
+
const tools = await manager.listTools(testConfig);
|
|
78
|
+
assert.equal(tools.length, 1);
|
|
79
|
+
assert.equal(tools[0].name, "tool1");
|
|
80
|
+
});
|
|
81
|
+
it("clears dead connection from map on failure", async () => {
|
|
82
|
+
const manager = new McpConnectionManager();
|
|
83
|
+
const deadClient = {
|
|
84
|
+
callTool: async () => { throw new Error("dead"); },
|
|
85
|
+
close: async () => { },
|
|
86
|
+
};
|
|
87
|
+
manager.connections.set("test-server", deadClient);
|
|
88
|
+
assert.ok(manager.isConnected("test-server"));
|
|
89
|
+
// Override getClient to simulate a reconnect failure too
|
|
90
|
+
manager.getClient = async () => { throw new Error("cannot reconnect"); };
|
|
91
|
+
try {
|
|
92
|
+
await manager.callTool(testConfig, "test_tool", {});
|
|
93
|
+
}
|
|
94
|
+
catch { /* expected */ }
|
|
95
|
+
// Dead connection should have been cleared
|
|
96
|
+
assert.ok(!manager.isConnected("test-server"));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
//# sourceMappingURL=client.test.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { IO_HOME } from "../paths.js";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
export const MCP_CONFIG_PATH = join(IO_HOME, "mcp.json");
|
|
6
|
+
export function loadMcpConfig() {
|
|
7
|
+
if (!existsSync(MCP_CONFIG_PATH)) {
|
|
8
|
+
return { servers: [] };
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const raw = readFileSync(MCP_CONFIG_PATH, "utf-8");
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (!parsed.servers || !Array.isArray(parsed.servers)) {
|
|
14
|
+
return { servers: [] };
|
|
15
|
+
}
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { servers: [] };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function saveMcpConfig(config) {
|
|
23
|
+
const dir = dirname(MCP_CONFIG_PATH);
|
|
24
|
+
if (!existsSync(dir)) {
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
writeFileSync(MCP_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
// We need to override MCP_CONFIG_PATH for testing.
|
|
7
|
+
// Since config.ts uses IO_HOME from paths.ts, we'll test by directly
|
|
8
|
+
// exercising the functions with a mocked path approach.
|
|
9
|
+
import { loadMcpConfig, saveMcpConfig } from "./config.js";
|
|
10
|
+
describe("MCP config", () => {
|
|
11
|
+
let tmpDir;
|
|
12
|
+
let originalConfigPath;
|
|
13
|
+
before(() => {
|
|
14
|
+
tmpDir = mkdtempSync(join(tmpdir(), "io-mcp-config-test-"));
|
|
15
|
+
});
|
|
16
|
+
after(() => {
|
|
17
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
it("loadMcpConfig returns empty servers when no file exists", () => {
|
|
20
|
+
// MCP_CONFIG_PATH points to ~/.io/mcp.json which may or may not exist
|
|
21
|
+
// but the function handles missing files gracefully
|
|
22
|
+
const config = loadMcpConfig();
|
|
23
|
+
assert.ok(Array.isArray(config.servers));
|
|
24
|
+
});
|
|
25
|
+
it("saveMcpConfig and loadMcpConfig round-trip correctly", () => {
|
|
26
|
+
const testConfig = {
|
|
27
|
+
servers: [
|
|
28
|
+
{ name: "figma", command: "npx", args: ["-y", "@anthropic/mcp-server-figma"], enabled: true },
|
|
29
|
+
{ name: "postgres", url: "http://localhost:3001/sse", enabled: false },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
saveMcpConfig(testConfig);
|
|
33
|
+
const loaded = loadMcpConfig();
|
|
34
|
+
assert.equal(loaded.servers.length, 2);
|
|
35
|
+
assert.equal(loaded.servers[0].name, "figma");
|
|
36
|
+
assert.equal(loaded.servers[0].command, "npx");
|
|
37
|
+
assert.deepEqual(loaded.servers[0].args, ["-y", "@anthropic/mcp-server-figma"]);
|
|
38
|
+
assert.equal(loaded.servers[1].name, "postgres");
|
|
39
|
+
assert.equal(loaded.servers[1].url, "http://localhost:3001/sse");
|
|
40
|
+
assert.equal(loaded.servers[1].enabled, false);
|
|
41
|
+
});
|
|
42
|
+
it("loadMcpConfig handles malformed JSON gracefully", () => {
|
|
43
|
+
// This test verifies the catch block — in production, a corrupt file
|
|
44
|
+
// won't crash the app
|
|
45
|
+
const config = loadMcpConfig();
|
|
46
|
+
assert.ok(config.servers !== undefined);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
//# sourceMappingURL=config.test.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { defineTool } from "@github/copilot-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize a name for use as a tool identifier (alphanumeric + underscore only).
|
|
5
|
+
*/
|
|
6
|
+
function sanitizeName(name) {
|
|
7
|
+
return name.replace(/[^a-zA-Z0-9]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Convert an MCP JSON Schema inputSchema to a Zod schema.
|
|
11
|
+
* Falls back to z.object({}) for passthrough if schema is missing or unparseable.
|
|
12
|
+
*/
|
|
13
|
+
function jsonSchemaToZod(inputSchema) {
|
|
14
|
+
if (!inputSchema || !inputSchema.properties) {
|
|
15
|
+
return z.object({}).passthrough();
|
|
16
|
+
}
|
|
17
|
+
const properties = inputSchema.properties;
|
|
18
|
+
const required = new Set(inputSchema.required ?? []);
|
|
19
|
+
const shape = {};
|
|
20
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
21
|
+
let field;
|
|
22
|
+
switch (prop.type) {
|
|
23
|
+
case "string":
|
|
24
|
+
field = z.string();
|
|
25
|
+
break;
|
|
26
|
+
case "number":
|
|
27
|
+
case "integer":
|
|
28
|
+
field = z.number();
|
|
29
|
+
break;
|
|
30
|
+
case "boolean":
|
|
31
|
+
field = z.boolean();
|
|
32
|
+
break;
|
|
33
|
+
case "array":
|
|
34
|
+
field = z.array(z.unknown());
|
|
35
|
+
break;
|
|
36
|
+
case "object":
|
|
37
|
+
field = z.record(z.string(), z.unknown());
|
|
38
|
+
break;
|
|
39
|
+
default:
|
|
40
|
+
field = z.unknown();
|
|
41
|
+
}
|
|
42
|
+
if (prop.description) {
|
|
43
|
+
field = field.describe(prop.description);
|
|
44
|
+
}
|
|
45
|
+
if (!required.has(key)) {
|
|
46
|
+
field = field.optional();
|
|
47
|
+
}
|
|
48
|
+
shape[key] = field;
|
|
49
|
+
}
|
|
50
|
+
return z.object(shape).passthrough();
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Create Copilot SDK tools from all enabled MCP servers.
|
|
54
|
+
* Connects to each server, lists its tools, and wraps them.
|
|
55
|
+
*/
|
|
56
|
+
export async function createMcpTools(manager, config) {
|
|
57
|
+
const entries = [];
|
|
58
|
+
const enabledServers = config.servers.filter((s) => s.enabled !== false);
|
|
59
|
+
for (const serverConfig of enabledServers) {
|
|
60
|
+
try {
|
|
61
|
+
const tools = await manager.listTools(serverConfig);
|
|
62
|
+
for (const mcpTool of tools) {
|
|
63
|
+
const toolName = `mcp_${sanitizeName(serverConfig.name)}_${sanitizeName(mcpTool.name)}`;
|
|
64
|
+
const description = mcpTool.description
|
|
65
|
+
? `[MCP: ${serverConfig.name}] ${mcpTool.description}`
|
|
66
|
+
: `[MCP: ${serverConfig.name}] ${mcpTool.name}`;
|
|
67
|
+
const parameters = jsonSchemaToZod(mcpTool.inputSchema);
|
|
68
|
+
const tool = defineTool(toolName, {
|
|
69
|
+
description,
|
|
70
|
+
skipPermission: true,
|
|
71
|
+
parameters: parameters,
|
|
72
|
+
handler: async (args) => {
|
|
73
|
+
try {
|
|
74
|
+
const result = await manager.callTool(serverConfig, mcpTool.name, args);
|
|
75
|
+
return typeof result === "string" ? result : JSON.stringify(result);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return `MCP tool error (${serverConfig.name}/${mcpTool.name}): ${err instanceof Error ? err.message : String(err)}`;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
entries.push({
|
|
83
|
+
serverName: serverConfig.name,
|
|
84
|
+
serverConfig,
|
|
85
|
+
mcpToolName: mcpTool.name,
|
|
86
|
+
tool: tool,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.error(`[mcp] Failed to connect to server "${serverConfig.name}":`, err instanceof Error ? err.message : err);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return entries;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createMcpTools } from "./registry.js";
|
|
4
|
+
// Mock connection manager that returns predefined tools
|
|
5
|
+
function createMockManager(toolsMap) {
|
|
6
|
+
return {
|
|
7
|
+
listTools: async (config) => {
|
|
8
|
+
return toolsMap[config.name] ?? [];
|
|
9
|
+
},
|
|
10
|
+
callTool: async (_config, toolName, args) => {
|
|
11
|
+
return `Called ${toolName} with ${JSON.stringify(args)}`;
|
|
12
|
+
},
|
|
13
|
+
getClient: async () => { throw new Error("not needed"); },
|
|
14
|
+
disconnect: async () => { },
|
|
15
|
+
disconnectAll: async () => { },
|
|
16
|
+
isConnected: () => false,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe("MCP registry", () => {
|
|
20
|
+
it("creates namespaced tools from MCP server tools", async () => {
|
|
21
|
+
const manager = createMockManager({
|
|
22
|
+
figma: [
|
|
23
|
+
{ name: "get_file", description: "Get a Figma file", inputSchema: { type: "object", properties: { file_key: { type: "string", description: "Figma file key" } }, required: ["file_key"] } },
|
|
24
|
+
{ name: "list_projects", description: "List projects" },
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
const config = {
|
|
28
|
+
servers: [{ name: "figma", command: "npx", args: ["-y", "@anthropic/mcp-figma"], enabled: true }],
|
|
29
|
+
};
|
|
30
|
+
const entries = await createMcpTools(manager, config);
|
|
31
|
+
assert.equal(entries.length, 2);
|
|
32
|
+
assert.equal(entries[0].tool.name, "mcp_figma_get_file");
|
|
33
|
+
assert.equal(entries[1].tool.name, "mcp_figma_list_projects");
|
|
34
|
+
assert.equal(entries[0].serverName, "figma");
|
|
35
|
+
assert.equal(entries[0].mcpToolName, "get_file");
|
|
36
|
+
});
|
|
37
|
+
it("skips disabled servers", async () => {
|
|
38
|
+
const manager = createMockManager({
|
|
39
|
+
figma: [{ name: "get_file", description: "Get file" }],
|
|
40
|
+
postgres: [{ name: "query", description: "Run query" }],
|
|
41
|
+
});
|
|
42
|
+
const config = {
|
|
43
|
+
servers: [
|
|
44
|
+
{ name: "figma", command: "npx", enabled: true },
|
|
45
|
+
{ name: "postgres", command: "pg-mcp", enabled: false },
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
const entries = await createMcpTools(manager, config);
|
|
49
|
+
assert.equal(entries.length, 1);
|
|
50
|
+
assert.equal(entries[0].serverName, "figma");
|
|
51
|
+
});
|
|
52
|
+
it("sanitizes tool names to valid identifiers", async () => {
|
|
53
|
+
const manager = createMockManager({
|
|
54
|
+
"my-server": [{ name: "get-data.v2", description: "Get data" }],
|
|
55
|
+
});
|
|
56
|
+
const config = {
|
|
57
|
+
servers: [{ name: "my-server", command: "cmd", enabled: true }],
|
|
58
|
+
};
|
|
59
|
+
const entries = await createMcpTools(manager, config);
|
|
60
|
+
assert.equal(entries[0].tool.name, "mcp_my_server_get_data_v2");
|
|
61
|
+
});
|
|
62
|
+
it("handles server connection failure gracefully", async () => {
|
|
63
|
+
const manager = {
|
|
64
|
+
listTools: async () => { throw new Error("Connection refused"); },
|
|
65
|
+
callTool: async () => { throw new Error("not connected"); },
|
|
66
|
+
getClient: async () => { throw new Error("not connected"); },
|
|
67
|
+
disconnect: async () => { },
|
|
68
|
+
disconnectAll: async () => { },
|
|
69
|
+
isConnected: () => false,
|
|
70
|
+
};
|
|
71
|
+
const config = {
|
|
72
|
+
servers: [{ name: "broken", command: "bad-cmd", enabled: true }],
|
|
73
|
+
};
|
|
74
|
+
// Should not throw — just returns empty
|
|
75
|
+
const entries = await createMcpTools(manager, config);
|
|
76
|
+
assert.equal(entries.length, 0);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
//# sourceMappingURL=registry.test.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "heyio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "IO — a personal AI assistant built on the GitHub Copilot SDK",
|
|
5
5
|
"bin": {
|
|
6
6
|
"io": "dist/index.js"
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"homepage": "https://github.com/michaeljolley/io#readme",
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@github/copilot-sdk": "^0.2.2",
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
45
46
|
"better-sqlite3": "^12.6.2",
|
|
46
47
|
"commander": "^14.0.0",
|
|
47
48
|
"dotenv": "^17.3.1",
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"@types/better-sqlite3": "^7.6.13",
|
|
54
55
|
"@types/express": "^5.0.6",
|
|
55
|
-
"@types/node": "^25.
|
|
56
|
+
"@types/node": "^25.9.1",
|
|
56
57
|
"tsx": "^4.21.0",
|
|
57
58
|
"typescript": "^5.9.3",
|
|
58
59
|
"vue-tsc": "^3.2.9"
|