@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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README-CN.md +1155 -0
  3. package/README.md +1155 -0
  4. package/apps/browserctl/src/commands/a11y-snapshot.ts +20 -0
  5. package/apps/browserctl/src/commands/act.ts +20 -0
  6. package/apps/browserctl/src/commands/common.test.ts +87 -0
  7. package/apps/browserctl/src/commands/common.ts +191 -0
  8. package/apps/browserctl/src/commands/console-list.ts +20 -0
  9. package/apps/browserctl/src/commands/cookie-clear.ts +18 -0
  10. package/apps/browserctl/src/commands/cookie-get.ts +18 -0
  11. package/apps/browserctl/src/commands/cookie-set.ts +22 -0
  12. package/apps/browserctl/src/commands/dialog-arm.ts +20 -0
  13. package/apps/browserctl/src/commands/dom-query-all.ts +18 -0
  14. package/apps/browserctl/src/commands/dom-query.ts +18 -0
  15. package/apps/browserctl/src/commands/download-trigger.ts +22 -0
  16. package/apps/browserctl/src/commands/download-wait.test.ts +67 -0
  17. package/apps/browserctl/src/commands/download-wait.ts +27 -0
  18. package/apps/browserctl/src/commands/element-screenshot.ts +20 -0
  19. package/apps/browserctl/src/commands/frame-list.ts +16 -0
  20. package/apps/browserctl/src/commands/frame-snapshot.ts +18 -0
  21. package/apps/browserctl/src/commands/network-wait-for.ts +100 -0
  22. package/apps/browserctl/src/commands/profile-list.ts +16 -0
  23. package/apps/browserctl/src/commands/profile-use.ts +18 -0
  24. package/apps/browserctl/src/commands/response-body.ts +24 -0
  25. package/apps/browserctl/src/commands/screenshot.ts +16 -0
  26. package/apps/browserctl/src/commands/snapshot.ts +16 -0
  27. package/apps/browserctl/src/commands/status.ts +10 -0
  28. package/apps/browserctl/src/commands/storage-get.ts +20 -0
  29. package/apps/browserctl/src/commands/storage-set.ts +22 -0
  30. package/apps/browserctl/src/commands/tab-close.ts +20 -0
  31. package/apps/browserctl/src/commands/tab-focus.ts +20 -0
  32. package/apps/browserctl/src/commands/tab-open.ts +19 -0
  33. package/apps/browserctl/src/commands/tabs.ts +13 -0
  34. package/apps/browserctl/src/commands/upload-arm.ts +26 -0
  35. package/apps/browserctl/src/daemon-client.test.ts +253 -0
  36. package/apps/browserctl/src/daemon-client.ts +632 -0
  37. package/apps/browserctl/src/e2e.test.ts +99 -0
  38. package/apps/browserctl/src/main.test.ts +215 -0
  39. package/apps/browserctl/src/main.ts +372 -0
  40. package/apps/browserctl/src/smoke.test.ts +16 -0
  41. package/apps/browserctl/src/smoke.ts +5 -0
  42. package/apps/browserd/src/bootstrap.ts +432 -0
  43. package/apps/browserd/src/chrome-relay-extension-bridge.test.ts +275 -0
  44. package/apps/browserd/src/chrome-relay-extension-bridge.ts +506 -0
  45. package/apps/browserd/src/container.ts +1531 -0
  46. package/apps/browserd/src/main.test.ts +864 -0
  47. package/apps/browserd/src/main.ts +7 -0
  48. package/bin/browserctl.cjs +21 -0
  49. package/bin/browserd.cjs +21 -0
  50. package/extensions/chrome-relay/README.md +36 -0
  51. package/extensions/chrome-relay/background.js +1687 -0
  52. package/extensions/chrome-relay/manifest.json +15 -0
  53. package/extensions/chrome-relay/popup.html +369 -0
  54. package/extensions/chrome-relay/popup.js +972 -0
  55. package/package.json +51 -0
  56. package/packages/core/src/bootstrap.test.ts +10 -0
  57. package/packages/core/src/driver-registry.test.ts +45 -0
  58. package/packages/core/src/driver-registry.ts +22 -0
  59. package/packages/core/src/driver.ts +47 -0
  60. package/packages/core/src/index.ts +5 -0
  61. package/packages/core/src/ref-cache.test.ts +61 -0
  62. package/packages/core/src/ref-cache.ts +28 -0
  63. package/packages/core/src/session-store.test.ts +49 -0
  64. package/packages/core/src/session-store.ts +33 -0
  65. package/packages/core/src/types.ts +9 -0
  66. package/packages/driver-chrome-relay/src/chrome-relay-driver.test.ts +634 -0
  67. package/packages/driver-chrome-relay/src/chrome-relay-driver.ts +2206 -0
  68. package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.test.ts +264 -0
  69. package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.ts +521 -0
  70. package/packages/driver-chrome-relay/src/index.ts +26 -0
  71. package/packages/driver-managed/src/index.ts +22 -0
  72. package/packages/driver-managed/src/managed-driver.test.ts +59 -0
  73. package/packages/driver-managed/src/managed-driver.ts +125 -0
  74. package/packages/driver-managed/src/managed-local-driver.test.ts +506 -0
  75. package/packages/driver-managed/src/managed-local-driver.ts +2021 -0
  76. package/packages/driver-remote-cdp/src/index.ts +19 -0
  77. package/packages/driver-remote-cdp/src/remote-cdp-driver.test.ts +617 -0
  78. package/packages/driver-remote-cdp/src/remote-cdp-driver.ts +2042 -0
  79. package/packages/protocol/src/envelope.test.ts +25 -0
  80. package/packages/protocol/src/envelope.ts +31 -0
  81. package/packages/protocol/src/errors.test.ts +17 -0
  82. package/packages/protocol/src/errors.ts +11 -0
  83. package/packages/protocol/src/index.ts +3 -0
  84. package/packages/protocol/src/tools.ts +3 -0
  85. package/packages/transport-mcp-stdio/src/index.ts +3 -0
  86. package/packages/transport-mcp-stdio/src/sdk-server.ts +139 -0
  87. package/packages/transport-mcp-stdio/src/server.test.ts +281 -0
  88. package/packages/transport-mcp-stdio/src/server.ts +183 -0
  89. package/packages/transport-mcp-stdio/src/tool-map.ts +67 -0
  90. 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,3 @@
1
+ export * from "./errors";
2
+ export * from "./envelope";
3
+ export * from "./tools";
@@ -0,0 +1,3 @@
1
+ import type { ToolErrorEnvelope, ToolSuccessEnvelope } from "./envelope";
2
+
3
+ export type ToolResponse<TData> = ToolSuccessEnvelope<TData> | ToolErrorEnvelope;
@@ -0,0 +1,3 @@
1
+ export * from "./tool-map";
2
+ export * from "./server";
3
+ export * from "./sdk-server";
@@ -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
+ }