| 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { |
| 4 | DeviceHostUnavailableError, |
| 5 | DeviceId, |
| 6 | EnvironmentId, |
| 7 | ProviderInstanceId, |
| 8 | ThreadId, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Layer from "effect/Layer"; |
| 12 | import { McpSchema, McpServer } from "effect/ai"; |
| 13 | |
| 14 | import * as ServerConfig from "../config.ts"; |
| 15 | import * as DeviceService from "../device/DeviceService.ts"; |
| 16 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| 17 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 18 | import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; |
| 19 | |
| 20 | const environmentId = EnvironmentId.make("environment-device-test"); |
| 21 | const threadId = ThreadId.make("thread-device-test"); |
| 22 | const 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 | }); |
| 34 | const 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 | |
| 47 | const 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 | }; |
| 56 | const 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 | }; |
| 79 | const png = new Uint8Array(24); |
| 80 | new DataView(png.buffer).setUint32(0, 0x89504e47); |
| 81 | new DataView(png.buffer).setUint32(4, 0x0d0a1a0a); |
| 82 | new DataView(png.buffer).setUint32(12, 0x49484452); |
| 83 | new DataView(png.buffer).setUint32(16, 1206); |
| 84 | new DataView(png.buffer).setUint32(20, 2622); |
| 85 | |
| 86 | const 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 | |
| 119 | const 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 | |
| 127 | it.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 | |
| 167 | it.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 | }); |