diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index ca96fd582f328..78173d8200f97 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -184,6 +184,22 @@ it.effect("registers the device tools and returns the screenshot as image conten expect(foreign.isError).toBe(true); expect(foreign.content.map((entry) => entry.type)).toEqual(["text"]); + const listed = yield* server + .callTool({ name: "device_list", arguments: { hostId: "THIS MACHINE" } }) + .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client)); + expect(listed.structuredContent).toMatchObject({ + hosts: [{ id: "local" }], + devices: [device], + }); + + const unknownHost = yield* server + .callTool({ name: "device_screenshot", arguments: { hostId: "oliverMac" } }) + .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client)); + expect(unknownHost.isError).toBe(true); + expect(unknownHost.content).toEqual([ + { type: "text", text: "No device host oliverMac. Known hosts: This machine (local)." }, + ]); + const denied = yield* server .callTool({ name: "device_list", arguments: {} }) .pipe(callWith(["preview"]), Effect.provideService(McpSchema.McpServerClient, client)); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 0d8837f0ab875..50f4f3c8e4eb3 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -14,6 +14,7 @@ import type * as Types from "effect/Types"; import { AiError, McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; import { + DeviceToolError, OrchestratorMcpFailure, PreviewAutomationError, type RunId, @@ -712,11 +713,12 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh Effect.provideService(ThreadManagementService.ThreadManagementService, threads), ), "screenshot", - "Device screenshot failed.", + (error) => (isDeviceToolError(error) ? error.message : "Device screenshot failed."), ); }); const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure); +const isDeviceToolError = Schema.is(DeviceToolError); const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(function* () { const htmlRender = yield* HtmlRender.HtmlRender; diff --git a/apps/server/src/mcp/toolkits/device/handlers.test.ts b/apps/server/src/mcp/toolkits/device/handlers.test.ts index 0ad4b95581b2d..37af754f7c362 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "vite-plus/test"; +import type { DeviceHostSummary } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "@effect/vitest"; -import { agentDeviceQuickStart, pngDimensions } from "./handlers.ts"; +import { agentDeviceQuickStart, pngDimensions, resolveHostId } from "./handlers.ts"; const device = { hostId: "local", @@ -12,7 +14,39 @@ const device = { physical: false, }; +const hosts = [ + { id: "local", label: "This machine" }, + { id: "0dc4f37b-5e0a-4f4c-9a51-0c6b8e8a1f2d", label: "oliverMac" }, + { id: "mini-1", label: "Mac mini" }, + { id: "mini-2", label: "Mac mini" }, +].map((host): DeviceHostSummary => ({ + ...host, + kind: host.id === "local" ? "local" : "ssh", + platforms: [], + hubInstalled: true, + agentDeviceInstalled: true, +})); + describe("device tool helpers", () => { + it.effect("resolves a host by exact id or case-insensitive label", () => + Effect.gen(function* () { + const mac = "0dc4f37b-5e0a-4f4c-9a51-0c6b8e8a1f2d"; + expect(yield* resolveHostId(hosts, mac)).toBe(mac); + expect(yield* resolveHostId(hosts, "olivermac")).toBe(mac); + }), + ); + + it.effect("rejects an unknown or ambiguous host and lists the candidates", () => + Effect.gen(function* () { + const unknown = yield* Effect.flip(resolveHostId(hosts, "studio")); + expect(unknown.reason).toContain("This machine (local)"); + expect(unknown.reason).toContain("oliverMac (0dc4f37b-5e0a-4f4c-9a51-0c6b8e8a1f2d)"); + const ambiguous = yield* Effect.flip(resolveHostId(hosts, "mac mini")); + expect(ambiguous.reason).toContain("Mac mini (mini-1), Mac mini (mini-2)"); + expect(ambiguous.reason).not.toContain("oliverMac"); + }), + ); + it("uses the absolute launcher in every quick-start command", () => { const text = agentDeviceQuickStart( device, diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 8fdbd264b8726..619d0a86248ac 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -1,6 +1,7 @@ import { type DeviceError, type DeviceHostId, + type DeviceHostSummary, type DeviceId, type DevicePlatform, type DeviceSummary, @@ -72,6 +73,29 @@ const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("dev ), ); +export const resolveHostId = Effect.fn("handlers.resolveHostId")(function* ( + hosts: ReadonlyArray, + hostId: string | undefined, +): Effect.fn.Return { + if (!hostId) return undefined; + const exact = hosts.find((host) => host.id === hostId); + if (exact) return exact.id; + const labelMatches = hosts.filter((host) => host.label.toLowerCase() === hostId.toLowerCase()); + if (labelMatches.length === 1) return labelMatches[0]!.id; + const candidates = labelMatches.length > 1 ? labelMatches : hosts; + const listed = [ + ...candidates.slice(0, 10).map((host) => `${host.label.slice(0, 64)} (${host.id})`), + ...(candidates.length > 10 ? [`${candidates.length - 10} more`] : []), + ].join(", "); + const selector = hostId.slice(0, 128); + return yield* new DeviceToolUnavailableError({ + reason: + labelMatches.length > 1 + ? `Several device hosts are named ${selector}; pass one id: ${listed}.` + : `No device host ${selector}. Known hosts: ${listed || "none"}.`, + }); +}); + const pickDevice = Effect.fn("handlers.pickDevice")(function* ( devices: ReadonlyArray, input: { @@ -126,7 +150,7 @@ const handlers = { "Device support is off. Ask the user to enable it in the Device panel before installing or starting device tools.", }); } - const hostId = input?.hostId; + const hostId = yield* resolveHostId(state.hosts, input?.hostId); const open = state.sessions .filter((session) => session.threadId === scope.thread.threadId) .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId })); @@ -153,7 +177,8 @@ const handlers = { "Device support is off. Ask the user to enable it in the Device panel before installing or starting device tools.", }); } - const target = yield* pickDevice(state.devices, input); + const hostId = yield* resolveHostId(state.hosts, input.hostId); + const target = yield* pickDevice(state.devices, { ...input, hostId }); // Resolve consent and agent connectivity before booting or registering a session. if (!(yield* devices.agentReadinessIfSupported(target.hostId))) { return yield* new DeviceToolUnavailableError({ @@ -221,8 +246,9 @@ const handlers = { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; const sessions = yield* devices.sessionsForThread(scope.thread.threadId); + const inputHostId = yield* resolveHostId((yield* devices.state).hosts, input.hostId); const hostId = - input.deviceId === undefined ? input.hostId : (input.hostId ?? LOCAL_DEVICE_HOST_ID); + input.deviceId === undefined ? inputHostId : (inputHostId ?? LOCAL_DEVICE_HOST_ID); const target = sessions.findLast( (session) => (hostId === undefined || session.hostId === hostId) && @@ -251,9 +277,10 @@ const handlers = { Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; + const hostId = yield* resolveHostId((yield* devices.state).hosts, input.hostId); yield* devices.close({ threadId: scope.thread.threadId, - ...(input.hostId === undefined ? {} : { hostId: input.hostId }), + ...(hostId === undefined ? {} : { hostId }), ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }), ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }), });