@myrialabs/clopen 0.1.9 → 0.2.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 (94) hide show
  1. package/README.md +23 -1
  2. package/backend/index.ts +25 -1
  3. package/backend/lib/auth/auth-service.ts +484 -0
  4. package/backend/lib/auth/index.ts +4 -0
  5. package/backend/lib/auth/permissions.ts +63 -0
  6. package/backend/lib/auth/rate-limiter.ts +145 -0
  7. package/backend/lib/auth/tokens.ts +53 -0
  8. package/backend/lib/chat/stream-manager.ts +4 -1
  9. package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
  10. package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
  11. package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
  12. package/backend/lib/database/migrations/index.ts +21 -0
  13. package/backend/lib/database/queries/auth-queries.ts +201 -0
  14. package/backend/lib/database/queries/index.ts +2 -1
  15. package/backend/lib/database/queries/session-queries.ts +13 -0
  16. package/backend/lib/database/queries/snapshot-queries.ts +1 -1
  17. package/backend/lib/engine/adapters/opencode/server.ts +9 -1
  18. package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
  19. package/backend/lib/mcp/config.ts +13 -18
  20. package/backend/lib/mcp/index.ts +9 -0
  21. package/backend/lib/mcp/remote-server.ts +132 -0
  22. package/backend/lib/mcp/servers/helper.ts +49 -3
  23. package/backend/lib/mcp/servers/index.ts +3 -2
  24. package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
  25. package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
  26. package/backend/lib/preview/browser/browser-pool.ts +73 -176
  27. package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
  28. package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
  29. package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
  30. package/backend/lib/snapshot/helpers.ts +22 -49
  31. package/backend/lib/snapshot/snapshot-service.ts +148 -83
  32. package/backend/lib/utils/ws.ts +65 -1
  33. package/backend/ws/auth/index.ts +17 -0
  34. package/backend/ws/auth/invites.ts +84 -0
  35. package/backend/ws/auth/login.ts +269 -0
  36. package/backend/ws/auth/status.ts +41 -0
  37. package/backend/ws/auth/users.ts +32 -0
  38. package/backend/ws/chat/stream.ts +13 -0
  39. package/backend/ws/engine/claude/accounts.ts +3 -1
  40. package/backend/ws/engine/utils.ts +38 -6
  41. package/backend/ws/index.ts +4 -4
  42. package/backend/ws/preview/browser/interact.ts +27 -5
  43. package/backend/ws/snapshot/restore.ts +111 -12
  44. package/backend/ws/snapshot/timeline.ts +56 -29
  45. package/bin/clopen.ts +56 -1
  46. package/bun.lock +113 -51
  47. package/frontend/App.svelte +47 -29
  48. package/frontend/lib/components/auth/InvitePage.svelte +215 -0
  49. package/frontend/lib/components/auth/LoginPage.svelte +129 -0
  50. package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
  51. package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
  52. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
  53. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
  54. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
  55. package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
  56. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
  57. package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
  58. package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
  59. package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
  60. package/frontend/lib/components/git/CommitForm.svelte +6 -4
  61. package/frontend/lib/components/history/HistoryModal.svelte +1 -1
  62. package/frontend/lib/components/history/HistoryView.svelte +1 -1
  63. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
  64. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
  65. package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
  66. package/frontend/lib/components/settings/SettingsView.svelte +21 -7
  67. package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
  68. package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
  69. package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
  70. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
  71. package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
  72. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  73. package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
  74. package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
  75. package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
  76. package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
  77. package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
  78. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
  79. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  80. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
  81. package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
  82. package/frontend/lib/stores/features/auth.svelte.ts +296 -0
  83. package/frontend/lib/stores/features/settings.svelte.ts +53 -9
  84. package/frontend/lib/stores/features/user.svelte.ts +26 -68
  85. package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
  86. package/frontend/lib/stores/ui/update.svelte.ts +2 -14
  87. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
  88. package/package.json +8 -6
  89. package/shared/types/stores/settings.ts +16 -2
  90. package/shared/utils/logger.ts +1 -0
  91. package/shared/utils/ws-client.ts +30 -13
  92. package/shared/utils/ws-server.ts +42 -4
  93. package/backend/lib/mcp/stdio-server.ts +0 -103
  94. package/backend/ws/mcp/index.ts +0 -61
@@ -40,7 +40,7 @@ import {
40
40
  convertSubtaskToolUseOnly,
41
41
  getToolInput,
42
42
  } from './message-converter';
43
- import { ensureClient, getClient } from './server';
43
+ import { ensureClient, getClient, getServerUrl } from './server';
44
44
  import { debug } from '$shared/utils/logger';
45
45
 
46
46
  /** Map SDK Model.status to our category */
@@ -75,6 +75,8 @@ export class OpenCodeEngine implements AIEngine {
75
75
  private activeAbortController: AbortController | null = null;
76
76
  private activeSessionId: string | null = null;
77
77
  private activeProjectPath: string | null = null;
78
+ /** Pending question requests keyed by tool callID → { requestId, questions } */
79
+ private pendingQuestions = new Map<string, { requestId: string; questions: Array<{ question: string }> }>();
78
80
 
79
81
  get isInitialized(): boolean {
80
82
  return this._isInitialized;
@@ -102,6 +104,7 @@ export class OpenCodeEngine implements AIEngine {
102
104
  */
103
105
  async dispose(): Promise<void> {
104
106
  await this.cancel();
107
+ this.pendingQuestions.clear();
105
108
  this._isInitialized = false;
106
109
  debug.log('engine', 'Open Code engine instance disposed');
107
110
  }
@@ -664,6 +667,39 @@ export class OpenCodeEngine implements AIEngine {
664
667
  break;
665
668
  }
666
669
 
670
+ // v2 question event — emitted when the question tool needs user input
671
+ case 'question.asked': {
672
+ const props = evt.properties as {
673
+ id: string;
674
+ sessionID: string;
675
+ questions: Array<{ question: string; header: string; options: Array<{ label: string; description: string }> }>;
676
+ tool?: { messageID: string; callID: string };
677
+ };
678
+ if (props.sessionID !== sessionId) break;
679
+ if (props.tool?.callID) {
680
+ this.pendingQuestions.set(props.tool.callID, {
681
+ requestId: props.id,
682
+ questions: props.questions,
683
+ });
684
+ debug.log('engine', `[OC] question.asked: stored question ${props.id} for callID ${props.tool.callID}`);
685
+ }
686
+ break;
687
+ }
688
+
689
+ // v2 permission event — auto-approve to avoid blocking the session
690
+ // (tool permissions like file_write, bash, etc. are bypassed)
691
+ case 'permission.asked':
692
+ case 'permission.updated': {
693
+ const props = evt.properties as {
694
+ id: string;
695
+ sessionID: string;
696
+ callID?: string;
697
+ };
698
+ if (props.sessionID !== sessionId) break;
699
+ this.autoApprovePermission(props.id, props.sessionID);
700
+ break;
701
+ }
702
+
667
703
  case 'session.error': {
668
704
  const errorProps = (event as EventSessionError).properties;
669
705
  // Only handle errors for our session (sessionID is optional on errors)
@@ -729,6 +765,7 @@ export class OpenCodeEngine implements AIEngine {
729
765
  this.activeAbortController = null;
730
766
  this.activeSessionId = null;
731
767
  this.activeProjectPath = null;
768
+ this.pendingQuestions.clear();
732
769
  }
733
770
  }
734
771
 
@@ -754,6 +791,7 @@ export class OpenCodeEngine implements AIEngine {
754
791
  this._isActive = false;
755
792
  this.activeSessionId = null;
756
793
  this.activeProjectPath = null;
794
+ this.pendingQuestions.clear();
757
795
  }
758
796
 
759
797
  /**
@@ -779,6 +817,142 @@ export class OpenCodeEngine implements AIEngine {
779
817
  await this.cancel();
780
818
  }
781
819
 
820
+ /**
821
+ * Resolve a pending AskUserQuestion by replying via the OpenCode question API.
822
+ *
823
+ * Flow:
824
+ * 1. If a `question.asked` event was received → use stored requestId to reply
825
+ * 2. Fallback → fetch pending questions from GET /question and match by callID
826
+ *
827
+ * The reply is sent to POST /question/{requestID}/reply with answers
828
+ * ordered by the original questions array.
829
+ */
830
+ resolveUserAnswer(toolUseId: string, answers: Record<string, string>): boolean {
831
+ const pending = this.pendingQuestions.get(toolUseId);
832
+
833
+ if (pending) {
834
+ // Convert Record<questionText, answerLabel> → Array<Array<string>> ordered by questions
835
+ const orderedAnswers = pending.questions.map(q => {
836
+ const answer = answers[q.question];
837
+ return answer ? [answer] : [];
838
+ });
839
+ this.replyToQuestion(pending.requestId, orderedAnswers);
840
+ this.pendingQuestions.delete(toolUseId);
841
+ return true;
842
+ }
843
+
844
+ // Fallback: fetch pending questions from the API and find matching one
845
+ debug.log('engine', `resolveUserAnswer: No stored question for toolUseId ${toolUseId}, fetching from API...`);
846
+ this.fetchAndReplyToQuestion(toolUseId, answers);
847
+ return true;
848
+ }
849
+
850
+ /**
851
+ * POST /question/{requestID}/reply to send user answers back to the OpenCode server.
852
+ */
853
+ private replyToQuestion(requestId: string, orderedAnswers: string[][]): void {
854
+ const serverUrl = getServerUrl();
855
+ if (!serverUrl) {
856
+ debug.warn('engine', 'replyToQuestion: Server URL not available');
857
+ return;
858
+ }
859
+
860
+ const dirParam = this.activeProjectPath ? `?directory=${encodeURIComponent(this.activeProjectPath)}` : '';
861
+ const url = `${serverUrl}/question/${requestId}/reply${dirParam}`;
862
+ debug.log('engine', `Replying to question ${requestId}:`, orderedAnswers);
863
+
864
+ fetch(url, {
865
+ method: 'POST',
866
+ headers: { 'Content-Type': 'application/json' },
867
+ body: JSON.stringify({ answers: orderedAnswers }),
868
+ }).then(async res => {
869
+ if (res.ok) {
870
+ debug.log('engine', `Question reply accepted: ${requestId} (${res.status})`);
871
+ } else {
872
+ const body = await res.text().catch(() => '');
873
+ debug.error('engine', `Question reply failed: ${res.status} ${res.statusText}`, body);
874
+ }
875
+ }).catch(error => {
876
+ debug.error('engine', 'Failed to reply to question:', error);
877
+ });
878
+ }
879
+
880
+ /**
881
+ * Fallback: GET /question to list pending questions, find the matching one, and reply.
882
+ */
883
+ private async fetchAndReplyToQuestion(toolUseId: string, answers: Record<string, string>): Promise<void> {
884
+ const serverUrl = getServerUrl();
885
+ if (!serverUrl) {
886
+ debug.warn('engine', 'fetchAndReplyToQuestion: Server URL not available');
887
+ return;
888
+ }
889
+
890
+ try {
891
+ const dirParam = this.activeProjectPath ? `?directory=${encodeURIComponent(this.activeProjectPath)}` : '';
892
+ const res = await fetch(`${serverUrl}/question${dirParam}`);
893
+ if (!res.ok) {
894
+ debug.error('engine', `Failed to list pending questions: ${res.status}`);
895
+ return;
896
+ }
897
+
898
+ const questions = await res.json() as Array<{
899
+ id: string;
900
+ questions: Array<{ question: string }>;
901
+ tool?: { callID: string };
902
+ }>;
903
+
904
+ const matching = questions.find(q => q.tool?.callID === toolUseId);
905
+ if (!matching) {
906
+ debug.warn('engine', 'fetchAndReplyToQuestion: No matching question for toolUseId:', toolUseId);
907
+ return;
908
+ }
909
+
910
+ const orderedAnswers = matching.questions.map(q => {
911
+ const answer = answers[q.question];
912
+ return answer ? [answer] : [];
913
+ });
914
+ this.replyToQuestion(matching.id, orderedAnswers);
915
+ } catch (error) {
916
+ debug.error('engine', 'Failed to fetch and reply to question:', error);
917
+ }
918
+ }
919
+
920
+ /**
921
+ * Auto-approve a permission request to avoid blocking the session.
922
+ * Uses direct HTTP since the v1 client may not have the v2 permission.reply method.
923
+ */
924
+ private autoApprovePermission(permissionId: string, sessionId: string): void {
925
+ const serverUrl = getServerUrl();
926
+ if (!serverUrl) return;
927
+
928
+ // Try v2 endpoint first (/permission/{requestID}/reply), fall back to v1
929
+ fetch(`${serverUrl}/permission/${permissionId}/reply`, {
930
+ method: 'POST',
931
+ headers: { 'Content-Type': 'application/json' },
932
+ body: JSON.stringify({ reply: 'once' }),
933
+ }).then(res => {
934
+ if (res.ok) {
935
+ debug.log('engine', `[OC] auto-approved permission ${permissionId} (v2)`);
936
+ return;
937
+ }
938
+ // v2 endpoint not available — try v1
939
+ const client = getClient();
940
+ if (client) {
941
+ client.postSessionIdPermissionsPermissionId({
942
+ path: { id: sessionId, permissionID: permissionId },
943
+ body: { response: 'once' },
944
+ ...(this.activeProjectPath && { query: { directory: this.activeProjectPath } }),
945
+ }).then(() => {
946
+ debug.log('engine', `[OC] auto-approved permission ${permissionId} (v1)`);
947
+ }).catch(err => {
948
+ debug.error('engine', 'Failed to auto-approve permission (v1):', err);
949
+ });
950
+ }
951
+ }).catch(error => {
952
+ debug.error('engine', 'Failed to auto-approve permission:', error);
953
+ });
954
+ }
955
+
782
956
  /**
783
957
  * Extract prompt parts (text + file attachments) from SDKUserMessage.
784
958
  * Converts Claude-format image/document blocks to OpenCode FilePartInput format.
@@ -6,11 +6,10 @@
6
6
  */
7
7
 
8
8
  import type { McpSdkServerConfigWithInstance, McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
9
- import type { McpLocalConfig } from '@opencode-ai/sdk';
9
+ import type { McpRemoteConfig } from '@opencode-ai/sdk';
10
10
  import type { ServerConfig, ParsedMcpToolName, ServerName } from './types';
11
11
  import { serverRegistry, serverFactories } from './servers';
12
12
  import { debug } from '$shared/utils/logger';
13
- import { resolve } from 'path';
14
13
  import { SERVER_ENV } from '../shared/env';
15
14
 
16
15
  /**
@@ -21,7 +20,7 @@ import { SERVER_ENV } from '../shared/env';
21
20
  *
22
21
  * Type-safe: Server names and tool names are validated at compile time!
23
22
  */
24
- const mcpServersConfig: Record<ServerName, ServerConfig> = {
23
+ export const mcpServersConfig: Record<ServerName, ServerConfig> = {
25
24
  "weather-service": {
26
25
  enabled: true,
27
26
  tools: [
@@ -256,6 +255,8 @@ export function getMcpStats() {
256
255
  * This function strips the prefix and maps back using the mcpServers
257
256
  * registry — the SAME source that defines which tools exist.
258
257
  *
258
+ * Works for both remote HTTP MCP and legacy stdio MCP (same naming convention).
259
+ *
259
260
  * Returns null if the tool name is not one of our custom MCP tools.
260
261
  */
261
262
  export function resolveOpenCodeToolName(toolName: string): string | null {
@@ -263,7 +264,7 @@ export function resolveOpenCodeToolName(toolName: string): string | null {
263
264
  if (toolName.startsWith('mcp__')) return toolName;
264
265
 
265
266
  // Strip Open Code MCP server prefix if present
266
- // Open Code prefixes with the stdio server name: "clopen-mcp_<tool>"
267
+ // Open Code prefixes with the server config key: "clopen-mcp_<tool>"
267
268
  let rawName = toolName;
268
269
  const ocPrefix = 'clopen-mcp_';
269
270
  if (rawName.startsWith(ocPrefix)) {
@@ -288,32 +289,26 @@ export function resolveOpenCodeToolName(toolName: string): string | null {
288
289
  /**
289
290
  * Get MCP configuration for Open Code engine.
290
291
  *
291
- * Open Code expects MCP servers as local (stdio subprocess) or remote (HTTP URL).
292
- * We provide a single local MCP server that wraps all our custom tools.
293
- * The server communicates with the main Clopen process via an HTTP bridge
294
- * for tools that need in-process access (browser-automation).
292
+ * Open Code connects to a remote MCP HTTP server running in the main Clopen
293
+ * process. Tool handlers execute in-process no subprocess, no bridge.
294
+ *
295
+ * This is the Open Code equivalent of Claude Code's in-process MCP servers.
295
296
  */
296
- export function getOpenCodeMcpConfig(): Record<string, McpLocalConfig> {
297
+ export function getOpenCodeMcpConfig(): Record<string, McpRemoteConfig> {
297
298
  // Check if any servers are enabled
298
299
  const enabledServers = getEnabledServerNames();
299
300
  if (enabledServers.length === 0) {
300
301
  return {};
301
302
  }
302
303
 
303
- // Resolve path to the stdio server script
304
- const stdioServerPath = resolve(import.meta.dir, 'stdio-server.ts');
305
304
  const port = SERVER_ENV.PORT;
306
305
 
307
- debug.log('mcp', `📦 Open Code MCP: stdio server at ${stdioServerPath}`);
308
- debug.log('mcp', `📦 Open Code MCP: bridge port ${port}`);
306
+ debug.log('mcp', `📦 Open Code MCP: remote server at http://localhost:${port}/mcp`);
309
307
 
310
308
  return {
311
309
  'clopen-mcp': {
312
- type: 'local',
313
- command: ['bun', 'run', stdioServerPath],
314
- environment: {
315
- CLOPEN_PORT: String(port),
316
- },
310
+ type: 'remote',
311
+ url: `http://localhost:${port}/mcp`,
317
312
  enabled: true,
318
313
  timeout: 10000,
319
314
  },
@@ -2,6 +2,11 @@
2
2
  * MCP (Model Context Protocol) Custom Tools
3
3
  *
4
4
  * Main export point for the custom MCP tools system.
5
+ *
6
+ * Claude Code: in-process MCP servers via createSdkMcpServer()
7
+ * Open Code: remote HTTP MCP server via createRemoteMcpServer()
8
+ *
9
+ * Both use the same tool definitions from defineServer() in servers/helper.ts.
5
10
  */
6
11
 
7
12
  // Type definitions
@@ -13,6 +18,7 @@ export type {
13
18
  // Main configuration and all utilities
14
19
  export {
15
20
  mcpServers,
21
+ mcpServersConfig,
16
22
  getEnabledMcpServers,
17
23
  getAllowedMcpTools,
18
24
  getServerConfig,
@@ -31,5 +37,8 @@ export {
31
37
  // Server implementations
32
38
  export * from './servers';
33
39
 
40
+ // Remote MCP HTTP server for Open Code
41
+ export { handleMcpRequest, closeMcpServer } from './remote-server';
42
+
34
43
  // Project context service for MCP tool handlers
35
44
  export { projectContextService } from './project-context';
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Remote MCP HTTP Server for Open Code
3
+ *
4
+ * Serves custom MCP tools over HTTP (Streamable HTTP transport) so Open Code
5
+ * can connect via `type: 'remote'` config instead of spawning a stdio subprocess.
6
+ *
7
+ * Tool handlers execute directly in the main Clopen process — no subprocess,
8
+ * no WebSocket bridge. This is architecturally identical to how Claude Code
9
+ * uses in-process MCP servers via createSdkMcpServer().
10
+ *
11
+ * Transport: WebStandardStreamableHTTPServerTransport (works natively with Bun)
12
+ */
13
+
14
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
15
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
16
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
+ import { createRemoteMcpServer } from './servers/helper';
18
+ import { debug } from '$shared/utils/logger';
19
+
20
+ // Lazy imports to avoid circular dependencies at module load time
21
+ let _allServers: Parameters<typeof createRemoteMcpServer>[0] | null = null;
22
+ let _enabledConfig: Parameters<typeof createRemoteMcpServer>[1] | null = null;
23
+
24
+ async function getServerDeps() {
25
+ if (!_allServers || !_enabledConfig) {
26
+ const { allServers } = await import('./servers/index');
27
+ const { mcpServersConfig } = await import('./config');
28
+ _allServers = allServers;
29
+ _enabledConfig = mcpServersConfig;
30
+ }
31
+ return { allServers: _allServers, enabledConfig: _enabledConfig };
32
+ }
33
+
34
+ // ============================================================================
35
+ // Session Management
36
+ // ============================================================================
37
+
38
+ /** Active transports keyed by MCP session ID */
39
+ const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
40
+
41
+ /** Active MCP servers keyed by MCP session ID */
42
+ const servers = new Map<string, McpServer>();
43
+
44
+ /**
45
+ * Handle an incoming MCP HTTP request (GET/POST/DELETE).
46
+ *
47
+ * Mounted at /mcp on the main Elysia server.
48
+ * Follows the Streamable HTTP transport protocol:
49
+ * - POST without session: initialization → create new transport + server
50
+ * - POST with session: route to existing transport
51
+ * - GET with session: SSE stream for server notifications
52
+ * - DELETE with session: close session
53
+ */
54
+ export async function handleMcpRequest(request: Request): Promise<Response> {
55
+ const sessionId = request.headers.get('mcp-session-id');
56
+
57
+ // Existing session — route to its transport
58
+ if (sessionId && transports.has(sessionId)) {
59
+ const transport = transports.get(sessionId)!;
60
+ return transport.handleRequest(request);
61
+ }
62
+
63
+ // New initialization request — create transport + MCP server
64
+ if (request.method === 'POST') {
65
+ // Parse body to check if it's an init request
66
+ const body = await request.json();
67
+
68
+ if (isInitializeRequest(body)) {
69
+ const { allServers, enabledConfig } = await getServerDeps();
70
+
71
+ const transport = new WebStandardStreamableHTTPServerTransport({
72
+ sessionIdGenerator: () => crypto.randomUUID(),
73
+ onsessioninitialized: (sid) => {
74
+ transports.set(sid, transport);
75
+ debug.log('mcp', `🌐 Remote MCP session initialized: ${sid}`);
76
+ },
77
+ onsessionclosed: (sid) => {
78
+ transports.delete(sid);
79
+ servers.delete(sid);
80
+ debug.log('mcp', `🌐 Remote MCP session closed: ${sid}`);
81
+ },
82
+ });
83
+
84
+ transport.onclose = () => {
85
+ if (transport.sessionId) {
86
+ transports.delete(transport.sessionId);
87
+ servers.delete(transport.sessionId);
88
+ }
89
+ };
90
+
91
+ // Create a fresh MCP server with all enabled tools (in-process handlers)
92
+ const mcpServer = createRemoteMcpServer(allServers, enabledConfig);
93
+ await mcpServer.connect(transport);
94
+
95
+ // Store server reference for cleanup
96
+ if (transport.sessionId) {
97
+ servers.set(transport.sessionId, mcpServer);
98
+ }
99
+
100
+ // Handle the initialization request with pre-parsed body
101
+ return transport.handleRequest(request, { parsedBody: body });
102
+ }
103
+ }
104
+
105
+ // Invalid request
106
+ return new Response(JSON.stringify({
107
+ jsonrpc: '2.0',
108
+ error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
109
+ id: null,
110
+ }), {
111
+ status: 400,
112
+ headers: { 'Content-Type': 'application/json' },
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Close all active MCP sessions and transports.
118
+ * Called during graceful server shutdown.
119
+ */
120
+ export async function closeMcpServer(): Promise<void> {
121
+ for (const [sessionId, transport] of transports) {
122
+ try {
123
+ await transport.close();
124
+ debug.log('mcp', `🌐 Remote MCP transport closed: ${sessionId}`);
125
+ } catch (error) {
126
+ debug.error('mcp', `Error closing MCP transport ${sessionId}:`, error);
127
+ }
128
+ }
129
+ transports.clear();
130
+ servers.clear();
131
+ debug.log('mcp', '🌐 All remote MCP sessions closed');
132
+ }
@@ -3,10 +3,14 @@
3
3
  *
4
4
  * Stores both the Claude SDK server instance AND raw tool definitions
5
5
  * so the same source can be used for Claude Code (in-process) and
6
- * Open Code (stdio subprocess via @modelcontextprotocol/sdk).
6
+ * Open Code (remote HTTP MCP via @modelcontextprotocol/sdk).
7
+ *
8
+ * Claude Code: createSdkMcpServer() → in-process MCP server
9
+ * Open Code: createRemoteMcpServer() → HTTP MCP server (same process, same handlers)
7
10
  */
8
11
 
9
12
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
14
  import type { z } from "zod";
11
15
 
12
16
  /**
@@ -35,8 +39,7 @@ type ToolHandler<TSchema extends Record<string, z.ZodType<any>> | undefined> =
35
39
  * Raw tool definition — schema, description, and handler.
36
40
  * Single source of truth used by:
37
41
  * - Claude Code: in-process via createSdkMcpServer
38
- * - Open Code stdio: schema/description for registration, handler via bridge
39
- * - MCP bridge: handler for in-process execution
42
+ * - Open Code: remote HTTP MCP via createRemoteMcpServer (in-process handlers)
40
43
  */
41
44
  export interface RawToolDef {
42
45
  description: string;
@@ -152,3 +155,46 @@ export function buildServerRegistries<
152
155
  }
153
156
  };
154
157
  }
158
+
159
+ // ============================================================================
160
+ // Remote MCP Server for Open Code (HTTP transport, in-process execution)
161
+ // ============================================================================
162
+
163
+ /**
164
+ * Create a McpServer instance (from @modelcontextprotocol/sdk) with tools registered
165
+ * from the same RawToolDef definitions used by Claude Code.
166
+ *
167
+ * This is the Open Code equivalent of createSdkMcpServer() for Claude Code.
168
+ * Handlers execute directly in-process — no subprocess, no bridge.
169
+ *
170
+ * @param servers - Server definitions from defineServer()
171
+ * @param enabledConfig - Which servers/tools are enabled (from mcpServersConfig)
172
+ */
173
+ export function createRemoteMcpServer(
174
+ servers: readonly ServerWithMeta<string, readonly string[]>[],
175
+ enabledConfig: Record<string, { enabled: boolean; tools: readonly string[] }>
176
+ ): McpServer {
177
+ const mcpServer = new McpServer({
178
+ name: 'clopen-mcp',
179
+ version: '1.0.0',
180
+ });
181
+
182
+ for (const srv of servers) {
183
+ const config = enabledConfig[srv.meta.name];
184
+ if (!config?.enabled) continue;
185
+
186
+ for (const toolName of config.tools) {
187
+ const def = srv.meta.toolDefs[toolName as string];
188
+ if (!def) continue;
189
+
190
+ mcpServer.registerTool(toolName as string, {
191
+ description: def.description,
192
+ inputSchema: def.schema,
193
+ }, async (args: Record<string, unknown>) => {
194
+ return await def.handler(args) as any;
195
+ });
196
+ }
197
+ }
198
+
199
+ return mcpServer;
200
+ }
@@ -14,8 +14,9 @@ import weather from './weather/index';
14
14
  import browserAutomation from './browser-automation/index';
15
15
  import { buildServerRegistries } from './helper';
16
16
 
17
- // Re-export types for stdio server
17
+ // Re-export types and remote server factory
18
18
  export type { RawToolDef } from './helper';
19
+ export { createRemoteMcpServer } from './helper';
19
20
 
20
21
  /**
21
22
  * All MCP Servers
@@ -23,7 +24,7 @@ export type { RawToolDef } from './helper';
23
24
  * Simply import and add new servers to this array.
24
25
  * Metadata and registry will be automatically built.
25
26
  */
26
- const allServers = [
27
+ export const allServers = [
27
28
  weather,
28
29
  browserAutomation,
29
30
  // Add more servers here...
@@ -11,14 +11,31 @@ import { audioCaptureScript } from './scripts/audio-stream';
11
11
 
12
12
  export class BrowserAudioCapture {
13
13
  /**
14
- * Setup audio capture for a page
15
- * Injects audio capture script that intercepts AudioContext before page loads
14
+ * Setup audio capture for a page (pre-navigation).
15
+ * WARNING: Uses evaluateOnNewDocument which patches AudioContext BEFORE page
16
+ * scripts run. This is detected by Cloudflare's fingerprinting.
17
+ * Prefer injectAudioCapture() for post-navigation injection.
16
18
  */
17
19
  async setupAudioCapture(page: Page, config: StreamingConfig['audio']): Promise<void> {
18
- // Inject audio capture script BEFORE page loads to intercept AudioContext
19
20
  await page.evaluateOnNewDocument(audioCaptureScript, config);
20
21
  }
21
22
 
23
+ /**
24
+ * Inject audio capture into the current page context (post-navigation).
25
+ * Uses page.evaluate() instead of evaluateOnNewDocument() to avoid
26
+ * Cloudflare detection — AudioContext constructor patching before page
27
+ * load is heavily flagged by CF's fingerprinting algorithms.
28
+ * Call this AFTER navigation completes and CF challenges pass.
29
+ */
30
+ async injectAudioCapture(page: Page, config: StreamingConfig['audio']): Promise<boolean> {
31
+ try {
32
+ await page.evaluate(audioCaptureScript, config);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
22
39
  /**
23
40
  * Check if audio encoder is supported in the page
24
41
  */
@@ -62,6 +62,8 @@ export class BrowserNavigationTracker extends EventEmitter {
62
62
  });
63
63
 
64
64
  // Track hash changes (fragment identifier changes like #contact-us)
65
+ // Temporarily disabled URL tracking injection to test CloudFlare evasion
66
+ /*
65
67
  await page.evaluateOnNewDocument(() => {
66
68
  let lastUrl = window.location.href;
67
69
 
@@ -86,6 +88,7 @@ export class BrowserNavigationTracker extends EventEmitter {
86
88
  // Periodically check for URL changes (for SPA navigation)
87
89
  setInterval(checkUrlChange, 500);
88
90
  });
91
+ */
89
92
  }
90
93
 
91
94
  async navigateSession(sessionId: string, session: BrowserTab, url: string): Promise<string> {