Skip to content

Commit 61ae3ab

Browse files
tonychang04claude
andauthored
feat(setup): register the insta-cloud remote MCP server during insta setup agent (#57)
* feat(setup): register the insta-cloud remote MCP server during `insta setup agent` Railway-parity: after the user-global skill install, setup now also registers https://mcp.instacloud.com/mcp with Claude Code (--transport http --scope user), authenticated by a durable insta_ API token minted via POST /tokens (named mcp-<hostname>; the CLI's own session token expires, so it can't be reused). Behavior: best-effort and idempotent — skipped silently when claude isn't installed, left alone when insta-cloud is already registered (no token re-mint), and a login hint is printed when the CLI isn't authenticated. INSTA_MCP_URL overrides the server URL for beta/self-host setups. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(setup): OAuth-first MCP registration; --mcp-token becomes the headless fallback The platform's Better Auth MCP authorization server is live (RFC 8414 metadata + DCR at /api/auth/mcp/*), so the default registration now carries NO credential: Claude Code discovers the AS via RFC 9728 and runs the browser flow on first /mcp use — managed, expiring, revocable tokens, nothing static on disk. `insta setup agent --mcp-token` keeps the minted insta_ token path for headless machines/CI where no browser exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(mcp): insta mcp install — multi-agent MCP config (cursor/codex/opencode/copilot/factory-droid) New command writes the insta-cloud remote server into each agent's own config format: Cursor ~/.cursor/mcp.json, Codex ~/.codex/config.toml, OpenCode ~/.config/opencode/opencode.json, Copilot ~/.copilot/mcp-config.json, Factory Droid ~/.factory/mcp.json. Claude Code keeps going through claude mcp add. All entries are OAuth (URL only, no credential). `insta setup agent` now also configures every detected agent after the Claude Code registration. Safety: merges never clobber — existing JSON keys preserved, unparseable configs skipped with a manual hint, TOML appended only when the table is absent; idempotent re-runs report already-configured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent af36fa3 commit 61ae3ab

5 files changed

Lines changed: 342 additions & 5 deletions

File tree

src/commands/mcp.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// `insta mcp install` — write the insta-cloud remote MCP server into each coding agent's own
2+
// config format. Claude Code is NOT handled here — it has a real registry CLI (`claude mcp add`,
3+
// see setup.ts registerMcp); these are the config-file agents. All entries are OAuth (no
4+
// credential written): each client discovers the platform AS via RFC 9728 and runs the browser
5+
// flow on first use.
6+
import { promises as fs } from 'node:fs'
7+
import { existsSync } from 'node:fs'
8+
import os from 'node:os'
9+
import path from 'node:path'
10+
import { info } from '../util.js'
11+
import { DEFAULT_MCP_URL, MCP_SERVER_NAME, registerMcp } from './setup.js'
12+
13+
export const MCP_AGENT_TARGETS = ['cursor', 'codex', 'opencode', 'copilot', 'factory-droid'] as const
14+
export type McpAgent = (typeof MCP_AGENT_TARGETS)[number]
15+
16+
export function configPath(slug: McpAgent, home: string): string {
17+
switch (slug) {
18+
case 'cursor': return path.join(home, '.cursor', 'mcp.json')
19+
case 'codex': return path.join(home, '.codex', 'config.toml')
20+
case 'opencode': return path.join(home, '.config', 'opencode', 'opencode.json')
21+
case 'copilot': return path.join(home, '.copilot', 'mcp-config.json')
22+
case 'factory-droid': return path.join(home, '.factory', 'mcp.json')
23+
}
24+
}
25+
26+
// An agent counts as "on this machine" when its config dir already exists — we configure what's
27+
// installed, never scaffold a tool the user doesn't have.
28+
export function detectAgents(home: string): McpAgent[] {
29+
return MCP_AGENT_TARGETS.filter((slug) => existsSync(path.dirname(configPath(slug, home))))
30+
}
31+
32+
// Merge our entry into existing JSON config. Returns null (skip, leave file alone) when the
33+
// existing content isn't valid JSON — never clobber a config we can't parse.
34+
export function renderJsonConfig(slug: McpAgent, existing: string | null, url: string): string | null {
35+
let root: any = {}
36+
if (existing && existing.trim()) {
37+
try { root = JSON.parse(existing) } catch { return null }
38+
if (typeof root !== 'object' || root === null || Array.isArray(root)) return null
39+
}
40+
if (slug === 'opencode') {
41+
// OpenCode: `mcp` key, `type: "remote"` schema (docs.opencode.ai).
42+
root.mcp = { ...(root.mcp ?? {}), [MCP_SERVER_NAME]: { type: 'remote', url, enabled: true } }
43+
root.$schema ??= 'https://opencode.ai/config.json'
44+
} else {
45+
const entry =
46+
slug === 'cursor' ? { url } // Cursor auto-detects HTTP from `url`
47+
: slug === 'copilot' ? { type: 'http', url, tools: ['*'] }
48+
: { type: 'http', url, disabled: false } // factory-droid
49+
root.mcpServers = { ...(root.mcpServers ?? {}), [MCP_SERVER_NAME]: entry }
50+
}
51+
return JSON.stringify(root, null, 2) + '\n'
52+
}
53+
54+
// Codex config is TOML. Appending a complete `[mcp_servers.<name>]` table is always valid at
55+
// EOF, so we avoid a TOML parser: string-detect for idempotency, append for install.
56+
export function renderCodexConfig(existing: string | null, url: string): string | null {
57+
const base = existing ?? ''
58+
if (base.includes(`[mcp_servers.${MCP_SERVER_NAME}]`)) return null // already configured
59+
const sep = base.length && !base.endsWith('\n') ? '\n' : ''
60+
return `${base}${sep}\n[mcp_servers.${MCP_SERVER_NAME}]\nurl = "${url}"\n`
61+
}
62+
63+
// Install for one agent. Returns 'installed' | 'already' | 'skipped' (unparseable config).
64+
export async function installFor(slug: McpAgent, home: string, url: string): Promise<'installed' | 'already' | 'skipped'> {
65+
const file = configPath(slug, home)
66+
let existing: string | null = null
67+
try { existing = await fs.readFile(file, 'utf8') } catch { existing = null }
68+
if (slug === 'codex') {
69+
const next = renderCodexConfig(existing, url)
70+
if (next === null) return 'already'
71+
await fs.mkdir(path.dirname(file), { recursive: true })
72+
await fs.writeFile(file, next)
73+
return 'installed'
74+
}
75+
if (existing) {
76+
try {
77+
const root = JSON.parse(existing)
78+
const entry = slug === 'opencode' ? root?.mcp?.[MCP_SERVER_NAME] : root?.mcpServers?.[MCP_SERVER_NAME]
79+
if (entry) return 'already'
80+
} catch { /* fall through to renderJsonConfig, which refuses to clobber */ }
81+
}
82+
const next = renderJsonConfig(slug, existing, url)
83+
if (next === null) return 'skipped'
84+
await fs.mkdir(path.dirname(file), { recursive: true })
85+
await fs.writeFile(file, next)
86+
return 'installed'
87+
}
88+
89+
const AGENT_LABELS: Record<McpAgent, string> = {
90+
cursor: 'Cursor', codex: 'OpenAI Codex', opencode: 'OpenCode', copilot: 'GitHub Copilot', 'factory-droid': 'Factory Droid',
91+
}
92+
93+
// Configure every detected config-file agent (or one forced via `agent`). Returns the labels of
94+
// agents now configured (installed or already present) for the caller's summary line.
95+
export async function installAgentConfigs(agent?: string, home: string = os.homedir()): Promise<string[]> {
96+
const url = process.env.INSTA_MCP_URL || DEFAULT_MCP_URL
97+
const targets = agent
98+
? (MCP_AGENT_TARGETS as readonly string[]).includes(agent) ? [agent as McpAgent] : []
99+
: detectAgents(home)
100+
if (agent && targets.length === 0) {
101+
info(`unknown --agent "${agent}" — supported: claude-code, ${MCP_AGENT_TARGETS.join(', ')}`)
102+
return []
103+
}
104+
const done: string[] = []
105+
for (const slug of targets) {
106+
const result = await installFor(slug, home, url)
107+
if (result === 'skipped') info(` ${AGENT_LABELS[slug]}: existing config at ${configPath(slug, home)} isn't valid JSON — add ${MCP_SERVER_NAME} manually`)
108+
else done.push(AGENT_LABELS[slug])
109+
}
110+
return done
111+
}
112+
113+
// `insta mcp install [--agent <slug>] [--mcp-token]` — claude-code goes through its registry CLI
114+
// (registerMcp); everything else is a config-file write. No --agent = claude-code + all detected.
115+
export async function mcpInstall(opts: { agent?: string; mcpToken?: boolean }): Promise<void> {
116+
if (!opts.agent || opts.agent === 'claude-code') {
117+
await registerMcp(undefined, undefined, !!opts.mcpToken)
118+
if (opts.agent) return
119+
}
120+
const done = await installAgentConfigs(opts.agent)
121+
if (done.length) info(`✓ MCP — configured for ${done.join(', ')} (restart those tools to pick it up)`)
122+
else if (opts.agent) { /* messages already printed */ }
123+
else info(' no other MCP-capable agents detected (supported: cursor, codex, opencode, copilot, factory-droid)')
124+
}

src/commands/setup.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
// Stack skills (neon/tigris/better-auth) intentionally stay per-project: their presence in a
77
// project doubles as its stack manifest — that install happens on `project create|link`.
88
import { spawn } from 'node:child_process'
9+
import os from 'node:os'
10+
import { ApiClient } from '../api.js'
911
import { info } from '../util.js'
12+
import { installAgentConfigs } from './mcp.js'
1013

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

97-
export async function setupAgent(opts: { yes?: boolean }, run: Runner = defaultRunner): Promise<void> {
100+
// ---- remote MCP registration ----
101+
102+
export const MCP_SERVER_NAME = 'insta-cloud'
103+
export const DEFAULT_MCP_URL = 'https://mcp.instacloud.com/mcp'
104+
105+
// Headless fallback only (`--mcp-token`): the MCP config outlives the CLI's refreshable
106+
// session, so a static-header registration needs a durable `insta_` API token — minted once,
107+
// named after this machine. Returns null when not logged in (or the mint fails); the caller
108+
// prints the login hint.
109+
export type TokenMinter = () => Promise<string | null>
110+
const defaultMinter: TokenMinter = async () => {
111+
try {
112+
const api = await ApiClient.load()
113+
if (!api.config.accessToken) return null
114+
const { token } = await api.request<{ token?: string }>('POST', '/tokens', { name: `mcp-${os.hostname()}` })
115+
return token ?? null
116+
} catch { return null }
117+
}
118+
119+
// Register the insta-cloud remote MCP server with Claude Code (user scope, so it follows the
120+
// machine like the skill install above). Default is OAuth: register with NO credential — the
121+
// platform's Better Auth MCP authorization server is discovered via RFC 9728 and Claude runs
122+
// the browser flow on first `/mcp` use, so no static token ever lands on disk. `--mcp-token`
123+
// is the headless fallback (CI, no browser): mint a durable token into the header instead.
124+
// Idempotent — an existing registration is left alone. Best-effort: the skill install is the
125+
// primary outcome; agents without an MCP registry are covered by the skill alone.
126+
export async function registerMcp(run: Runner = defaultRunner, mint: TokenMinter = defaultMinter, useToken = false): Promise<void> {
127+
const url = process.env.INSTA_MCP_URL || DEFAULT_MCP_URL
128+
if (!(await run('claude', ['--version'])).ok) return // no Claude Code on this machine
129+
if ((await run('claude', ['mcp', 'get', MCP_SERVER_NAME])).ok) {
130+
info(`✓ MCP — ${MCP_SERVER_NAME} already registered with Claude Code`)
131+
return
132+
}
133+
const args = ['mcp', 'add', '--transport', 'http', '--scope', 'user', MCP_SERVER_NAME, url]
134+
if (useToken) {
135+
const token = await mint()
136+
if (!token) {
137+
info(' MCP not registered (--mcp-token needs a login) — run `insta login`, then `insta setup agent --mcp-token` again')
138+
return
139+
}
140+
args.push('--header', `Authorization: Bearer ${token}`)
141+
}
142+
const res = await run('claude', args)
143+
if (res.ok) {
144+
info(`✓ MCP — ${MCP_SERVER_NAME} registered with Claude Code (\`claude mcp list\` to verify)`)
145+
if (!useToken) info(' first use: run `/mcp` in Claude Code and authorize in the browser (headless machines: `insta setup agent --mcp-token`)')
146+
} else {
147+
info(` MCP registration failed — add manually:\n claude mcp add --transport http ${MCP_SERVER_NAME} ${url}`)
148+
}
149+
}
150+
151+
export async function setupAgent(
152+
opts: { yes?: boolean; mcpToken?: boolean },
153+
run: Runner = defaultRunner,
154+
mint?: TokenMinter,
155+
installConfigs: (agent?: string) => Promise<string[]> = installAgentConfigs,
156+
): Promise<void> {
98157
if (!opts.yes && !process.stdout.isTTY) {
99158
info('non-interactive shell — assuming -y')
100159
}
@@ -115,4 +174,7 @@ export async function setupAgent(opts: { yes?: boolean }, run: Runner = defaultR
115174
}
116175
info(summarizeInstall(res.output ?? ''))
117176
info(' every coding agent on this machine now knows InstaCloud (review skills before use — they run with full permissions).')
177+
await registerMcp(run, mint, !!opts.mcpToken)
178+
const others = await installConfigs()
179+
if (others.length) info(`✓ MCP — also configured for ${others.join(', ')} (restart those tools to pick it up)`)
118180
}

src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ApiError } from './api.js'
55
import { die } from './util.js'
66
import * as auth from './commands/auth.js'
77
import * as setup from './commands/setup.js'
8+
import * as mcp from './commands/mcp.js'
89
import * as runCmd from './commands/run.js'
910
import * as org from './commands/org.js'
1011
import * as project from './commands/project.js'
@@ -68,8 +69,16 @@ program.command('run <cmd> [args...]').description('Run a command with the branc
6869
const setupCmd = program.command('setup').description('Set up this machine for InstaCloud agent workflows')
6970
setupCmd.command('agent').description('Install the insta skill user-globally for all coding agents')
7071
.option('-y, --yes', 'non-interactive')
72+
.option('--mcp-token', 'register the MCP server with a minted insta_ API token instead of OAuth (headless machines / CI)')
7173
.action(guard((o) => setup.setupAgent(o)))
7274

75+
// ---- MCP server integration ----
76+
const mcpCmd = program.command('mcp').description('insta-cloud remote MCP server integration')
77+
mcpCmd.command('install').description('Register the remote MCP server with coding agents (default: Claude Code + all detected)')
78+
.option('--agent <slug>', 'one agent: claude-code, cursor, codex, opencode, copilot, factory-droid')
79+
.option('--mcp-token', 'claude-code only: minted insta_ API token instead of OAuth (headless machines / CI)')
80+
.action(guard((o) => mcp.mcpInstall(o)))
81+
7382
// ---- org ----
7483
const orgCmd = program.command('org').description('Manage organizations')
7584
orgCmd.command('list').option('--json').action(guard((o) => org.orgList(o)))

test/mcp-install.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { test, expect } from 'vitest'
2+
import { promises as fs } from 'node:fs'
3+
import os from 'node:os'
4+
import path from 'node:path'
5+
import { configPath, detectAgents, installFor, renderCodexConfig } from '../src/commands/mcp.js'
6+
import { MCP_SERVER_NAME, DEFAULT_MCP_URL } from '../src/commands/setup.js'
7+
8+
async function tmpHome(): Promise<string> { return fs.mkdtemp(path.join(os.tmpdir(), 'insta-mcp-test-')) }
9+
10+
test('cursor: fresh install writes mcpServers entry with just a url (OAuth, no credential)', async () => {
11+
const home = await tmpHome()
12+
expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('installed')
13+
const cfg = JSON.parse(await fs.readFile(configPath('cursor', home), 'utf8'))
14+
expect(cfg.mcpServers[MCP_SERVER_NAME]).toEqual({ url: DEFAULT_MCP_URL })
15+
})
16+
17+
test('cursor: merge preserves existing servers and is idempotent', async () => {
18+
const home = await tmpHome()
19+
const file = configPath('cursor', home)
20+
await fs.mkdir(path.dirname(file), { recursive: true })
21+
await fs.writeFile(file, JSON.stringify({ mcpServers: { other: { url: 'https://x' } } }))
22+
expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('installed')
23+
expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('already')
24+
const cfg = JSON.parse(await fs.readFile(file, 'utf8'))
25+
expect(cfg.mcpServers.other).toEqual({ url: 'https://x' })
26+
expect(cfg.mcpServers[MCP_SERVER_NAME].url).toBe(DEFAULT_MCP_URL)
27+
})
28+
29+
test('opencode: remote entry under `mcp` with $schema default', async () => {
30+
const home = await tmpHome()
31+
expect(await installFor('opencode', home, DEFAULT_MCP_URL)).toBe('installed')
32+
const cfg = JSON.parse(await fs.readFile(configPath('opencode', home), 'utf8'))
33+
expect(cfg.mcp[MCP_SERVER_NAME]).toEqual({ type: 'remote', url: DEFAULT_MCP_URL, enabled: true })
34+
expect(cfg.$schema).toBe('https://opencode.ai/config.json')
35+
})
36+
37+
test('copilot and factory-droid: http entries with their extra fields', async () => {
38+
const home = await tmpHome()
39+
await installFor('copilot', home, DEFAULT_MCP_URL)
40+
await installFor('factory-droid', home, DEFAULT_MCP_URL)
41+
const cop = JSON.parse(await fs.readFile(configPath('copilot', home), 'utf8'))
42+
const fac = JSON.parse(await fs.readFile(configPath('factory-droid', home), 'utf8'))
43+
expect(cop.mcpServers[MCP_SERVER_NAME]).toEqual({ type: 'http', url: DEFAULT_MCP_URL, tools: ['*'] })
44+
expect(fac.mcpServers[MCP_SERVER_NAME]).toEqual({ type: 'http', url: DEFAULT_MCP_URL, disabled: false })
45+
})
46+
47+
test('codex: TOML table appended once, existing content preserved', async () => {
48+
const home = await tmpHome()
49+
const file = configPath('codex', home)
50+
await fs.mkdir(path.dirname(file), { recursive: true })
51+
await fs.writeFile(file, 'model = "gpt-5"\n')
52+
expect(await installFor('codex', home, DEFAULT_MCP_URL)).toBe('installed')
53+
expect(await installFor('codex', home, DEFAULT_MCP_URL)).toBe('already')
54+
const out = await fs.readFile(file, 'utf8')
55+
expect(out).toContain('model = "gpt-5"')
56+
expect(out).toContain(`[mcp_servers.${MCP_SERVER_NAME}]`)
57+
expect(out).toContain(`url = "${DEFAULT_MCP_URL}"`)
58+
})
59+
60+
test('renderCodexConfig appends a newline separator when the file lacks one', () => {
61+
const out = renderCodexConfig('a = 1', 'https://u')!
62+
expect(out.startsWith('a = 1\n')).toBe(true)
63+
})
64+
65+
test('unparseable JSON config is skipped, never clobbered', async () => {
66+
const home = await tmpHome()
67+
const file = configPath('cursor', home)
68+
await fs.mkdir(path.dirname(file), { recursive: true })
69+
await fs.writeFile(file, '{ this is not json')
70+
expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('skipped')
71+
expect(await fs.readFile(file, 'utf8')).toBe('{ this is not json')
72+
})
73+
74+
test('detectAgents only reports agents whose config dir exists', async () => {
75+
const home = await tmpHome()
76+
expect(detectAgents(home)).toEqual([])
77+
await fs.mkdir(path.join(home, '.cursor'), { recursive: true })
78+
await fs.mkdir(path.join(home, '.codex'), { recursive: true })
79+
expect(detectAgents(home)).toEqual(['cursor', 'codex'])
80+
})

0 commit comments

Comments
 (0)