heyio 0.30.1 → 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.
@@ -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,4 @@
1
+ export { loadMcpConfig, saveMcpConfig, MCP_CONFIG_PATH } from "./config.js";
2
+ export { McpConnectionManager } from "./client.js";
3
+ export { createMcpTools } from "./registry.js";
4
+ //# sourceMappingURL=index.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/dist/store/db.js CHANGED
@@ -162,7 +162,7 @@ GROUP BY agent_slug`,
162
162
  )`,
163
163
  `CREATE TABLE IF NOT EXISTS unified_feed (
164
164
  id INTEGER PRIMARY KEY AUTOINCREMENT,
165
- type TEXT NOT NULL CHECK(type IN ('deliverable', 'notification')),
165
+ type TEXT NOT NULL CHECK(type IN ('inbox', 'notification')),
166
166
  title TEXT NOT NULL,
167
167
  body TEXT NOT NULL,
168
168
  source_type TEXT,
@@ -193,6 +193,26 @@ GROUP BY agent_slug`,
193
193
  )`,
194
194
  `ALTER TABLE agent_tasks ADD COLUMN instance_id TEXT`,
195
195
  `CREATE INDEX IF NOT EXISTS idx_instance_decisions_instance ON instance_decisions(instance_id, merged_to_master)`,
196
+ `ALTER TABLE unified_feed RENAME TO unified_feed_old`,
197
+ `CREATE TABLE unified_feed (
198
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
199
+ type TEXT NOT NULL CHECK(type IN ('inbox', 'notification')),
200
+ title TEXT NOT NULL,
201
+ body TEXT NOT NULL,
202
+ source_type TEXT,
203
+ source_ref TEXT,
204
+ squad_slug TEXT,
205
+ instance_id TEXT,
206
+ task_id TEXT,
207
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
208
+ read_at DATETIME
209
+ )`,
210
+ `INSERT INTO unified_feed (id, type, title, body, source_type, source_ref, created_at, read_at)
211
+ SELECT id, CASE WHEN type='deliverable' THEN 'inbox' ELSE type END, title, body, source_type, source_ref, created_at, read_at
212
+ FROM unified_feed_old`,
213
+ `DROP TABLE unified_feed_old`,
214
+ `CREATE INDEX IF NOT EXISTS idx_unified_feed_type ON unified_feed(type, created_at)`,
215
+ `CREATE INDEX IF NOT EXISTS idx_unified_feed_unread ON unified_feed(read_at, created_at)`,
196
216
  ];
197
217
  for (const migration of migrations) {
198
218
  try {
@@ -2,9 +2,9 @@ import { getDb } from "./db.js";
2
2
  export function createFeedEntry(input) {
3
3
  const db = getDb();
4
4
  const info = db
5
- .prepare(`INSERT INTO unified_feed (type, title, body, source_type, source_ref)
6
- VALUES (?, ?, ?, ?, ?)`)
7
- .run(input.type, input.title, input.body, input.source_type ?? null, input.source_ref ?? null);
5
+ .prepare(`INSERT INTO unified_feed (type, title, body, source_type, source_ref, squad_slug, instance_id, task_id)
6
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
7
+ .run(input.type, input.title, input.body, input.source_type ?? null, input.source_ref ?? null, input.squad_slug ?? null, input.instance_id ?? null, input.task_id ?? null);
8
8
  return db
9
9
  .prepare("SELECT * FROM unified_feed WHERE id = ?")
10
10
  .get(info.lastInsertRowid);
@@ -27,8 +27,8 @@ beforeEach(() => {
27
27
  // ── createFeedEntry ───────────────────────────────────────────────────────────
28
28
  describe("createFeedEntry", () => {
29
29
  it("creates a deliverable entry with correct fields", () => {
30
- const entry = createFeedEntry({ type: "deliverable", title: "Task done", body: "Here are the results." });
31
- assert.equal(entry.type, "deliverable");
30
+ const entry = createFeedEntry({ type: "inbox", title: "Task done", body: "Here are the results." });
31
+ assert.equal(entry.type, "inbox");
32
32
  assert.equal(entry.title, "Task done");
33
33
  assert.equal(entry.body, "Here are the results.");
34
34
  assert.equal(entry.read_at, null);
@@ -54,7 +54,7 @@ describe("createFeedEntry", () => {
54
54
  assert.equal(entry.source_ref, JSON.stringify({ id: 42 }));
55
55
  });
56
56
  it("autoincrements ids", () => {
57
- const a = createFeedEntry({ type: "deliverable", title: "A", body: "a" });
57
+ const a = createFeedEntry({ type: "inbox", title: "A", body: "a" });
58
58
  const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
59
59
  assert.ok(b.id > a.id);
60
60
  });
@@ -62,7 +62,7 @@ describe("createFeedEntry", () => {
62
62
  // ── listFeedEntries ───────────────────────────────────────────────────────────
63
63
  describe("listFeedEntries", () => {
64
64
  it("returns all entries newest first", () => {
65
- createFeedEntry({ type: "deliverable", title: "First", body: "x" });
65
+ createFeedEntry({ type: "inbox", title: "First", body: "x" });
66
66
  createFeedEntry({ type: "notification", title: "Second", body: "y" });
67
67
  const entries = listFeedEntries();
68
68
  assert.equal(entries.length, 2);
@@ -70,14 +70,14 @@ describe("listFeedEntries", () => {
70
70
  assert.equal(entries[1].title, "First");
71
71
  });
72
72
  it("filters by type=deliverable", () => {
73
- createFeedEntry({ type: "deliverable", title: "D", body: "d" });
73
+ createFeedEntry({ type: "inbox", title: "D", body: "d" });
74
74
  createFeedEntry({ type: "notification", title: "N", body: "n" });
75
- const entries = listFeedEntries({ type: "deliverable" });
75
+ const entries = listFeedEntries({ type: "inbox" });
76
76
  assert.equal(entries.length, 1);
77
- assert.equal(entries[0].type, "deliverable");
77
+ assert.equal(entries[0].type, "inbox");
78
78
  });
79
79
  it("filters by type=notification", () => {
80
- createFeedEntry({ type: "deliverable", title: "D", body: "d" });
80
+ createFeedEntry({ type: "inbox", title: "D", body: "d" });
81
81
  createFeedEntry({ type: "notification", title: "N", body: "n" });
82
82
  const entries = listFeedEntries({ type: "notification" });
83
83
  assert.equal(entries.length, 1);
@@ -108,20 +108,20 @@ describe("countUnreadFeedEntries", () => {
108
108
  assert.equal(countUnreadFeedEntries(), 0);
109
109
  });
110
110
  it("increments on insert", () => {
111
- createFeedEntry({ type: "deliverable", title: "T", body: "b" });
111
+ createFeedEntry({ type: "inbox", title: "T", body: "b" });
112
112
  assert.equal(countUnreadFeedEntries(), 1);
113
113
  createFeedEntry({ type: "notification", title: "N", body: "b" });
114
114
  assert.equal(countUnreadFeedEntries(), 2);
115
115
  });
116
116
  it("decreases when marked read", () => {
117
- const e = createFeedEntry({ type: "deliverable", title: "T", body: "b" });
117
+ const e = createFeedEntry({ type: "inbox", title: "T", body: "b" });
118
118
  markFeedEntryRead(e.id);
119
119
  assert.equal(countUnreadFeedEntries(), 0);
120
120
  });
121
121
  it("filters by type", () => {
122
- createFeedEntry({ type: "deliverable", title: "D", body: "d" });
122
+ createFeedEntry({ type: "inbox", title: "D", body: "d" });
123
123
  createFeedEntry({ type: "notification", title: "N", body: "n" });
124
- assert.equal(countUnreadFeedEntries("deliverable"), 1);
124
+ assert.equal(countUnreadFeedEntries("inbox"), 1);
125
125
  assert.equal(countUnreadFeedEntries("notification"), 1);
126
126
  });
127
127
  });
@@ -158,11 +158,11 @@ describe("markAllFeedEntriesRead", () => {
158
158
  assert.equal(markAllFeedEntriesRead(), 0);
159
159
  });
160
160
  it("respects type filter — only marks matching type", () => {
161
- createFeedEntry({ type: "deliverable", title: "D", body: "d" });
161
+ createFeedEntry({ type: "inbox", title: "D", body: "d" });
162
162
  createFeedEntry({ type: "notification", title: "N", body: "n" });
163
163
  const count = markAllFeedEntriesRead("notification");
164
164
  assert.equal(count, 1);
165
- assert.equal(countUnreadFeedEntries("deliverable"), 1);
165
+ assert.equal(countUnreadFeedEntries("inbox"), 1);
166
166
  assert.equal(countUnreadFeedEntries("notification"), 0);
167
167
  });
168
168
  });
@@ -170,7 +170,7 @@ describe("markAllFeedEntriesRead", () => {
170
170
  describe("markFeedEntriesRead", () => {
171
171
  it("marks multiple entries read and returns change count", () => {
172
172
  const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
173
- const b = createFeedEntry({ type: "deliverable", title: "B", body: "b" });
173
+ const b = createFeedEntry({ type: "inbox", title: "B", body: "b" });
174
174
  const c = createFeedEntry({ type: "notification", title: "C", body: "c" });
175
175
  const count = markFeedEntriesRead([a.id, b.id, c.id]);
176
176
  assert.equal(count, 3);
@@ -182,7 +182,7 @@ describe("markFeedEntriesRead", () => {
182
182
  assert.equal(countUnreadFeedEntries(), 1);
183
183
  });
184
184
  it("works correctly for a single id", () => {
185
- const e = createFeedEntry({ type: "deliverable", title: "Solo", body: "b" });
185
+ const e = createFeedEntry({ type: "inbox", title: "Solo", body: "b" });
186
186
  assert.equal(markFeedEntriesRead([e.id]), 1);
187
187
  const entries = listFeedEntries();
188
188
  assert.ok(entries[0].read_at !== null);
@@ -211,13 +211,13 @@ describe("deleteFeedEntry", () => {
211
211
  assert.equal(deleteFeedEntry(9999), false);
212
212
  });
213
213
  it("returns true and removes the entry", () => {
214
- const e = createFeedEntry({ type: "deliverable", title: "T", body: "b" });
214
+ const e = createFeedEntry({ type: "inbox", title: "T", body: "b" });
215
215
  assert.equal(deleteFeedEntry(e.id), true);
216
216
  const entries = listFeedEntries();
217
217
  assert.equal(entries.find((x) => x.id === e.id), undefined);
218
218
  });
219
219
  it("second delete returns false (not idempotent)", () => {
220
- const e = createFeedEntry({ type: "deliverable", title: "T", body: "b" });
220
+ const e = createFeedEntry({ type: "inbox", title: "T", body: "b" });
221
221
  deleteFeedEntry(e.id);
222
222
  assert.equal(deleteFeedEntry(e.id), false);
223
223
  });
@@ -226,7 +226,7 @@ describe("deleteFeedEntry", () => {
226
226
  describe("deleteFeedEntries", () => {
227
227
  it("deletes multiple entries and returns change count", () => {
228
228
  const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
229
- const b = createFeedEntry({ type: "deliverable", title: "B", body: "b" });
229
+ const b = createFeedEntry({ type: "inbox", title: "B", body: "b" });
230
230
  const c = createFeedEntry({ type: "notification", title: "C", body: "c" });
231
231
  const count = deleteFeedEntries([a.id, b.id, c.id]);
232
232
  assert.equal(count, 3);
@@ -249,7 +249,7 @@ describe("deleteFeedEntries", () => {
249
249
  assert.equal(deleteFeedEntries([9991, 9992, 9993]), 0);
250
250
  });
251
251
  it("mix of existing and non-existent ids — only deletes what exists", () => {
252
- const e = createFeedEntry({ type: "deliverable", title: "Real", body: "b" });
252
+ const e = createFeedEntry({ type: "inbox", title: "Real", body: "b" });
253
253
  const count = deleteFeedEntries([e.id, 9999]);
254
254
  assert.equal(count, 1);
255
255
  assert.deepEqual(listFeedEntries(), []);
@@ -133,7 +133,7 @@ export function logDecision(squadSlug, decision, context) {
133
133
  }
134
134
  export function getDecisions(squadSlug, limit = 20) {
135
135
  return getDb()
136
- .prepare("SELECT * FROM squad_decisions WHERE squad_slug = ? ORDER BY created_at DESC LIMIT ?")
136
+ .prepare("SELECT * FROM squad_decisions WHERE squad_slug = ? ORDER BY created_at DESC, id DESC LIMIT ?")
137
137
  .all(squadSlug, limit);
138
138
  }
139
139
  export function getDecisionsSummary(squadSlug) {