Skip to content
Open
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
16 changes: 16 additions & 0 deletions apps/server/src/mcp/McpDeviceToolkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
38 changes: 36 additions & 2 deletions apps/server/src/mcp/toolkits/device/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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,
Expand Down
35 changes: 31 additions & 4 deletions apps/server/src/mcp/toolkits/device/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type DeviceError,
type DeviceHostId,
type DeviceHostSummary,
type DeviceId,
type DevicePlatform,
type DeviceSummary,
Expand Down Expand Up @@ -72,6 +73,29 @@ const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("dev
),
);

export const resolveHostId = Effect.fn("handlers.resolveHostId")(function* (
hosts: ReadonlyArray<DeviceHostSummary>,
hostId: string | undefined,
): Effect.fn.Return<DeviceHostId | undefined, DeviceToolUnavailableError> {
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<DeviceSummary>,
input: {
Expand Down Expand Up @@ -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 }));
Expand All @@ -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({
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const target = sessions.findLast(
(session) =>
(hostId === undefined || session.hostId === hostId) &&
Expand Down Expand Up @@ -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 }),
});
Expand Down
Loading