@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.
- package/README.md +23 -1
- package/backend/index.ts +25 -1
- package/backend/lib/auth/auth-service.ts +484 -0
- package/backend/lib/auth/index.ts +4 -0
- package/backend/lib/auth/permissions.ts +63 -0
- package/backend/lib/auth/rate-limiter.ts +145 -0
- package/backend/lib/auth/tokens.ts +53 -0
- package/backend/lib/chat/stream-manager.ts +4 -1
- package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
- package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
- package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
- package/backend/lib/database/migrations/index.ts +21 -0
- package/backend/lib/database/queries/auth-queries.ts +201 -0
- package/backend/lib/database/queries/index.ts +2 -1
- package/backend/lib/database/queries/session-queries.ts +13 -0
- package/backend/lib/database/queries/snapshot-queries.ts +1 -1
- package/backend/lib/engine/adapters/opencode/server.ts +9 -1
- package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
- package/backend/lib/mcp/config.ts +13 -18
- package/backend/lib/mcp/index.ts +9 -0
- package/backend/lib/mcp/remote-server.ts +132 -0
- package/backend/lib/mcp/servers/helper.ts +49 -3
- package/backend/lib/mcp/servers/index.ts +3 -2
- package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
- package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
- package/backend/lib/preview/browser/browser-pool.ts +73 -176
- package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
- package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
- package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
- package/backend/lib/snapshot/helpers.ts +22 -49
- package/backend/lib/snapshot/snapshot-service.ts +148 -83
- package/backend/lib/utils/ws.ts +65 -1
- package/backend/ws/auth/index.ts +17 -0
- package/backend/ws/auth/invites.ts +84 -0
- package/backend/ws/auth/login.ts +269 -0
- package/backend/ws/auth/status.ts +41 -0
- package/backend/ws/auth/users.ts +32 -0
- package/backend/ws/chat/stream.ts +13 -0
- package/backend/ws/engine/claude/accounts.ts +3 -1
- package/backend/ws/engine/utils.ts +38 -6
- package/backend/ws/index.ts +4 -4
- package/backend/ws/preview/browser/interact.ts +27 -5
- package/backend/ws/snapshot/restore.ts +111 -12
- package/backend/ws/snapshot/timeline.ts +56 -29
- package/bin/clopen.ts +56 -1
- package/bun.lock +113 -51
- package/frontend/App.svelte +47 -29
- package/frontend/lib/components/auth/InvitePage.svelte +215 -0
- package/frontend/lib/components/auth/LoginPage.svelte +129 -0
- package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
- package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
- package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
- package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
- package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
- package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
- package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
- package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
- package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
- package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
- package/frontend/lib/components/git/CommitForm.svelte +6 -4
- package/frontend/lib/components/history/HistoryModal.svelte +1 -1
- package/frontend/lib/components/history/HistoryView.svelte +1 -1
- package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
- package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
- package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
- package/frontend/lib/components/settings/SettingsView.svelte +21 -7
- package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
- package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
- package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
- package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
- package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
- package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
- package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
- package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
- package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
- package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
- package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
- package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
- package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
- package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
- package/frontend/lib/stores/features/auth.svelte.ts +296 -0
- package/frontend/lib/stores/features/settings.svelte.ts +53 -9
- package/frontend/lib/stores/features/user.svelte.ts +26 -68
- package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
- package/frontend/lib/stores/ui/update.svelte.ts +2 -14
- package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
- package/package.json +8 -6
- package/shared/types/stores/settings.ts +16 -2
- package/shared/utils/logger.ts +1 -0
- package/shared/utils/ws-client.ts +30 -13
- package/shared/utils/ws-server.ts +42 -4
- package/backend/lib/mcp/stdio-server.ts +0 -103
- 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 {
|
|
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
|
|
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
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
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,
|
|
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:
|
|
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: '
|
|
313
|
-
|
|
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
|
},
|
package/backend/lib/mcp/index.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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> {
|