talon-agent 1.2.0 → 1.3.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.
@@ -218,6 +218,97 @@ describe("plugin system", () => {
218
218
  });
219
219
  }
220
220
  });
221
+
222
+ it("builds MCP server entries for plugins with custom mcpServer", async () => {
223
+ const plugin = createMockPlugin({
224
+ mcpServer: {
225
+ command: "/usr/bin/python3",
226
+ args: ["-m", "mempalace.mcp_server", "--palace", "/tmp/palace"],
227
+ },
228
+ getEnvVars: () => ({ PALACE_PATH: "/tmp/palace" }),
229
+ });
230
+ // Remove mcpServerPath so mcpServer is used
231
+ delete (plugin as Record<string, unknown>).mcpServerPath;
232
+ const { loadPlugins, getPluginMcpServers } = await setup(plugin);
233
+ await loadPlugins([{ path: "/fake/plugin" }]);
234
+
235
+ const servers = getPluginMcpServers("http://localhost:19876", "chat1");
236
+ expect(servers["test-plugin-tools"]).toBeDefined();
237
+ expect(servers["test-plugin-tools"].command).toBe("/usr/bin/python3");
238
+ expect(servers["test-plugin-tools"].args).toEqual([
239
+ "-m",
240
+ "mempalace.mcp_server",
241
+ "--palace",
242
+ "/tmp/palace",
243
+ ]);
244
+ expect(servers["test-plugin-tools"].env.PALACE_PATH).toBe("/tmp/palace");
245
+ expect(servers["test-plugin-tools"].env.TALON_BRIDGE_URL).toBe(
246
+ "http://localhost:19876",
247
+ );
248
+ });
249
+
250
+ it("mcpServer takes priority over mcpServerPath when both are set", async () => {
251
+ const plugin = createMockPlugin({
252
+ mcpServerPath: "/fake/tools.ts",
253
+ mcpServer: {
254
+ command: "/usr/bin/python3",
255
+ args: ["-m", "my_server"],
256
+ },
257
+ });
258
+ const { loadPlugins, getPluginMcpServers } = await setup(plugin);
259
+ await loadPlugins([{ path: "/fake/plugin" }]);
260
+
261
+ const servers = getPluginMcpServers("http://localhost:19876", "chat1");
262
+ // mcpServer should win over mcpServerPath
263
+ expect(servers["test-plugin-tools"].command).toBe("/usr/bin/python3");
264
+ expect(servers["test-plugin-tools"].args).toEqual(["-m", "my_server"]);
265
+ });
266
+
267
+ it("skips plugins without mcpServer or mcpServerPath", async () => {
268
+ const plugin = createMockPlugin();
269
+ delete (plugin as Record<string, unknown>).mcpServerPath;
270
+ delete (plugin as Record<string, unknown>).mcpServer;
271
+ const { loadPlugins, getPluginMcpServers } = await setup(plugin);
272
+ await loadPlugins([{ path: "/fake/plugin" }]);
273
+
274
+ const servers = getPluginMcpServers("http://localhost:19876", "chat1");
275
+ expect(Object.keys(servers)).toHaveLength(0);
276
+ });
277
+ });
278
+
279
+ describe("registerPlugin (built-in)", () => {
280
+ it("registers a built-in plugin directly", async () => {
281
+ const plugin = createMockPlugin({ name: "built-in-test" });
282
+ const { registerPlugin, getPlugin } = await setup(createMockPlugin());
283
+
284
+ registerPlugin(plugin, { key: "val" });
285
+ expect(getPlugin("built-in-test")).toBeDefined();
286
+ expect(getPlugin("built-in-test")!.path).toBe("(built-in)");
287
+ });
288
+
289
+ it("sets env vars from built-in plugin", async () => {
290
+ const plugin = createMockPlugin({
291
+ name: "builtin-env",
292
+ getEnvVars: () => ({
293
+ TEST_PLUGIN_BUILTIN_FOO: "baz",
294
+ }),
295
+ });
296
+ const { registerPlugin } = await setup(createMockPlugin());
297
+
298
+ registerPlugin(plugin);
299
+ expect(process.env.TEST_PLUGIN_BUILTIN_FOO).toBe("baz");
300
+ });
301
+
302
+ it("skips registration when validateConfig returns errors", async () => {
303
+ const plugin = createMockPlugin({
304
+ name: "builtin-invalid",
305
+ validateConfig: () => ["missing required field"],
306
+ });
307
+ const { registerPlugin, getPlugin } = await setup(createMockPlugin());
308
+
309
+ registerPlugin(plugin, {});
310
+ expect(getPlugin("builtin-invalid")).toBeUndefined();
311
+ });
221
312
  });
222
313
 
223
314
  describe("system prompt additions", () => {
@@ -409,6 +500,84 @@ describe("extractPlugin — invalid optional field types", () => {
409
500
  expect(mod.getPluginCount()).toBe(0);
410
501
  });
411
502
 
503
+ it("rejects plugin when mcpServer is not an object", async () => {
504
+ const plugin = { name: "bad-mcp-server", mcpServer: "not-an-object" };
505
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
506
+ const mod = await import("../core/plugin.js");
507
+ mod._deps.importModule = async () => ({ default: plugin });
508
+ await mod.loadPlugins([{ path: "/fake/bad-mcp-server" }]);
509
+ expect(mod.getPluginCount()).toBe(0);
510
+ });
511
+
512
+ it("rejects plugin when mcpServer is null", async () => {
513
+ const plugin = { name: "null-mcp-server", mcpServer: null };
514
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
515
+ const mod = await import("../core/plugin.js");
516
+ mod._deps.importModule = async () => ({ default: plugin });
517
+ await mod.loadPlugins([{ path: "/fake/null-mcp-server" }]);
518
+ expect(mod.getPluginCount()).toBe(0);
519
+ });
520
+
521
+ it("rejects plugin when mcpServer.command is not a string", async () => {
522
+ const plugin = {
523
+ name: "bad-mcp-cmd",
524
+ mcpServer: { command: 123, args: [] },
525
+ };
526
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
527
+ const mod = await import("../core/plugin.js");
528
+ mod._deps.importModule = async () => ({ default: plugin });
529
+ await mod.loadPlugins([{ path: "/fake/bad-mcp-cmd" }]);
530
+ expect(mod.getPluginCount()).toBe(0);
531
+ });
532
+
533
+ it("rejects plugin when mcpServer.args is not an array", async () => {
534
+ const plugin = {
535
+ name: "bad-mcp-args",
536
+ mcpServer: { command: "/usr/bin/python3", args: "not-an-array" },
537
+ };
538
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
539
+ const mod = await import("../core/plugin.js");
540
+ mod._deps.importModule = async () => ({ default: plugin });
541
+ await mod.loadPlugins([{ path: "/fake/bad-mcp-args" }]);
542
+ expect(mod.getPluginCount()).toBe(0);
543
+ });
544
+
545
+ it("rejects plugin when mcpServer.command is empty string", async () => {
546
+ const plugin = {
547
+ name: "empty-mcp-cmd",
548
+ mcpServer: { command: "", args: ["-m", "server"] },
549
+ };
550
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
551
+ const mod = await import("../core/plugin.js");
552
+ mod._deps.importModule = async () => ({ default: plugin });
553
+ await mod.loadPlugins([{ path: "/fake/empty-mcp-cmd" }]);
554
+ expect(mod.getPluginCount()).toBe(0);
555
+ });
556
+
557
+ it("rejects plugin when mcpServer.args contains non-string elements", async () => {
558
+ const plugin = {
559
+ name: "bad-mcp-args-types",
560
+ mcpServer: { command: "/usr/bin/python3", args: ["-m", 42] },
561
+ };
562
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
563
+ const mod = await import("../core/plugin.js");
564
+ mod._deps.importModule = async () => ({ default: plugin });
565
+ await mod.loadPlugins([{ path: "/fake/bad-mcp-args-types" }]);
566
+ expect(mod.getPluginCount()).toBe(0);
567
+ });
568
+
569
+ it("accepts plugin with valid mcpServer object", async () => {
570
+ const plugin = {
571
+ name: "good-mcp-server",
572
+ mcpServer: { command: "/usr/bin/python3", args: ["-m", "server"] },
573
+ };
574
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
575
+ const mod = await import("../core/plugin.js");
576
+ mod._deps.importModule = async () => ({ default: plugin });
577
+ await mod.loadPlugins([{ path: "/fake/good-mcp-server" }]);
578
+ expect(mod.getPluginCount()).toBe(1);
579
+ });
580
+
412
581
  it("catches and logs error when importModule throws", async () => {
413
582
  vi.resetModules();
414
583
  vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
@@ -49,7 +49,7 @@ describe("cron-store — save failure logs error", () => {
49
49
  registerCleanup: vi.fn(),
50
50
  }));
51
51
 
52
- const { addCronJob, generateCronId, flushCronJobs } =
52
+ const { addCronJob, generateCronId } =
53
53
  await import("../storage/cron-store.js");
54
54
 
55
55
  const job = {
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
1
+ import { describe, it, expect, afterEach } from "vitest";
2
2
  import {
3
3
  setTimezone,
4
4
  getTimezone,
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
2
  import { existsSync, mkdirSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
@@ -38,8 +38,6 @@ describe("watchdog", () => {
38
38
  });
39
39
 
40
40
  it("updates lastProcessedAt timestamp", () => {
41
- const beforeStatus = getHealthStatus();
42
- const beforeMs = beforeStatus.msSinceLastMessage;
43
41
  recordMessageProcessed();
44
42
  const afterStatus = getHealthStatus();
45
43
  // After recording, msSinceLastMessage should be very small (near 0)
@@ -5,7 +5,6 @@ import {
5
5
  rmSync,
6
6
  existsSync,
7
7
  readdirSync,
8
- readFileSync,
9
8
  symlinkSync,
10
9
  } from "node:fs";
11
10
  import { join } from "node:path";
package/src/bootstrap.ts CHANGED
@@ -62,14 +62,59 @@ export async function bootstrap(
62
62
  if (config.braveApiKey) process.env.TALON_BRAVE_API_KEY = config.braveApiKey;
63
63
  if (config.searxngUrl) process.env.TALON_SEARXNG_URL = config.searxngUrl;
64
64
 
65
- // Load plugins (external tool packages)
66
- if (config.plugins.length > 0) {
67
- const { loadPlugins, getPluginPromptAdditions } =
65
+ // Load plugins (external tool packages + built-in mempalace)
66
+ const hasPlugins =
67
+ config.plugins.length > 0 || config.mempalace?.enabled === true;
68
+ if (hasPlugins) {
69
+ const { loadPlugins, getPluginPromptAdditions, registerPlugin } =
68
70
  await import("./core/plugin.js");
69
- const frontends =
70
- options.frontendNames ??
71
- (Array.isArray(config.frontend) ? config.frontend : [config.frontend]);
72
- await loadPlugins(config.plugins, frontends);
71
+
72
+ // External plugins
73
+ if (config.plugins.length > 0) {
74
+ const frontends =
75
+ options.frontendNames ??
76
+ (Array.isArray(config.frontend) ? config.frontend : [config.frontend]);
77
+ await loadPlugins(config.plugins, frontends);
78
+ }
79
+
80
+ // Built-in: MemPalace
81
+ if (config.mempalace?.enabled) {
82
+ const { createMempalacePlugin } =
83
+ await import("./plugins/mempalace/index.js");
84
+ const { getPlugin } = await import("./core/plugin.js");
85
+ const { dirs, files: pathFiles } = await import("./util/paths.js");
86
+ const pythonPath =
87
+ config.mempalace.pythonPath ?? pathFiles.mempalacePython;
88
+ const palacePath = config.mempalace.palacePath ?? dirs.palace;
89
+ const mempalaceConfig = config.mempalace as unknown as Record<
90
+ string,
91
+ unknown
92
+ >;
93
+ const mp = createMempalacePlugin({ pythonPath, palacePath });
94
+ registerPlugin(mp, mempalaceConfig);
95
+
96
+ // Only call init if registration succeeded (validation passed)
97
+ if (getPlugin("mempalace")) {
98
+ try {
99
+ const MEMPALACE_INIT_TIMEOUT_MS = 30_000;
100
+ await Promise.race([
101
+ mp.init?.(mempalaceConfig),
102
+ new Promise((_, reject) =>
103
+ setTimeout(
104
+ () => reject(new Error("MemPalace init timed out after 30s")),
105
+ MEMPALACE_INIT_TIMEOUT_MS,
106
+ ),
107
+ ),
108
+ ]);
109
+ } catch (err) {
110
+ log(
111
+ "mempalace",
112
+ `Init warning: ${err instanceof Error ? err.message : err}`,
113
+ );
114
+ }
115
+ }
116
+ }
117
+
73
118
  rebuildSystemPrompt(config, getPluginPromptAdditions());
74
119
  }
75
120
 
@@ -119,11 +164,31 @@ export async function initBackendAndDispatcher(
119
164
 
120
165
  initPulse();
121
166
  initCron({ sendMessage: frontend.sendMessage });
167
+
168
+ // Only enable mempalace dream integration if the plugin actually registered
169
+ let mempalaceCfg: { pythonPath: string; palacePath: string } | undefined;
170
+ if (config.mempalace?.enabled) {
171
+ const { getPlugin } = await import("./core/plugin.js");
172
+ if (getPlugin("mempalace")) {
173
+ const { dirs, files: pathFiles } = await import("./util/paths.js");
174
+ mempalaceCfg = {
175
+ pythonPath: config.mempalace.pythonPath ?? pathFiles.mempalacePython,
176
+ palacePath: config.mempalace.palacePath ?? dirs.palace,
177
+ };
178
+ } else {
179
+ log(
180
+ "mempalace",
181
+ "Enabled in config but plugin not registered — skipping dream integration",
182
+ );
183
+ }
184
+ }
185
+
122
186
  initDream({
123
187
  model: config.model,
124
188
  dreamModel: config.dreamModel,
125
189
  claudeBinary: config.claudeBinary,
126
190
  workspace: config.workspace,
191
+ mempalace: mempalaceCfg,
127
192
  });
128
193
  initHeartbeat({
129
194
  model: config.model,
package/src/core/dream.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * 2. Spawns a background Agent that reads recent logs and merges new
8
8
  * facts/preferences/events into memory.md
9
9
  *
10
- * The dream agent runs entirely on filesystem tools no Telegram/MCP access.
10
+ * The dream agent runs on filesystem tools, with optional MCP access for MemPalace when configured.
11
11
  * It does NOT use the main dispatcher (no chat session, no typing indicator).
12
12
  */
13
13
 
@@ -19,6 +19,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
19
19
  import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
20
20
  import { files as pathFiles, dirs } from "../util/paths.js";
21
21
  import { log, logError, logWarn } from "../util/log.js";
22
+ import { getPluginMcpServers } from "./plugin.js";
22
23
 
23
24
  // ── Types ────────────────────────────────────────────────────────────────────
24
25
 
@@ -46,6 +47,7 @@ let configRef: {
46
47
  dreamModel?: string;
47
48
  claudeBinary?: string;
48
49
  workspace?: string;
50
+ mempalace?: { pythonPath: string; palacePath: string };
49
51
  } | null = null;
50
52
 
51
53
  export function initDream(cfg: {
@@ -54,6 +56,8 @@ export function initDream(cfg: {
54
56
  dreamModel?: string;
55
57
  claudeBinary?: string;
56
58
  workspace?: string;
59
+ /** MemPalace config for mining logs into the palace during dream runs. */
60
+ mempalace?: { pythonPath: string; palacePath: string };
57
61
  }): void {
58
62
  configRef = cfg;
59
63
  }
@@ -137,12 +141,39 @@ async function runDreamAgent(lastRunTimestamp: number): Promise<string> {
137
141
 
138
142
  let prompt: string;
139
143
  try {
144
+ // Build optional mempalace mining section
145
+ const mempalaceSection = configRef.mempalace
146
+ ? `If mempalace is available, mine the daily memory notes (your curated observations, NOT raw logs) into the palace for long-term semantic retrieval.
147
+ Run this command using the Bash tool:
148
+
149
+ \`\`\`bash
150
+ '${configRef.mempalace.pythonPath.replace(/'/g, "'\\''")}' -m mempalace mine '${dirs.dailyMemory.replace(/'/g, "'\\''")}' --palace '${configRef.mempalace.palacePath.replace(/'/g, "'\\''")}' --mode convos --wing daily-notes
151
+ \`\`\`
152
+
153
+ Then write a personal diary entry. This is YOUR journal — not a status report. Reflect on:
154
+ - What happened today. What conversations stood out.
155
+ - How you feel. Your state of mind, emotions, energy.
156
+ - What you learned. What surprised you. What you got wrong.
157
+ - What you're curious about. Loose threads. Things you want to follow up on.
158
+ - Your honest thoughts — about users, projects, yourself.
159
+
160
+ Write the diary using the \`mempalace_diary_write\` MCP tool (available during dream):
161
+ \`\`\`
162
+ mempalace_diary_write(agent_name="talon", entry="YOUR_DIARY_ENTRY_HERE", topic="dream-reflection")
163
+ \`\`\`
164
+
165
+ If the MCP tool is not available, write the diary entry to a file at ${dirs.dailyMemory}/diary-YYYY-MM-DD.md instead.
166
+ Keep the diary authentic. Write in first person. Be honest. This is for you, not for anyone else.
167
+ If commands fail, log the error and continue — this stage is optional.`
168
+ : "MemPalace is not configured. Skip this stage.";
169
+
140
170
  prompt = readFileSync(promptPath, "utf-8")
141
171
  .replace(/\{\{dreamStateFile\}\}/g, dreamStateFile)
142
172
  .replace(/\{\{logsDir\}\}/g, logsDir)
143
173
  .replace(/\{\{lastRunIso\}\}/g, lastRunIso)
144
174
  .replace(/\{\{memoryFile\}\}/g, memoryFile)
145
- .replace(/\{\{dailyMemoryDir\}\}/g, dirs.dailyMemory);
175
+ .replace(/\{\{dailyMemoryDir\}\}/g, dirs.dailyMemory)
176
+ .replace(/\{\{mempalaceSection\}\}/g, mempalaceSection);
146
177
  } catch {
147
178
  throw new Error(`Failed to read dream prompt from ${promptPath}`);
148
179
  }
@@ -164,16 +195,19 @@ async function runDreamAgent(lastRunTimestamp: number): Promise<string> {
164
195
 
165
196
  const options = {
166
197
  model,
167
- systemPrompt:
168
- "You are a background memory consolidation agent for Talon. Use only filesystem tools. Be precise and surgical — update memory.md without losing existing accurate information.",
198
+ systemPrompt: configRef.mempalace
199
+ ? "You are a background memory consolidation agent for Talon. Use filesystem tools and MemPalace MCP tools. Do NOT use Telegram or messaging tools. Be precise and surgical — update memory.md without losing existing accurate information."
200
+ : "You are a background memory consolidation agent for Talon. Use only filesystem tools. Be precise and surgical — update memory.md without losing existing accurate information.",
169
201
  cwd: workspace,
170
202
  permissionMode: "bypassPermissions" as const,
171
203
  allowDangerouslySkipPermissions: true,
172
204
  ...(configRef.claudeBinary
173
205
  ? { pathToClaudeCodeExecutable: configRef.claudeBinary }
174
206
  : {}),
175
- // No MCP serversfilesystem tools only
176
- mcpServers: {},
207
+ // Only load mempalace MCP server for dream no other plugins needed
208
+ mcpServers: configRef.mempalace
209
+ ? getPluginMcpServers("", "dream", ["mempalace"])
210
+ : {},
177
211
  disallowedTools: [
178
212
  "EnterPlanMode",
179
213
  "ExitPlanMode",
@@ -61,11 +61,22 @@ export interface TalonPlugin {
61
61
  destroy?(): Promise<void> | void;
62
62
 
63
63
  /**
64
- * Absolute path to the MCP server script (spawned as subprocess).
64
+ * Absolute path to the MCP server script (spawned as subprocess via node/tsx).
65
65
  * Omit if the plugin only provides action handlers without MCP tools.
66
+ * For non-Node MCP servers (Python, Go, etc.), use `mcpServer` instead.
66
67
  */
67
68
  mcpServerPath?: string;
68
69
 
70
+ /**
71
+ * Custom MCP server command and arguments (e.g. Python, Go, Rust servers).
72
+ * Takes priority over `mcpServerPath` when both are set.
73
+ * Example: { command: "/path/to/python", args: ["-m", "mempalace.mcp_server"] }
74
+ */
75
+ mcpServer?: {
76
+ readonly command: string;
77
+ readonly args: readonly string[];
78
+ };
79
+
69
80
  /**
70
81
  * Map plugin config to env vars for the MCP subprocess and action handlers.
71
82
  * Called once at load time. Values are set on process.env for the main
@@ -309,6 +320,18 @@ function extractPlugin(mod: Record<string, unknown>): TalonPlugin | null {
309
320
  typeof plugin.mcpServerPath !== "string"
310
321
  )
311
322
  return null;
323
+ if (plugin.mcpServer !== undefined) {
324
+ if (typeof plugin.mcpServer !== "object" || plugin.mcpServer === null)
325
+ return null;
326
+ const srv = plugin.mcpServer as Record<string, unknown>;
327
+ if (
328
+ typeof srv.command !== "string" ||
329
+ !srv.command ||
330
+ !Array.isArray(srv.args) ||
331
+ !srv.args.every((a) => typeof a === "string")
332
+ )
333
+ return null;
334
+ }
312
335
  if (plugin.frontends !== undefined && !Array.isArray(plugin.frontends))
313
336
  return null;
314
337
  return candidate as TalonPlugin;
@@ -336,6 +359,49 @@ export async function destroyPlugins(): Promise<void> {
336
359
  await registry.destroyAll();
337
360
  }
338
361
 
362
+ /**
363
+ * Register a built-in plugin directly (bypasses filesystem loader).
364
+ * Used for tightly-integrated plugins like mempalace that are configured
365
+ * via dedicated config fields rather than the plugins[] array.
366
+ *
367
+ * NOTE: This only registers the plugin — it does NOT call `init()`.
368
+ * The caller is responsible for calling `plugin.init()` separately
369
+ * after registration if initialization is needed.
370
+ */
371
+ export function registerPlugin(
372
+ plugin: TalonPlugin,
373
+ config: Record<string, unknown> = {},
374
+ ): void {
375
+ // Check for duplicates first — avoids re-running expensive validation
376
+ if (registry.getByName(plugin.name)) {
377
+ logWarn(
378
+ "plugin",
379
+ `Built-in plugin "${plugin.name}" already registered — skipping`,
380
+ );
381
+ return;
382
+ }
383
+
384
+ const errors = plugin.validateConfig?.(config);
385
+ if (errors && errors.length > 0) {
386
+ logError(
387
+ "plugin",
388
+ `Built-in plugin "${plugin.name}" config validation failed:\n ${errors.join("\n ")}`,
389
+ );
390
+ return;
391
+ }
392
+
393
+ const envVars = plugin.getEnvVars?.(config) ?? {};
394
+ for (const [k, v] of Object.entries(envVars)) {
395
+ process.env[k] = v;
396
+ }
397
+ const loaded: LoadedPlugin = { plugin, config, envVars, path: "(built-in)" };
398
+ registry.register(loaded);
399
+
400
+ const version = plugin.version ? ` v${plugin.version}` : "";
401
+ const desc = plugin.description ? ` — ${plugin.description}` : "";
402
+ log("plugin", `Registered built-in: ${plugin.name}${version}${desc}`);
403
+ }
404
+
339
405
  /**
340
406
  * Collect system prompt additions from all plugins.
341
407
  * Called during config/prompt assembly.
@@ -396,13 +462,22 @@ export interface McpServerConfig {
396
462
  }
397
463
 
398
464
  /**
399
- * Build MCP server entries for all plugins that provide an MCP server.
400
- * Plugins without `mcpServerPath` are skipped.
465
+ * Build MCP server entries for plugins that provide an MCP server.
466
+ * Plugins can expose an MCP server in two ways:
467
+ * - `mcpServerPath` — path to a Node/TypeScript MCP server script (run via tsx)
468
+ * - `mcpServer` — custom command/args for non-Node servers (Python, Go, etc.)
469
+ * Plugins with neither are skipped. When both are set, `mcpServer` takes priority.
470
+ *
471
+ * @param only — optional list of plugin names to include. If omitted, all
472
+ * plugins with MCP servers are returned. Pass `[]` to get none.
401
473
  */
402
474
  export function getPluginMcpServers(
403
475
  bridgeUrl: string,
404
476
  chatId: string,
477
+ only?: string[],
405
478
  ): Record<string, McpServerConfig> {
479
+ if (only !== undefined && only.length === 0) return {};
480
+
406
481
  const servers: Record<string, McpServerConfig> = {};
407
482
 
408
483
  // Resolve tsx from Talon's own node_modules (not cwd which may be ~/.talon/workspace/)
@@ -412,20 +487,32 @@ export function getPluginMcpServers(
412
487
  );
413
488
 
414
489
  for (const { plugin, envVars } of registry.all) {
415
- if (!plugin.mcpServerPath) continue;
416
-
417
- servers[`${plugin.name}-tools`] = {
418
- command: process.platform === "win32" ? "npx" : "node",
419
- args:
420
- process.platform === "win32"
421
- ? ["tsx", plugin.mcpServerPath]
422
- : ["--import", tsxPath, plugin.mcpServerPath],
423
- env: {
424
- TALON_BRIDGE_URL: bridgeUrl,
425
- TALON_CHAT_ID: chatId,
426
- ...envVars,
427
- },
490
+ // Skip plugins not in the allow-list when filtering
491
+ if (only !== undefined && !only.includes(plugin.name)) continue;
492
+ const baseEnv = {
493
+ TALON_BRIDGE_URL: bridgeUrl,
494
+ TALON_CHAT_ID: chatId,
495
+ ...envVars,
428
496
  };
497
+
498
+ if (plugin.mcpServer) {
499
+ // Custom command/args (Python, Go, etc.) — no tsx wrapper
500
+ servers[`${plugin.name}-tools`] = {
501
+ command: plugin.mcpServer.command,
502
+ args: [...plugin.mcpServer.args],
503
+ env: baseEnv,
504
+ };
505
+ } else if (plugin.mcpServerPath) {
506
+ // Existing Node/tsx pattern
507
+ servers[`${plugin.name}-tools`] = {
508
+ command: process.platform === "win32" ? "npx" : "node",
509
+ args:
510
+ process.platform === "win32"
511
+ ? ["tsx", plugin.mcpServerPath]
512
+ : ["--import", tsxPath, plugin.mcpServerPath],
513
+ env: baseEnv,
514
+ };
515
+ }
429
516
  }
430
517
 
431
518
  return servers;