Skip to content

Commit 0daa3db

Browse files
vibeguiclaude
andcommitted
feat(connect-studio): add Connect Studio modal for IDE MCP configuration
Add a "Connect Studio" sidebar button that opens a modal allowing users to connect their Deco Studio as an MCP server to Claude Code, Cursor, or Codex. Supports one-click auto-configuration when running locally and copyable config snippets for hosted deployments. Each connection creates a per-user API key with full access to the studio's management MCP endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fb75d6b commit 0daa3db

4 files changed

Lines changed: 751 additions & 2 deletions

File tree

apps/mesh/src/api/routes/decopilot/routes.ts

Lines changed: 344 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Uses Memory and ModelProvider abstractions.
66
*/
77

8-
import type { MeshContext } from "@/core/mesh-context";
8+
import { getUserId, type MeshContext } from "@/core/mesh-context";
99
import {
1010
consumeStream,
1111
createUIMessageStream,
@@ -192,6 +192,349 @@ export function createDecopilotRoutes(deps: DecopilotDeps) {
192192
}
193193
});
194194

195+
// ============================================================================
196+
// Connect Studio — register/check/remove MCP servers in IDEs
197+
// ============================================================================
198+
199+
async function runCli(
200+
cmd: string,
201+
args: string[],
202+
timeoutMs = 5000,
203+
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
204+
const { spawn } = await import("node:child_process");
205+
return new Promise((resolve) => {
206+
const proc = spawn(cmd, args, {
207+
stdio: ["ignore", "pipe", "pipe"],
208+
});
209+
let stdout = "";
210+
let stderr = "";
211+
const timer = setTimeout(() => {
212+
proc.kill();
213+
resolve({ ok: false, stdout, stderr: "timeout" });
214+
}, timeoutMs);
215+
proc.stdout.on("data", (chunk: Buffer) => {
216+
stdout += chunk.toString();
217+
});
218+
proc.stderr.on("data", (chunk: Buffer) => {
219+
stderr += chunk.toString();
220+
});
221+
proc.on("close", (code) => {
222+
clearTimeout(timer);
223+
resolve({ ok: code === 0, stdout, stderr });
224+
});
225+
proc.on("error", (err) => {
226+
clearTimeout(timer);
227+
resolve({ ok: false, stdout, stderr: err.message });
228+
});
229+
});
230+
}
231+
232+
function buildMcpOrigin(): string {
233+
const serverPort = process.env.PORT || "3000";
234+
return `http://localhost:${serverPort}`;
235+
}
236+
237+
function buildClaudeCodeConfig(
238+
origin: string,
239+
apiKey: string,
240+
orgId: string,
241+
) {
242+
return {
243+
type: "http",
244+
url: `${origin}/mcp/self`,
245+
headers: {
246+
Authorization: `Bearer ${apiKey}`,
247+
"x-org-id": orgId,
248+
"x-mesh-client": "Claude Code",
249+
},
250+
};
251+
}
252+
253+
function buildCursorConfig(origin: string, apiKey: string, orgId: string) {
254+
return {
255+
mcpServers: {
256+
"deco-studio": {
257+
url: `${origin}/mcp/self`,
258+
headers: {
259+
Authorization: `Bearer ${apiKey}`,
260+
"x-org-id": orgId,
261+
},
262+
},
263+
},
264+
};
265+
}
266+
267+
function buildCodexConfig(origin: string, apiKey: string, orgId: string) {
268+
return [
269+
"[mcp_servers.deco-studio]",
270+
`url = "${origin}/mcp/self"`,
271+
`http_headers = { "Authorization" = "Bearer ${apiKey}", "x-org-id" = "${orgId}" }`,
272+
].join("\n");
273+
}
274+
275+
async function getClaudeStatus() {
276+
const { ok: connected } = await runCli("claude", [
277+
"mcp",
278+
"get",
279+
"deco-studio",
280+
]);
281+
let auth: Record<string, string | undefined> | null = null;
282+
if (connected) {
283+
try {
284+
const { stdout } = await runCli("claude", ["auth", "status"]);
285+
const parsed = JSON.parse(stdout);
286+
if (parsed.loggedIn) {
287+
auth = {
288+
email: parsed.email,
289+
orgName: parsed.orgName,
290+
subscriptionType: parsed.subscriptionType,
291+
};
292+
}
293+
} catch {
294+
// Auth info not available
295+
}
296+
}
297+
return { connected, auth };
298+
}
299+
300+
async function getCursorStatus() {
301+
try {
302+
const { readFile } = await import("node:fs/promises");
303+
const { homedir } = await import("node:os");
304+
const configPath = `${homedir()}/.cursor/mcp.json`;
305+
const content = await readFile(configPath, "utf-8");
306+
const config = JSON.parse(content);
307+
return { connected: !!config?.mcpServers?.["deco-studio"] };
308+
} catch {
309+
return { connected: false };
310+
}
311+
}
312+
313+
async function getCodexStatus() {
314+
try {
315+
const { readFile } = await import("node:fs/promises");
316+
const { homedir } = await import("node:os");
317+
const configPath = `${homedir()}/.codex/config.toml`;
318+
const content = await readFile(configPath, "utf-8");
319+
return { connected: content.includes("[mcp_servers.deco-studio]") };
320+
} catch {
321+
return { connected: false };
322+
}
323+
}
324+
325+
app.get("/:org/decopilot/connect-studio/status", async (c) => {
326+
const ctx = c.get("meshContext");
327+
if (!getUserId(ctx)) {
328+
throw new HTTPException(401, { message: "Authentication required" });
329+
}
330+
331+
const [claude, cursor, codex] = await Promise.all([
332+
getClaudeStatus(),
333+
getCursorStatus(),
334+
getCodexStatus(),
335+
]);
336+
337+
return c.json({ claude, cursor, codex });
338+
});
339+
340+
app.post("/:org/decopilot/connect-studio", async (c) => {
341+
const ctx = c.get("meshContext");
342+
const organization = ensureOrganization(c);
343+
const userId = getUserId(ctx);
344+
if (!userId) {
345+
throw new HTTPException(401, { message: "Authentication required" });
346+
}
347+
348+
const body = await c.req.json().catch(() => ({}));
349+
const target = (body as { target?: string }).target;
350+
const origin = buildMcpOrigin();
351+
352+
if (target === "claude-code") {
353+
const apiKey = await ctx.boundAuth.apiKey.create({
354+
name: `studio-connect-claude-code-${userId}`,
355+
permissions: { "*": ["*"] },
356+
metadata: { internal: true, target: "claude-code", organization },
357+
});
358+
359+
const config = buildClaudeCodeConfig(origin, apiKey.key, organization.id);
360+
const configJson = JSON.stringify(config);
361+
362+
// Remove existing MCP first (ignore failure — may not exist yet)
363+
await runCli("claude", [
364+
"mcp",
365+
"remove",
366+
"deco-studio",
367+
"--scope",
368+
"user",
369+
]);
370+
const result = await runCli(
371+
"claude",
372+
["mcp", "add-json", "deco-studio", configJson, "--scope", "user"],
373+
10000,
374+
);
375+
376+
if (!result.ok) {
377+
console.error("[connect-studio] claude mcp add-json failed", {
378+
stdout: result.stdout,
379+
stderr: result.stderr,
380+
});
381+
// Return config for manual setup even if CLI fails
382+
return c.json({ success: false, config, configRaw: configJson });
383+
}
384+
return c.json({ success: true, config, configRaw: configJson });
385+
}
386+
387+
if (target === "cursor") {
388+
const apiKey = await ctx.boundAuth.apiKey.create({
389+
name: `studio-connect-cursor-${userId}`,
390+
permissions: { "*": ["*"] },
391+
metadata: { internal: true, target: "cursor", organization },
392+
});
393+
394+
const config = buildCursorConfig(origin, apiKey.key, organization.id);
395+
const configRaw = JSON.stringify(config, null, 2);
396+
397+
// Try to write to ~/.cursor/mcp.json
398+
try {
399+
const { readFile, writeFile, mkdir } = await import("node:fs/promises");
400+
const { homedir } = await import("node:os");
401+
const cursorDir = `${homedir()}/.cursor`;
402+
const configPath = `${cursorDir}/mcp.json`;
403+
404+
await mkdir(cursorDir, { recursive: true });
405+
406+
let existing: Record<string, unknown> = {};
407+
try {
408+
existing = JSON.parse(await readFile(configPath, "utf-8"));
409+
} catch {
410+
// File doesn't exist yet
411+
}
412+
413+
const merged = {
414+
...existing,
415+
mcpServers: {
416+
...(existing.mcpServers as Record<string, unknown> | undefined),
417+
"deco-studio": config.mcpServers["deco-studio"],
418+
},
419+
};
420+
421+
await writeFile(configPath, JSON.stringify(merged, null, 2));
422+
return c.json({ success: true, config, configRaw });
423+
} catch (err) {
424+
console.error("[connect-studio] cursor config write failed", err);
425+
return c.json({ success: false, config, configRaw });
426+
}
427+
}
428+
429+
if (target === "codex") {
430+
const apiKey = await ctx.boundAuth.apiKey.create({
431+
name: `studio-connect-codex-${userId}`,
432+
permissions: { "*": ["*"] },
433+
metadata: { internal: true, target: "codex", organization },
434+
});
435+
436+
const configRaw = buildCodexConfig(origin, apiKey.key, organization.id);
437+
438+
// Try to append to ~/.codex/config.toml
439+
try {
440+
const { readFile, writeFile, mkdir } = await import("node:fs/promises");
441+
const { homedir } = await import("node:os");
442+
const codexDir = `${homedir()}/.codex`;
443+
const configPath = `${codexDir}/config.toml`;
444+
445+
await mkdir(codexDir, { recursive: true });
446+
447+
let existing = "";
448+
try {
449+
existing = await readFile(configPath, "utf-8");
450+
} catch {
451+
// File doesn't exist yet
452+
}
453+
454+
// Remove existing deco-studio section if present
455+
const cleaned = existing.replace(
456+
/\[mcp_servers\.deco-studio\][^\[]*/s,
457+
"",
458+
);
459+
const updated = cleaned.trimEnd() + "\n\n" + configRaw + "\n";
460+
await writeFile(configPath, updated);
461+
return c.json({ success: true, configRaw });
462+
} catch (err) {
463+
console.error("[connect-studio] codex config write failed", err);
464+
return c.json({ success: false, configRaw });
465+
}
466+
}
467+
468+
throw new HTTPException(400, { message: `Unknown target: ${target}` });
469+
});
470+
471+
app.delete("/:org/decopilot/connect-studio", async (c) => {
472+
const ctx = c.get("meshContext");
473+
ensureOrganization(c);
474+
if (!getUserId(ctx)) {
475+
throw new HTTPException(401, { message: "Authentication required" });
476+
}
477+
478+
const body = await c.req.json().catch(() => ({}));
479+
const target = (body as { target?: string }).target;
480+
481+
if (target === "claude-code") {
482+
const result = await runCli("claude", [
483+
"mcp",
484+
"remove",
485+
"deco-studio",
486+
"--scope",
487+
"user",
488+
]);
489+
if (!result.ok) {
490+
throw new HTTPException(500, {
491+
message: "Failed to remove deco-studio MCP from Claude Code",
492+
});
493+
}
494+
return c.json({ success: true });
495+
}
496+
497+
if (target === "cursor") {
498+
try {
499+
const { readFile, writeFile } = await import("node:fs/promises");
500+
const { homedir } = await import("node:os");
501+
const configPath = `${homedir()}/.cursor/mcp.json`;
502+
const content = await readFile(configPath, "utf-8");
503+
const config = JSON.parse(content);
504+
if (config?.mcpServers?.["deco-studio"]) {
505+
delete config.mcpServers["deco-studio"];
506+
await writeFile(configPath, JSON.stringify(config, null, 2));
507+
}
508+
return c.json({ success: true });
509+
} catch {
510+
throw new HTTPException(500, {
511+
message: "Failed to remove deco-studio from Cursor config",
512+
});
513+
}
514+
}
515+
516+
if (target === "codex") {
517+
try {
518+
const { readFile, writeFile } = await import("node:fs/promises");
519+
const { homedir } = await import("node:os");
520+
const configPath = `${homedir()}/.codex/config.toml`;
521+
const content = await readFile(configPath, "utf-8");
522+
const updated = content.replace(
523+
/\[mcp_servers\.deco-studio\][^\[]*/s,
524+
"",
525+
);
526+
await writeFile(configPath, updated.trimEnd() + "\n");
527+
return c.json({ success: true });
528+
} catch {
529+
throw new HTTPException(500, {
530+
message: "Failed to remove deco-studio from Codex config",
531+
});
532+
}
533+
}
534+
535+
throw new HTTPException(400, { message: `Unknown target: ${target}` });
536+
});
537+
195538
// ============================================================================
196539
// Cancel Endpoint — cancel ongoing run (local or via NATS to owning pod)
197540
// ============================================================================

0 commit comments

Comments
 (0)