Skip to content
Draft
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
4 changes: 3 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"test": "jest"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.21.0",
"@types/picomatch": "^4.0.0",
"commander": "^10.0.1",
"common-types": "1.0.0",
Expand All @@ -29,7 +30,8 @@
"graphql-request": "^6.1.0",
"picomatch": "^4.0.2",
"snack-content": "2.0.0-preview.2",
"strip-ansi": "^6.0.0"
"strip-ansi": "^6.0.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.0",
Expand Down
17 changes: 17 additions & 0 deletions apps/cli/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,20 @@ expo-orbit-cli detect-apple-app-type <app-path>
Inspects an iOS app bundle and reports whether it’s a simulator build, App Store build, etc.

---

### mcp

```bash
expo-orbit-cli mcp [--port <number>] [--token <string>]
```

- **Options**
`--port <number>`
: Port to listen on. _Default_: `8765`.
`--token <string>`
: Override the bearer token. _Default_: persisted in `~/.config/expo/user-settings.json`.

- **Description**
Starts the Model Context Protocol server so AI coding agents (Cursor, Claude Code, Claude Desktop) can drive simulators, emulators, and devices through Orbit. See [mcp/README.md](./mcp/README.md) for the full setup, client config snippets, and tool reference.

---
126 changes: 126 additions & 0 deletions apps/cli/src/commands/Mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import http, { IncomingMessage, ServerResponse } from 'http';

import { ensureMcpTokenAsync, isAuthorized, isLocalhost } from '../mcp/auth';
import { registerPhase1Tools } from '../mcp/tools';

const SERVER_INFO = { name: 'expo-orbit', version: '0.1.0' };
const MAX_BODY_BYTES = 4 * 1024 * 1024;

type McpOptions = {
port?: string;
token?: string;
};

export async function mcpServerAsync(options: McpOptions = {}): Promise<void> {
const port = parsePort(options.port);
const token = await ensureMcpTokenAsync(options.token);

const httpServer = http.createServer((req, res) => {
handleRequest(req, res, token).catch((err) => {
logErr('unhandled request error', err);
writeError(res, 500, 'Internal Server Error');
});
});

httpServer.on('error', (err) => {
logErr('HTTP server error', err);
});

await new Promise<void>((resolve) => {
httpServer.listen(port, '127.0.0.1', () => resolve());
});

const address = httpServer.address();
const boundPort = typeof address === 'object' && address ? address.port : port;
log(`Expo Orbit MCP listening on http://127.0.0.1:${boundPort}/mcp`);
log(`Token: ${token}`);

const shutdown = () => {
httpServer.close(() => process.exit(0));
};
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
}

async function handleRequest(req: IncomingMessage, res: ServerResponse, token: string) {
if (!isLocalhost(req.socket.remoteAddress)) {
return writeError(res, 403, 'Forbidden');
}

if (!isAuthorized(req.headers.authorization, token)) {
res.setHeader('WWW-Authenticate', 'Bearer');
return writeError(res, 401, 'Unauthorized');
}

const url = req.url ?? '';
const pathOnly = url.split('?')[0];
if (pathOnly !== '/mcp') {
return writeError(res, 404, 'Not Found');
}

let body: unknown;
if (req.method === 'POST') {
try {
body = await readJsonBody(req);
} catch (err) {
return writeError(res, 400, err instanceof Error ? err.message : 'Bad Request');
}
}

const server = new McpServer(SERVER_INFO);
registerPhase1Tools(server);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });

res.on('close', () => {
transport.close().catch(() => undefined);
server.close().catch(() => undefined);
});

await server.connect(transport);
await transport.handleRequest(req, res, body);
}

async function readJsonBody(req: IncomingMessage): Promise<unknown> {
let total = 0;
const chunks: Buffer[] = [];
for await (const chunk of req) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buf.length;
if (total > MAX_BODY_BYTES) {
throw new Error('Request body too large');
}
chunks.push(buf);
}
if (chunks.length === 0) return undefined;
const text = Buffer.concat(chunks).toString('utf8');
try {
return JSON.parse(text);
} catch {
throw new Error('Invalid JSON body');
}
}

function writeError(res: ServerResponse, status: number, message: string) {
if (!res.headersSent) {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json');
}
res.end(JSON.stringify({ error: message }));
}

function parsePort(value: string | undefined): number {
const parsed = value ? Number(value) : NaN;
if (Number.isInteger(parsed) && parsed >= 0 && parsed <= 65535) return parsed;
return 8765;
}

function log(message: string) {
process.stderr.write(`[mcp] ${message}\n`);
}

function logErr(message: string, error: unknown) {
const detail = error instanceof Error ? error.stack ?? error.message : String(error);
process.stderr.write(`[mcp] ${message}: ${detail}\n`);
}
10 changes: 10 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ program
.argument('<string>', 'Trusted sources')
.action(returnLoggerMiddleware(setCustomTrustedSourcesAsync));

program
.command('mcp')
.description('Start the Model Context Protocol server')
.option('--port <number>', 'Port to listen on (default: 8765)')
.option('--token <string>', 'Override the bearer token (default: persisted in user settings)')
.action(async (options) => {
const { mcpServerAsync } = await import('./commands/Mcp');
await mcpServerAsync(options);
});

if (process.argv.length < 3) {
program.help();
}
Expand Down
125 changes: 125 additions & 0 deletions apps/cli/src/mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Expo Orbit MCP server

The Orbit CLI ships an MCP (Model Context Protocol) server that lets AI coding agents — Cursor, Claude Code, Claude Desktop — drive your local iOS simulators, Android emulators, and connected devices through Orbit. You ask the agent to "list my simulators" or "check my Android tooling" and it calls into the same code paths the Orbit menu-bar uses.

The server is exposed as a CLI subcommand:

```bash
expo-orbit-cli mcp [--port <number>] [--token <string>]
```

It listens on `127.0.0.1` over HTTP/SSE, gated by a bearer token.

---

## Quick start

### 1. Build the CLI

```bash
yarn install
yarn --cwd apps/cli build
```

### 2. Start the server

```bash
node apps/cli/build/index.js mcp
```

Defaults: port `8765`, token persisted in `~/.expo/orbit/auth.json` (auto-generated on first run). On startup the server prints both:

```
[mcp] Expo Orbit MCP listening on http://127.0.0.1:8765/mcp
[mcp] Token: <hex string>
```

Override either flag for ad-hoc runs:

```bash
node apps/cli/build/index.js mcp --port 9000 --token devtoken
```

### 3. Hook up a client

Add a server entry in your AI client's MCP config. The shape is the same across clients — set the URL to `http://127.0.0.1:<port>/mcp` and pass the token in the `Authorization` header:

```json
{
"mcpServers": {
"expo-orbit": {
"url": "http://127.0.0.1:8765/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
```

Config locations:

| Client | Path |
|---|---|
| Claude Desktop (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` |
| Cursor (global) | `~/.cursor/mcp.json` |
| Cursor (per-project) | `<repo>/.cursor/mcp.json` |
| Claude Code | `claude mcp add` or `~/.config/claude-code/mcp.json` |

After saving the config, restart the client. The tools below should appear in the agent's tool list.

---

## Tools

| Tool | Inputs | Output | Read-only |
|---|---|---|---|
| `list_devices` | `platform?: ios \| android \| tvos \| watchos \| all` (default `all`) | iOS / Android / tvOS / watchOS device arrays | yes |
| `check_tools` | `platform?: ios \| android \| all` (default `all`) | `{ ios?, android?: { success, reason? } }` | yes |
| `get_trusted_sources` | — | `string[]` of allowlisted URL globs | yes |
| `detect_apple_app_type` | `appPath: string` (path to `.app`, `.ipa`, or archive) | `{ deviceType: 'simulator' \| 'device', ... }` | yes |

All current tools are read-only — they don't boot devices, install apps, or change state. Mutating tools (`boot_device`, `install_and_launch`, `launch_update`, etc.) ship in later phases.

---

## Authentication

The MCP server is bound to `127.0.0.1` and rejects any non-local request, but localhost is still reachable by every process on your machine — so the bearer token is required.

- **First run:** a 64-character hex token is generated and stored in `~/.expo/orbit/auth.json` under `mcpToken`. Reuse it across restarts.
- **Override:** pass `--token <value>` for an ephemeral token (not persisted).
- **Rotate:** delete the `mcpToken` field in `user-settings.json` and restart. A fresh token will be generated.

Bad credentials → `401 Unauthorized`. Non-localhost source → `403 Forbidden`.

---

## Dev loop

For iterating on tools without an LLM in the loop, the MCP Inspector is faster than connecting Claude Desktop:

```bash
node apps/cli/build/index.js mcp --port 8765 --token devtoken &
npx @modelcontextprotocol/inspector
```

Then in the Inspector UI, point it at `http://127.0.0.1:8765/mcp` with `Authorization: Bearer devtoken`. You can browse the schema, call tools, and inspect raw JSON-RPC traffic.

You can also exercise it directly with curl:

```bash
curl -sS -X POST http://127.0.0.1:8765/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Authorization: Bearer devtoken' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

---

## How it works

The `mcp` subcommand starts a long-lived Node HTTP server using `@modelcontextprotocol/sdk`'s `StreamableHTTPServerTransport` in stateless mode. Every tool call re-forks the same CLI binary for the underlying subcommand — `list_devices` shells out to `expo-orbit-cli list-devices`, `check_tools` shells out to `expo-orbit-cli check-tools`, and so on.

That means MCP tools inherit the CLI's existing behavior for free: trusted-source URL allowlist, error codes (`UNTRUSTED_SOURCE`, `UNAUTHORIZED_ACCOUNT`, …), session secret resolution, etc. There's no parallel implementation to keep in sync.

When Orbit's menu-bar app starts the MCP server (a future phase), the same CLI binary it already bundles is reused — both the macOS native host and the Electron host on Windows/Linux launch `expo-orbit-cli mcp` the same way they launch any other CLI subcommand.
35 changes: 35 additions & 0 deletions apps/cli/src/mcp/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { randomBytes } from 'crypto';

import { getMcpToken, setMcpToken } from '../storage';

export async function ensureMcpTokenAsync(provided?: string): Promise<string> {
if (provided) return provided;

const existing = getMcpToken();
if (existing) return existing;

const token = randomBytes(32).toString('hex');
await setMcpToken(token);
return token;
}

export function isAuthorized(headerValue: string | undefined, token: string): boolean {
if (!headerValue) return false;
const expected = `Bearer ${token}`;
if (headerValue.length !== expected.length) return false;
// Constant-time compare
let mismatch = 0;
for (let i = 0; i < expected.length; i++) {
mismatch |= headerValue.charCodeAt(i) ^ expected.charCodeAt(i);
}
return mismatch === 0;
}

export function isLocalhost(remoteAddress: string | undefined): boolean {
if (!remoteAddress) return false;
return (
remoteAddress === '127.0.0.1' ||
remoteAddress === '::1' ||
remoteAddress === '::ffff:127.0.0.1'
);
}
Loading
Loading