From 6cde19019fa067dff2fabf26c31a8ea3b1fa137a Mon Sep 17 00:00:00 2001 From: sarthakNITT Date: Thu, 25 Jun 2026 03:14:59 +0530 Subject: [PATCH 1/2] feat(mcp): add mcp connect and disconnect commands --- .antigravity/mcp.json | 3 + README.md | 60 ++++++++++++++ src/commands/mcp/connect.ts | 55 +++++++++++++ src/commands/mcp/disconnect.ts | 69 ++++++++++++++++ src/commands/mcp/index.ts | 8 ++ src/commands/mcp/mcp.test.ts | 102 ++++++++++++++++++++++++ src/index.ts | 2 + src/lib/api/oss.ts | 9 +++ src/lib/mcp-config.test.ts | 74 +++++++++++++++++ src/lib/mcp-config.ts | 140 +++++++++++++++++++++++++++++++++ 10 files changed, 522 insertions(+) create mode 100644 .antigravity/mcp.json create mode 100644 src/commands/mcp/connect.ts create mode 100644 src/commands/mcp/disconnect.ts create mode 100644 src/commands/mcp/index.ts create mode 100644 src/commands/mcp/mcp.test.ts create mode 100644 src/lib/mcp-config.test.ts create mode 100644 src/lib/mcp-config.ts diff --git a/.antigravity/mcp.json b/.antigravity/mcp.json new file mode 100644 index 00000000..da39e4ff --- /dev/null +++ b/.antigravity/mcp.json @@ -0,0 +1,3 @@ +{ + "mcpServers": {} +} diff --git a/README.md b/README.md index 557082cc..8e01e379 100644 --- a/README.md +++ b/README.md @@ -1114,6 +1114,66 @@ Skill files are written to per-agent directories (e.g. `.claude/`, `.cursor/`, ` For bare agent harnesses that follow the open [agents.md](https://agents.md) standard (a single `AGENTS.md` at the project root) rather than the per-agent skill directories, the CLI also writes an `AGENTS.md` into your project. It contains a delimited `` block with InsForge context (where credentials live, when to reach for the SDK vs. the CLI, and a few correctness patterns). If you already have an `AGENTS.md`, the block is appended once and refreshed in place on subsequent runs, leaving your own content untouched. Unlike the per-agent skill files, `AGENTS.md` is **not** gitignored, so you can commit and share it. +### MCP Connection + +After linking a project, connect or disconnect a local MCP provider config. + +#### `npx @insforge/cli connect [provider]` + +Writes the InsForge MCP server configuration into the provider's local config file, then marks the backend MCP status as `connected`. + +```bash +# Connect the default provider (cursor) +npx @insforge/cli connect + +# Connect a specific provider +npx @insforge/cli connect cursor +npx @insforge/cli connect claude-code +npx @insforge/cli connect windsurf +npx @insforge/cli connect cline +npx @insforge/cli connect roo +npx @insforge/cli connect codex +npx @insforge/cli connect antigravity + +# Output as JSON +npx @insforge/cli connect cursor --json +``` + +The `connect` command: +1. Reads the linked project config from `.insforge/project.json` +2. Writes an `insforge` MCP server entry into the provider's config file (e.g. `.cursor/mcp.json` for Cursor, `.mcp.json` for Claude Code) +3. Reports the backend MCP status as `connected` + +Config files written per provider: + +| Provider | Config file written | +| ------------- | -------------------------------- | +| `cursor` | `.cursor/mcp.json` | +| `claude-code` | `.mcp.json` | +| `windsurf` | `.windsurf/mcp_config.json` | +| `cline` | `.cline/mcp.json` | +| `roo` | `.roo/mcp.json` | +| `codex` | `.codex/mcp.json` | +| `antigravity` | `.antigravity/mcp.json` | + +#### `npx @insforge/cli disconnect [provider]` + +Removes the InsForge MCP server entry from the provider's local config file, then marks the backend MCP status as `disconnected`. + +```bash +# Disconnect from all known local provider configs +npx @insforge/cli disconnect + +# Disconnect from a specific provider +npx @insforge/cli disconnect cursor +npx @insforge/cli disconnect claude-code + +# Output as JSON +npx @insforge/cli disconnect cursor --json +``` + +The `disconnect` command removes only the `insforge` key from `mcpServers` — all other MCP server entries in the config file are left untouched. + ## Analytics The CLI reports anonymous usage events to [PostHog](https://posthog.com) so we can understand which features are being used and prioritize improvements. diff --git a/src/commands/mcp/connect.ts b/src/commands/mcp/connect.ts new file mode 100644 index 00000000..3e4597dc --- /dev/null +++ b/src/commands/mcp/connect.ts @@ -0,0 +1,55 @@ +import type { Command } from 'commander'; +import * as clack from '@clack/prompts'; +import { updateMcpConnectionStatus } from '../../lib/api/oss.js'; +import { getProjectConfig } from '../../lib/config.js'; +import { handleError, getRootOpts, ProjectNotLinkedError } from '../../lib/errors.js'; +import { connectMcpProvider, displayMcpConfigPath, parseMcpProvider } from '../../lib/mcp-config.js'; +import { outputJson, outputSuccess } from '../../lib/output.js'; +import { reportCliUsage } from '../../lib/skills.js'; +import { captureEvent, shutdownAnalytics } from '../../lib/analytics.js'; + +export function registerMcpConnectCommand(program: Command): void { + program + .command('connect [provider]') + .description('Connect an MCP provider to the linked InsForge project') + .action(async (providerArg: string | undefined, _opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + const project = getProjectConfig(); + if (!project) throw new ProjectNotLinkedError(); + + const provider = parseMcpProvider(providerArg ?? 'cursor'); + const result = connectMcpProvider(provider, project); + await updateMcpConnectionStatus('connected'); + captureEvent(project.project_id, 'cli_mcp_connect', { + provider, + project_id: project.project_id, + project_name: project.project_name, + org_id: project.org_id, + region: project.region, + changed: result.changed, + }); + await reportCliUsage('cli.mcp.connect', true); + + if (json) { + outputJson({ + success: true, + status: 'connected', + provider, + config_path: result.path, + changed: result.changed, + }); + } else { + outputSuccess(`Connected ${provider} to InsForge MCP in ${displayMcpConfigPath(result.path)}.`); + if (!result.changed) { + clack.log.info('The existing InsForge MCP entry was already up to date.'); + } + } + } catch (err) { + await reportCliUsage('cli.mcp.connect', false); + handleError(err, json); + } finally { + await shutdownAnalytics(); + } + }); +} diff --git a/src/commands/mcp/disconnect.ts b/src/commands/mcp/disconnect.ts new file mode 100644 index 00000000..04311cfd --- /dev/null +++ b/src/commands/mcp/disconnect.ts @@ -0,0 +1,69 @@ +import type { Command } from 'commander'; +import * as clack from '@clack/prompts'; +import { updateMcpConnectionStatus } from '../../lib/api/oss.js'; +import { getProjectConfig } from '../../lib/config.js'; +import { handleError, getRootOpts, ProjectNotLinkedError } from '../../lib/errors.js'; +import { MCP_PROVIDERS, disconnectMcpProvider, displayMcpConfigPath, parseMcpProvider } from '../../lib/mcp-config.js'; +import { outputJson, outputSuccess } from '../../lib/output.js'; +import { reportCliUsage } from '../../lib/skills.js'; +import { captureEvent, shutdownAnalytics } from '../../lib/analytics.js'; + +export function registerMcpDisconnectCommand(program: Command): void { + program + .command('disconnect [provider]') + .description('Disconnect an MCP provider from the linked InsForge project') + .action(async (providerArg: string | undefined, _opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + const project = getProjectConfig(); + if (!project) throw new ProjectNotLinkedError(); + + const providers = providerArg ? [parseMcpProvider(providerArg)] : MCP_PROVIDERS; + const results = providers.map((provider) => disconnectMcpProvider(provider)); + await updateMcpConnectionStatus('disconnected'); + const changed = results.some((result) => result.changed); + captureEvent(project.project_id, 'cli_mcp_disconnect', { + provider: providerArg ? results[0].provider : 'all', + project_id: project.project_id, + project_name: project.project_name, + org_id: project.org_id, + region: project.region, + changed, + }); + await reportCliUsage('cli.mcp.disconnect', true); + + if (json) { + outputJson({ + success: true, + status: 'disconnected', + provider: providerArg ? results[0].provider : 'all', + results: results.map((result) => ({ + provider: result.provider, + config_path: result.path, + changed: result.changed, + })), + changed, + }); + } else { + if (providerArg) { + const result = results[0]; + outputSuccess(`Disconnected ${result.provider} from InsForge MCP in ${displayMcpConfigPath(result.path)}.`); + } else { + outputSuccess('Disconnected InsForge MCP from all known local provider configs.'); + const updated = results.filter((result) => result.changed); + if (updated.length > 0) { + clack.log.info(`Updated: ${updated.map((result) => displayMcpConfigPath(result.path)).join(', ')}`); + } + } + if (!changed) { + clack.log.info('No InsForge MCP entries were present; backend status was still marked disconnected.'); + } + } + } catch (err) { + await reportCliUsage('cli.mcp.disconnect', false); + handleError(err, json); + } finally { + await shutdownAnalytics(); + } + }); +} diff --git a/src/commands/mcp/index.ts b/src/commands/mcp/index.ts new file mode 100644 index 00000000..23f8d80f --- /dev/null +++ b/src/commands/mcp/index.ts @@ -0,0 +1,8 @@ +import type { Command } from 'commander'; +import { registerMcpConnectCommand } from './connect.js'; +import { registerMcpDisconnectCommand } from './disconnect.js'; + +export function registerMcpCommands(program: Command): void { + registerMcpConnectCommand(program); + registerMcpDisconnectCommand(program); +} diff --git a/src/commands/mcp/mcp.test.ts b/src/commands/mcp/mcp.test.ts new file mode 100644 index 00000000..0a1eb2d6 --- /dev/null +++ b/src/commands/mcp/mcp.test.ts @@ -0,0 +1,102 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; +import { registerMcpCommands } from './index.js'; + +const project = { + project_id: 'p1', + project_name: 'demo', + org_id: 'o1', + appkey: 'app', + region: 'us', + api_key: 'secret', + oss_host: 'https://app.us.insforge.app', +}; + +vi.mock('../../lib/config.js', () => ({ + getProjectConfig: vi.fn(() => project), +})); + +vi.mock('../../lib/api/oss.js', async (importOriginal: () => Promise>) => { + const actual = await importOriginal(); + return { + ...actual, + updateMcpConnectionStatus: vi.fn(async () => {}), + }; +}); + +vi.mock('../../lib/skills.js', () => ({ + reportCliUsage: vi.fn(async () => {}), +})); + +vi.mock('../../lib/analytics.js', () => ({ + captureEvent: vi.fn(), + shutdownAnalytics: vi.fn(async () => {}), +})); + +let cwd: string; +const originalCwd = process.cwd(); + +function makeProgram() { + const program = new Command().exitOverride(); + program.option('--json'); + registerMcpCommands(program); + return program; +} + +async function run(argv: string[]) { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await makeProgram().parseAsync(argv, { from: 'user' }); + } finally { + logSpy.mockRestore(); + } +} + +describe('mcp commands', () => { + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'insforge-mcp-command-')); + process.chdir(cwd); + vi.clearAllMocks(); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(cwd, { recursive: true, force: true }); + }); + + it('connect writes provider config and marks the backend connected', async () => { + await run(['connect', 'cursor', '--json']); + + const config = JSON.parse(readFileSync(join(cwd, '.cursor/mcp.json'), 'utf-8')); + expect(config.mcpServers.insforge.url).toBe('https://app.us.insforge.app/api/usage/mcp'); + + const { updateMcpConnectionStatus } = await import('../../lib/api/oss.js'); + expect(updateMcpConnectionStatus).toHaveBeenCalledWith('connected'); + }); + + it('disconnect removes a provider config and marks the backend disconnected', async () => { + await run(['connect', 'cursor', '--json']); + await run(['disconnect', 'cursor', '--json']); + + const config = JSON.parse(readFileSync(join(cwd, '.cursor/mcp.json'), 'utf-8')); + expect(config.mcpServers.insforge).toBeUndefined(); + + const { updateMcpConnectionStatus } = await import('../../lib/api/oss.js'); + expect(updateMcpConnectionStatus).toHaveBeenLastCalledWith('disconnected'); + }); + + it('bare disconnect removes insforge from every supported local provider config', async () => { + await run(['connect', 'cursor', '--json']); + await run(['connect', 'claude-code', '--json']); + + await run(['disconnect', '--json']); + + const cursor = JSON.parse(readFileSync(join(cwd, '.cursor/mcp.json'), 'utf-8')); + const claude = JSON.parse(readFileSync(join(cwd, '.mcp.json'), 'utf-8')); + expect(cursor.mcpServers.insforge).toBeUndefined(); + expect(claude.mcpServers.insforge).toBeUndefined(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 568a8c55..e34c2c56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,7 @@ import { registerConfigCommand } from './commands/config/index.js'; import { registerAiCommands } from './commands/ai/index.js'; import { registerDomainsCommands } from './commands/domains/index.js'; import { registerMemoryCommands } from './commands/memory/index.js'; +import { registerMcpCommands } from './commands/mcp/index.js'; import { guardHook } from './lib/guard/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -124,6 +125,7 @@ registerContextCommand(program); registerListCommand(program); registerDocsCommand(program); registerProjectLinkCommand(program); +registerMcpCommands(program); // Orgs commands (hidden — use `insforge list` instead) const orgsCmd = program.command('orgs', { hidden: true }).description('Manage organizations'); diff --git a/src/lib/api/oss.ts b/src/lib/api/oss.ts index 02a3c102..5886605b 100644 --- a/src/lib/api/oss.ts +++ b/src/lib/api/oss.ts @@ -50,6 +50,15 @@ export async function getJwtSecret(): Promise { } } +export type McpConnectionStatus = 'connected' | 'disconnected'; + +export async function updateMcpConnectionStatus(status: McpConnectionStatus): Promise { + await ossFetch('/api/usage/mcp/status', { + method: 'POST', + body: JSON.stringify({ status }), + }); +} + // Splice the real password into a masked Postgres URL like // `postgresql://postgres:********@host:5432/db?sslmode=require`. Replaces // the segment between the first `://:` and the next `@`. Exported diff --git a/src/lib/mcp-config.test.ts b/src/lib/mcp-config.test.ts new file mode 100644 index 00000000..24c72b09 --- /dev/null +++ b/src/lib/mcp-config.test.ts @@ -0,0 +1,74 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + connectMcpProvider, + disconnectMcpProvider, + getMcpConfigPath, + parseMcpProvider, +} from './mcp-config.js'; +import type { ProjectConfig } from '../types.js'; + +const project: ProjectConfig = { + project_id: 'p1', + project_name: 'demo', + org_id: 'o1', + appkey: 'app', + region: 'us', + api_key: 'secret', + oss_host: 'https://app.us.insforge.app', +}; + +let dirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'insforge-mcp-')); + dirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }); + } + dirs = []; +}); + +describe('mcp config helpers', () => { + it('writes an insforge MCP server while preserving existing entries', () => { + const cwd = tempDir(); + const path = getMcpConfigPath('cursor', cwd); + mkdirSync(join(cwd, '.cursor'), { recursive: true }); + writeFileSync(path, JSON.stringify({ mcpServers: { other: { command: 'node' } } })); + + const result = connectMcpProvider('cursor', project, cwd); + const config = JSON.parse(readFileSync(path, 'utf-8')); + + expect(result.changed).toBe(true); + expect(config.mcpServers.other).toEqual({ command: 'node' }); + expect(config.mcpServers.insforge).toMatchObject({ + type: 'http', + url: 'https://app.us.insforge.app/api/usage/mcp', + headers: { + Authorization: 'Bearer secret', + 'x-api-key': 'secret', + }, + }); + }); + + it('removes only the insforge MCP server', () => { + const cwd = tempDir(); + connectMcpProvider('claude-code', project, cwd); + + const result = disconnectMcpProvider('claude-code', cwd); + const config = JSON.parse(readFileSync(getMcpConfigPath('claude-code', cwd), 'utf-8')); + + expect(result.changed).toBe(true); + expect(config.mcpServers.insforge).toBeUndefined(); + }); + + it('accepts claude as an alias for claude-code', () => { + expect(parseMcpProvider('claude')).toBe('claude-code'); + }); +}); diff --git a/src/lib/mcp-config.ts b/src/lib/mcp-config.ts new file mode 100644 index 00000000..31a6e4f6 --- /dev/null +++ b/src/lib/mcp-config.ts @@ -0,0 +1,140 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import type { ProjectConfig } from '../types.js'; +import { CLIError } from './errors.js'; + +export const MCP_SERVER_NAME = 'insforge'; + +export const MCP_PROVIDERS = [ + 'cursor', + 'claude-code', + 'windsurf', + 'cline', + 'roo', + 'codex', + 'antigravity', +] as const; + +export type McpProvider = typeof MCP_PROVIDERS[number]; + +interface ProviderConfig { + path: string; +} + +const PROVIDER_CONFIGS: Record = { + cursor: { path: '.cursor/mcp.json' }, + 'claude-code': { path: '.mcp.json' }, + windsurf: { path: '.windsurf/mcp_config.json' }, + cline: { path: '.cline/mcp.json' }, + roo: { path: '.roo/mcp.json' }, + codex: { path: '.codex/mcp.json' }, + antigravity: { path: '.antigravity/mcp.json' }, +}; + +type JsonObject = Record; + +export interface McpConfigUpdateResult { + provider: McpProvider; + path: string; + serverName: typeof MCP_SERVER_NAME; + changed: boolean; +} + +export function parseMcpProvider(value: string): McpProvider { + const normalized = value.trim().toLowerCase(); + if (normalized === 'claude') return 'claude-code'; + if (MCP_PROVIDERS.includes(normalized as McpProvider)) { + return normalized as McpProvider; + } + throw new CLIError(`Invalid MCP provider "${value}". Valid: ${MCP_PROVIDERS.join(', ')}`); +} + +export function getMcpConfigPath(provider: McpProvider, cwd = process.cwd()): string { + return resolve(cwd, PROVIDER_CONFIGS[provider].path); +} + +export function displayMcpConfigPath(path: string, cwd = process.cwd()): string { + const rel = relative(cwd, path); + if (!rel || rel.startsWith('..')) return path; + return rel; +} + +export function connectMcpProvider(provider: McpProvider, project: ProjectConfig, cwd = process.cwd()): McpConfigUpdateResult { + const path = getMcpConfigPath(provider, cwd); + const config = readMcpJson(path); + const existingServers = isObject(config.mcpServers) ? config.mcpServers : {}; + const server = buildMcpServerConfig(project); + const changed = JSON.stringify(existingServers[MCP_SERVER_NAME]) !== JSON.stringify(server); + + config.mcpServers = { + ...existingServers, + [MCP_SERVER_NAME]: server, + }; + writeMcpJson(path, config); + + return { + provider, + path, + serverName: MCP_SERVER_NAME, + changed, + }; +} + +export function disconnectMcpProvider(provider: McpProvider, cwd = process.cwd()): McpConfigUpdateResult { + const path = getMcpConfigPath(provider, cwd); + const config = readMcpJson(path); + const existingServers = isObject(config.mcpServers) ? config.mcpServers : {}; + const changed = Object.prototype.hasOwnProperty.call(existingServers, MCP_SERVER_NAME); + + if (changed) { + const nextServers = { ...existingServers }; + delete nextServers[MCP_SERVER_NAME]; + config.mcpServers = nextServers; + writeMcpJson(path, config); + } + + return { + provider, + path, + serverName: MCP_SERVER_NAME, + changed, + }; +} + +function buildMcpServerConfig(project: ProjectConfig): JsonObject { + return { + type: 'http', + url: `${project.oss_host.replace(/\/$/, '')}/api/usage/mcp`, + headers: { + Authorization: `Bearer ${project.api_key}`, + 'x-api-key': project.api_key, + }, + }; +} + +function readMcpJson(path: string): JsonObject { + if (!existsSync(path)) return {}; + + const raw = readFileSync(path, 'utf-8').trim(); + if (!raw) return {}; + + try { + const parsed = JSON.parse(raw) as unknown; + if (!isObject(parsed)) { + throw new CLIError(`${displayMcpConfigPath(path)} must contain a JSON object.`); + } + return parsed; + } catch (err) { + if (err instanceof CLIError) throw err; + throw new CLIError(`Could not parse ${displayMcpConfigPath(path)} as JSON.`); + } +} + +function writeMcpJson(path: string, config: JsonObject): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} From 254221d1fa57a9a2c215c11d8e9dd1d7cb8429ed Mon Sep 17 00:00:00 2001 From: sarthakNITT Date: Thu, 25 Jun 2026 11:28:53 +0530 Subject: [PATCH 2/2] feat: allow mcp connect/disconnect using explicit api-key and base-url --- src/commands/mcp/connect.ts | 51 +++++++++++++++++++++-------- src/commands/mcp/disconnect.ts | 46 ++++++++++++++++++-------- src/commands/mcp/mcp.test.ts | 60 ++++++++++++++++++++++++++++++++++ src/lib/api/oss.ts | 20 +++++++++++- src/lib/mcp-config.ts | 16 ++++++--- 5 files changed, 160 insertions(+), 33 deletions(-) diff --git a/src/commands/mcp/connect.ts b/src/commands/mcp/connect.ts index 3e4597dc..573a9b77 100644 --- a/src/commands/mcp/connect.ts +++ b/src/commands/mcp/connect.ts @@ -2,33 +2,56 @@ import type { Command } from 'commander'; import * as clack from '@clack/prompts'; import { updateMcpConnectionStatus } from '../../lib/api/oss.js'; import { getProjectConfig } from '../../lib/config.js'; -import { handleError, getRootOpts, ProjectNotLinkedError } from '../../lib/errors.js'; +import { handleError, getRootOpts, ProjectNotLinkedError, CLIError } from '../../lib/errors.js'; import { connectMcpProvider, displayMcpConfigPath, parseMcpProvider } from '../../lib/mcp-config.js'; import { outputJson, outputSuccess } from '../../lib/output.js'; import { reportCliUsage } from '../../lib/skills.js'; import { captureEvent, shutdownAnalytics } from '../../lib/analytics.js'; +import type { ProjectConfig } from '../../types.js'; export function registerMcpConnectCommand(program: Command): void { program .command('connect [provider]') .description('Connect an MCP provider to the linked InsForge project') - .action(async (providerArg: string | undefined, _opts, cmd) => { + .option('--api-key ', 'API key for InsForge MCP') + .option('--api-base-url ', 'Base URL of the InsForge backend') + .action(async (providerArg: string | undefined, options: { apiKey?: string; apiBaseUrl?: string }, cmd) => { const { json } = getRootOpts(cmd); try { - const project = getProjectConfig(); - if (!project) throw new ProjectNotLinkedError(); + const { apiKey, apiBaseUrl } = options; + let connectionConfig: { apiKey: string; apiBaseUrl: string } | ProjectConfig; + let projectId: string | undefined; + + if (apiKey || apiBaseUrl) { + if (!apiKey || !apiBaseUrl) { + throw new CLIError('Both --api-key and --api-base-url must be provided if not using a linked project.'); + } + connectionConfig = { apiKey, apiBaseUrl }; + } else { + const project = getProjectConfig(); + if (!project) throw new ProjectNotLinkedError(); + connectionConfig = project; + projectId = project.project_id; + } const provider = parseMcpProvider(providerArg ?? 'cursor'); - const result = connectMcpProvider(provider, project); - await updateMcpConnectionStatus('connected'); - captureEvent(project.project_id, 'cli_mcp_connect', { - provider, - project_id: project.project_id, - project_name: project.project_name, - org_id: project.org_id, - region: project.region, - changed: result.changed, - }); + const result = connectMcpProvider(provider, connectionConfig); + if (apiKey && apiBaseUrl) { + await updateMcpConnectionStatus('connected', { apiKey, apiBaseUrl }); + } else { + await updateMcpConnectionStatus('connected'); + } + + if (projectId && 'project_id' in connectionConfig) { + captureEvent(connectionConfig.project_id, 'cli_mcp_connect', { + provider, + project_id: connectionConfig.project_id, + project_name: connectionConfig.project_name, + org_id: connectionConfig.org_id, + region: connectionConfig.region, + changed: result.changed, + }); + } await reportCliUsage('cli.mcp.connect', true); if (json) { diff --git a/src/commands/mcp/disconnect.ts b/src/commands/mcp/disconnect.ts index 04311cfd..314ecd46 100644 --- a/src/commands/mcp/disconnect.ts +++ b/src/commands/mcp/disconnect.ts @@ -2,7 +2,7 @@ import type { Command } from 'commander'; import * as clack from '@clack/prompts'; import { updateMcpConnectionStatus } from '../../lib/api/oss.js'; import { getProjectConfig } from '../../lib/config.js'; -import { handleError, getRootOpts, ProjectNotLinkedError } from '../../lib/errors.js'; +import { handleError, getRootOpts, ProjectNotLinkedError, CLIError } from '../../lib/errors.js'; import { MCP_PROVIDERS, disconnectMcpProvider, displayMcpConfigPath, parseMcpProvider } from '../../lib/mcp-config.js'; import { outputJson, outputSuccess } from '../../lib/output.js'; import { reportCliUsage } from '../../lib/skills.js'; @@ -12,24 +12,44 @@ export function registerMcpDisconnectCommand(program: Command): void { program .command('disconnect [provider]') .description('Disconnect an MCP provider from the linked InsForge project') - .action(async (providerArg: string | undefined, _opts, cmd) => { + .option('--api-key ', 'API key for InsForge MCP') + .option('--api-base-url ', 'Base URL of the InsForge backend') + .action(async (providerArg: string | undefined, options: { apiKey?: string; apiBaseUrl?: string }, cmd) => { const { json } = getRootOpts(cmd); try { - const project = getProjectConfig(); - if (!project) throw new ProjectNotLinkedError(); + const { apiKey, apiBaseUrl } = options; + let projectId: string | undefined; + let projectConfig: ReturnType = null; + + if (apiKey || apiBaseUrl) { + if (!apiKey || !apiBaseUrl) { + throw new CLIError('Both --api-key and --api-base-url must be provided if not using a linked project.'); + } + } else { + projectConfig = getProjectConfig(); + if (!projectConfig) throw new ProjectNotLinkedError(); + projectId = projectConfig.project_id; + } const providers = providerArg ? [parseMcpProvider(providerArg)] : MCP_PROVIDERS; const results = providers.map((provider) => disconnectMcpProvider(provider)); - await updateMcpConnectionStatus('disconnected'); + if (apiKey && apiBaseUrl) { + await updateMcpConnectionStatus('disconnected', { apiKey, apiBaseUrl }); + } else { + await updateMcpConnectionStatus('disconnected'); + } const changed = results.some((result) => result.changed); - captureEvent(project.project_id, 'cli_mcp_disconnect', { - provider: providerArg ? results[0].provider : 'all', - project_id: project.project_id, - project_name: project.project_name, - org_id: project.org_id, - region: project.region, - changed, - }); + + if (projectId && projectConfig) { + captureEvent(projectConfig.project_id, 'cli_mcp_disconnect', { + provider: providerArg ? results[0].provider : 'all', + project_id: projectConfig.project_id, + project_name: projectConfig.project_name, + org_id: projectConfig.org_id, + region: projectConfig.region, + changed, + }); + } await reportCliUsage('cli.mcp.disconnect', true); if (json) { diff --git a/src/commands/mcp/mcp.test.ts b/src/commands/mcp/mcp.test.ts index 0a1eb2d6..0f464500 100644 --- a/src/commands/mcp/mcp.test.ts +++ b/src/commands/mcp/mcp.test.ts @@ -99,4 +99,64 @@ describe('mcp commands', () => { expect(cursor.mcpServers.insforge).toBeUndefined(); expect(claude.mcpServers.insforge).toBeUndefined(); }); + + it('connect with explicit --api-key and --api-base-url works without linked project', async () => { + const { getProjectConfig } = await import('../../lib/config.js'); + vi.mocked(getProjectConfig).mockReturnValueOnce(null); + + await run([ + 'connect', + 'cursor', + '--api-key', + 'ik_custom_key', + '--api-base-url', + 'https://custom.insforge.app', + '--json', + ]); + + const config = JSON.parse(readFileSync(join(cwd, '.cursor/mcp.json'), 'utf-8')); + expect(config.mcpServers.insforge.url).toBe('https://custom.insforge.app/api/usage/mcp'); + expect(config.mcpServers.insforge.headers.Authorization).toBe('Bearer ik_custom_key'); + + const { updateMcpConnectionStatus } = await import('../../lib/api/oss.js'); + expect(updateMcpConnectionStatus).toHaveBeenCalledWith('connected', { + apiKey: 'ik_custom_key', + apiBaseUrl: 'https://custom.insforge.app', + }); + }); + + it('disconnect with explicit --api-key and --api-base-url works without linked project', async () => { + const { getProjectConfig } = await import('../../lib/config.js'); + vi.mocked(getProjectConfig).mockReturnValueOnce(null); + + await run([ + 'connect', + 'cursor', + '--api-key', + 'ik_custom_key', + '--api-base-url', + 'https://custom.insforge.app', + '--json', + ]); + + vi.mocked(getProjectConfig).mockReturnValueOnce(null); + await run([ + 'disconnect', + 'cursor', + '--api-key', + 'ik_custom_key', + '--api-base-url', + 'https://custom.insforge.app', + '--json', + ]); + + const config = JSON.parse(readFileSync(join(cwd, '.cursor/mcp.json'), 'utf-8')); + expect(config.mcpServers.insforge).toBeUndefined(); + + const { updateMcpConnectionStatus } = await import('../../lib/api/oss.js'); + expect(updateMcpConnectionStatus).toHaveBeenLastCalledWith('disconnected', { + apiKey: 'ik_custom_key', + apiBaseUrl: 'https://custom.insforge.app', + }); + }); }); diff --git a/src/lib/api/oss.ts b/src/lib/api/oss.ts index 5886605b..bf8c83cc 100644 --- a/src/lib/api/oss.ts +++ b/src/lib/api/oss.ts @@ -52,7 +52,25 @@ export async function getJwtSecret(): Promise { export type McpConnectionStatus = 'connected' | 'disconnected'; -export async function updateMcpConnectionStatus(status: McpConnectionStatus): Promise { +export async function updateMcpConnectionStatus( + status: McpConnectionStatus, + opts?: { apiKey: string; apiBaseUrl: string } +): Promise { + if (opts) { + const res = await fetch(`${opts.apiBaseUrl.replace(/\/$/, '')}/api/usage/mcp/status`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${opts.apiKey}`, + }, + body: JSON.stringify({ status }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})) as { message?: string }; + throw new CLIError(err.message ?? `Failed to update MCP status: ${res.status}`); + } + return; + } await ossFetch('/api/usage/mcp/status', { method: 'POST', body: JSON.stringify({ status }), diff --git a/src/lib/mcp-config.ts b/src/lib/mcp-config.ts index 31a6e4f6..75addf8f 100644 --- a/src/lib/mcp-config.ts +++ b/src/lib/mcp-config.ts @@ -59,7 +59,11 @@ export function displayMcpConfigPath(path: string, cwd = process.cwd()): string return rel; } -export function connectMcpProvider(provider: McpProvider, project: ProjectConfig, cwd = process.cwd()): McpConfigUpdateResult { +export function connectMcpProvider( + provider: McpProvider, + project: ProjectConfig | { apiKey: string; apiBaseUrl: string }, + cwd = process.cwd() +): McpConfigUpdateResult { const path = getMcpConfigPath(provider, cwd); const config = readMcpJson(path); const existingServers = isObject(config.mcpServers) ? config.mcpServers : {}; @@ -101,13 +105,15 @@ export function disconnectMcpProvider(provider: McpProvider, cwd = process.cwd() }; } -function buildMcpServerConfig(project: ProjectConfig): JsonObject { +function buildMcpServerConfig(config: ProjectConfig | { apiKey: string; apiBaseUrl: string }): JsonObject { + const host = 'oss_host' in config ? config.oss_host : config.apiBaseUrl; + const key = 'api_key' in config ? config.api_key : config.apiKey; return { type: 'http', - url: `${project.oss_host.replace(/\/$/, '')}/api/usage/mcp`, + url: `${host.replace(/\/$/, '')}/api/usage/mcp`, headers: { - Authorization: `Bearer ${project.api_key}`, - 'x-api-key': project.api_key, + Authorization: `Bearer ${key}`, + 'x-api-key': key, }, }; }