|
5 | 5 | * Uses Memory and ModelProvider abstractions. |
6 | 6 | */ |
7 | 7 |
|
8 | | -import type { MeshContext } from "@/core/mesh-context"; |
| 8 | +import { getUserId, type MeshContext } from "@/core/mesh-context"; |
9 | 9 | import { |
10 | 10 | consumeStream, |
11 | 11 | createUIMessageStream, |
@@ -192,6 +192,349 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { |
192 | 192 | } |
193 | 193 | }); |
194 | 194 |
|
| 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 (!ctx.auth?.user?.id) { |
| 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 = ctx.auth?.user?.id; |
| 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 | + |
195 | 538 | // ============================================================================ |
196 | 539 | // Cancel Endpoint — cancel ongoing run (local or via NATS to owning pod) |
197 | 540 | // ============================================================================ |
|
0 commit comments