codemaxxing 0.3.0 → 0.3.1

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/agent.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ConnectedServer } from "./utils/mcp.js";
1
2
  import type { ProviderConfig } from "./config.js";
2
3
  export interface AgentOptions {
3
4
  provider: ProviderConfig;
@@ -13,6 +14,7 @@ export interface AgentOptions {
13
14
  onContextCompressed?: (oldTokens: number, newTokens: number) => void;
14
15
  onArchitectPlan?: (plan: string) => void;
15
16
  onLintResult?: (file: string, errors: string) => void;
17
+ onMCPStatus?: (server: string, status: string) => void;
16
18
  contextCompressionThreshold?: number;
17
19
  }
18
20
  export declare class CodingAgent {
@@ -41,6 +43,7 @@ export declare class CodingAgent {
41
43
  private architectModel;
42
44
  private autoLintEnabled;
43
45
  private detectedLinter;
46
+ private mcpServers;
44
47
  constructor(options: AgentOptions);
45
48
  /**
46
49
  * Initialize the agent — call this after constructor to build async context
@@ -124,5 +127,9 @@ export declare class CodingAgent {
124
127
  * Run the architect model to generate a plan, then feed to editor model
125
128
  */
126
129
  private architectChat;
130
+ getMCPServerCount(): number;
131
+ getMCPServers(): ConnectedServer[];
132
+ disconnectMCP(): Promise<void>;
133
+ reconnectMCP(): Promise<void>;
127
134
  reset(): void;
128
135
  }
package/dist/agent.js CHANGED
@@ -6,6 +6,7 @@ import { buildProjectContext, getSystemPrompt, loadProjectRules } from "./utils/
6
6
  import { isGitRepo, autoCommit } from "./utils/git.js";
7
7
  import { buildSkillPrompts, getActiveSkillCount } from "./utils/skills.js";
8
8
  import { createSession, saveMessage, updateTokenEstimate, updateSessionCost, loadMessages } from "./utils/sessions.js";
9
+ import { loadMCPConfig, connectToServers, disconnectAll, getAllMCPTools, parseMCPToolName, callMCPTool } from "./utils/mcp.js";
9
10
  // Tools that can modify your project — require approval
10
11
  const DANGEROUS_TOOLS = new Set(["write_file", "run_command"]);
11
12
  // Cost per 1M tokens (input/output) for common models
@@ -78,6 +79,7 @@ export class CodingAgent {
78
79
  architectModel = null;
79
80
  autoLintEnabled = true;
80
81
  detectedLinter = null;
82
+ mcpServers = [];
81
83
  constructor(options) {
82
84
  this.options = options;
83
85
  this.providerType = options.provider.type || "openai";
@@ -113,6 +115,15 @@ export class CodingAgent {
113
115
  this.systemPrompt = await getSystemPrompt(context, skillPrompts, rules?.content ?? "");
114
116
  // Detect project linter
115
117
  this.detectedLinter = detectLinter(this.cwd);
118
+ // Connect to MCP servers
119
+ const mcpConfig = loadMCPConfig(this.cwd);
120
+ if (Object.keys(mcpConfig.mcpServers).length > 0) {
121
+ this.mcpServers = await connectToServers(mcpConfig, this.options.onMCPStatus);
122
+ if (this.mcpServers.length > 0) {
123
+ const mcpTools = getAllMCPTools(this.mcpServers);
124
+ this.tools = [...FILE_TOOLS, ...mcpTools];
125
+ }
126
+ }
116
127
  this.messages = [
117
128
  { role: "system", content: this.systemPrompt },
118
129
  ];
@@ -305,7 +316,15 @@ export class CodingAgent {
305
316
  }
306
317
  }
307
318
  }
308
- const result = await executeTool(toolCall.name, args, this.cwd);
319
+ // Route to MCP or built-in tool
320
+ const mcpParsed = parseMCPToolName(toolCall.name);
321
+ let result;
322
+ if (mcpParsed) {
323
+ result = await callMCPTool(mcpParsed.serverName, mcpParsed.toolName, args);
324
+ }
325
+ else {
326
+ result = await executeTool(toolCall.name, args, this.cwd);
327
+ }
309
328
  this.options.onToolResult?.(toolCall.name, result);
310
329
  // Auto-commit after successful write_file (only if enabled)
311
330
  if (this.gitEnabled && this.autoCommitEnabled && toolCall.name === "write_file" && result.startsWith("✅")) {
@@ -502,7 +521,15 @@ export class CodingAgent {
502
521
  }
503
522
  }
504
523
  }
505
- const result = await executeTool(toolCall.name, args, this.cwd);
524
+ // Route to MCP or built-in tool
525
+ const mcpParsed = parseMCPToolName(toolCall.name);
526
+ let result;
527
+ if (mcpParsed) {
528
+ result = await callMCPTool(mcpParsed.serverName, mcpParsed.toolName, args);
529
+ }
530
+ else {
531
+ result = await executeTool(toolCall.name, args, this.cwd);
532
+ }
506
533
  this.options.onToolResult?.(toolCall.name, result);
507
534
  // Auto-commit after successful write_file
508
535
  if (this.gitEnabled && this.autoCommitEnabled && toolCall.name === "write_file" && result.startsWith("✅")) {
@@ -738,6 +765,28 @@ export class CodingAgent {
738
765
  const editorPrompt = `## Architect Plan\n${plan}\n\n## Original Request\n${userMessage}\n\nExecute the plan above. Follow it step by step.`;
739
766
  return this.chat(editorPrompt);
740
767
  }
768
+ getMCPServerCount() {
769
+ return this.mcpServers.length;
770
+ }
771
+ getMCPServers() {
772
+ return this.mcpServers;
773
+ }
774
+ async disconnectMCP() {
775
+ await disconnectAll();
776
+ this.mcpServers = [];
777
+ this.tools = FILE_TOOLS;
778
+ }
779
+ async reconnectMCP() {
780
+ await this.disconnectMCP();
781
+ const mcpConfig = loadMCPConfig(this.cwd);
782
+ if (Object.keys(mcpConfig.mcpServers).length > 0) {
783
+ this.mcpServers = await connectToServers(mcpConfig, this.options.onMCPStatus);
784
+ if (this.mcpServers.length > 0) {
785
+ const mcpTools = getAllMCPTools(this.mcpServers);
786
+ this.tools = [...FILE_TOOLS, ...mcpTools];
787
+ }
788
+ }
789
+ }
741
790
  reset() {
742
791
  const systemMsg = this.messages[0];
743
792
  this.messages = [systemMsg];
package/dist/exec.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { CodingAgent } from "./agent.js";
8
8
  import { loadConfig, applyOverrides, detectLocalProvider } from "./config.js";
9
+ import { disconnectAll } from "./utils/mcp.js";
9
10
  function parseExecArgs(argv) {
10
11
  const args = {
11
12
  prompt: "",
@@ -124,9 +125,16 @@ export async function runExec(argv) {
124
125
  process.stderr.write(`⚠ Denied ${name} (use --auto-approve to allow)\n`);
125
126
  return "no";
126
127
  },
128
+ onMCPStatus: (server, status) => {
129
+ process.stderr.write(`MCP ${server}: ${status}\n`);
130
+ },
127
131
  });
128
132
  try {
129
133
  await agent.init();
134
+ const mcpCount = agent.getMCPServerCount();
135
+ if (mcpCount > 0) {
136
+ process.stderr.write(`MCP: ${mcpCount} server${mcpCount > 1 ? "s" : ""} connected\n`);
137
+ }
130
138
  await agent.send(args.prompt);
131
139
  if (!args.json) {
132
140
  // Ensure newline at end of output
@@ -142,9 +150,11 @@ export async function runExec(argv) {
142
150
  };
143
151
  process.stdout.write(JSON.stringify(output, null, 2) + "\n");
144
152
  }
153
+ await disconnectAll();
145
154
  process.exit(hasChanges ? 0 : 2);
146
155
  }
147
156
  catch (err) {
157
+ await disconnectAll();
148
158
  process.stderr.write(`Error: ${err.message}\n`);
149
159
  if (args.json) {
150
160
  process.stdout.write(JSON.stringify({ error: err.message }, null, 2) + "\n");
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { isGitRepo, getBranch, getStatus, getDiff, undoLastCommit } from "./util
12
12
  import { getTheme, listThemes, THEMES, DEFAULT_THEME } from "./themes.js";
13
13
  import { PROVIDERS, getCredentials, openRouterOAuth, anthropicSetupToken, importCodexToken, importQwenToken, copilotDeviceFlow } from "./utils/auth.js";
14
14
  import { listInstalledSkills, installSkill, removeSkill, getRegistrySkills, searchRegistry, createSkillScaffold, getActiveSkills, getActiveSkillCount } from "./utils/skills.js";
15
+ import { listServers, addServer, removeServer, getConnectedServers } from "./utils/mcp.js";
15
16
  const VERSION = "0.1.9";
16
17
  // ── Helpers ──
17
18
  function formatTimeAgo(date) {
@@ -58,6 +59,11 @@ const SLASH_COMMANDS = [
58
59
  { cmd: "/lint", desc: "show auto-lint status" },
59
60
  { cmd: "/lint on", desc: "enable auto-lint" },
60
61
  { cmd: "/lint off", desc: "disable auto-lint" },
62
+ { cmd: "/mcp", desc: "show MCP servers" },
63
+ { cmd: "/mcp tools", desc: "list MCP tools" },
64
+ { cmd: "/mcp add", desc: "add MCP server" },
65
+ { cmd: "/mcp remove", desc: "remove MCP server" },
66
+ { cmd: "/mcp reconnect", desc: "reconnect MCP servers" },
61
67
  { cmd: "/quit", desc: "exit" },
62
68
  ];
63
69
  const SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
@@ -246,6 +252,9 @@ function App() {
246
252
  onLintResult: (file, errors) => {
247
253
  addMsg("info", `🔍 Lint errors in ${file}:\n${errors}`);
248
254
  },
255
+ onMCPStatus: (server, status) => {
256
+ addMsg("info", `🔌 MCP ${server}: ${status}`);
257
+ },
249
258
  contextCompressionThreshold: config.defaults.contextCompressionThreshold,
250
259
  onToolApproval: (name, args, diff) => {
251
260
  return new Promise((resolve) => {
@@ -262,6 +271,12 @@ function App() {
262
271
  info.push(`📋 ${rulesSource} loaded`);
263
272
  setConnectionInfo([...info]);
264
273
  }
274
+ // Show MCP server count
275
+ const mcpCount = a.getMCPServerCount();
276
+ if (mcpCount > 0) {
277
+ info.push(`🔌 ${mcpCount} MCP server${mcpCount > 1 ? "s" : ""} connected`);
278
+ setConnectionInfo([...info]);
279
+ }
265
280
  setAgent(a);
266
281
  setModelName(provider.model);
267
282
  providerRef.current = { baseUrl: provider.baseUrl, apiKey: provider.apiKey };
@@ -367,6 +382,11 @@ function App() {
367
382
  " /lint — show auto-lint status & detected linter",
368
383
  " /lint on — enable auto-lint",
369
384
  " /lint off — disable auto-lint",
385
+ " /mcp — show MCP servers & status",
386
+ " /mcp tools — list all MCP tools",
387
+ " /mcp add — add MCP server to global config",
388
+ " /mcp remove — remove MCP server",
389
+ " /mcp reconnect — reconnect all MCP servers",
370
390
  " /quit — exit",
371
391
  ].join("\n"));
372
392
  return;
@@ -534,6 +554,71 @@ function App() {
534
554
  addMsg("info", "🔍 Auto-lint OFF");
535
555
  return;
536
556
  }
557
+ // ── MCP commands (partially work without agent) ──
558
+ if (trimmed === "/mcp" || trimmed === "/mcp list") {
559
+ const servers = listServers(process.cwd());
560
+ if (servers.length === 0) {
561
+ addMsg("info", "🔌 No MCP servers configured.\n Add one: /mcp add <name> <command> [args...]");
562
+ }
563
+ else {
564
+ const lines = servers.map((s) => {
565
+ const status = s.connected ? `✔ connected (${s.toolCount} tools)` : "✗ not connected";
566
+ return ` ${s.connected ? "●" : "○"} ${s.name} [${s.source}] — ${s.command}\n ${status}`;
567
+ });
568
+ addMsg("info", `🔌 MCP Servers:\n${lines.join("\n")}`);
569
+ }
570
+ return;
571
+ }
572
+ if (trimmed === "/mcp tools") {
573
+ const servers = getConnectedServers();
574
+ if (servers.length === 0) {
575
+ addMsg("info", "🔌 No MCP servers connected.");
576
+ return;
577
+ }
578
+ const lines = [];
579
+ for (const server of servers) {
580
+ lines.push(`${server.name} (${server.tools.length} tools):`);
581
+ for (const tool of server.tools) {
582
+ lines.push(` • ${tool.name} — ${tool.description ?? "(no description)"}`);
583
+ }
584
+ }
585
+ addMsg("info", `🔌 MCP Tools:\n${lines.join("\n")}`);
586
+ return;
587
+ }
588
+ if (trimmed.startsWith("/mcp add ")) {
589
+ const parts = trimmed.replace("/mcp add ", "").trim().split(/\s+/);
590
+ if (parts.length < 2) {
591
+ addMsg("info", "Usage: /mcp add <name> <command> [args...]\n Example: /mcp add github npx -y @modelcontextprotocol/server-github");
592
+ return;
593
+ }
594
+ const [name, command, ...cmdArgs] = parts;
595
+ const result = addServer(name, { command, args: cmdArgs.length > 0 ? cmdArgs : undefined });
596
+ addMsg(result.ok ? "info" : "error", result.ok ? `✅ ${result.message}` : `✗ ${result.message}`);
597
+ return;
598
+ }
599
+ if (trimmed.startsWith("/mcp remove ")) {
600
+ const name = trimmed.replace("/mcp remove ", "").trim();
601
+ if (!name) {
602
+ addMsg("info", "Usage: /mcp remove <name>");
603
+ return;
604
+ }
605
+ const result = removeServer(name);
606
+ addMsg(result.ok ? "info" : "error", result.ok ? `✅ ${result.message}` : `✗ ${result.message}`);
607
+ return;
608
+ }
609
+ if (trimmed === "/mcp reconnect") {
610
+ if (!agent) {
611
+ addMsg("info", "⚠ No agent connected. Connect first.");
612
+ return;
613
+ }
614
+ addMsg("info", "🔌 Reconnecting MCP servers...");
615
+ await agent.reconnectMCP();
616
+ const count = agent.getMCPServerCount();
617
+ addMsg("info", count > 0
618
+ ? `✅ ${count} MCP server${count > 1 ? "s" : ""} reconnected.`
619
+ : "No MCP servers connected.");
620
+ return;
621
+ }
537
622
  // Commands below require an active LLM connection
538
623
  if (!agent) {
539
624
  addMsg("info", "⚠ No LLM connected. Use /login to authenticate with a provider, or start a local server.");
@@ -0,0 +1,55 @@
1
+ /**
2
+ * MCP (Model Context Protocol) client support
3
+ * Connects to external MCP servers and exposes their tools to the LLM agent.
4
+ */
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
+ import type { ChatCompletionTool } from "openai/resources/chat/completions";
8
+ export interface MCPServerConfig {
9
+ command: string;
10
+ args?: string[];
11
+ env?: Record<string, string>;
12
+ }
13
+ export interface MCPConfig {
14
+ mcpServers: Record<string, MCPServerConfig>;
15
+ }
16
+ export interface ConnectedServer {
17
+ name: string;
18
+ client: Client;
19
+ transport: StdioClientTransport;
20
+ tools: Array<{
21
+ name: string;
22
+ description?: string;
23
+ inputSchema: Record<string, unknown>;
24
+ }>;
25
+ }
26
+ export declare function loadMCPConfig(cwd: string): MCPConfig;
27
+ export declare function connectToServers(config: MCPConfig, onStatus?: (name: string, status: string) => void): Promise<ConnectedServer[]>;
28
+ export declare function disconnectAll(): Promise<void>;
29
+ export declare function getConnectedServers(): ConnectedServer[];
30
+ export declare function getAllMCPTools(servers: ConnectedServer[]): ChatCompletionTool[];
31
+ /**
32
+ * Parse an MCP tool call name to extract server name and tool name.
33
+ * Format: mcp_<serverName>_<toolName>
34
+ * Server names can contain hyphens but not underscores (by convention).
35
+ */
36
+ export declare function parseMCPToolName(fullName: string): {
37
+ serverName: string;
38
+ toolName: string;
39
+ } | null;
40
+ export declare function callMCPTool(serverName: string, toolName: string, args: Record<string, unknown>): Promise<string>;
41
+ export declare function addServer(name: string, config: MCPServerConfig): {
42
+ ok: boolean;
43
+ message: string;
44
+ };
45
+ export declare function removeServer(name: string): {
46
+ ok: boolean;
47
+ message: string;
48
+ };
49
+ export declare function listServers(cwd: string): Array<{
50
+ name: string;
51
+ source: string;
52
+ command: string;
53
+ connected: boolean;
54
+ toolCount: number;
55
+ }>;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * MCP (Model Context Protocol) client support
3
+ * Connects to external MCP servers and exposes their tools to the LLM agent.
4
+ */
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
8
+ import { join } from "path";
9
+ import { homedir } from "os";
10
+ // ── Config paths ──
11
+ const GLOBAL_CONFIG_DIR = join(homedir(), ".codemaxxing");
12
+ const GLOBAL_CONFIG_PATH = join(GLOBAL_CONFIG_DIR, "mcp.json");
13
+ function getProjectConfigPaths(cwd) {
14
+ return [
15
+ join(cwd, ".codemaxxing", "mcp.json"),
16
+ join(cwd, ".cursor", "mcp.json"),
17
+ join(cwd, "opencode.json"),
18
+ ];
19
+ }
20
+ // ── Config loading ──
21
+ function loadConfigFile(path) {
22
+ try {
23
+ if (!existsSync(path))
24
+ return null;
25
+ const raw = readFileSync(path, "utf-8");
26
+ const parsed = JSON.parse(raw);
27
+ if (parsed.mcpServers && typeof parsed.mcpServers === "object") {
28
+ return parsed;
29
+ }
30
+ return null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ export function loadMCPConfig(cwd) {
37
+ const merged = { mcpServers: {} };
38
+ // Load global config first (lower priority)
39
+ const globalConfig = loadConfigFile(GLOBAL_CONFIG_PATH);
40
+ if (globalConfig) {
41
+ Object.assign(merged.mcpServers, globalConfig.mcpServers);
42
+ }
43
+ // Load project configs (higher priority — later overwrites earlier)
44
+ for (const configPath of getProjectConfigPaths(cwd)) {
45
+ const config = loadConfigFile(configPath);
46
+ if (config) {
47
+ Object.assign(merged.mcpServers, config.mcpServers);
48
+ }
49
+ }
50
+ return merged;
51
+ }
52
+ // ── Connection management ──
53
+ const connectedServers = [];
54
+ export async function connectToServers(config, onStatus) {
55
+ const entries = Object.entries(config.mcpServers);
56
+ if (entries.length === 0)
57
+ return [];
58
+ for (const [name, serverConfig] of entries) {
59
+ try {
60
+ onStatus?.(name, "connecting");
61
+ const transport = new StdioClientTransport({
62
+ command: serverConfig.command,
63
+ args: serverConfig.args ?? [],
64
+ env: { ...process.env, ...(serverConfig.env ?? {}) },
65
+ });
66
+ const client = new Client({
67
+ name: "codemaxxing",
68
+ version: "0.3.0",
69
+ });
70
+ await client.connect(transport);
71
+ // Fetch available tools
72
+ const toolsResult = await client.listTools();
73
+ const tools = (toolsResult.tools ?? []).map((t) => ({
74
+ name: t.name,
75
+ description: t.description,
76
+ inputSchema: (t.inputSchema ?? { type: "object", properties: {} }),
77
+ }));
78
+ const server = { name, client, transport, tools };
79
+ connectedServers.push(server);
80
+ onStatus?.(name, `connected (${tools.length} tools)`);
81
+ }
82
+ catch (err) {
83
+ onStatus?.(name, `failed: ${err.message}`);
84
+ }
85
+ }
86
+ return connectedServers;
87
+ }
88
+ export async function disconnectAll() {
89
+ for (const server of connectedServers) {
90
+ try {
91
+ await server.client.close();
92
+ }
93
+ catch {
94
+ // Ignore cleanup errors
95
+ }
96
+ }
97
+ connectedServers.length = 0;
98
+ }
99
+ export function getConnectedServers() {
100
+ return connectedServers;
101
+ }
102
+ // ── Tool format conversion ──
103
+ export function getAllMCPTools(servers) {
104
+ const tools = [];
105
+ for (const server of servers) {
106
+ for (const tool of server.tools) {
107
+ tools.push({
108
+ type: "function",
109
+ function: {
110
+ name: `mcp_${server.name}_${tool.name}`,
111
+ description: `[MCP: ${server.name}] ${tool.description ?? tool.name}`,
112
+ parameters: tool.inputSchema,
113
+ },
114
+ });
115
+ }
116
+ }
117
+ return tools;
118
+ }
119
+ /**
120
+ * Parse an MCP tool call name to extract server name and tool name.
121
+ * Format: mcp_<serverName>_<toolName>
122
+ * Server names can contain hyphens but not underscores (by convention).
123
+ */
124
+ export function parseMCPToolName(fullName) {
125
+ if (!fullName.startsWith("mcp_"))
126
+ return null;
127
+ const rest = fullName.slice(4); // Remove "mcp_"
128
+ // Find the server by matching known connected server names
129
+ for (const server of connectedServers) {
130
+ const prefix = server.name + "_";
131
+ if (rest.startsWith(prefix)) {
132
+ return { serverName: server.name, toolName: rest.slice(prefix.length) };
133
+ }
134
+ }
135
+ // Fallback: split on first underscore
136
+ const idx = rest.indexOf("_");
137
+ if (idx === -1)
138
+ return null;
139
+ return { serverName: rest.slice(0, idx), toolName: rest.slice(idx + 1) };
140
+ }
141
+ // ── Tool execution ──
142
+ export async function callMCPTool(serverName, toolName, args) {
143
+ const server = connectedServers.find((s) => s.name === serverName);
144
+ if (!server) {
145
+ return `Error: MCP server "${serverName}" not found or not connected.`;
146
+ }
147
+ try {
148
+ const result = await server.client.callTool({ name: toolName, arguments: args });
149
+ // MCP tool results have a content array
150
+ const content = result.content;
151
+ if (Array.isArray(content)) {
152
+ return content
153
+ .map((c) => {
154
+ if (c.type === "text")
155
+ return c.text;
156
+ if (c.type === "image")
157
+ return `[image: ${c.mimeType}]`;
158
+ return JSON.stringify(c);
159
+ })
160
+ .join("\n");
161
+ }
162
+ return typeof content === "string" ? content : JSON.stringify(content);
163
+ }
164
+ catch (err) {
165
+ return `Error calling MCP tool "${toolName}" on server "${serverName}": ${err.message}`;
166
+ }
167
+ }
168
+ // ── Server management ──
169
+ export function addServer(name, config) {
170
+ try {
171
+ if (!existsSync(GLOBAL_CONFIG_DIR)) {
172
+ mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
173
+ }
174
+ let existing = { mcpServers: {} };
175
+ if (existsSync(GLOBAL_CONFIG_PATH)) {
176
+ try {
177
+ existing = JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, "utf-8"));
178
+ if (!existing.mcpServers)
179
+ existing.mcpServers = {};
180
+ }
181
+ catch {
182
+ existing = { mcpServers: {} };
183
+ }
184
+ }
185
+ existing.mcpServers[name] = config;
186
+ writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(existing, null, 2) + "\n", "utf-8");
187
+ return { ok: true, message: `Added MCP server "${name}" to global config.` };
188
+ }
189
+ catch (err) {
190
+ return { ok: false, message: `Failed to add server: ${err.message}` };
191
+ }
192
+ }
193
+ export function removeServer(name) {
194
+ try {
195
+ if (!existsSync(GLOBAL_CONFIG_PATH)) {
196
+ return { ok: false, message: `No global MCP config found.` };
197
+ }
198
+ const existing = JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, "utf-8"));
199
+ if (!existing.mcpServers || !existing.mcpServers[name]) {
200
+ return { ok: false, message: `Server "${name}" not found in global config.` };
201
+ }
202
+ delete existing.mcpServers[name];
203
+ writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(existing, null, 2) + "\n", "utf-8");
204
+ return { ok: true, message: `Removed MCP server "${name}" from global config.` };
205
+ }
206
+ catch (err) {
207
+ return { ok: false, message: `Failed to remove server: ${err.message}` };
208
+ }
209
+ }
210
+ export function listServers(cwd) {
211
+ const result = [];
212
+ // Gather from global config
213
+ const globalConfig = loadConfigFile(GLOBAL_CONFIG_PATH);
214
+ if (globalConfig) {
215
+ for (const [name, cfg] of Object.entries(globalConfig.mcpServers)) {
216
+ const connected = connectedServers.find((s) => s.name === name);
217
+ result.push({
218
+ name,
219
+ source: "global",
220
+ command: `${cfg.command} ${(cfg.args ?? []).join(" ")}`.trim(),
221
+ connected: !!connected,
222
+ toolCount: connected?.tools.length ?? 0,
223
+ });
224
+ }
225
+ }
226
+ // Gather from project configs
227
+ for (const configPath of getProjectConfigPaths(cwd)) {
228
+ const config = loadConfigFile(configPath);
229
+ if (config) {
230
+ const source = configPath.includes(".cursor") ? "cursor" : configPath.includes("opencode") ? "opencode" : "project";
231
+ for (const [name, cfg] of Object.entries(config.mcpServers)) {
232
+ // Skip if already listed from global (project overrides)
233
+ const existing = result.find((r) => r.name === name);
234
+ if (existing) {
235
+ existing.source = source;
236
+ existing.command = `${cfg.command} ${(cfg.args ?? []).join(" ")}`.trim();
237
+ continue;
238
+ }
239
+ const connected = connectedServers.find((s) => s.name === name);
240
+ result.push({
241
+ name,
242
+ source,
243
+ command: `${cfg.command} ${(cfg.args ?? []).join(" ")}`.trim(),
244
+ connected: !!connected,
245
+ toolCount: connected?.tools.length ?? 0,
246
+ });
247
+ }
248
+ }
249
+ }
250
+ return result;
251
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codemaxxing",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Open-source terminal coding agent. Connect any LLM. Max your code.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "@anthropic-ai/sdk": "^0.78.0",
30
+ "@modelcontextprotocol/sdk": "^1.27.1",
30
31
  "@types/react": "^19.2.14",
31
32
  "better-sqlite3": "^12.6.2",
32
33
  "chalk": "^5.3.0",
package/src/agent.ts CHANGED
@@ -11,6 +11,7 @@ import { buildProjectContext, getSystemPrompt, loadProjectRules } from "./utils/
11
11
  import { isGitRepo, autoCommit } from "./utils/git.js";
12
12
  import { buildSkillPrompts, getActiveSkillCount } from "./utils/skills.js";
13
13
  import { createSession, saveMessage, updateTokenEstimate, updateSessionCost, loadMessages } from "./utils/sessions.js";
14
+ import { loadMCPConfig, connectToServers, disconnectAll, getAllMCPTools, parseMCPToolName, callMCPTool, getConnectedServers, type ConnectedServer } from "./utils/mcp.js";
14
15
  import type { ProviderConfig } from "./config.js";
15
16
 
16
17
  // Tools that can modify your project — require approval
@@ -74,6 +75,7 @@ export interface AgentOptions {
74
75
  onContextCompressed?: (oldTokens: number, newTokens: number) => void;
75
76
  onArchitectPlan?: (plan: string) => void;
76
77
  onLintResult?: (file: string, errors: string) => void;
78
+ onMCPStatus?: (server: string, status: string) => void;
77
79
  contextCompressionThreshold?: number;
78
80
  }
79
81
 
@@ -108,6 +110,7 @@ export class CodingAgent {
108
110
  private architectModel: string | null = null;
109
111
  private autoLintEnabled: boolean = true;
110
112
  private detectedLinter: { command: string; name: string } | null = null;
113
+ private mcpServers: ConnectedServer[] = [];
111
114
 
112
115
  constructor(private options: AgentOptions) {
113
116
  this.providerType = options.provider.type || "openai";
@@ -145,6 +148,16 @@ export class CodingAgent {
145
148
  // Detect project linter
146
149
  this.detectedLinter = detectLinter(this.cwd);
147
150
 
151
+ // Connect to MCP servers
152
+ const mcpConfig = loadMCPConfig(this.cwd);
153
+ if (Object.keys(mcpConfig.mcpServers).length > 0) {
154
+ this.mcpServers = await connectToServers(mcpConfig, this.options.onMCPStatus);
155
+ if (this.mcpServers.length > 0) {
156
+ const mcpTools = getAllMCPTools(this.mcpServers);
157
+ this.tools = [...FILE_TOOLS, ...mcpTools];
158
+ }
159
+ }
160
+
148
161
  this.messages = [
149
162
  { role: "system", content: this.systemPrompt },
150
163
  ];
@@ -357,7 +370,14 @@ export class CodingAgent {
357
370
  }
358
371
  }
359
372
 
360
- const result = await executeTool(toolCall.name, args, this.cwd);
373
+ // Route to MCP or built-in tool
374
+ const mcpParsed = parseMCPToolName(toolCall.name);
375
+ let result: string;
376
+ if (mcpParsed) {
377
+ result = await callMCPTool(mcpParsed.serverName, mcpParsed.toolName, args);
378
+ } else {
379
+ result = await executeTool(toolCall.name, args, this.cwd);
380
+ }
361
381
  this.options.onToolResult?.(toolCall.name, result);
362
382
 
363
383
  // Auto-commit after successful write_file (only if enabled)
@@ -569,7 +589,14 @@ export class CodingAgent {
569
589
  }
570
590
  }
571
591
 
572
- const result = await executeTool(toolCall.name, args, this.cwd);
592
+ // Route to MCP or built-in tool
593
+ const mcpParsed = parseMCPToolName(toolCall.name);
594
+ let result: string;
595
+ if (mcpParsed) {
596
+ result = await callMCPTool(mcpParsed.serverName, mcpParsed.toolName, args);
597
+ } else {
598
+ result = await executeTool(toolCall.name, args, this.cwd);
599
+ }
573
600
  this.options.onToolResult?.(toolCall.name, result);
574
601
 
575
602
  // Auto-commit after successful write_file
@@ -834,6 +861,32 @@ export class CodingAgent {
834
861
  return this.chat(editorPrompt);
835
862
  }
836
863
 
864
+ getMCPServerCount(): number {
865
+ return this.mcpServers.length;
866
+ }
867
+
868
+ getMCPServers(): ConnectedServer[] {
869
+ return this.mcpServers;
870
+ }
871
+
872
+ async disconnectMCP(): Promise<void> {
873
+ await disconnectAll();
874
+ this.mcpServers = [];
875
+ this.tools = FILE_TOOLS;
876
+ }
877
+
878
+ async reconnectMCP(): Promise<void> {
879
+ await this.disconnectMCP();
880
+ const mcpConfig = loadMCPConfig(this.cwd);
881
+ if (Object.keys(mcpConfig.mcpServers).length > 0) {
882
+ this.mcpServers = await connectToServers(mcpConfig, this.options.onMCPStatus);
883
+ if (this.mcpServers.length > 0) {
884
+ const mcpTools = getAllMCPTools(this.mcpServers);
885
+ this.tools = [...FILE_TOOLS, ...mcpTools];
886
+ }
887
+ }
888
+ }
889
+
837
890
  reset(): void {
838
891
  const systemMsg = this.messages[0];
839
892
  this.messages = [systemMsg];
package/src/exec.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import { CodingAgent } from "./agent.js";
9
9
  import { loadConfig, applyOverrides, detectLocalProvider } from "./config.js";
10
10
  import { getCredential } from "./utils/auth.js";
11
+ import { disconnectAll } from "./utils/mcp.js";
11
12
 
12
13
  interface ExecArgs {
13
14
  prompt: string;
@@ -140,10 +141,19 @@ export async function runExec(argv: string[]): Promise<void> {
140
141
  process.stderr.write(`⚠ Denied ${name} (use --auto-approve to allow)\n`);
141
142
  return "no";
142
143
  },
144
+ onMCPStatus: (server, status) => {
145
+ process.stderr.write(`MCP ${server}: ${status}\n`);
146
+ },
143
147
  });
144
148
 
145
149
  try {
146
150
  await agent.init();
151
+
152
+ const mcpCount = agent.getMCPServerCount();
153
+ if (mcpCount > 0) {
154
+ process.stderr.write(`MCP: ${mcpCount} server${mcpCount > 1 ? "s" : ""} connected\n`);
155
+ }
156
+
147
157
  await agent.send(args.prompt);
148
158
 
149
159
  if (!args.json) {
@@ -160,8 +170,10 @@ export async function runExec(argv: string[]): Promise<void> {
160
170
  process.stdout.write(JSON.stringify(output, null, 2) + "\n");
161
171
  }
162
172
 
173
+ await disconnectAll();
163
174
  process.exit(hasChanges ? 0 : 2);
164
175
  } catch (err: any) {
176
+ await disconnectAll();
165
177
  process.stderr.write(`Error: ${err.message}\n`);
166
178
  if (args.json) {
167
179
  process.stdout.write(JSON.stringify({ error: err.message }, null, 2) + "\n");
package/src/index.tsx CHANGED
@@ -12,6 +12,7 @@ import { isGitRepo, getBranch, getStatus, getDiff, undoLastCommit } from "./util
12
12
  import { getTheme, listThemes, THEMES, DEFAULT_THEME, type Theme } from "./themes.js";
13
13
  import { PROVIDERS, getCredentials, openRouterOAuth, anthropicSetupToken, importCodexToken, importQwenToken, copilotDeviceFlow, saveApiKey } from "./utils/auth.js";
14
14
  import { listInstalledSkills, installSkill, removeSkill, getRegistrySkills, searchRegistry, createSkillScaffold, getActiveSkills, getActiveSkillCount } from "./utils/skills.js";
15
+ import { listServers, addServer, removeServer, getAllMCPTools, getConnectedServers } from "./utils/mcp.js";
15
16
 
16
17
  const VERSION = "0.1.9";
17
18
 
@@ -58,6 +59,11 @@ const SLASH_COMMANDS = [
58
59
  { cmd: "/lint", desc: "show auto-lint status" },
59
60
  { cmd: "/lint on", desc: "enable auto-lint" },
60
61
  { cmd: "/lint off", desc: "disable auto-lint" },
62
+ { cmd: "/mcp", desc: "show MCP servers" },
63
+ { cmd: "/mcp tools", desc: "list MCP tools" },
64
+ { cmd: "/mcp add", desc: "add MCP server" },
65
+ { cmd: "/mcp remove", desc: "remove MCP server" },
66
+ { cmd: "/mcp reconnect", desc: "reconnect MCP servers" },
61
67
  { cmd: "/quit", desc: "exit" },
62
68
  ];
63
69
 
@@ -287,6 +293,9 @@ function App() {
287
293
  onLintResult: (file, errors) => {
288
294
  addMsg("info", `🔍 Lint errors in ${file}:\n${errors}`);
289
295
  },
296
+ onMCPStatus: (server, status) => {
297
+ addMsg("info", `🔌 MCP ${server}: ${status}`);
298
+ },
290
299
  contextCompressionThreshold: config.defaults.contextCompressionThreshold,
291
300
  onToolApproval: (name, args, diff) => {
292
301
  return new Promise((resolve) => {
@@ -306,6 +315,13 @@ function App() {
306
315
  setConnectionInfo([...info]);
307
316
  }
308
317
 
318
+ // Show MCP server count
319
+ const mcpCount = a.getMCPServerCount();
320
+ if (mcpCount > 0) {
321
+ info.push(`🔌 ${mcpCount} MCP server${mcpCount > 1 ? "s" : ""} connected`);
322
+ setConnectionInfo([...info]);
323
+ }
324
+
309
325
  setAgent(a);
310
326
  setModelName(provider.model);
311
327
  providerRef.current = { baseUrl: provider.baseUrl, apiKey: provider.apiKey };
@@ -419,6 +435,11 @@ function App() {
419
435
  " /lint — show auto-lint status & detected linter",
420
436
  " /lint on — enable auto-lint",
421
437
  " /lint off — disable auto-lint",
438
+ " /mcp — show MCP servers & status",
439
+ " /mcp tools — list all MCP tools",
440
+ " /mcp add — add MCP server to global config",
441
+ " /mcp remove — remove MCP server",
442
+ " /mcp reconnect — reconnect all MCP servers",
422
443
  " /quit — exit",
423
444
  ].join("\n"));
424
445
  return;
@@ -579,6 +600,71 @@ function App() {
579
600
  return;
580
601
  }
581
602
 
603
+ // ── MCP commands (partially work without agent) ──
604
+ if (trimmed === "/mcp" || trimmed === "/mcp list") {
605
+ const servers = listServers(process.cwd());
606
+ if (servers.length === 0) {
607
+ addMsg("info", "🔌 No MCP servers configured.\n Add one: /mcp add <name> <command> [args...]");
608
+ } else {
609
+ const lines = servers.map((s) => {
610
+ const status = s.connected ? `✔ connected (${s.toolCount} tools)` : "✗ not connected";
611
+ return ` ${s.connected ? "●" : "○"} ${s.name} [${s.source}] — ${s.command}\n ${status}`;
612
+ });
613
+ addMsg("info", `🔌 MCP Servers:\n${lines.join("\n")}`);
614
+ }
615
+ return;
616
+ }
617
+ if (trimmed === "/mcp tools") {
618
+ const servers = getConnectedServers();
619
+ if (servers.length === 0) {
620
+ addMsg("info", "🔌 No MCP servers connected.");
621
+ return;
622
+ }
623
+ const lines: string[] = [];
624
+ for (const server of servers) {
625
+ lines.push(`${server.name} (${server.tools.length} tools):`);
626
+ for (const tool of server.tools) {
627
+ lines.push(` • ${tool.name} — ${tool.description ?? "(no description)"}`);
628
+ }
629
+ }
630
+ addMsg("info", `🔌 MCP Tools:\n${lines.join("\n")}`);
631
+ return;
632
+ }
633
+ if (trimmed.startsWith("/mcp add ")) {
634
+ const parts = trimmed.replace("/mcp add ", "").trim().split(/\s+/);
635
+ if (parts.length < 2) {
636
+ addMsg("info", "Usage: /mcp add <name> <command> [args...]\n Example: /mcp add github npx -y @modelcontextprotocol/server-github");
637
+ return;
638
+ }
639
+ const [name, command, ...cmdArgs] = parts;
640
+ const result = addServer(name, { command, args: cmdArgs.length > 0 ? cmdArgs : undefined });
641
+ addMsg(result.ok ? "info" : "error", result.ok ? `✅ ${result.message}` : `✗ ${result.message}`);
642
+ return;
643
+ }
644
+ if (trimmed.startsWith("/mcp remove ")) {
645
+ const name = trimmed.replace("/mcp remove ", "").trim();
646
+ if (!name) {
647
+ addMsg("info", "Usage: /mcp remove <name>");
648
+ return;
649
+ }
650
+ const result = removeServer(name);
651
+ addMsg(result.ok ? "info" : "error", result.ok ? `✅ ${result.message}` : `✗ ${result.message}`);
652
+ return;
653
+ }
654
+ if (trimmed === "/mcp reconnect") {
655
+ if (!agent) {
656
+ addMsg("info", "⚠ No agent connected. Connect first.");
657
+ return;
658
+ }
659
+ addMsg("info", "🔌 Reconnecting MCP servers...");
660
+ await agent.reconnectMCP();
661
+ const count = agent.getMCPServerCount();
662
+ addMsg("info", count > 0
663
+ ? `✅ ${count} MCP server${count > 1 ? "s" : ""} reconnected.`
664
+ : "No MCP servers connected.");
665
+ return;
666
+ }
667
+
582
668
  // Commands below require an active LLM connection
583
669
  if (!agent) {
584
670
  addMsg("info", "⚠ No LLM connected. Use /login to authenticate with a provider, or start a local server.");
@@ -0,0 +1,307 @@
1
+ /**
2
+ * MCP (Model Context Protocol) client support
3
+ * Connects to external MCP servers and exposes their tools to the LLM agent.
4
+ */
5
+
6
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
9
+ import { join } from "path";
10
+ import { homedir } from "os";
11
+ import type { ChatCompletionTool } from "openai/resources/chat/completions";
12
+
13
+ // ── Types ──
14
+
15
+ export interface MCPServerConfig {
16
+ command: string;
17
+ args?: string[];
18
+ env?: Record<string, string>;
19
+ }
20
+
21
+ export interface MCPConfig {
22
+ mcpServers: Record<string, MCPServerConfig>;
23
+ }
24
+
25
+ export interface ConnectedServer {
26
+ name: string;
27
+ client: Client;
28
+ transport: StdioClientTransport;
29
+ tools: Array<{ name: string; description?: string; inputSchema: Record<string, unknown> }>;
30
+ }
31
+
32
+ // ── Config paths ──
33
+
34
+ const GLOBAL_CONFIG_DIR = join(homedir(), ".codemaxxing");
35
+ const GLOBAL_CONFIG_PATH = join(GLOBAL_CONFIG_DIR, "mcp.json");
36
+
37
+ function getProjectConfigPaths(cwd: string): string[] {
38
+ return [
39
+ join(cwd, ".codemaxxing", "mcp.json"),
40
+ join(cwd, ".cursor", "mcp.json"),
41
+ join(cwd, "opencode.json"),
42
+ ];
43
+ }
44
+
45
+ // ── Config loading ──
46
+
47
+ function loadConfigFile(path: string): MCPConfig | null {
48
+ try {
49
+ if (!existsSync(path)) return null;
50
+ const raw = readFileSync(path, "utf-8");
51
+ const parsed = JSON.parse(raw);
52
+ if (parsed.mcpServers && typeof parsed.mcpServers === "object") {
53
+ return parsed as MCPConfig;
54
+ }
55
+ return null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export function loadMCPConfig(cwd: string): MCPConfig {
62
+ const merged: MCPConfig = { mcpServers: {} };
63
+
64
+ // Load global config first (lower priority)
65
+ const globalConfig = loadConfigFile(GLOBAL_CONFIG_PATH);
66
+ if (globalConfig) {
67
+ Object.assign(merged.mcpServers, globalConfig.mcpServers);
68
+ }
69
+
70
+ // Load project configs (higher priority — later overwrites earlier)
71
+ for (const configPath of getProjectConfigPaths(cwd)) {
72
+ const config = loadConfigFile(configPath);
73
+ if (config) {
74
+ Object.assign(merged.mcpServers, config.mcpServers);
75
+ }
76
+ }
77
+
78
+ return merged;
79
+ }
80
+
81
+ // ── Connection management ──
82
+
83
+ const connectedServers: ConnectedServer[] = [];
84
+
85
+ export async function connectToServers(
86
+ config: MCPConfig,
87
+ onStatus?: (name: string, status: string) => void,
88
+ ): Promise<ConnectedServer[]> {
89
+ const entries = Object.entries(config.mcpServers);
90
+ if (entries.length === 0) return [];
91
+
92
+ for (const [name, serverConfig] of entries) {
93
+ try {
94
+ onStatus?.(name, "connecting");
95
+
96
+ const transport = new StdioClientTransport({
97
+ command: serverConfig.command,
98
+ args: serverConfig.args ?? [],
99
+ env: { ...process.env, ...(serverConfig.env ?? {}) } as Record<string, string>,
100
+ });
101
+
102
+ const client = new Client({
103
+ name: "codemaxxing",
104
+ version: "0.3.0",
105
+ });
106
+
107
+ await client.connect(transport);
108
+
109
+ // Fetch available tools
110
+ const toolsResult = await client.listTools();
111
+ const tools = (toolsResult.tools ?? []).map((t) => ({
112
+ name: t.name,
113
+ description: t.description,
114
+ inputSchema: (t.inputSchema ?? { type: "object", properties: {} }) as Record<string, unknown>,
115
+ }));
116
+
117
+ const server: ConnectedServer = { name, client, transport, tools };
118
+ connectedServers.push(server);
119
+ onStatus?.(name, `connected (${tools.length} tools)`);
120
+ } catch (err: any) {
121
+ onStatus?.(name, `failed: ${err.message}`);
122
+ }
123
+ }
124
+
125
+ return connectedServers;
126
+ }
127
+
128
+ export async function disconnectAll(): Promise<void> {
129
+ for (const server of connectedServers) {
130
+ try {
131
+ await server.client.close();
132
+ } catch {
133
+ // Ignore cleanup errors
134
+ }
135
+ }
136
+ connectedServers.length = 0;
137
+ }
138
+
139
+ export function getConnectedServers(): ConnectedServer[] {
140
+ return connectedServers;
141
+ }
142
+
143
+ // ── Tool format conversion ──
144
+
145
+ export function getAllMCPTools(servers: ConnectedServer[]): ChatCompletionTool[] {
146
+ const tools: ChatCompletionTool[] = [];
147
+
148
+ for (const server of servers) {
149
+ for (const tool of server.tools) {
150
+ tools.push({
151
+ type: "function",
152
+ function: {
153
+ name: `mcp_${server.name}_${tool.name}`,
154
+ description: `[MCP: ${server.name}] ${tool.description ?? tool.name}`,
155
+ parameters: tool.inputSchema as any,
156
+ },
157
+ });
158
+ }
159
+ }
160
+
161
+ return tools;
162
+ }
163
+
164
+ /**
165
+ * Parse an MCP tool call name to extract server name and tool name.
166
+ * Format: mcp_<serverName>_<toolName>
167
+ * Server names can contain hyphens but not underscores (by convention).
168
+ */
169
+ export function parseMCPToolName(fullName: string): { serverName: string; toolName: string } | null {
170
+ if (!fullName.startsWith("mcp_")) return null;
171
+ const rest = fullName.slice(4); // Remove "mcp_"
172
+
173
+ // Find the server by matching known connected server names
174
+ for (const server of connectedServers) {
175
+ const prefix = server.name + "_";
176
+ if (rest.startsWith(prefix)) {
177
+ return { serverName: server.name, toolName: rest.slice(prefix.length) };
178
+ }
179
+ }
180
+
181
+ // Fallback: split on first underscore
182
+ const idx = rest.indexOf("_");
183
+ if (idx === -1) return null;
184
+ return { serverName: rest.slice(0, idx), toolName: rest.slice(idx + 1) };
185
+ }
186
+
187
+ // ── Tool execution ──
188
+
189
+ export async function callMCPTool(
190
+ serverName: string,
191
+ toolName: string,
192
+ args: Record<string, unknown>,
193
+ ): Promise<string> {
194
+ const server = connectedServers.find((s) => s.name === serverName);
195
+ if (!server) {
196
+ return `Error: MCP server "${serverName}" not found or not connected.`;
197
+ }
198
+
199
+ try {
200
+ const result = await server.client.callTool({ name: toolName, arguments: args });
201
+ // MCP tool results have a content array
202
+ const content = result.content;
203
+ if (Array.isArray(content)) {
204
+ return content
205
+ .map((c: any) => {
206
+ if (c.type === "text") return c.text;
207
+ if (c.type === "image") return `[image: ${c.mimeType}]`;
208
+ return JSON.stringify(c);
209
+ })
210
+ .join("\n");
211
+ }
212
+ return typeof content === "string" ? content : JSON.stringify(content);
213
+ } catch (err: any) {
214
+ return `Error calling MCP tool "${toolName}" on server "${serverName}": ${err.message}`;
215
+ }
216
+ }
217
+
218
+ // ── Server management ──
219
+
220
+ export function addServer(name: string, config: MCPServerConfig): { ok: boolean; message: string } {
221
+ try {
222
+ if (!existsSync(GLOBAL_CONFIG_DIR)) {
223
+ mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
224
+ }
225
+
226
+ let existing: MCPConfig = { mcpServers: {} };
227
+ if (existsSync(GLOBAL_CONFIG_PATH)) {
228
+ try {
229
+ existing = JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, "utf-8"));
230
+ if (!existing.mcpServers) existing.mcpServers = {};
231
+ } catch {
232
+ existing = { mcpServers: {} };
233
+ }
234
+ }
235
+
236
+ existing.mcpServers[name] = config;
237
+ writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(existing, null, 2) + "\n", "utf-8");
238
+ return { ok: true, message: `Added MCP server "${name}" to global config.` };
239
+ } catch (err: any) {
240
+ return { ok: false, message: `Failed to add server: ${err.message}` };
241
+ }
242
+ }
243
+
244
+ export function removeServer(name: string): { ok: boolean; message: string } {
245
+ try {
246
+ if (!existsSync(GLOBAL_CONFIG_PATH)) {
247
+ return { ok: false, message: `No global MCP config found.` };
248
+ }
249
+
250
+ const existing: MCPConfig = JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, "utf-8"));
251
+ if (!existing.mcpServers || !existing.mcpServers[name]) {
252
+ return { ok: false, message: `Server "${name}" not found in global config.` };
253
+ }
254
+
255
+ delete existing.mcpServers[name];
256
+ writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(existing, null, 2) + "\n", "utf-8");
257
+ return { ok: true, message: `Removed MCP server "${name}" from global config.` };
258
+ } catch (err: any) {
259
+ return { ok: false, message: `Failed to remove server: ${err.message}` };
260
+ }
261
+ }
262
+
263
+ export function listServers(cwd: string): Array<{ name: string; source: string; command: string; connected: boolean; toolCount: number }> {
264
+ const result: Array<{ name: string; source: string; command: string; connected: boolean; toolCount: number }> = [];
265
+
266
+ // Gather from global config
267
+ const globalConfig = loadConfigFile(GLOBAL_CONFIG_PATH);
268
+ if (globalConfig) {
269
+ for (const [name, cfg] of Object.entries(globalConfig.mcpServers)) {
270
+ const connected = connectedServers.find((s) => s.name === name);
271
+ result.push({
272
+ name,
273
+ source: "global",
274
+ command: `${cfg.command} ${(cfg.args ?? []).join(" ")}`.trim(),
275
+ connected: !!connected,
276
+ toolCount: connected?.tools.length ?? 0,
277
+ });
278
+ }
279
+ }
280
+
281
+ // Gather from project configs
282
+ for (const configPath of getProjectConfigPaths(cwd)) {
283
+ const config = loadConfigFile(configPath);
284
+ if (config) {
285
+ const source = configPath.includes(".cursor") ? "cursor" : configPath.includes("opencode") ? "opencode" : "project";
286
+ for (const [name, cfg] of Object.entries(config.mcpServers)) {
287
+ // Skip if already listed from global (project overrides)
288
+ const existing = result.find((r) => r.name === name);
289
+ if (existing) {
290
+ existing.source = source;
291
+ existing.command = `${cfg.command} ${(cfg.args ?? []).join(" ")}`.trim();
292
+ continue;
293
+ }
294
+ const connected = connectedServers.find((s) => s.name === name);
295
+ result.push({
296
+ name,
297
+ source,
298
+ command: `${cfg.command} ${(cfg.args ?? []).join(" ")}`.trim(),
299
+ connected: !!connected,
300
+ toolCount: connected?.tools.length ?? 0,
301
+ });
302
+ }
303
+ }
304
+ }
305
+
306
+ return result;
307
+ }