@flrande/browserctl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README-CN.md +1155 -0
- package/README.md +1155 -0
- package/apps/browserctl/src/commands/a11y-snapshot.ts +20 -0
- package/apps/browserctl/src/commands/act.ts +20 -0
- package/apps/browserctl/src/commands/common.test.ts +87 -0
- package/apps/browserctl/src/commands/common.ts +191 -0
- package/apps/browserctl/src/commands/console-list.ts +20 -0
- package/apps/browserctl/src/commands/cookie-clear.ts +18 -0
- package/apps/browserctl/src/commands/cookie-get.ts +18 -0
- package/apps/browserctl/src/commands/cookie-set.ts +22 -0
- package/apps/browserctl/src/commands/dialog-arm.ts +20 -0
- package/apps/browserctl/src/commands/dom-query-all.ts +18 -0
- package/apps/browserctl/src/commands/dom-query.ts +18 -0
- package/apps/browserctl/src/commands/download-trigger.ts +22 -0
- package/apps/browserctl/src/commands/download-wait.test.ts +67 -0
- package/apps/browserctl/src/commands/download-wait.ts +27 -0
- package/apps/browserctl/src/commands/element-screenshot.ts +20 -0
- package/apps/browserctl/src/commands/frame-list.ts +16 -0
- package/apps/browserctl/src/commands/frame-snapshot.ts +18 -0
- package/apps/browserctl/src/commands/network-wait-for.ts +100 -0
- package/apps/browserctl/src/commands/profile-list.ts +16 -0
- package/apps/browserctl/src/commands/profile-use.ts +18 -0
- package/apps/browserctl/src/commands/response-body.ts +24 -0
- package/apps/browserctl/src/commands/screenshot.ts +16 -0
- package/apps/browserctl/src/commands/snapshot.ts +16 -0
- package/apps/browserctl/src/commands/status.ts +10 -0
- package/apps/browserctl/src/commands/storage-get.ts +20 -0
- package/apps/browserctl/src/commands/storage-set.ts +22 -0
- package/apps/browserctl/src/commands/tab-close.ts +20 -0
- package/apps/browserctl/src/commands/tab-focus.ts +20 -0
- package/apps/browserctl/src/commands/tab-open.ts +19 -0
- package/apps/browserctl/src/commands/tabs.ts +13 -0
- package/apps/browserctl/src/commands/upload-arm.ts +26 -0
- package/apps/browserctl/src/daemon-client.test.ts +253 -0
- package/apps/browserctl/src/daemon-client.ts +632 -0
- package/apps/browserctl/src/e2e.test.ts +99 -0
- package/apps/browserctl/src/main.test.ts +215 -0
- package/apps/browserctl/src/main.ts +372 -0
- package/apps/browserctl/src/smoke.test.ts +16 -0
- package/apps/browserctl/src/smoke.ts +5 -0
- package/apps/browserd/src/bootstrap.ts +432 -0
- package/apps/browserd/src/chrome-relay-extension-bridge.test.ts +275 -0
- package/apps/browserd/src/chrome-relay-extension-bridge.ts +506 -0
- package/apps/browserd/src/container.ts +1531 -0
- package/apps/browserd/src/main.test.ts +864 -0
- package/apps/browserd/src/main.ts +7 -0
- package/bin/browserctl.cjs +21 -0
- package/bin/browserd.cjs +21 -0
- package/extensions/chrome-relay/README.md +36 -0
- package/extensions/chrome-relay/background.js +1687 -0
- package/extensions/chrome-relay/manifest.json +15 -0
- package/extensions/chrome-relay/popup.html +369 -0
- package/extensions/chrome-relay/popup.js +972 -0
- package/package.json +51 -0
- package/packages/core/src/bootstrap.test.ts +10 -0
- package/packages/core/src/driver-registry.test.ts +45 -0
- package/packages/core/src/driver-registry.ts +22 -0
- package/packages/core/src/driver.ts +47 -0
- package/packages/core/src/index.ts +5 -0
- package/packages/core/src/ref-cache.test.ts +61 -0
- package/packages/core/src/ref-cache.ts +28 -0
- package/packages/core/src/session-store.test.ts +49 -0
- package/packages/core/src/session-store.ts +33 -0
- package/packages/core/src/types.ts +9 -0
- package/packages/driver-chrome-relay/src/chrome-relay-driver.test.ts +634 -0
- package/packages/driver-chrome-relay/src/chrome-relay-driver.ts +2206 -0
- package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.test.ts +264 -0
- package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.ts +521 -0
- package/packages/driver-chrome-relay/src/index.ts +26 -0
- package/packages/driver-managed/src/index.ts +22 -0
- package/packages/driver-managed/src/managed-driver.test.ts +59 -0
- package/packages/driver-managed/src/managed-driver.ts +125 -0
- package/packages/driver-managed/src/managed-local-driver.test.ts +506 -0
- package/packages/driver-managed/src/managed-local-driver.ts +2021 -0
- package/packages/driver-remote-cdp/src/index.ts +19 -0
- package/packages/driver-remote-cdp/src/remote-cdp-driver.test.ts +617 -0
- package/packages/driver-remote-cdp/src/remote-cdp-driver.ts +2042 -0
- package/packages/protocol/src/envelope.test.ts +25 -0
- package/packages/protocol/src/envelope.ts +31 -0
- package/packages/protocol/src/errors.test.ts +17 -0
- package/packages/protocol/src/errors.ts +11 -0
- package/packages/protocol/src/index.ts +3 -0
- package/packages/protocol/src/tools.ts +3 -0
- package/packages/transport-mcp-stdio/src/index.ts +3 -0
- package/packages/transport-mcp-stdio/src/sdk-server.ts +139 -0
- package/packages/transport-mcp-stdio/src/server.test.ts +281 -0
- package/packages/transport-mcp-stdio/src/server.ts +183 -0
- package/packages/transport-mcp-stdio/src/tool-map.ts +67 -0
- package/scripts/smoke.ps1 +127 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { ErrorCode } from "./errors";
|
|
4
|
+
import { createErr, createOk } from "./envelope";
|
|
5
|
+
|
|
6
|
+
describe("createOk", () => {
|
|
7
|
+
it("returns a success envelope", () => {
|
|
8
|
+
expect(createOk({ value: 42 })).toEqual({
|
|
9
|
+
ok: true,
|
|
10
|
+
data: { value: 42 }
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("createErr", () => {
|
|
16
|
+
it("returns an error envelope", () => {
|
|
17
|
+
expect(createErr(ErrorCode.E_INVALID_ARG, "bad input")).toEqual({
|
|
18
|
+
ok: false,
|
|
19
|
+
error: {
|
|
20
|
+
code: "E_INVALID_ARG",
|
|
21
|
+
message: "bad input"
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ErrorCode } from "./errors";
|
|
2
|
+
|
|
3
|
+
export type ToolSuccessEnvelope<TData> = {
|
|
4
|
+
ok: true;
|
|
5
|
+
data: TData;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type ToolErrorEnvelope = {
|
|
9
|
+
ok: false;
|
|
10
|
+
error: {
|
|
11
|
+
code: ErrorCode;
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createOk<TData>(data: TData): ToolSuccessEnvelope<TData> {
|
|
17
|
+
return {
|
|
18
|
+
ok: true,
|
|
19
|
+
data
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createErr(code: ErrorCode, message: string): ToolErrorEnvelope {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
error: {
|
|
27
|
+
code,
|
|
28
|
+
message
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { ErrorCode } from "./errors";
|
|
4
|
+
|
|
5
|
+
describe("ErrorCode", () => {
|
|
6
|
+
it("contains the canonical error code registry", () => {
|
|
7
|
+
expect(ErrorCode).toEqual({
|
|
8
|
+
E_INVALID_ARG: "E_INVALID_ARG",
|
|
9
|
+
E_NOT_FOUND: "E_NOT_FOUND",
|
|
10
|
+
E_CONFLICT: "E_CONFLICT",
|
|
11
|
+
E_TIMEOUT: "E_TIMEOUT",
|
|
12
|
+
E_DRIVER_UNAVAILABLE: "E_DRIVER_UNAVAILABLE",
|
|
13
|
+
E_PERMISSION: "E_PERMISSION",
|
|
14
|
+
E_INTERNAL: "E_INTERNAL"
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const ErrorCode = {
|
|
2
|
+
E_INVALID_ARG: "E_INVALID_ARG",
|
|
3
|
+
E_NOT_FOUND: "E_NOT_FOUND",
|
|
4
|
+
E_CONFLICT: "E_CONFLICT",
|
|
5
|
+
E_TIMEOUT: "E_TIMEOUT",
|
|
6
|
+
E_DRIVER_UNAVAILABLE: "E_DRIVER_UNAVAILABLE",
|
|
7
|
+
E_PERMISSION: "E_PERMISSION",
|
|
8
|
+
E_INTERNAL: "E_INTERNAL"
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
4
|
+
import type { CallToolResult, Implementation } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { Readable, Writable } from "node:stream";
|
|
6
|
+
import * as z from "zod/v4";
|
|
7
|
+
|
|
8
|
+
import { ErrorCode } from "../../protocol/src/index";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createMcpStdioServer,
|
|
12
|
+
type McpStdioServer,
|
|
13
|
+
type McpToolCallEnvelope
|
|
14
|
+
} from "./server";
|
|
15
|
+
import { buildToolMap, type ToolMap } from "./tool-map";
|
|
16
|
+
|
|
17
|
+
const UNKNOWN_TRACE_ID = "trace:unknown";
|
|
18
|
+
const DEFAULT_SERVER_INFO: Implementation = {
|
|
19
|
+
name: "browserd",
|
|
20
|
+
version: "0.1.0"
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type McpSdkServerOptions = {
|
|
24
|
+
toolMap?: ToolMap;
|
|
25
|
+
serverInfo?: Implementation;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type McpSdkStdioTransport = {
|
|
29
|
+
input?: Readable;
|
|
30
|
+
output?: Writable;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type McpSdkServer = {
|
|
34
|
+
readonly mcpServer: McpServer;
|
|
35
|
+
readonly mcpStdioServer: McpStdioServer;
|
|
36
|
+
listTools(): string[];
|
|
37
|
+
connect(transport: Transport): Promise<void>;
|
|
38
|
+
connectStdio(transport?: McpSdkStdioTransport): Promise<void>;
|
|
39
|
+
close(): Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveTraceId(rawArgs: unknown): string {
|
|
47
|
+
if (!isObjectRecord(rawArgs)) {
|
|
48
|
+
return UNKNOWN_TRACE_ID;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const traceId = rawArgs.traceId;
|
|
52
|
+
if (typeof traceId !== "string") {
|
|
53
|
+
return UNKNOWN_TRACE_ID;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const trimmedTraceId = traceId.trim();
|
|
57
|
+
return trimmedTraceId.length > 0 ? trimmedTraceId : UNKNOWN_TRACE_ID;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stringifyContent(value: unknown): string {
|
|
61
|
+
if (typeof value === "string") {
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
return JSON.stringify(value);
|
|
67
|
+
} catch {
|
|
68
|
+
return String(value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toCallToolResult(envelope: McpToolCallEnvelope): CallToolResult {
|
|
73
|
+
if (envelope.ok) {
|
|
74
|
+
return {
|
|
75
|
+
content: [
|
|
76
|
+
{
|
|
77
|
+
type: "text",
|
|
78
|
+
text: stringifyContent(envelope.data ?? {})
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
structuredContent: envelope
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const code = envelope.error?.code ?? ErrorCode.E_INTERNAL;
|
|
86
|
+
const message = envelope.error?.message ?? "Unexpected tool failure.";
|
|
87
|
+
return {
|
|
88
|
+
content: [
|
|
89
|
+
{
|
|
90
|
+
type: "text",
|
|
91
|
+
text: `${code}: ${message}`
|
|
92
|
+
}
|
|
93
|
+
],
|
|
94
|
+
structuredContent: envelope,
|
|
95
|
+
isError: true
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createMcpSdkServer(options: McpSdkServerOptions = {}): McpSdkServer {
|
|
100
|
+
const toolMap = options.toolMap ?? buildToolMap();
|
|
101
|
+
const mcpStdioServer = createMcpStdioServer(toolMap);
|
|
102
|
+
const mcpServer = new McpServer(options.serverInfo ?? DEFAULT_SERVER_INFO);
|
|
103
|
+
|
|
104
|
+
for (const toolName of mcpStdioServer.listTools()) {
|
|
105
|
+
mcpServer.registerTool(
|
|
106
|
+
toolName,
|
|
107
|
+
{
|
|
108
|
+
description: `Browser control tool: ${toolName}`,
|
|
109
|
+
inputSchema: z.object({}).passthrough()
|
|
110
|
+
},
|
|
111
|
+
async (rawArgs) => {
|
|
112
|
+
const response = await mcpStdioServer.callTool({
|
|
113
|
+
name: toolName,
|
|
114
|
+
traceId: resolveTraceId(rawArgs),
|
|
115
|
+
arguments: rawArgs
|
|
116
|
+
});
|
|
117
|
+
return toCallToolResult(response);
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
mcpServer,
|
|
124
|
+
mcpStdioServer,
|
|
125
|
+
listTools() {
|
|
126
|
+
return mcpStdioServer.listTools();
|
|
127
|
+
},
|
|
128
|
+
connect(transport: Transport) {
|
|
129
|
+
return mcpServer.connect(transport);
|
|
130
|
+
},
|
|
131
|
+
connectStdio(transport: McpSdkStdioTransport = {}) {
|
|
132
|
+
const stdioTransport = new StdioServerTransport(transport.input, transport.output);
|
|
133
|
+
return mcpServer.connect(stdioTransport);
|
|
134
|
+
},
|
|
135
|
+
close() {
|
|
136
|
+
return mcpServer.close();
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { ErrorCode, createOk } from "../../protocol/src/index";
|
|
6
|
+
|
|
7
|
+
import { createMcpSdkServer } from "./sdk-server";
|
|
8
|
+
import { createMcpStdioServer } from "./server";
|
|
9
|
+
import { buildToolMap } from "./tool-map";
|
|
10
|
+
|
|
11
|
+
describe("tool map", () => {
|
|
12
|
+
it("includes browser.status", () => {
|
|
13
|
+
const map = buildToolMap();
|
|
14
|
+
|
|
15
|
+
expect(map.has("browser.status")).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("createMcpStdioServer", () => {
|
|
20
|
+
it("returns success envelope for a successful handler result", async () => {
|
|
21
|
+
const server = createMcpStdioServer(
|
|
22
|
+
new Map([
|
|
23
|
+
[
|
|
24
|
+
"browser.status",
|
|
25
|
+
async () =>
|
|
26
|
+
createOk({
|
|
27
|
+
status: "ok"
|
|
28
|
+
})
|
|
29
|
+
]
|
|
30
|
+
])
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const response = await server.callTool({
|
|
34
|
+
name: "browser.status",
|
|
35
|
+
traceId: "trace:test",
|
|
36
|
+
arguments: {
|
|
37
|
+
sessionId: "session:alpha",
|
|
38
|
+
profile: "profile:alpha",
|
|
39
|
+
targetId: "target:alpha"
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(response).toEqual({
|
|
44
|
+
ok: true,
|
|
45
|
+
traceId: "trace:test",
|
|
46
|
+
sessionId: "session:alpha",
|
|
47
|
+
profile: "profile:alpha",
|
|
48
|
+
targetId: "target:alpha",
|
|
49
|
+
data: {
|
|
50
|
+
status: "ok"
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns validation errors through the common envelope", async () => {
|
|
56
|
+
const server = createMcpStdioServer();
|
|
57
|
+
|
|
58
|
+
const response = await server.callTool({
|
|
59
|
+
name: "browser.status",
|
|
60
|
+
traceId: "trace:validation",
|
|
61
|
+
arguments: {
|
|
62
|
+
sessionId: ""
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(response).toEqual({
|
|
67
|
+
ok: false,
|
|
68
|
+
traceId: "trace:validation",
|
|
69
|
+
sessionId: "session:unknown",
|
|
70
|
+
error: {
|
|
71
|
+
code: ErrorCode.E_INVALID_ARG,
|
|
72
|
+
message: "sessionId is required and must be a non-empty string."
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns not found for unknown tools", async () => {
|
|
78
|
+
const server = createMcpStdioServer();
|
|
79
|
+
|
|
80
|
+
const response = await server.callTool({
|
|
81
|
+
name: "browser.unknown",
|
|
82
|
+
traceId: "trace:unknown",
|
|
83
|
+
arguments: {
|
|
84
|
+
sessionId: "session:alpha"
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(response).toEqual({
|
|
89
|
+
ok: false,
|
|
90
|
+
traceId: "trace:unknown",
|
|
91
|
+
sessionId: "session:alpha",
|
|
92
|
+
error: {
|
|
93
|
+
code: ErrorCode.E_NOT_FOUND,
|
|
94
|
+
message: "Unknown tool: browser.unknown"
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns a dedicated code for not implemented tools", async () => {
|
|
100
|
+
const server = createMcpStdioServer();
|
|
101
|
+
|
|
102
|
+
const response = await server.callTool({
|
|
103
|
+
name: "browser.profile.list",
|
|
104
|
+
traceId: "trace:not-implemented",
|
|
105
|
+
arguments: {
|
|
106
|
+
sessionId: "session:alpha"
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(response).toEqual({
|
|
111
|
+
ok: false,
|
|
112
|
+
traceId: "trace:not-implemented",
|
|
113
|
+
sessionId: "session:alpha",
|
|
114
|
+
error: {
|
|
115
|
+
code: ErrorCode.E_DRIVER_UNAVAILABLE,
|
|
116
|
+
message: "Tool is not implemented: browser.profile.list"
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("wraps thrown handler errors as E_INTERNAL envelopes", async () => {
|
|
122
|
+
const server = createMcpStdioServer(
|
|
123
|
+
new Map([
|
|
124
|
+
[
|
|
125
|
+
"browser.status",
|
|
126
|
+
async () => {
|
|
127
|
+
throw new Error("boom");
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
])
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const response = await server.callTool({
|
|
134
|
+
name: "browser.status",
|
|
135
|
+
traceId: "trace:throw",
|
|
136
|
+
arguments: {
|
|
137
|
+
sessionId: "session:alpha"
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(response).toEqual({
|
|
142
|
+
ok: false,
|
|
143
|
+
traceId: "trace:throw",
|
|
144
|
+
sessionId: "session:alpha",
|
|
145
|
+
error: {
|
|
146
|
+
code: ErrorCode.E_INTERNAL,
|
|
147
|
+
message: "boom"
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("uses the provided toolMap override instead of defaults", async () => {
|
|
153
|
+
const server = createMcpStdioServer(
|
|
154
|
+
new Map([
|
|
155
|
+
[
|
|
156
|
+
"custom.ping",
|
|
157
|
+
async () =>
|
|
158
|
+
createOk({
|
|
159
|
+
pong: true
|
|
160
|
+
})
|
|
161
|
+
]
|
|
162
|
+
])
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(server.listTools()).toEqual(["custom.ping"]);
|
|
166
|
+
|
|
167
|
+
const response = await server.callTool({
|
|
168
|
+
name: "custom.ping",
|
|
169
|
+
traceId: "trace:override",
|
|
170
|
+
arguments: {
|
|
171
|
+
sessionId: "session:alpha"
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(response).toEqual({
|
|
176
|
+
ok: true,
|
|
177
|
+
traceId: "trace:override",
|
|
178
|
+
sessionId: "session:alpha",
|
|
179
|
+
data: {
|
|
180
|
+
pong: true
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("createMcpSdkServer", () => {
|
|
187
|
+
it("registers tool names and returns structured envelopes through MCP", async () => {
|
|
188
|
+
const sdkServer = createMcpSdkServer({
|
|
189
|
+
toolMap: new Map([
|
|
190
|
+
[
|
|
191
|
+
"browser.status",
|
|
192
|
+
async (args) =>
|
|
193
|
+
createOk({
|
|
194
|
+
status: "ok",
|
|
195
|
+
sessionId: args.sessionId
|
|
196
|
+
})
|
|
197
|
+
]
|
|
198
|
+
]),
|
|
199
|
+
serverInfo: {
|
|
200
|
+
name: "browserctl-test-server",
|
|
201
|
+
version: "0.0.0-test"
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const client = new Client({
|
|
206
|
+
name: "browserctl-test-client",
|
|
207
|
+
version: "0.0.0-test"
|
|
208
|
+
});
|
|
209
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
210
|
+
await Promise.all([sdkServer.connect(serverTransport), client.connect(clientTransport)]);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const toolsResult = await client.listTools();
|
|
214
|
+
expect(toolsResult.tools.map((tool) => tool.name)).toEqual(["browser.status"]);
|
|
215
|
+
|
|
216
|
+
const result = await client.callTool({
|
|
217
|
+
name: "browser.status",
|
|
218
|
+
arguments: {
|
|
219
|
+
sessionId: "session:alpha",
|
|
220
|
+
traceId: "trace:alpha"
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect("structuredContent" in result).toBe(true);
|
|
225
|
+
if (!("structuredContent" in result)) {
|
|
226
|
+
throw new Error("Expected a CallToolResult payload with structuredContent.");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
expect(result.isError).not.toBe(true);
|
|
230
|
+
expect(result.structuredContent).toMatchObject({
|
|
231
|
+
ok: true,
|
|
232
|
+
traceId: "trace:alpha",
|
|
233
|
+
sessionId: "session:alpha",
|
|
234
|
+
data: {
|
|
235
|
+
status: "ok",
|
|
236
|
+
sessionId: "session:alpha"
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
} finally {
|
|
240
|
+
await client.close();
|
|
241
|
+
await sdkServer.close();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("maps envelope validation errors to MCP tool errors", async () => {
|
|
246
|
+
const sdkServer = createMcpSdkServer();
|
|
247
|
+
const client = new Client({
|
|
248
|
+
name: "browserctl-test-client",
|
|
249
|
+
version: "0.0.0-test"
|
|
250
|
+
});
|
|
251
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
252
|
+
await Promise.all([sdkServer.connect(serverTransport), client.connect(clientTransport)]);
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const result = await client.callTool({
|
|
256
|
+
name: "browser.status",
|
|
257
|
+
arguments: {
|
|
258
|
+
sessionId: ""
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect("structuredContent" in result).toBe(true);
|
|
263
|
+
if (!("structuredContent" in result)) {
|
|
264
|
+
throw new Error("Expected a CallToolResult payload with structuredContent.");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
expect(result.isError).toBe(true);
|
|
268
|
+
expect(result.structuredContent).toMatchObject({
|
|
269
|
+
ok: false,
|
|
270
|
+
sessionId: "session:unknown",
|
|
271
|
+
error: {
|
|
272
|
+
code: ErrorCode.E_INVALID_ARG,
|
|
273
|
+
message: "sessionId is required and must be a non-empty string."
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
} finally {
|
|
277
|
+
await client.close();
|
|
278
|
+
await sdkServer.close();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { ErrorCode, createErr, createOk, type ToolResponse } from "../../protocol/src/index";
|
|
2
|
+
|
|
3
|
+
import { buildToolMap, type ToolCallArgs, type ToolMap } from "./tool-map";
|
|
4
|
+
|
|
5
|
+
const UNKNOWN_TRACE_ID = "trace:unknown";
|
|
6
|
+
const UNKNOWN_SESSION_ID = "session:unknown";
|
|
7
|
+
|
|
8
|
+
export type McpToolCallRequest = {
|
|
9
|
+
name: string;
|
|
10
|
+
arguments?: unknown;
|
|
11
|
+
traceId?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type McpToolCallEnvelope<TData = Record<string, unknown>> = {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
traceId: string;
|
|
17
|
+
sessionId: string;
|
|
18
|
+
profile?: string;
|
|
19
|
+
targetId?: string;
|
|
20
|
+
data?: TData;
|
|
21
|
+
error?: {
|
|
22
|
+
code: string;
|
|
23
|
+
message: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type McpStdioServer = {
|
|
28
|
+
readonly toolMap: ToolMap;
|
|
29
|
+
listTools(): string[];
|
|
30
|
+
callTool(request: McpToolCallRequest): Promise<McpToolCallEnvelope>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveTraceId(value: string | undefined): string {
|
|
38
|
+
if (typeof value !== "string") {
|
|
39
|
+
return UNKNOWN_TRACE_ID;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const trimmedValue = value.trim();
|
|
43
|
+
return trimmedValue.length === 0 ? UNKNOWN_TRACE_ID : trimmedValue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toErrorMessage(error: unknown): string {
|
|
47
|
+
if (error instanceof Error) {
|
|
48
|
+
return error.message;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return "Unexpected tool failure.";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateToolCallArgs(value: unknown): ToolResponse<ToolCallArgs> {
|
|
55
|
+
if (!isObjectRecord(value)) {
|
|
56
|
+
return createErr(ErrorCode.E_INVALID_ARG, "Tool arguments must be an object.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const sessionIdValue = value.sessionId;
|
|
60
|
+
if (typeof sessionIdValue !== "string" || sessionIdValue.trim().length === 0) {
|
|
61
|
+
return createErr(ErrorCode.E_INVALID_ARG, "sessionId is required and must be a non-empty string.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const profileValue = value.profile;
|
|
65
|
+
if (profileValue !== undefined && (typeof profileValue !== "string" || profileValue.trim().length === 0)) {
|
|
66
|
+
return createErr(ErrorCode.E_INVALID_ARG, "profile must be a non-empty string when provided.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const targetIdValue = value.targetId;
|
|
70
|
+
if (targetIdValue !== undefined && (typeof targetIdValue !== "string" || targetIdValue.trim().length === 0)) {
|
|
71
|
+
return createErr(ErrorCode.E_INVALID_ARG, "targetId must be a non-empty string when provided.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const timeoutMsValue = value.timeoutMs;
|
|
75
|
+
if (
|
|
76
|
+
timeoutMsValue !== undefined &&
|
|
77
|
+
(typeof timeoutMsValue !== "number" || !Number.isFinite(timeoutMsValue) || timeoutMsValue < 0)
|
|
78
|
+
) {
|
|
79
|
+
return createErr(
|
|
80
|
+
ErrorCode.E_INVALID_ARG,
|
|
81
|
+
"timeoutMs must be a non-negative finite number when provided."
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const normalizedArgs: ToolCallArgs = {
|
|
86
|
+
...value,
|
|
87
|
+
sessionId: sessionIdValue.trim()
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (typeof profileValue === "string") {
|
|
91
|
+
normalizedArgs.profile = profileValue.trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (typeof targetIdValue === "string") {
|
|
95
|
+
normalizedArgs.targetId = targetIdValue.trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (typeof timeoutMsValue === "number") {
|
|
99
|
+
normalizedArgs.timeoutMs = timeoutMsValue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return createOk(normalizedArgs);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function adaptToolResponse<TData>(
|
|
106
|
+
context: Pick<ToolCallArgs, "sessionId" | "profile" | "targetId">,
|
|
107
|
+
traceId: string,
|
|
108
|
+
response: ToolResponse<TData>
|
|
109
|
+
): McpToolCallEnvelope<TData> {
|
|
110
|
+
if (response.ok) {
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
traceId,
|
|
114
|
+
sessionId: context.sessionId,
|
|
115
|
+
profile: context.profile,
|
|
116
|
+
targetId: context.targetId,
|
|
117
|
+
data: response.data
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
traceId,
|
|
124
|
+
sessionId: context.sessionId,
|
|
125
|
+
profile: context.profile,
|
|
126
|
+
targetId: context.targetId,
|
|
127
|
+
error: response.error
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createMcpStdioServer(toolMap: ToolMap = buildToolMap()): McpStdioServer {
|
|
132
|
+
return {
|
|
133
|
+
toolMap,
|
|
134
|
+
listTools() {
|
|
135
|
+
return [...toolMap.keys()];
|
|
136
|
+
},
|
|
137
|
+
async callTool(request) {
|
|
138
|
+
const traceId = resolveTraceId(request.traceId);
|
|
139
|
+
const rawToolName = request.name;
|
|
140
|
+
if (typeof rawToolName !== "string" || rawToolName.trim().length === 0) {
|
|
141
|
+
return adaptToolResponse(
|
|
142
|
+
{
|
|
143
|
+
sessionId: UNKNOWN_SESSION_ID
|
|
144
|
+
},
|
|
145
|
+
traceId,
|
|
146
|
+
createErr(ErrorCode.E_INVALID_ARG, "Tool name must be a non-empty string.")
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const validatedArgs = validateToolCallArgs(request.arguments);
|
|
151
|
+
if (!validatedArgs.ok) {
|
|
152
|
+
return adaptToolResponse(
|
|
153
|
+
{
|
|
154
|
+
sessionId: UNKNOWN_SESSION_ID
|
|
155
|
+
},
|
|
156
|
+
traceId,
|
|
157
|
+
validatedArgs
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const toolName = rawToolName.trim();
|
|
162
|
+
const handler = toolMap.get(toolName);
|
|
163
|
+
if (handler === undefined) {
|
|
164
|
+
return adaptToolResponse(
|
|
165
|
+
validatedArgs.data,
|
|
166
|
+
traceId,
|
|
167
|
+
createErr(ErrorCode.E_NOT_FOUND, `Unknown tool: ${toolName}`)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const response = await handler(validatedArgs.data);
|
|
173
|
+
return adaptToolResponse(validatedArgs.data, traceId, response);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return adaptToolResponse(
|
|
176
|
+
validatedArgs.data,
|
|
177
|
+
traceId,
|
|
178
|
+
createErr(ErrorCode.E_INTERNAL, toErrorMessage(error))
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|