Skip to content

Commit c3ae1eb

Browse files
committed
feat(mcp): add miosa mcp install for one-command remote MCP setup
Runs the existing /auth/cli/start device flow, opens browser for approval, polls for the minted msk_u_ key, then shells out to `claude mcp add` to wire the hosted https://api.miosa.ai/api/v1/mcp endpoint into Claude Code. Supports --client claude|cursor|gemini|manual and --scope local|user|project. Prints manual snippets for non-Claude clients.
1 parent 498dd07 commit c3ae1eb

1 file changed

Lines changed: 297 additions & 1 deletion

File tree

src/commands/mcp.ts

Lines changed: 297 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
*/
1919

2020
import { createInterface } from "node:readline";
21+
import { spawn, spawnSync } from "node:child_process";
2122
import type { Command } from "commander";
23+
import { request } from "undici";
24+
import chalk from "chalk";
2225
import { loadConfig } from "../config.js";
2326
import { MiosaClient } from "../client.js";
2427

@@ -974,6 +977,262 @@ async function runServer(): Promise<void> {
974977
}
975978
}
976979

980+
// ── install: device-flow auth + auto-wire the host MCP into a client ────────
981+
982+
interface DeviceFlow {
983+
device_code: string;
984+
user_code: string;
985+
verification_uri: string;
986+
verification_uri_complete: string;
987+
expires_in: number;
988+
interval: number;
989+
}
990+
991+
interface TokenResponse {
992+
api_key?: string;
993+
error?: string;
994+
}
995+
996+
const MCP_REMOTE_URL = "https://api.miosa.ai/api/v1/mcp";
997+
const MCP_SERVER_NAME = "miosa";
998+
999+
type SupportedClient = "claude" | "cursor" | "gemini" | "manual";
1000+
1001+
function sleep(ms: number): Promise<void> {
1002+
return new Promise((resolve) => setTimeout(resolve, ms));
1003+
}
1004+
1005+
function openUrl(url: string): void {
1006+
const command =
1007+
process.platform === "darwin"
1008+
? "open"
1009+
: process.platform === "win32"
1010+
? "cmd"
1011+
: "xdg-open";
1012+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
1013+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
1014+
child.unref();
1015+
}
1016+
1017+
async function postJson<T>(
1018+
endpoint: string,
1019+
path: string,
1020+
body: unknown,
1021+
): Promise<{ status: number; body: T }> {
1022+
const res = await request(`${endpoint.replace(/\/$/, "")}${path}`, {
1023+
method: "POST",
1024+
headers: { "Content-Type": "application/json", Accept: "application/json" },
1025+
body: JSON.stringify(body),
1026+
});
1027+
const text = await res.body.text();
1028+
const parsed = text ? (JSON.parse(text) as T) : ({} as T);
1029+
return { status: res.statusCode, body: parsed };
1030+
}
1031+
1032+
async function runDeviceFlow(
1033+
endpoint: string,
1034+
clientName: string,
1035+
): Promise<string> {
1036+
const start = await postJson<DeviceFlow>(endpoint, "/api/v1/auth/cli/start", {
1037+
client_name: clientName,
1038+
});
1039+
if (start.status >= 400) {
1040+
throw new Error(`Failed to start auth flow (HTTP ${start.status})`);
1041+
}
1042+
const flow = start.body;
1043+
1044+
console.log();
1045+
console.log(chalk.bold("Authorize MIOSA MCP for this device"));
1046+
console.log();
1047+
console.log(` Open: ${chalk.cyan(flow.verification_uri_complete)}`);
1048+
console.log(` Code: ${chalk.bold(flow.user_code)}`);
1049+
console.log();
1050+
1051+
try {
1052+
openUrl(flow.verification_uri_complete);
1053+
console.log(chalk.dim(" Browser opened. Waiting for approval..."));
1054+
} catch {
1055+
console.log(chalk.dim(" Could not open a browser automatically."));
1056+
}
1057+
1058+
const deadline = Date.now() + flow.expires_in * 1000;
1059+
const intervalMs = Math.max(flow.interval || 3, 1) * 1000;
1060+
1061+
while (Date.now() < deadline) {
1062+
await sleep(intervalMs);
1063+
const poll = await postJson<TokenResponse>(
1064+
endpoint,
1065+
"/api/v1/auth/cli/token",
1066+
{ device_code: flow.device_code },
1067+
);
1068+
if (poll.status === 200 && poll.body.api_key) {
1069+
return poll.body.api_key;
1070+
}
1071+
if (poll.status === 428 || poll.body.error === "authorization_pending") {
1072+
continue;
1073+
}
1074+
if (poll.body.error === "access_denied") {
1075+
throw new Error("Login was denied in the browser.");
1076+
}
1077+
if (poll.body.error === "expired_token" || poll.status === 410) {
1078+
throw new Error("Login request expired. Run the command again.");
1079+
}
1080+
throw new Error(
1081+
`Login failed: ${poll.body.error ?? `HTTP ${poll.status}`}`,
1082+
);
1083+
}
1084+
1085+
throw new Error("Login timed out. Run the command again.");
1086+
}
1087+
1088+
function wireClaudeCode(
1089+
apiKey: string,
1090+
remoteUrl: string,
1091+
scope: "local" | "user" | "project",
1092+
): { ok: true } | { ok: false; reason: string } {
1093+
// `claude mcp remove` first so re-running install replaces cleanly.
1094+
spawnSync("claude", ["mcp", "remove", MCP_SERVER_NAME, "--scope", scope], {
1095+
stdio: "ignore",
1096+
});
1097+
1098+
const result = spawnSync(
1099+
"claude",
1100+
[
1101+
"mcp",
1102+
"add",
1103+
"--transport",
1104+
"http",
1105+
"--scope",
1106+
scope,
1107+
MCP_SERVER_NAME,
1108+
remoteUrl,
1109+
"--header",
1110+
`Authorization: Bearer ${apiKey}`,
1111+
],
1112+
{ stdio: "pipe", encoding: "utf8" },
1113+
);
1114+
1115+
if (result.error) {
1116+
return {
1117+
ok: false,
1118+
reason: `Could not run \`claude\` CLI: ${result.error.message}`,
1119+
};
1120+
}
1121+
if (typeof result.status === "number" && result.status !== 0) {
1122+
return {
1123+
ok: false,
1124+
reason: result.stderr?.trim() || `claude mcp add exited ${result.status}`,
1125+
};
1126+
}
1127+
return { ok: true };
1128+
}
1129+
1130+
function printManualSnippet(
1131+
client: SupportedClient,
1132+
apiKey: string,
1133+
remoteUrl: string,
1134+
): void {
1135+
const masked =
1136+
apiKey.length > 12
1137+
? apiKey.slice(0, 6) + "…" + apiKey.slice(-4)
1138+
: "msk_u_…";
1139+
console.log();
1140+
console.log(chalk.bold("Manual install snippet"));
1141+
console.log(
1142+
chalk.dim(` (your key: ${masked} — saved to ~/.miosa/config.json)`),
1143+
);
1144+
console.log();
1145+
1146+
if (client === "cursor") {
1147+
console.log(chalk.dim(" Add to ~/.cursor/mcp.json:"));
1148+
console.log();
1149+
console.log(
1150+
JSON.stringify(
1151+
{
1152+
mcpServers: {
1153+
miosa: {
1154+
transport: "http",
1155+
url: remoteUrl,
1156+
headers: { Authorization: `Bearer ${apiKey}` },
1157+
},
1158+
},
1159+
},
1160+
null,
1161+
2,
1162+
),
1163+
);
1164+
return;
1165+
}
1166+
1167+
if (client === "gemini") {
1168+
console.log(
1169+
` ${chalk.cyan(`gemini mcp add ${MCP_SERVER_NAME} ${remoteUrl} \\`)}`,
1170+
);
1171+
console.log(
1172+
` ${chalk.cyan(`--header "Authorization: Bearer ${apiKey}"`)}`,
1173+
);
1174+
return;
1175+
}
1176+
1177+
// claude / manual
1178+
console.log(
1179+
` ${chalk.cyan(`claude mcp add --transport http --scope user ${MCP_SERVER_NAME} \\`)}`,
1180+
);
1181+
console.log(` ${chalk.cyan(remoteUrl + " \\")}`);
1182+
console.log(
1183+
` ${chalk.cyan(`--header "Authorization: Bearer ${apiKey}"`)}`,
1184+
);
1185+
}
1186+
1187+
async function runInstall(opts: {
1188+
client: SupportedClient;
1189+
scope: "local" | "user" | "project";
1190+
remoteUrl: string;
1191+
}): Promise<void> {
1192+
const config = loadConfig();
1193+
const clientName = `MIOSA MCP (${opts.client === "manual" ? "manual" : opts.client})`;
1194+
1195+
console.log(
1196+
chalk.bold("MIOSA MCP installer"),
1197+
chalk.dim(`— wiring ${opts.client}${opts.remoteUrl}`),
1198+
);
1199+
1200+
const apiKey = await runDeviceFlow(config.endpoint, clientName);
1201+
1202+
if (opts.client === "claude") {
1203+
const wired = wireClaudeCode(apiKey, opts.remoteUrl, opts.scope);
1204+
if (wired.ok) {
1205+
console.log();
1206+
console.log(
1207+
chalk.green("✓"),
1208+
`MCP server '${MCP_SERVER_NAME}' added to Claude Code (${opts.scope} scope).`,
1209+
);
1210+
console.log();
1211+
console.log(chalk.dim("Verify:"));
1212+
console.log(` ${chalk.cyan("claude mcp list")}`);
1213+
console.log();
1214+
console.log(chalk.dim("Try in a fresh Claude Code session:"));
1215+
console.log(
1216+
chalk.dim(
1217+
` "Create a MIOSA sandbox, run \`python -c 'print(2+2)'\`, then destroy it."`,
1218+
),
1219+
);
1220+
return;
1221+
}
1222+
console.log();
1223+
console.log(
1224+
chalk.yellow("!"),
1225+
`Could not auto-wire Claude Code: ${wired.reason}`,
1226+
);
1227+
console.log(chalk.yellow(" Falling back to manual snippet:"));
1228+
printManualSnippet("claude", apiKey, opts.remoteUrl);
1229+
return;
1230+
}
1231+
1232+
// cursor / gemini / manual — print the snippet
1233+
printManualSnippet(opts.client, apiKey, opts.remoteUrl);
1234+
}
1235+
9771236
// ── Commander registration ────────────────────────────────────────────────────
9781237

9791238
export function register(program: Command): void {
@@ -987,11 +1246,48 @@ export function register(program: Command): void {
9871246
"Start an MCP server over stdio (JSON-RPC 2.0). Add to .claude/mcp.json to use with Claude.",
9881247
)
9891248
.action(() => {
990-
// runServer() is an infinite async loop; attach an unhandled-rejection guard
9911249
runServer().catch((e: unknown) => {
9921250
const msg = e instanceof Error ? e.message : String(e);
9931251
process.stderr.write(`miosa mcp serve fatal: ${msg}\n`);
9941252
process.exit(1);
9951253
});
9961254
});
1255+
1256+
mcp
1257+
.command("install")
1258+
.description(
1259+
"Install the hosted MIOSA MCP server (https://api.miosa.ai/api/v1/mcp) into your AI client. Opens a browser to log in and wire your account.",
1260+
)
1261+
.option(
1262+
"-c, --client <client>",
1263+
"Which AI client to wire: claude (default), cursor, gemini, manual",
1264+
"claude",
1265+
)
1266+
.option(
1267+
"-s, --scope <scope>",
1268+
"Claude Code config scope: local, user (default), or project",
1269+
"user",
1270+
)
1271+
.option(
1272+
"--url <url>",
1273+
"Override the hosted MCP URL (default: https://api.miosa.ai/api/v1/mcp)",
1274+
MCP_REMOTE_URL,
1275+
)
1276+
.action(async (opts: { client: string; scope: string; url: string }) => {
1277+
const client = (
1278+
["claude", "cursor", "gemini", "manual"].includes(opts.client)
1279+
? opts.client
1280+
: "claude"
1281+
) as SupportedClient;
1282+
const scope = (
1283+
["local", "user", "project"].includes(opts.scope) ? opts.scope : "user"
1284+
) as "local" | "user" | "project";
1285+
try {
1286+
await runInstall({ client, scope, remoteUrl: opts.url });
1287+
} catch (e: unknown) {
1288+
const msg = e instanceof Error ? e.message : String(e);
1289+
console.error(chalk.red(`Error: ${msg}`));
1290+
process.exit(3);
1291+
}
1292+
});
9971293
}

0 commit comments

Comments
 (0)