@easonwumac/computer-linker 0.1.2

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 (82) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/LICENSE +21 -0
  3. package/README.md +539 -0
  4. package/SECURITY.md +48 -0
  5. package/dist/api.d.ts +2 -0
  6. package/dist/api.js +360 -0
  7. package/dist/audit.d.ts +70 -0
  8. package/dist/audit.js +102 -0
  9. package/dist/capabilities.d.ts +98 -0
  10. package/dist/capabilities.js +718 -0
  11. package/dist/capability-policy.d.ts +22 -0
  12. package/dist/capability-policy.js +103 -0
  13. package/dist/chatgpt.d.ts +167 -0
  14. package/dist/chatgpt.js +561 -0
  15. package/dist/cli.d.ts +2 -0
  16. package/dist/cli.js +4621 -0
  17. package/dist/client-smoke.d.ts +44 -0
  18. package/dist/client-smoke.js +639 -0
  19. package/dist/client.d.ts +217 -0
  20. package/dist/client.js +357 -0
  21. package/dist/codex-runs.d.ts +35 -0
  22. package/dist/codex-runs.js +66 -0
  23. package/dist/computer-contract.d.ts +33 -0
  24. package/dist/computer-contract.js +384 -0
  25. package/dist/computer-operation-registry.d.ts +45 -0
  26. package/dist/computer-operation-registry.js +179 -0
  27. package/dist/config-diagnostics.d.ts +11 -0
  28. package/dist/config-diagnostics.js +185 -0
  29. package/dist/config.d.ts +10 -0
  30. package/dist/config.js +69 -0
  31. package/dist/history-insights.d.ts +132 -0
  32. package/dist/history-insights.js +457 -0
  33. package/dist/http-auth.d.ts +3 -0
  34. package/dist/http-auth.js +15 -0
  35. package/dist/mcp-surface.d.ts +5 -0
  36. package/dist/mcp-surface.js +25 -0
  37. package/dist/oauth-provider.d.ts +52 -0
  38. package/dist/oauth-provider.js +325 -0
  39. package/dist/package-metadata.d.ts +7 -0
  40. package/dist/package-metadata.js +24 -0
  41. package/dist/permissions.d.ts +43 -0
  42. package/dist/permissions.js +150 -0
  43. package/dist/platform-shell.d.ts +28 -0
  44. package/dist/platform-shell.js +124 -0
  45. package/dist/processes.d.ts +50 -0
  46. package/dist/processes.js +178 -0
  47. package/dist/profile.d.ts +159 -0
  48. package/dist/profile.js +416 -0
  49. package/dist/screenshot.d.ts +47 -0
  50. package/dist/screenshot.js +302 -0
  51. package/dist/search.d.ts +34 -0
  52. package/dist/search.js +340 -0
  53. package/dist/security.d.ts +10 -0
  54. package/dist/security.js +108 -0
  55. package/dist/sensitive-files.d.ts +4 -0
  56. package/dist/sensitive-files.js +96 -0
  57. package/dist/server.d.ts +9 -0
  58. package/dist/server.js +713 -0
  59. package/dist/service.d.ts +125 -0
  60. package/dist/service.js +486 -0
  61. package/dist/sessions.d.ts +26 -0
  62. package/dist/sessions.js +34 -0
  63. package/dist/tunnels.d.ts +161 -0
  64. package/dist/tunnels.js +1243 -0
  65. package/dist/workspace-operations.d.ts +170 -0
  66. package/dist/workspace-operations.js +3219 -0
  67. package/dist/workspaces.d.ts +61 -0
  68. package/dist/workspaces.js +353 -0
  69. package/docs/agent-instructions.md +65 -0
  70. package/docs/alpha-evidence.example.json +54 -0
  71. package/docs/api-compatibility.md +56 -0
  72. package/docs/architecture.md +561 -0
  73. package/docs/chatgpt-setup.md +397 -0
  74. package/docs/client-recipes.md +98 -0
  75. package/docs/client-sdk.md +163 -0
  76. package/docs/computer-operation-v1.schema.json +143 -0
  77. package/docs/manual-test-plan.md +322 -0
  78. package/docs/product-spec.md +911 -0
  79. package/docs/release-checklist.md +285 -0
  80. package/docs/service-mode.md +99 -0
  81. package/examples/minimal-mcp-client.mjs +114 -0
  82. package/package.json +87 -0
package/dist/server.js ADDED
@@ -0,0 +1,713 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
4
+ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
8
+ import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
9
+ import express from "express";
10
+ import * as z from "zod";
11
+ import { registerApiRoutes } from "./api.js";
12
+ import { errorMessage, writeAuditEvent, writeAuthFailureEvent } from "./audit.js";
13
+ import { workspaceCapabilityPolicy } from "./capability-policy.js";
14
+ import { getLocalPortCapabilities } from "./capabilities.js";
15
+ import { computerOperationAuditFields, getComputerInfo, getOperationHistory, runComputerOperation } from "./computer-contract.js";
16
+ import { loadConfig, oauthStatePath } from "./config.js";
17
+ import { isAuthorizedLocalPortRequest } from "./http-auth.js";
18
+ import { mcpToolSurface } from "./mcp-surface.js";
19
+ import { LocalPortOAuthProvider } from "./oauth-provider.js";
20
+ import { workspaceLinkerVersion } from "./package-metadata.js";
21
+ import { localPublicBaseUrl } from "./profile.js";
22
+ import { stopAllManagedProcesses } from "./processes.js";
23
+ import { stopAllTunnelProcesses } from "./tunnels.js";
24
+ import { closeActiveSession, registerActiveSession, touchActiveSession, } from "./sessions.js";
25
+ import { WorkspaceRegistry } from "./workspaces.js";
26
+ import { allowedWorkspaceOperations, normalizeWorkspaceOperationInput, runWorkspaceOperation, workspaceOperationAuditFields, workspaceOperationNames, } from "./workspace-operations.js";
27
+ const workspaceOperationSchema = {
28
+ workspaceId: z.string(),
29
+ op: z.enum(workspaceOperationNames),
30
+ target: z.string().optional(),
31
+ input: z.record(z.string(), z.unknown()).optional(),
32
+ options: z.record(z.string(), z.unknown()).optional(),
33
+ };
34
+ const readOnlyAnnotations = {
35
+ readOnlyHint: true,
36
+ destructiveHint: false,
37
+ idempotentHint: true,
38
+ openWorldHint: false,
39
+ };
40
+ const workspaceActionAnnotations = {
41
+ readOnlyHint: false,
42
+ destructiveHint: true,
43
+ idempotentHint: false,
44
+ openWorldHint: true,
45
+ };
46
+ const createOnlyAnnotations = {
47
+ readOnlyHint: false,
48
+ destructiveHint: false,
49
+ idempotentHint: false,
50
+ openWorldHint: false,
51
+ };
52
+ const looseObjectOutputSchema = z.object({}).passthrough();
53
+ const permissionOutputSchema = z.object({
54
+ read: z.boolean(),
55
+ write: z.boolean(),
56
+ shell: z.boolean(),
57
+ codex: z.boolean(),
58
+ screen: z.boolean().optional(),
59
+ });
60
+ const capabilityPolicyOutputSchema = z.object({
61
+ capabilities: z.array(z.string()),
62
+ }).passthrough();
63
+ const workspaceOutputSchema = z.object({
64
+ id: z.string(),
65
+ name: z.string(),
66
+ path: z.string(),
67
+ permissions: permissionOutputSchema,
68
+ capabilityPolicy: capabilityPolicyOutputSchema,
69
+ allowedOperations: z.array(z.string()),
70
+ }).passthrough();
71
+ const computerInfoOutputSchema = z.object({
72
+ kind: z.literal("computer-linker-computer-info"),
73
+ schemaVersion: z.number(),
74
+ machineId: z.string(),
75
+ machineName: z.string(),
76
+ platform: looseObjectOutputSchema,
77
+ service: z.object({
78
+ name: z.string(),
79
+ version: z.string(),
80
+ transports: z.array(z.string()),
81
+ localUrl: z.string(),
82
+ publicUrl: z.string().nullable(),
83
+ }).passthrough(),
84
+ scopes: z.array(z.object({
85
+ id: z.string(),
86
+ name: z.string(),
87
+ type: z.string(),
88
+ roots: z.array(z.string()),
89
+ permissions: permissionOutputSchema,
90
+ capabilityPolicy: capabilityPolicyOutputSchema,
91
+ allowedOperations: z.array(z.string()),
92
+ }).passthrough()),
93
+ tools: looseObjectOutputSchema,
94
+ operationContract: looseObjectOutputSchema,
95
+ operationRegistry: z.array(looseObjectOutputSchema),
96
+ compatibility: z.object({
97
+ workspaceTools: z.array(z.string()),
98
+ genericTools: z.array(z.string()),
99
+ }).passthrough(),
100
+ status: looseObjectOutputSchema,
101
+ }).passthrough();
102
+ const computerOperationOutputSchema = z.object({
103
+ ok: z.boolean(),
104
+ operationId: z.string(),
105
+ scope: z.string(),
106
+ op: z.string(),
107
+ startedAt: z.string(),
108
+ durationMs: z.number(),
109
+ data: z.unknown().optional(),
110
+ error: z.object({
111
+ code: z.string(),
112
+ message: z.string(),
113
+ retryable: z.boolean(),
114
+ details: z.record(z.string(), z.unknown()),
115
+ }).optional(),
116
+ warnings: z.array(z.string()),
117
+ }).passthrough();
118
+ const operationHistoryOutputSchema = z.object({
119
+ view: z.string().optional(),
120
+ events: z.array(z.unknown()).optional(),
121
+ last: z.unknown().optional(),
122
+ timeline: z.unknown().optional(),
123
+ sessions: z.unknown().optional(),
124
+ failedReplay: z.unknown().optional(),
125
+ debugBundle: z.unknown().optional(),
126
+ }).passthrough();
127
+ const capabilitiesOutputSchema = z.object({
128
+ name: z.string(),
129
+ machineId: z.string(),
130
+ machineName: z.string(),
131
+ auth: looseObjectOutputSchema,
132
+ machine: looseObjectOutputSchema,
133
+ workspaces: z.array(workspaceOutputSchema),
134
+ mcpTools: z.array(z.string()),
135
+ jsonApi: looseObjectOutputSchema,
136
+ clientGuidance: looseObjectOutputSchema,
137
+ workspaceOperations: z.array(z.string()),
138
+ operationRegistry: z.array(looseObjectOutputSchema),
139
+ computerOperationRegistry: z.array(looseObjectOutputSchema),
140
+ capabilityPolicy: looseObjectOutputSchema,
141
+ codingCapabilities: looseObjectOutputSchema,
142
+ tunnels: looseObjectOutputSchema,
143
+ }).passthrough();
144
+ const listWorkspacesOutputSchema = z.object({
145
+ machineId: z.string(),
146
+ machineName: z.string(),
147
+ workspaces: z.array(workspaceOutputSchema),
148
+ }).passthrough();
149
+ const openWorkspaceOutputSchema = z.object({
150
+ workspaceId: z.string(),
151
+ root: z.string(),
152
+ configuredWorkspaceId: z.string(),
153
+ permissions: permissionOutputSchema,
154
+ capabilityPolicy: capabilityPolicyOutputSchema,
155
+ allowedOperations: z.array(z.string()),
156
+ }).passthrough();
157
+ export function createLocalPortMcpServer() {
158
+ const config = loadConfig();
159
+ const workspaces = new WorkspaceRegistry(config);
160
+ const surface = mcpToolSurface();
161
+ const server = new McpServer({
162
+ name: "computer-linker",
163
+ title: `Computer Linker (${config.machineName})`,
164
+ version: workspaceLinkerVersion(),
165
+ description: "Permissioned local workspace MCP server for reading, editing, searching, running commands, and delegating Codex inside explicitly exposed folders.",
166
+ }, {
167
+ instructions: `You are connected to Computer Linker on ${config.machineName}. ` +
168
+ "Use the three-tool flow: start with get_computer_info, call computer_operation, then call get_operation_history when auditing. " +
169
+ "computer_operation always uses the stable envelope: scope, op, target, input, options. " +
170
+ (surface === "compatibility"
171
+ ? "Compatibility clients may still use legacy workspace tools, but new clients should prefer computer_operation. "
172
+ : "Compatibility workspace tools are hidden by default; set COMPUTER_LINKER_MCP_TOOL_SURFACE=compatibility only for legacy clients. ") +
173
+ "Start coding tasks with op=code.context. Use file.search, file.read, git.diff, and package.run as needed. " +
174
+ "Only use write, command, process, codex, or screen operations when the selected scope explicitly allows them.",
175
+ });
176
+ server.registerTool("get_computer_info", {
177
+ title: "Get computer info",
178
+ description: "Step 1. Inspect this computer: identity, scopes, permissions, readiness, URLs, and available operations.",
179
+ inputSchema: {
180
+ include: z.array(z.string()).optional(),
181
+ },
182
+ outputSchema: computerInfoOutputSchema,
183
+ annotations: readOnlyAnnotations,
184
+ }, async () => auditedToolCall("get_computer_info", {}, async () => toolResponse(getComputerInfo())));
185
+ server.registerTool("computer_operation", {
186
+ title: "Computer operation",
187
+ description: [
188
+ "Step 2. Run one scoped operation with the stable envelope: scope, op, target, input, options.",
189
+ "Use dotted ops returned by get_computer_info. Common ops: code.context, file.list, file.search, file.read, git.diff, package.run.",
190
+ ].join(" "),
191
+ inputSchema: {
192
+ scope: z.string(),
193
+ op: z.string(),
194
+ target: z.string().optional(),
195
+ input: z.record(z.string(), z.unknown()).optional(),
196
+ options: z.record(z.string(), z.unknown()).optional(),
197
+ },
198
+ outputSchema: computerOperationOutputSchema,
199
+ annotations: workspaceActionAnnotations,
200
+ }, async (input) => auditedToolCall("computer_operation", await computerOperationAuditFields(input), async () => {
201
+ const result = await runComputerOperation(input);
202
+ return toolResponse(result);
203
+ }, mcpOperationResultSucceeded));
204
+ server.registerTool("get_operation_history", {
205
+ title: "Get operation history",
206
+ description: "Step 3. Read redacted history for actions, sessions, tunnel connections, failures, and debug bundles.",
207
+ inputSchema: {
208
+ scope: z.string().optional(),
209
+ view: z.string().optional(),
210
+ limit: z.number().optional(),
211
+ query: z.string().optional(),
212
+ },
213
+ outputSchema: operationHistoryOutputSchema,
214
+ annotations: readOnlyAnnotations,
215
+ }, async (input) => auditedToolCall("get_operation_history", {
216
+ workspaceRef: input.scope,
217
+ }, async () => toolResponse(getOperationHistory(input))));
218
+ if (surface === "compatibility") {
219
+ server.registerTool("get_capabilities", {
220
+ title: "Get capabilities",
221
+ description: "Step 1. Inspect this computer: available tools, operationRegistry, workspace permissions, tunnel/auth status, and safety boundaries.",
222
+ inputSchema: {},
223
+ outputSchema: capabilitiesOutputSchema,
224
+ annotations: readOnlyAnnotations,
225
+ }, async () => auditedToolCall("get_capabilities", {}, async () => toolResponse(getLocalPortCapabilities())));
226
+ server.registerTool("list_workspaces", {
227
+ title: "List workspaces",
228
+ description: "Step 2. List predefined exposed workspaces and each workspace's allowedOperations. Choose one before calling open_workspace.",
229
+ inputSchema: {},
230
+ outputSchema: listWorkspacesOutputSchema,
231
+ annotations: readOnlyAnnotations,
232
+ }, async () => auditedToolCall("list_workspaces", {}, async () => {
233
+ const definedWorkspaces = workspaces.listDefinedWorkspaces().map((workspace) => ({
234
+ ...workspace,
235
+ capabilityPolicy: workspaceCapabilityPolicy(workspace.permissions),
236
+ allowedOperations: allowedWorkspaceOperations(workspace.permissions),
237
+ }));
238
+ return toolResponse({ machineId: config.machineId, machineName: config.machineName, workspaces: definedWorkspaces });
239
+ }));
240
+ server.registerTool("open_workspace", {
241
+ title: "Open workspace",
242
+ description: "Step 3. Open one predefined workspace by id, name, or exact configured path. Returns workspaceId for workspace_operation.",
243
+ inputSchema: {
244
+ workspaceRef: z.string(),
245
+ },
246
+ outputSchema: openWorkspaceOutputSchema,
247
+ annotations: readOnlyAnnotations,
248
+ }, async ({ workspaceRef }) => auditedToolCall("open_workspace", { workspaceRef }, async () => {
249
+ const workspace = await workspaces.openWorkspace(workspaceRef);
250
+ writeAuditEvent({
251
+ type: "workspace_open",
252
+ success: true,
253
+ tool: "open_workspace",
254
+ workspaceId: workspace.id,
255
+ workspaceRoot: workspace.root,
256
+ workspaceRef,
257
+ });
258
+ return toolResponse({
259
+ workspaceId: workspace.id,
260
+ root: workspace.root,
261
+ configuredWorkspaceId: workspace.exposedPath.id,
262
+ permissions: workspace.exposedPath.permissions,
263
+ capabilityPolicy: workspaceCapabilityPolicy(workspace.exposedPath.permissions),
264
+ allowedOperations: allowedWorkspaceOperations(workspace.exposedPath.permissions),
265
+ });
266
+ }));
267
+ server.registerTool("read", {
268
+ title: "Read file",
269
+ description: "Read one UTF-8 file from an opened predefined workspace. Call list_workspaces and open_workspace first; path must be relative to that workspace.",
270
+ inputSchema: {
271
+ workspaceId: z.string(),
272
+ path: z.string(),
273
+ startLine: z.number().int().positive().optional(),
274
+ lineCount: z.number().int().positive().optional(),
275
+ maxBytes: z.number().int().positive().optional(),
276
+ },
277
+ outputSchema: looseObjectOutputSchema,
278
+ annotations: readOnlyAnnotations,
279
+ }, async (input) => runWorkspaceTool("read", workspaces, input.workspaceId, {
280
+ operation: "read",
281
+ path: input.path,
282
+ startLine: input.startLine,
283
+ lineCount: input.lineCount,
284
+ maxBytes: input.maxBytes,
285
+ }));
286
+ server.registerTool("ls", {
287
+ title: "List directory",
288
+ description: "List directory entries in an opened predefined workspace with type, size, and modified time. Use only paths relative to the opened workspace.",
289
+ inputSchema: {
290
+ workspaceId: z.string(),
291
+ path: z.string().optional(),
292
+ },
293
+ outputSchema: looseObjectOutputSchema,
294
+ annotations: readOnlyAnnotations,
295
+ }, async (input) => runWorkspaceTool("ls", workspaces, input.workspaceId, {
296
+ operation: "list_details",
297
+ path: input.path ?? ".",
298
+ }));
299
+ server.registerTool("grep", {
300
+ title: "Search text",
301
+ description: "Search text in an opened predefined workspace, using ripgrep when available. Use path/glob to keep results bounded.",
302
+ inputSchema: {
303
+ workspaceId: z.string(),
304
+ query: z.string(),
305
+ path: z.string().optional(),
306
+ glob: z.string().optional(),
307
+ fixedStrings: z.boolean().optional(),
308
+ caseSensitive: z.boolean().optional(),
309
+ beforeContext: z.number().int().nonnegative().optional(),
310
+ afterContext: z.number().int().nonnegative().optional(),
311
+ maxResults: z.number().int().positive().optional(),
312
+ },
313
+ outputSchema: looseObjectOutputSchema,
314
+ annotations: readOnlyAnnotations,
315
+ }, async (input) => runWorkspaceTool("grep", workspaces, input.workspaceId, {
316
+ operation: "search_text",
317
+ path: input.path ?? ".",
318
+ query: input.query,
319
+ glob: input.glob,
320
+ fixedStrings: input.fixedStrings,
321
+ caseSensitive: input.caseSensitive,
322
+ beforeContext: input.beforeContext,
323
+ afterContext: input.afterContext,
324
+ maxResults: input.maxResults,
325
+ }));
326
+ server.registerTool("glob", {
327
+ title: "Find files",
328
+ description: "Find file paths in an opened predefined workspace by glob pattern. Use this before broad reads.",
329
+ inputSchema: {
330
+ workspaceId: z.string(),
331
+ pattern: z.string(),
332
+ path: z.string().optional(),
333
+ maxResults: z.number().int().positive().optional(),
334
+ },
335
+ outputSchema: looseObjectOutputSchema,
336
+ annotations: readOnlyAnnotations,
337
+ }, async (input) => runWorkspaceTool("glob", workspaces, input.workspaceId, {
338
+ operation: "find_files",
339
+ path: input.path ?? ".",
340
+ pattern: input.pattern,
341
+ maxResults: input.maxResults,
342
+ }));
343
+ server.registerTool("create_file", {
344
+ title: "Create file",
345
+ description: "Create a new UTF-8 file in an opened predefined workspace and fail if it already exists. Use write/edit/patch only when overwriting or changing an existing file is intended.",
346
+ inputSchema: {
347
+ workspaceId: z.string(),
348
+ path: z.string(),
349
+ content: z.string(),
350
+ },
351
+ outputSchema: looseObjectOutputSchema,
352
+ annotations: createOnlyAnnotations,
353
+ }, async (input) => runWorkspaceTool("create_file", workspaces, input.workspaceId, {
354
+ operation: "create_file",
355
+ path: input.path,
356
+ content: input.content,
357
+ }));
358
+ server.registerTool("workspace_operation", {
359
+ title: "Workspace operation",
360
+ description: [
361
+ "Step 4. Run one operation in an opened workspace.",
362
+ "Use only the stable envelope fields: workspaceId, op, target, input, options.",
363
+ "Examples: {op:'coding_context', target:'.'}, {op:'read', target:'README.md', options:{maxBytes:65536}}, {op:'search_text', target:'.', input:{query:'TODO', glob:'*.ts'}, options:{maxResults:20}}.",
364
+ "For writes use op=write/edit/patch/write_if_unchanged. For Git use op=git_changes/git_diff/git_stage/git_commit. For commands use op=package_run/command only when allowedOperations includes them. For Codex prefer op=codex_plan/codex_review/codex_fix/codex_test/codex_continue when allowedOperations includes them; use raw op=codex only for custom prompts.",
365
+ "If unsure whether an operation is allowed, call {op:'explain_operation', target:'operation_name'} first.",
366
+ ].join(" "),
367
+ inputSchema: workspaceOperationSchema,
368
+ outputSchema: looseObjectOutputSchema,
369
+ annotations: workspaceActionAnnotations,
370
+ }, async ({ workspaceId, ...input }) => {
371
+ const operationInput = normalizeWorkspaceOperationInput(input);
372
+ return auditedToolCall("workspace_operation", {
373
+ workspaceId,
374
+ ...workspaceOperationAuditFields(operationInput),
375
+ }, async () => {
376
+ const workspace = workspaces.getWorkspace(workspaceId);
377
+ return toolResponse(await runWorkspaceOperation(workspaces, workspace, operationInput));
378
+ });
379
+ });
380
+ }
381
+ return server;
382
+ }
383
+ function runWorkspaceTool(tool, workspaces, workspaceId, operationInput) {
384
+ return auditedToolCall(tool, {
385
+ workspaceId,
386
+ ...workspaceOperationAuditFields(operationInput),
387
+ }, async () => {
388
+ const workspace = workspaces.getWorkspace(workspaceId);
389
+ return toolResponse(await runWorkspaceOperation(workspaces, workspace, operationInput));
390
+ });
391
+ }
392
+ function toolResponse(data) {
393
+ return {
394
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
395
+ structuredContent: jsonObject(data),
396
+ };
397
+ }
398
+ function jsonObject(data) {
399
+ if (data && typeof data === "object" && !Array.isArray(data)) {
400
+ return data;
401
+ }
402
+ return { result: data };
403
+ }
404
+ async function auditedToolCall(tool, fields, run, success) {
405
+ const startedAt = performance.now();
406
+ try {
407
+ const result = await run();
408
+ writeAuditEvent({
409
+ type: "tool_call",
410
+ tool,
411
+ success: success ? success(result) : true,
412
+ durationMs: Math.round(performance.now() - startedAt),
413
+ ...fields,
414
+ });
415
+ return result;
416
+ }
417
+ catch (error) {
418
+ writeAuditEvent({
419
+ type: "tool_call",
420
+ tool,
421
+ success: false,
422
+ durationMs: Math.round(performance.now() - startedAt),
423
+ error: errorMessage(error),
424
+ ...fields,
425
+ });
426
+ throw error;
427
+ }
428
+ }
429
+ function mcpOperationResultSucceeded(result) {
430
+ if (!result || typeof result !== "object")
431
+ return true;
432
+ const structuredContent = result.structuredContent;
433
+ if (structuredContent && typeof structuredContent === "object" && !Array.isArray(structuredContent)) {
434
+ const ok = structuredContent.ok;
435
+ if (ok === false)
436
+ return false;
437
+ }
438
+ const content = result.content;
439
+ if (!Array.isArray(content))
440
+ return true;
441
+ const text = content.find((item) => (item &&
442
+ typeof item === "object" &&
443
+ item.type === "text" &&
444
+ typeof item.text === "string"));
445
+ if (!text?.text)
446
+ return true;
447
+ try {
448
+ const parsed = JSON.parse(text.text);
449
+ return parsed.ok !== false;
450
+ }
451
+ catch {
452
+ return true;
453
+ }
454
+ }
455
+ export async function serveStdio() {
456
+ await createLocalPortMcpServer().connect(new StdioServerTransport());
457
+ }
458
+ export function serveHttp() {
459
+ const config = loadConfig();
460
+ const host = config.host ?? "127.0.0.1";
461
+ const port = config.port ?? 3939;
462
+ const publicBaseUrl = config.publicBaseUrl ?? localPublicBaseUrl(host, port);
463
+ const mcpUrl = new URL("/mcp", publicBaseUrl);
464
+ const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl);
465
+ const localMcpUrl = `http://${host}:${port}/mcp`;
466
+ const localApiUrl = `http://${host}:${port}/api/v1`;
467
+ const app = express();
468
+ app.use(express.json({ limit: "10mb" }));
469
+ app.use(express.urlencoded({ extended: false }));
470
+ const publicMcpOnlyHost = publicMcpOnlyHostFromConfig(config);
471
+ if (config.publicMcpOnly) {
472
+ app.use((req, res, next) => {
473
+ if (!isPublicMcpOnlyRequest(req, publicMcpOnlyHost, host) || req.path === "/mcp") {
474
+ next();
475
+ return;
476
+ }
477
+ res.status(404).json({
478
+ ok: false,
479
+ error: "public MCP-only mode exposes /mcp only",
480
+ });
481
+ });
482
+ }
483
+ const transports = new Map();
484
+ const oauthProvider = config.ownerToken
485
+ ? new LocalPortOAuthProvider({
486
+ ownerToken: config.ownerToken,
487
+ scopes: ["computer-linker"],
488
+ accessTokenTtlSeconds: 60 * 60,
489
+ refreshTokenTtlSeconds: 30 * 24 * 60 * 60,
490
+ }, mcpUrl, { statePath: oauthStatePath() })
491
+ : undefined;
492
+ const bearerAuth = oauthProvider
493
+ ? requireBearerAuth({
494
+ verifier: oauthProvider,
495
+ requiredScopes: ["computer-linker"],
496
+ resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl),
497
+ })
498
+ : undefined;
499
+ if (oauthProvider) {
500
+ app.use(mcpAuthRouter({
501
+ provider: oauthProvider,
502
+ issuerUrl: new URL(publicBaseUrl),
503
+ baseUrl: new URL(publicBaseUrl),
504
+ resourceServerUrl,
505
+ scopesSupported: ["computer-linker"],
506
+ resourceName: "Computer Linker",
507
+ }));
508
+ }
509
+ app.get("/healthz", (_req, res) => {
510
+ res.json({ ok: true, name: "computer-linker", machineId: config.machineId, machineName: config.machineName });
511
+ });
512
+ registerApiRoutes(app);
513
+ app.all("/mcp", async (req, res) => {
514
+ let authType;
515
+ const currentOwnerToken = loadConfig().ownerToken;
516
+ if (isAuthorizedLocalPortRequest(req, currentOwnerToken)) {
517
+ // Owner token compatibility path for clients that support custom headers.
518
+ authType = currentOwnerToken ? "owner-token" : "loopback";
519
+ }
520
+ else if (bearerAuth) {
521
+ try {
522
+ await new Promise((resolve, reject) => {
523
+ bearerAuth(req, res, (error) => {
524
+ if (error)
525
+ reject(error);
526
+ else
527
+ resolve();
528
+ });
529
+ });
530
+ }
531
+ catch (error) {
532
+ writeMcpAuthFailure(req, errorMessage(error));
533
+ if (!res.headersSent)
534
+ sendJsonRpcError(res, 401, -32001, "Unauthorized");
535
+ return;
536
+ }
537
+ if (res.headersSent) {
538
+ writeMcpAuthFailure(req, "oauth middleware rejected request");
539
+ return;
540
+ }
541
+ if (!req.auth?.resource || !checkResourceAllowed({ requestedResource: req.auth.resource, configuredResource: resourceServerUrl })) {
542
+ writeMcpAuthFailure(req, "oauth resource is not allowed");
543
+ sendJsonRpcError(res, 401, -32001, "Unauthorized");
544
+ return;
545
+ }
546
+ authType = "oauth";
547
+ }
548
+ else {
549
+ writeMcpAuthFailure(req, "unauthorized");
550
+ sendJsonRpcError(res, 401, -32001, "Unauthorized");
551
+ return;
552
+ }
553
+ const sessionId = req.header("mcp-session-id");
554
+ const initializeRequest = req.method === "POST" && isInitializeRequest(req.body);
555
+ try {
556
+ let transport;
557
+ if (sessionId) {
558
+ transport = transports.get(sessionId);
559
+ if (!transport) {
560
+ sendJsonRpcError(res, 404, -32000, "Unknown MCP session");
561
+ return;
562
+ }
563
+ touchActiveSession(sessionId);
564
+ }
565
+ else if (initializeRequest) {
566
+ transport = new StreamableHTTPServerTransport({
567
+ sessionIdGenerator: () => randomUUID(),
568
+ onsessioninitialized: (newSessionId) => {
569
+ if (transport)
570
+ transports.set(newSessionId, transport);
571
+ registerActiveSession({
572
+ id: newSessionId,
573
+ authType: authType ?? "owner-token",
574
+ clientId: req.auth?.clientId,
575
+ clientName: initializeClientName(req.body),
576
+ userAgent: req.header("user-agent"),
577
+ remoteAddress: req.ip,
578
+ });
579
+ writeAuditEvent({
580
+ type: "mcp_session",
581
+ success: true,
582
+ detail: `created:${newSessionId.slice(0, 8)}`,
583
+ });
584
+ },
585
+ });
586
+ transport.onclose = () => {
587
+ const closedSessionId = transport?.sessionId;
588
+ if (closedSessionId) {
589
+ transports.delete(closedSessionId);
590
+ closeActiveSession(closedSessionId);
591
+ writeAuditEvent({
592
+ type: "mcp_session",
593
+ success: true,
594
+ detail: `closed:${closedSessionId.slice(0, 8)}`,
595
+ });
596
+ }
597
+ };
598
+ await createLocalPortMcpServer().connect(transport);
599
+ }
600
+ else {
601
+ sendJsonRpcError(res, 400, -32000, "No valid MCP session");
602
+ return;
603
+ }
604
+ await transport.handleRequest(req, res, req.body);
605
+ }
606
+ catch (error) {
607
+ if (!res.headersSent) {
608
+ const message = error instanceof Error ? error.message : String(error);
609
+ sendJsonRpcError(res, 500, -32603, message);
610
+ }
611
+ }
612
+ });
613
+ const server = app.listen(port, host);
614
+ return {
615
+ url: localMcpUrl,
616
+ publicUrl: mcpUrl.href,
617
+ apiUrl: localApiUrl,
618
+ close: () => {
619
+ void stopAllManagedProcesses();
620
+ void stopAllTunnelProcesses();
621
+ server.close();
622
+ },
623
+ };
624
+ }
625
+ function publicMcpOnlyHostFromConfig(config) {
626
+ if (!config.publicMcpOnly || !config.publicBaseUrl)
627
+ return undefined;
628
+ try {
629
+ return new URL(config.publicBaseUrl).host.toLowerCase();
630
+ }
631
+ catch {
632
+ return undefined;
633
+ }
634
+ }
635
+ function isPublicMcpOnlyRequest(req, publicHost, localHost) {
636
+ const hosts = [
637
+ req.header("host"),
638
+ req.header("x-forwarded-host"),
639
+ ].flatMap((host) => {
640
+ const normalized = normalizeRequestHost(host);
641
+ return normalized ? [normalized] : [];
642
+ });
643
+ if (hosts.length === 0)
644
+ return false;
645
+ return hosts.some((host) => host === publicHost || !isLocalRequestHost(host, localHost));
646
+ }
647
+ function normalizeRequestHost(host) {
648
+ if (typeof host !== "string")
649
+ return undefined;
650
+ const value = host.toLowerCase().split(",")[0]?.trim();
651
+ if (!value)
652
+ return undefined;
653
+ if (value.endsWith(":443"))
654
+ return value.slice(0, -4);
655
+ if (value.endsWith(":80"))
656
+ return value.slice(0, -3);
657
+ return value;
658
+ }
659
+ function isLocalRequestHost(host, localHost) {
660
+ const hostname = hostnameFromHostHeader(host);
661
+ const configuredHost = hostnameFromHostHeader(localHost);
662
+ const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
663
+ if (configuredHost && configuredHost !== "0.0.0.0" && configuredHost !== "::") {
664
+ localHosts.add(configuredHost);
665
+ }
666
+ return Boolean(hostname && localHosts.has(hostname));
667
+ }
668
+ function hostnameFromHostHeader(host) {
669
+ if (!host)
670
+ return undefined;
671
+ if (host.startsWith("[")) {
672
+ const close = host.indexOf("]");
673
+ return close === -1 ? host : host.slice(1, close);
674
+ }
675
+ const colonCount = (host.match(/:/g) ?? []).length;
676
+ if (colonCount === 1)
677
+ return host.split(":")[0];
678
+ return host;
679
+ }
680
+ function initializeClientName(body) {
681
+ if (!body || typeof body !== "object")
682
+ return undefined;
683
+ const params = body.params;
684
+ if (!params || typeof params !== "object")
685
+ return undefined;
686
+ const clientInfo = params.clientInfo;
687
+ if (!clientInfo || typeof clientInfo !== "object")
688
+ return undefined;
689
+ const name = clientInfo.name;
690
+ const version = clientInfo.version;
691
+ if (typeof name !== "string" || !name.trim())
692
+ return undefined;
693
+ return typeof version === "string" && version.trim() ? `${name} ${version}` : name;
694
+ }
695
+ function isExecError(error) {
696
+ return error instanceof Error && "code" in error && typeof error.code === "number";
697
+ }
698
+ function sendJsonRpcError(res, status, code, message) {
699
+ res.status(status).json({
700
+ jsonrpc: "2.0",
701
+ error: { code, message },
702
+ id: null,
703
+ });
704
+ }
705
+ function writeMcpAuthFailure(req, detail) {
706
+ writeAuthFailureEvent({
707
+ surface: "mcp",
708
+ method: req.method,
709
+ requestPath: req.path,
710
+ remoteAddress: req.ip,
711
+ detail,
712
+ });
713
+ }