1import { expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 DeviceHostUnavailableError,
5 DeviceId,
6 EnvironmentId,
7 ProviderInstanceId,
8 ThreadId,
9} from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import { McpSchema, McpServer } from "effect/ai";
13
14import * as ServerConfig from "../config.ts";
15import * as DeviceService from "../device/DeviceService.ts";
16import * as McpHttpServer from "./McpHttpServer.ts";
17import * as McpInvocationContext from "./McpInvocationContext.ts";
18import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts";
19
20const environmentId = EnvironmentId.make("environment-device-test");
21const threadId = ThreadId.make("thread-device-test");
22const invocation = (capabilities: ReadonlyArray<McpInvocationContext.McpCapability>) => ({
23 environmentId,
24 requestNamespace: "provider-session-device-test",
25 thread: {
26 threadId,
27 providerSessionId: "provider-session-device-test",
28 providerInstanceId: ProviderInstanceId.make("codex"),
29 },
30 client: undefined,
31 capabilities: new Set(capabilities),
32 issuedAt: 1,
33});
34const client = McpSchema.McpServerClient.of({
35 clientId: 1,
36 clientCapabilities: {},
37 clientInfo: { name: "mcp-test", version: "1.0.0" },
38 protocolVersion: "2025-06-18",
39 initializePayload: {
40 protocolVersion: "2025-06-18",
41 capabilities: {},
42 clientInfo: { name: "mcp-test", version: "1.0.0" },
43 },
44 getClient: Effect.die("unused"),
45});
46
47const device = {
48 hostId: "local",
49 id: "UDID-1",
50 platform: "ios" as const,
51 name: "iPhone 17 Pro",
52 version: "iOS 27.0",
53 booted: true,
54 physical: false,
55};
56const state = {
57 hosts: [
58 {
59 id: "local",
60 kind: "local" as const,
61 label: "This machine",
62 platforms: [
63 { platform: "ios" as const, available: true },
64 { platform: "android" as const, available: false, reason: "No SDK" },
65 ],
66 hubInstalled: true,
67 agentDeviceInstalled: true,
68 },
69 ],
70 hostStatus: "ready" as const,
71 hostStatuses: { local: { status: "ready" as const } },
72 devices: [device],
73 sessions: [],
74 onboardingCompleted: true,
75 agentAccessEnabled: true,
76 hubBasePath: "/api/device-hub",
77 revision: 1,
78};
79const png = new Uint8Array(24);
80new DataView(png.buffer).setUint32(0, 0x89504e47);
81new DataView(png.buffer).setUint32(4, 0x0d0a1a0a);
82new DataView(png.buffer).setUint32(12, 0x49484452);
83new DataView(png.buffer).setUint32(16, 1206);
84new DataView(png.buffer).setUint32(20, 2622);
85
86const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({
87 state: Effect.succeed(state),
88 list: Effect.succeed(state),
89 open: (input) =>
90 Effect.succeed({
91 threadId: input.threadId,
92 hostId: "local",
93 deviceId: input.deviceId,
94 platform: input.platform,
95 openedAt: "2026-09-08T00:00:00.000Z",
96 }),
97 // UDID-1 is open in the test thread; UDID-2 exists but belongs to another thread.
98 sessionsForThread: (id) =>
99 Effect.succeed(
100 id === threadId
101 ? [
102 {
103 threadId,
104 hostId: "local",
105 deviceId: DeviceId.make("UDID-1"),
106 platform: "ios" as const,
107 openedAt: "2026-09-08T00:00:00.000Z",
108 },
109 ]
110 : [],
111 ),
112 screenshot: () => Effect.succeed({ device, png }),
113 close: () => Effect.void,
114 agentCli: Effect.succeed("/cli"),
115 testHost: () => Effect.die("not used"),
116 agentTarget: () => Effect.succeed(["--config", "/host.json", "--session", "thread-device"]),
117});
118
119const layerTest = McpHttpServer.layerDeviceToolkit.pipe(
120 Layer.provideMerge(McpServer.McpServer.layer),
121 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
122 Layer.provideMerge(layerDeviceServiceMock),
123 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" })),
124 Layer.provide(NodeServices.layer),
125);
126
127it.effect("registers the device tools and returns the screenshot as image content", () =>
128 Effect.scoped(
129 Effect.gen(function* () {
130 const server = yield* McpServer.McpServer;
131 const names = server.tools.map(({ tool }) => tool.name).toSorted();
132 expect(names).toEqual(["device_close", "device_list", "device_open", "device_screenshot"]);
133
134 const callWith = (capabilities: ReadonlyArray<McpInvocationContext.McpCapability>) =>
135 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities));
136
137 const opened = yield* server
138 .callTool({ name: "device_open", arguments: { platform: "ios" } })
139 .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client));
140 expect(opened.isError).toBe(false);
141 const openedContent = opened.structuredContent as { quickStart: string };
142 expect(openedContent.quickStart).toContain("--udid UDID-1");
143
144 const shot = yield* server
145 .callTool({ name: "device_screenshot", arguments: { deviceId: "UDID-1" } })
146 .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client));
147 expect(shot.isError).toBe(false);
148 expect(shot.content.map((entry) => entry.type)).toEqual(["text", "image"]);
149 expect(shot.structuredContent).toMatchObject({
150 screenshot: { mimeType: "image/png", width: 1206, height: 2622 },
151 });
152
153 const foreign = yield* server
154 .callTool({ name: "device_screenshot", arguments: { deviceId: "UDID-2" } })
155 .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client));
156 expect(foreign.isError).toBe(true);
157 expect(foreign.content.map((entry) => entry.type)).toEqual(["text"]);
158
159 const denied = yield* server
160 .callTool({ name: "device_list", arguments: {} })
161 .pipe(callWith(["preview"]), Effect.provideService(McpSchema.McpServerClient, client));
162 expect(denied.isError).toBe(true);
163 }),
164 ).pipe(Effect.provide(layerTest)),
165);
166
167it.effect("rejects unavailable agent access before booting or opening a device", () => {
168 const layerUnavailable = Layer.mock(DeviceService.DeviceService)({
169 list: Effect.succeed(state),
170 agentTarget: () =>
171 Effect.fail(
172 new DeviceHostUnavailableError({ hostId: "local", reason: "Agent access is disabled." }),
173 ),
174 open: () => Effect.die("Must not boot or register a device when agent access fails"),
175 });
176 return Effect.gen(function* () {
177 const server = yield* McpServer.McpServer;
178 const result = yield* server
179 .callTool({ name: "device_open", arguments: { platform: "ios" } })
180 .pipe(
181 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])),
182 Effect.provideService(McpSchema.McpServerClient, client),
183 );
184 expect(result.isError).toBe(true);
185 expect(result.content).toEqual(
186 expect.arrayContaining([
187 expect.objectContaining({
188 type: "text",
189 text: expect.stringContaining("Agent access is disabled."),
190 }),
191 ]),
192 );
193 }).pipe(
194 Effect.scoped,
195 Effect.provide(
196 McpHttpServer.layerDeviceToolkit.pipe(
197 Layer.provideMerge(McpServer.McpServer.layer),
198 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
199 Layer.provide(layerUnavailable),
200 Layer.provide(
201 ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" }),
202 ),
203 Layer.provide(NodeServices.layer),
204 ),
205 ),
206 );
207});