Skip to content

Commit 65d82fb

Browse files
kulvirgitanandgupta42claude
authored
feat: auto-discover MCP servers from external AI tool configs (#311)
* feat: auto-discover MCP servers from VS Code, Cursor, Claude Code, Copilot, and Gemini configs * fix: harden MCP discovery against adversarial inputs Bugs found by adversarial testing (56 new tests): 1. **Prototype pollution** — result object inherited from Object.prototype, so `servers["constructor"]` and `servers["__proto__"]` returned inherited values even when those names were correctly filtered. Fix: use `Object.create(null)` for the result map. 2. **Crash on malformed args** — `entry.args.map(String)` throws TypeError when args contain objects with overridden `toString` (e.g., `{toString: "hacked"}`). Fix: wrap in `safeStr()` with try/catch fallback to `"[invalid]"`, and filter out null/undefined args. 3. **JSONC trailing commas rejected** — `jsonc-parser` reports errors for trailing commas by default, causing VS Code configs (which commonly use trailing commas) to be silently ignored. Fix: pass `{allowTrailingComma: true}`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: anandgupta42 <anand@altimate.ai> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fb28a0a commit 65d82fb

File tree

12 files changed

+1748
-0
lines changed

12 files changed

+1748
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ __pycache__
77
.idea
88
.vscode
99
.codex
10+
.claude
1011
*~
1112
playground
1213
tmp
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
description: "Discover MCP servers from external AI tool configs and add them permanently"
3+
---
4+
5+
Discover MCP servers configured in other AI tools (VS Code, Cursor, GitHub Copilot, Claude Code, Gemini CLI) and add them to the altimate-code config.
6+
7+
## Instructions
8+
9+
1. First, call the `mcp_discover` tool with `action: "list"` to see what's available.
10+
11+
2. Show the user the results — which servers are new and which are already configured.
12+
13+
3. If there are new servers, ask the user which ones they want to add and what scope (project or global).
14+
15+
4. Call `mcp_discover` with `action: "add"`, the chosen `scope`, and the selected `servers` array.
16+
17+
5. Report what was added and where.
18+
19+
If $ARGUMENTS contains `--scope global`, use `scope: "global"`. Otherwise default to `scope: "project"`.

.opencode/opencode.jsonc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,7 @@
1515
"github-triage": false,
1616
"github-pr-search": false,
1717
},
18+
"experimental": {
19+
"auto_mcp_discovery": false,
20+
},
1821
}

docs/docs/configure/tools.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,41 @@ When [LSP servers](lsp.md) are configured, the `lsp` tool provides:
110110
- Go-to-definition
111111
- Hover information
112112
- Completions
113+
114+
### MCP Discover Tool
115+
116+
The `mcp_discover` tool finds MCP servers configured in other AI coding tools and can add them to your altimate-code config permanently.
117+
118+
**Supported sources:**
119+
120+
| Tool | Config Path | Key |
121+
|------|------------|-----|
122+
| VS Code / Copilot | `.vscode/mcp.json` | `servers` |
123+
| Cursor | `.cursor/mcp.json` | `mcpServers` |
124+
| GitHub Copilot | `.github/copilot/mcp.json` | `mcpServers` |
125+
| Claude Code | `.mcp.json`, `~/.claude.json` | `mcpServers` |
126+
| Gemini CLI | `.gemini/settings.json` | `mcpServers` |
127+
128+
**Actions:**
129+
130+
- `mcp_discover(action: "list")` — Show discovered servers and which are already in your config
131+
- `mcp_discover(action: "add", scope: "project")` — Write new servers to `.altimate-code/altimate-code.json`
132+
- `mcp_discover(action: "add", scope: "global")` — Write to the global config dir (`~/.config/opencode/`)
133+
134+
**Auto-discovery:** At startup, altimate-code discovers external MCP servers and shows a toast notification. Servers from your home directory (`~/.claude.json`, `~/.gemini/settings.json`) are auto-enabled since they're user-owned. Servers from project-level files (`.vscode/mcp.json`, `.mcp.json`, `.cursor/mcp.json`) are discovered but **disabled by default** for security — run `/discover-and-add-mcps` to review and enable them.
135+
136+
!!! tip
137+
Home-directory MCP servers (from `~/.claude.json`, `~/.gemini/settings.json`) are loaded automatically. Project-scoped servers require explicit approval via `/discover-and-add-mcps` or `mcp_discover(action: "add")`.
138+
139+
!!! warning "Security: untrusted repositories"
140+
Project-level MCP configs (`.vscode/mcp.json`, `.mcp.json`, `.cursor/mcp.json`) are discovered but not auto-connected. This prevents malicious repositories from executing arbitrary commands. You must explicitly approve project-scoped servers before they run.
141+
142+
To disable auto-discovery, set in your config:
143+
144+
```json
145+
{
146+
"experimental": {
147+
"auto_mcp_discovery": false
148+
}
149+
}
150+
```

packages/opencode/src/altimate/telemetry/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,14 @@ export namespace Telemetry {
260260
tool_count: number
261261
resource_count: number
262262
}
263+
| {
264+
type: "mcp_discovery"
265+
timestamp: number
266+
session_id: string
267+
server_count: number
268+
server_names: string[]
269+
sources: string[]
270+
}
263271
| {
264272
type: "memory_operation"
265273
timestamp: number
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import z from "zod"
2+
import { Tool } from "../../tool/tool"
3+
import { discoverExternalMcp } from "../../mcp/discover"
4+
import { resolveConfigPath, addMcpToConfig, findAllConfigPaths, listMcpInConfig } from "../../mcp/config"
5+
import { Instance } from "../../project/instance"
6+
import { Global } from "../../global"
7+
8+
/**
9+
* Check which MCP server names are permanently configured on disk
10+
* (as opposed to ephemeral auto-discovered servers in memory).
11+
*/
12+
async function getPersistedMcpNames(): Promise<Set<string>> {
13+
const configPaths = await findAllConfigPaths(Instance.worktree, Global.Path.config)
14+
const names = new Set<string>()
15+
for (const p of configPaths) {
16+
for (const name of await listMcpInConfig(p)) {
17+
names.add(name)
18+
}
19+
}
20+
return names
21+
}
22+
23+
/** Redact server details for safe display — show type and name only, not commands/URLs */
24+
function safeDetail(server: { type: string } & Record<string, any>): string {
25+
if (server.type === "remote") return "(remote)"
26+
if (server.type === "local" && Array.isArray(server.command) && server.command.length > 0) {
27+
// Show only the executable name, not args (which may contain credentials)
28+
return `(local: ${server.command[0]})`
29+
}
30+
return `(${server.type})`
31+
}
32+
33+
export const McpDiscoverTool = Tool.define("mcp_discover", {
34+
description:
35+
"Discover MCP servers from external AI tool configs (VS Code, Cursor, Claude Code, Copilot, Gemini) and optionally add them to altimate-code config permanently.",
36+
parameters: z.object({
37+
action: z
38+
.enum(["list", "add"])
39+
.describe('"list" to show discovered servers, "add" to write them to config'),
40+
scope: z
41+
.enum(["project", "global"])
42+
.optional()
43+
.default("project")
44+
.describe('Where to write when action is "add". "project" = .altimate-code/altimate-code.json, "global" = ~/.config/opencode/'),
45+
servers: z
46+
.array(z.string())
47+
.optional()
48+
.describe('Server names to add. If omitted with action "add", adds all new servers.'),
49+
}),
50+
async execute(args, ctx) {
51+
const { servers: discovered } = await discoverExternalMcp(Instance.worktree)
52+
const discoveredNames = Object.keys(discovered)
53+
54+
if (discoveredNames.length === 0) {
55+
return {
56+
title: "MCP Discover: none found",
57+
metadata: { discovered: 0, new: 0, existing: 0, added: 0 },
58+
output:
59+
"No MCP servers found in external configs.\nChecked: .vscode/mcp.json, .cursor/mcp.json, .github/copilot/mcp.json, .mcp.json (project + home), .gemini/settings.json (project + home), ~/.claude.json",
60+
}
61+
}
62+
63+
// Check what's actually persisted on disk, NOT the merged in-memory config
64+
const persistedNames = await getPersistedMcpNames()
65+
const newServers = discoveredNames.filter((n) => !persistedNames.has(n))
66+
const alreadyAdded = discoveredNames.filter((n) => persistedNames.has(n))
67+
68+
// Build discovery report — redact details for security (no raw commands/URLs)
69+
const lines: string[] = []
70+
if (newServers.length > 0) {
71+
lines.push(`New servers (not yet in config):`)
72+
for (const name of newServers) {
73+
lines.push(` - ${name} ${safeDetail(discovered[name])}`)
74+
}
75+
}
76+
if (alreadyAdded.length > 0) {
77+
lines.push(`\nAlready in config: ${alreadyAdded.join(", ")}`)
78+
}
79+
80+
if (args.action === "list") {
81+
return {
82+
title: `MCP Discover: ${newServers.length} new, ${alreadyAdded.length} existing`,
83+
metadata: { discovered: discoveredNames.length, new: newServers.length, existing: alreadyAdded.length, added: 0 },
84+
output: lines.join("\n"),
85+
}
86+
}
87+
88+
// action === "add"
89+
const toAdd = args.servers
90+
? args.servers.filter((n) => newServers.includes(n))
91+
: newServers
92+
93+
if (toAdd.length === 0) {
94+
return {
95+
title: "MCP Discover: nothing to add",
96+
metadata: { discovered: discoveredNames.length, new: newServers.length, existing: alreadyAdded.length, added: 0 },
97+
output: lines.join("\n") + "\n\nNo matching servers to add.",
98+
}
99+
}
100+
101+
const useGlobal = args.scope === "global"
102+
const configPath = await resolveConfigPath(
103+
useGlobal ? Global.Path.config : Instance.worktree,
104+
useGlobal,
105+
)
106+
107+
for (const name of toAdd) {
108+
await addMcpToConfig(name, discovered[name], configPath)
109+
}
110+
111+
lines.push(`\nAdded ${toAdd.length} server(s) to ${configPath}: ${toAdd.join(", ")}`)
112+
lines.push("These servers are already active in the current session via auto-discovery.")
113+
114+
return {
115+
title: `MCP Discover: added ${toAdd.length} server(s)`,
116+
metadata: { discovered: discoveredNames.length, new: newServers.length, existing: alreadyAdded.length, added: toAdd.length },
117+
output: lines.join("\n"),
118+
}
119+
},
120+
})

packages/opencode/src/config/config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,24 @@ export namespace Config {
264264

265265
result.plugin = deduplicatePlugins(result.plugin ?? [])
266266

267+
// altimate_change start — auto-discover MCP servers from external AI tool configs
268+
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG && result.experimental?.auto_mcp_discovery !== false) {
269+
const { discoverExternalMcp, setDiscoveryResult } = await import("../mcp/discover")
270+
const { servers: externalMcp, sources } = await discoverExternalMcp(Instance.worktree)
271+
if (Object.keys(externalMcp).length > 0) {
272+
result.mcp ??= {}
273+
const added: string[] = []
274+
for (const [name, server] of Object.entries(externalMcp)) {
275+
if (!(name in result.mcp)) {
276+
result.mcp[name] = server
277+
added.push(name)
278+
}
279+
}
280+
setDiscoveryResult(added, sources)
281+
}
282+
}
283+
// altimate_change end
284+
267285
return {
268286
config: result,
269287
directories,
@@ -1273,6 +1291,12 @@ export namespace Config {
12731291
.optional()
12741292
.describe("Use environment fingerprint to select relevant skills once per session (default: false). Set to true to enable LLM-based skill filtering."),
12751293
// altimate_change end
1294+
// altimate_change start - auto MCP discovery toggle
1295+
auto_mcp_discovery: z
1296+
.boolean()
1297+
.optional()
1298+
.describe("Auto-discover MCP servers from VS Code, Claude Code, Copilot, and Gemini configs at startup (default: true). Set to false to disable."),
1299+
// altimate_change end
12761300
})
12771301
.optional(),
12781302
})

0 commit comments

Comments
 (0)