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
4 changes: 3 additions & 1 deletion apps/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@
"@linkcode/schema": "workspace:*",
"@linkcode/sim": "workspace:*",
"@linkcode/transport": "workspace:*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@sentry/node": "10.62.0",
"@sentry/profiling-node": "10.62.0",
"better-sqlite3": "^12.11.1",
"foxts": "^5.8.0",
"pino": "^10.3.1"
"pino": "^10.3.1",
"zod": "catalog:"
},
"devDependencies": {
"@effect/opentelemetry": "4.0.0-beta.98",
Expand Down
129 changes: 129 additions & 0 deletions apps/daemon/src/__tests__/sim-mcp-endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { SimulatorBackend } from '@linkcode/engine';
import { SimulatorService } from '@linkcode/engine';
import type { McpServer, SessionId } from '@linkcode/schema';
// eslint-disable-next-line import-x/no-unresolved -- the SDK's exports-map subpaths defeat the resolver; tsc resolves them fine
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
// eslint-disable-next-line import-x/no-unresolved -- same exports-map subpath as above
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { asyncNoop, noop } from 'foxts/noop';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SimulatorMcpEndpoint } from '../sim/mcp-endpoint';

const S1 = 'session-1' as SessionId;
const S2 = 'session-2' as SessionId;

function fakeBackend(): SimulatorBackend {
return {
probe: vi.fn(() => Promise.resolve({ simctlPath: '/usr/bin/simctl', developerDir: '/dev' })),
list: vi.fn(() =>
Promise.resolve([
{
udid: 'U-1',
name: 'iPhone 17',
state: 'Shutdown',
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
deviceType: null,
},
]),
),
boot: vi.fn(asyncNoop),
shutdownDevice: vi.fn(asyncNoop),
install: vi.fn(asyncNoop),
launch: vi.fn(() => Promise.resolve<number | null>(77)),
terminate: vi.fn(asyncNoop),
openUrl: vi.fn(asyncNoop),
screenshot: vi.fn(() => Promise.resolve(new Uint8Array([0xff, 0xd8, 0x02]))),
close: vi.fn(noop),
};
}

function urlOf(entry: McpServer | undefined): string {
if (entry?.type !== 'http') throw new Error('expected an http MCP endpoint');
return entry.url;
}

async function connect(url: string): Promise<Client> {
const client = new Client({ name: 'test', version: '0.0.0' });
await client.connect(new StreamableHTTPClientTransport(new URL(url)));
return client;
}

describe('SimulatorMcpEndpoint', () => {
let endpoint: SimulatorMcpEndpoint | undefined;

afterEach(() => {
endpoint?.close();
endpoint = undefined;
});

it('serves session-scoped tools over MCP streamable http', async () => {
const activity: string[] = [];
endpoint = await SimulatorMcpEndpoint.create(new SimulatorService(fakeBackend()), {
activity(a) {
activity.push(`${a.tool}:${a.phase}:${a.sessionId}`);
},
});
const entry = endpoint.endpointFor(S1);
expect(entry).toMatchObject({ type: 'http', name: 'linkcode-sim' });

const client = await connect(urlOf(entry));
const tools = await client.listTools();
expect(tools.tools.map((t) => t.name).sort()).toEqual([
'sim_boot',
'sim_install',
'sim_launch',
'sim_list_devices',
'sim_open_url',
'sim_screenshot',
'sim_shutdown',
'sim_terminate',
]);

const listed = await client.callTool({ name: 'sim_list_devices', arguments: {} });
expect(JSON.stringify(listed.content)).toContain('U-1');

const shot = await client.callTool({
name: 'sim_screenshot',
arguments: { udid: 'U-1' },
});
expect(shot.content).toEqual([
{
type: 'image',
data: Buffer.from([0xff, 0xd8, 0x02]).toString('base64'),
mimeType: 'image/jpeg',
},
]);
expect(activity).toContain('sim_screenshot:started:session-1');
expect(activity).toContain('sim_screenshot:settled:session-1');
await client.close();
});

it('enforces cross-session ownership through the shared service', async () => {
const service = new SimulatorService(fakeBackend());
endpoint = await SimulatorMcpEndpoint.create(service);
const first = await connect(urlOf(endpoint.endpointFor(S1)));
const second = await connect(urlOf(endpoint.endpointFor(S2)));

const claimed = await first.callTool({
name: 'sim_launch',
arguments: { udid: 'U-1', bundleId: 'com.example' },
});
expect(claimed.isError).toBeFalsy();

const stolen = await second.callTool({
name: 'sim_launch',
arguments: { udid: 'U-1', bundleId: 'com.example' },
});
expect(stolen.isError).toBe(true);
expect(JSON.stringify(stolen.content)).toContain('in use by another session');
await first.close();
await second.close();
});

it('rejects unknown tokens and released sessions', async () => {
endpoint = await SimulatorMcpEndpoint.create(new SimulatorService(fakeBackend()));
const url = urlOf(endpoint.endpointFor(S1));
endpoint.release(S1);
await expect(connect(url)).rejects.toThrow();
});
});
30 changes: 27 additions & 3 deletions apps/daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import {
EngineService,
makeEngineInfrastructureLayer,
PreviewRouteRegistry,
SimulatorService,
} from '@linkcode/engine';
import type { DaemonIdentity, DaemonListenerInfo, DaemonRuntimeInfo } from '@linkcode/schema';
import { DAEMON_EXIT_ALREADY_RUNNING, ManagedAssetIdSchema } from '@linkcode/schema';
import { SimSidecarClient } from '@linkcode/sim';
import { createWireMessage } from '@linkcode/transport';
import { Hub } from '@linkcode/transport/server';
import * as Sentry from '@sentry/node';
import type { Runtime } from 'effect';
Expand All @@ -38,6 +40,7 @@ import {
import { createScheduleStore } from './schedule-store';
import { createSessionStore } from './session-store';
import { resolveSimSidecarPath } from './sim/backend';
import { SimulatorMcpEndpoint } from './sim/mcp-endpoint';
import { createWorkspaceStore } from './workspace-store';

// After an uncaught exception the process state (live sessions, mid-writes) is untrustworthy —
Expand Down Expand Up @@ -208,13 +211,34 @@ async function main(): Promise<void> {
// status`) that take seconds on a cold machine — listener bind must not wait on them, or
// every client sits on ECONNREFUSED for the whole probe. The engine seeds from the promise.
const agentRuntimesReady = agentRuntimeProber.collect();
// Absent (not a rejecting stub) off macOS or unconfigured: the engine then has no
// simulator surface at all, which is what the capability gate reads. The daemon owns the
// service (not just the sidecar client) so the MCP endpoint and the engine share one
// device-claims registry.
const simSidecarPath = resolveSimSidecarPath();
const simulators = simSidecarPath
? new SimulatorService(new SimSidecarClient(simSidecarPath))
: undefined;
const simulatorMcp = simulators
? yield* Effect.promise(() =>
SimulatorMcpEndpoint.create(simulators, {
activity(activity) {
hub.send(createWireMessage({ kind: 'simulator.activity', ...activity }));
},
devicesChanged(devices) {
hub.send(createWireMessage({ kind: 'simulator.devices.changed', devices }));
},
}),
)
: undefined;
if (simulatorMcp) {
yield* Effect.addFinalizer(() => finalize(() => simulatorMcp.close()));
}
const EngineInfrastructureLive = makeEngineInfrastructureLayer(hub, {
providerStore: store,
ptyBackend: new SidecarPtyBackend(resolveSidecarPath()),
// Absent (not a rejecting stub) off macOS or unconfigured: the engine then has no
// simulator surface at all, which is what the capability gate reads.
simulatorBackend: simSidecarPath ? new SimSidecarClient(simSidecarPath) : undefined,
simulators,
simulatorMcp,
sessionStore: createSessionStore(databasePath()),
// After sessionStore so its migration-ledger reconcile runs before this store migrates.
scheduleStore: createScheduleStore(databasePath()),
Expand Down
Loading