Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// `insta mcp install` — write the insta-cloud remote MCP server into each coding agent's own
// config format. Claude Code is NOT handled here — it has a real registry CLI (`claude mcp add`,
// see setup.ts registerMcp); these are the config-file agents. All entries are OAuth (no
// credential written): each client discovers the platform AS via RFC 9728 and runs the browser
// flow on first use.
import { promises as fs } from 'node:fs'
import { existsSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { info } from '../util.js'
import { DEFAULT_MCP_URL, MCP_SERVER_NAME, registerMcp } from './setup.js'

export const MCP_AGENT_TARGETS = ['cursor', 'codex', 'opencode', 'copilot', 'factory-droid'] as const
export type McpAgent = (typeof MCP_AGENT_TARGETS)[number]

export function configPath(slug: McpAgent, home: string): string {
switch (slug) {
case 'cursor': return path.join(home, '.cursor', 'mcp.json')
case 'codex': return path.join(home, '.codex', 'config.toml')
case 'opencode': return path.join(home, '.config', 'opencode', 'opencode.json')
case 'copilot': return path.join(home, '.copilot', 'mcp-config.json')
case 'factory-droid': return path.join(home, '.factory', 'mcp.json')
}
}

// An agent counts as "on this machine" when its config dir already exists — we configure what's
// installed, never scaffold a tool the user doesn't have.
export function detectAgents(home: string): McpAgent[] {
return MCP_AGENT_TARGETS.filter((slug) => existsSync(path.dirname(configPath(slug, home))))
}

// Merge our entry into existing JSON config. Returns null (skip, leave file alone) when the
// existing content isn't valid JSON — never clobber a config we can't parse.
export function renderJsonConfig(slug: McpAgent, existing: string | null, url: string): string | null {
let root: any = {}
if (existing && existing.trim()) {
try { root = JSON.parse(existing) } catch { return null }
if (typeof root !== 'object' || root === null || Array.isArray(root)) return null
}
if (slug === 'opencode') {
// OpenCode: `mcp` key, `type: "remote"` schema (docs.opencode.ai).
root.mcp = { ...(root.mcp ?? {}), [MCP_SERVER_NAME]: { type: 'remote', url, enabled: true } }
root.$schema ??= 'https://opencode.ai/config.json'
} else {
const entry =
slug === 'cursor' ? { url } // Cursor auto-detects HTTP from `url`
: slug === 'copilot' ? { type: 'http', url, tools: ['*'] }
: { type: 'http', url, disabled: false } // factory-droid
root.mcpServers = { ...(root.mcpServers ?? {}), [MCP_SERVER_NAME]: entry }
}
return JSON.stringify(root, null, 2) + '\n'
}

// Codex config is TOML. Appending a complete `[mcp_servers.<name>]` table is always valid at
// EOF, so we avoid a TOML parser: string-detect for idempotency, append for install.
export function renderCodexConfig(existing: string | null, url: string): string | null {
const base = existing ?? ''
if (base.includes(`[mcp_servers.${MCP_SERVER_NAME}]`)) return null // already configured
const sep = base.length && !base.endsWith('\n') ? '\n' : ''
return `${base}${sep}\n[mcp_servers.${MCP_SERVER_NAME}]\nurl = "${url}"\n`
}

// Install for one agent. Returns 'installed' | 'already' | 'skipped' (unparseable config).
export async function installFor(slug: McpAgent, home: string, url: string): Promise<'installed' | 'already' | 'skipped'> {
const file = configPath(slug, home)
let existing: string | null = null
try { existing = await fs.readFile(file, 'utf8') } catch { existing = null }
if (slug === 'codex') {
const next = renderCodexConfig(existing, url)
if (next === null) return 'already'
await fs.mkdir(path.dirname(file), { recursive: true })
await fs.writeFile(file, next)
return 'installed'
}
if (existing) {
try {
const root = JSON.parse(existing)
const entry = slug === 'opencode' ? root?.mcp?.[MCP_SERVER_NAME] : root?.mcpServers?.[MCP_SERVER_NAME]
if (entry) return 'already'
} catch { /* fall through to renderJsonConfig, which refuses to clobber */ }
}
const next = renderJsonConfig(slug, existing, url)
if (next === null) return 'skipped'
await fs.mkdir(path.dirname(file), { recursive: true })
await fs.writeFile(file, next)
return 'installed'
}

const AGENT_LABELS: Record<McpAgent, string> = {
cursor: 'Cursor', codex: 'OpenAI Codex', opencode: 'OpenCode', copilot: 'GitHub Copilot', 'factory-droid': 'Factory Droid',
}

// Configure every detected config-file agent (or one forced via `agent`). Returns the labels of
// agents now configured (installed or already present) for the caller's summary line.
export async function installAgentConfigs(agent?: string, home: string = os.homedir()): Promise<string[]> {
const url = process.env.INSTA_MCP_URL || DEFAULT_MCP_URL
const targets = agent
? (MCP_AGENT_TARGETS as readonly string[]).includes(agent) ? [agent as McpAgent] : []
: detectAgents(home)
if (agent && targets.length === 0) {
info(`unknown --agent "${agent}" — supported: claude-code, ${MCP_AGENT_TARGETS.join(', ')}`)
return []
}
const done: string[] = []
for (const slug of targets) {
const result = await installFor(slug, home, url)
if (result === 'skipped') info(` ${AGENT_LABELS[slug]}: existing config at ${configPath(slug, home)} isn't valid JSON — add ${MCP_SERVER_NAME} manually`)
else done.push(AGENT_LABELS[slug])
}
return done
}

// `insta mcp install [--agent <slug>] [--mcp-token]` — claude-code goes through its registry CLI
// (registerMcp); everything else is a config-file write. No --agent = claude-code + all detected.
export async function mcpInstall(opts: { agent?: string; mcpToken?: boolean }): Promise<void> {
if (!opts.agent || opts.agent === 'claude-code') {
await registerMcp(undefined, undefined, !!opts.mcpToken)
if (opts.agent) return
}
const done = await installAgentConfigs(opts.agent)
if (done.length) info(`✓ MCP — configured for ${done.join(', ')} (restart those tools to pick it up)`)
else if (opts.agent) { /* messages already printed */ }
else info(' no other MCP-capable agents detected (supported: cursor, codex, opencode, copilot, factory-droid)')
}
64 changes: 63 additions & 1 deletion src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
// Stack skills (neon/tigris/better-auth) intentionally stay per-project: their presence in a
// project doubles as its stack manifest — that install happens on `project create|link`.
import { spawn } from 'node:child_process'
import os from 'node:os'
import { ApiClient } from '../api.js'
import { info } from '../util.js'
import { installAgentConfigs } from './mcp.js'

// The `skills` tool we shell out to prints a clack UI: a frame-by-frame clone spinner, an
// "Installing to all N agents" banner, a full N-line install-path box, and a third-party
Expand Down Expand Up @@ -94,7 +97,63 @@ const defaultRunner: Runner = (cmd, args) =>
// (Claude Code, Codex, Cursor, OpenCode, Copilot, …); --copy = real files, not cache symlinks.
export const SETUP_ARGS = ['skills', 'add', 'InsForge/insta-skills', '-s', 'insta', '-a', '*', '-g', '-y', '--copy']

export async function setupAgent(opts: { yes?: boolean }, run: Runner = defaultRunner): Promise<void> {
// ---- remote MCP registration ----

export const MCP_SERVER_NAME = 'insta-cloud'
export const DEFAULT_MCP_URL = 'https://mcp.instacloud.com/mcp'

// Headless fallback only (`--mcp-token`): the MCP config outlives the CLI's refreshable
// session, so a static-header registration needs a durable `insta_` API token — minted once,
// named after this machine. Returns null when not logged in (or the mint fails); the caller
// prints the login hint.
export type TokenMinter = () => Promise<string | null>
const defaultMinter: TokenMinter = async () => {
try {
const api = await ApiClient.load()
if (!api.config.accessToken) return null
const { token } = await api.request<{ token?: string }>('POST', '/tokens', { name: `mcp-${os.hostname()}` })
return token ?? null
} catch { return null }
}

// Register the insta-cloud remote MCP server with Claude Code (user scope, so it follows the
// machine like the skill install above). Default is OAuth: register with NO credential — the
// platform's Better Auth MCP authorization server is discovered via RFC 9728 and Claude runs
// the browser flow on first `/mcp` use, so no static token ever lands on disk. `--mcp-token`
// is the headless fallback (CI, no browser): mint a durable token into the header instead.
// Idempotent — an existing registration is left alone. Best-effort: the skill install is the
// primary outcome; agents without an MCP registry are covered by the skill alone.
export async function registerMcp(run: Runner = defaultRunner, mint: TokenMinter = defaultMinter, useToken = false): Promise<void> {
const url = process.env.INSTA_MCP_URL || DEFAULT_MCP_URL
if (!(await run('claude', ['--version'])).ok) return // no Claude Code on this machine
if ((await run('claude', ['mcp', 'get', MCP_SERVER_NAME])).ok) {
info(`✓ MCP — ${MCP_SERVER_NAME} already registered with Claude Code`)
return
}
const args = ['mcp', 'add', '--transport', 'http', '--scope', 'user', MCP_SERVER_NAME, url]
if (useToken) {
const token = await mint()
if (!token) {
info(' MCP not registered (--mcp-token needs a login) — run `insta login`, then `insta setup agent --mcp-token` again')
return
}
args.push('--header', `Authorization: Bearer ${token}`)
}
const res = await run('claude', args)
if (res.ok) {
info(`✓ MCP — ${MCP_SERVER_NAME} registered with Claude Code (\`claude mcp list\` to verify)`)
if (!useToken) info(' first use: run `/mcp` in Claude Code and authorize in the browser (headless machines: `insta setup agent --mcp-token`)')
} else {
info(` MCP registration failed — add manually:\n claude mcp add --transport http ${MCP_SERVER_NAME} ${url}`)
}
}

export async function setupAgent(
opts: { yes?: boolean; mcpToken?: boolean },
run: Runner = defaultRunner,
mint?: TokenMinter,
installConfigs: (agent?: string) => Promise<string[]> = installAgentConfigs,
): Promise<void> {
if (!opts.yes && !process.stdout.isTTY) {
info('non-interactive shell — assuming -y')
}
Expand All @@ -115,4 +174,7 @@ export async function setupAgent(opts: { yes?: boolean }, run: Runner = defaultR
}
info(summarizeInstall(res.output ?? ''))
info(' every coding agent on this machine now knows InstaCloud (review skills before use — they run with full permissions).')
await registerMcp(run, mint, !!opts.mcpToken)
const others = await installConfigs()
if (others.length) info(`✓ MCP — also configured for ${others.join(', ')} (restart those tools to pick it up)`)
}
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ApiError } from './api.js'
import { die } from './util.js'
import * as auth from './commands/auth.js'
import * as setup from './commands/setup.js'
import * as mcp from './commands/mcp.js'
import * as runCmd from './commands/run.js'
import * as org from './commands/org.js'
import * as project from './commands/project.js'
Expand Down Expand Up @@ -68,8 +69,16 @@ program.command('run <cmd> [args...]').description('Run a command with the branc
const setupCmd = program.command('setup').description('Set up this machine for InstaCloud agent workflows')
setupCmd.command('agent').description('Install the insta skill user-globally for all coding agents')
.option('-y, --yes', 'non-interactive')
.option('--mcp-token', 'register the MCP server with a minted insta_ API token instead of OAuth (headless machines / CI)')
.action(guard((o) => setup.setupAgent(o)))

// ---- MCP server integration ----
const mcpCmd = program.command('mcp').description('insta-cloud remote MCP server integration')
mcpCmd.command('install').description('Register the remote MCP server with coding agents (default: Claude Code + all detected)')
.option('--agent <slug>', 'one agent: claude-code, cursor, codex, opencode, copilot, factory-droid')
.option('--mcp-token', 'claude-code only: minted insta_ API token instead of OAuth (headless machines / CI)')
.action(guard((o) => mcp.mcpInstall(o)))

// ---- org ----
const orgCmd = program.command('org').description('Manage organizations')
orgCmd.command('list').option('--json').action(guard((o) => org.orgList(o)))
Expand Down
80 changes: 80 additions & 0 deletions test/mcp-install.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { test, expect } from 'vitest'
import { promises as fs } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { configPath, detectAgents, installFor, renderCodexConfig } from '../src/commands/mcp.js'
import { MCP_SERVER_NAME, DEFAULT_MCP_URL } from '../src/commands/setup.js'

async function tmpHome(): Promise<string> { return fs.mkdtemp(path.join(os.tmpdir(), 'insta-mcp-test-')) }

test('cursor: fresh install writes mcpServers entry with just a url (OAuth, no credential)', async () => {
const home = await tmpHome()
expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('installed')
const cfg = JSON.parse(await fs.readFile(configPath('cursor', home), 'utf8'))
expect(cfg.mcpServers[MCP_SERVER_NAME]).toEqual({ url: DEFAULT_MCP_URL })
})

test('cursor: merge preserves existing servers and is idempotent', async () => {
const home = await tmpHome()
const file = configPath('cursor', home)
await fs.mkdir(path.dirname(file), { recursive: true })
await fs.writeFile(file, JSON.stringify({ mcpServers: { other: { url: 'https://x' } } }))
expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('installed')
expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('already')
const cfg = JSON.parse(await fs.readFile(file, 'utf8'))
expect(cfg.mcpServers.other).toEqual({ url: 'https://x' })
expect(cfg.mcpServers[MCP_SERVER_NAME].url).toBe(DEFAULT_MCP_URL)
})

test('opencode: remote entry under `mcp` with $schema default', async () => {
const home = await tmpHome()
expect(await installFor('opencode', home, DEFAULT_MCP_URL)).toBe('installed')
const cfg = JSON.parse(await fs.readFile(configPath('opencode', home), 'utf8'))
expect(cfg.mcp[MCP_SERVER_NAME]).toEqual({ type: 'remote', url: DEFAULT_MCP_URL, enabled: true })
expect(cfg.$schema).toBe('https://opencode.ai/config.json')
})

test('copilot and factory-droid: http entries with their extra fields', async () => {
const home = await tmpHome()
await installFor('copilot', home, DEFAULT_MCP_URL)
await installFor('factory-droid', home, DEFAULT_MCP_URL)
const cop = JSON.parse(await fs.readFile(configPath('copilot', home), 'utf8'))
const fac = JSON.parse(await fs.readFile(configPath('factory-droid', home), 'utf8'))
expect(cop.mcpServers[MCP_SERVER_NAME]).toEqual({ type: 'http', url: DEFAULT_MCP_URL, tools: ['*'] })
expect(fac.mcpServers[MCP_SERVER_NAME]).toEqual({ type: 'http', url: DEFAULT_MCP_URL, disabled: false })
})

test('codex: TOML table appended once, existing content preserved', async () => {
const home = await tmpHome()
const file = configPath('codex', home)
await fs.mkdir(path.dirname(file), { recursive: true })
await fs.writeFile(file, 'model = "gpt-5"\n')
expect(await installFor('codex', home, DEFAULT_MCP_URL)).toBe('installed')
expect(await installFor('codex', home, DEFAULT_MCP_URL)).toBe('already')
const out = await fs.readFile(file, 'utf8')
expect(out).toContain('model = "gpt-5"')
expect(out).toContain(`[mcp_servers.${MCP_SERVER_NAME}]`)
expect(out).toContain(`url = "${DEFAULT_MCP_URL}"`)
})

test('renderCodexConfig appends a newline separator when the file lacks one', () => {
const out = renderCodexConfig('a = 1', 'https://u')!
expect(out.startsWith('a = 1\n')).toBe(true)
})

test('unparseable JSON config is skipped, never clobbered', async () => {
const home = await tmpHome()
const file = configPath('cursor', home)
await fs.mkdir(path.dirname(file), { recursive: true })
await fs.writeFile(file, '{ this is not json')
expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('skipped')
expect(await fs.readFile(file, 'utf8')).toBe('{ this is not json')
})

test('detectAgents only reports agents whose config dir exists', async () => {
const home = await tmpHome()
expect(detectAgents(home)).toEqual([])
await fs.mkdir(path.join(home, '.cursor'), { recursive: true })
await fs.mkdir(path.join(home, '.codex'), { recursive: true })
expect(detectAgents(home)).toEqual(['cursor', 'codex'])
})
Loading
Loading