diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 1b9f7cc46..d37ada86e 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -3350,13 +3350,38 @@ export default function App() { const canReloadLocalEngine = () => isTauriRuntime() && workspaceStore.activeWorkspaceDisplay().workspaceType === "local"; - const canReloadWorkspace = createMemo(() => canReloadLocalEngine()); + const canReloadWorkspace = createMemo(() => { + if (canReloadLocalEngine()) return true; + if (workspaceStore.activeWorkspaceDisplay().workspaceType !== "remote") return false; + return openworkServerStatus() === "connected" && Boolean(openworkServerClient() && openworkServerWorkspaceId()); + }); const reloadWorkspaceEngineFromUi = async () => { - if (!canReloadLocalEngine()) { + if (canReloadLocalEngine()) { + return workspaceStore.reloadWorkspaceEngine(); + } + + if (workspaceStore.activeWorkspaceDisplay().workspaceType !== "remote") { + return false; + } + + const client = openworkServerClient(); + const workspaceId = openworkServerWorkspaceId(); + if (!client || !workspaceId || openworkServerStatus() !== "connected") { + setError("Connect to this worker before applying runtime changes."); + return false; + } + + try { + await client.reloadEngine(workspaceId); + await workspaceStore.activateWorkspace(workspaceStore.activeWorkspaceId()); + await refreshMcpServers(); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to apply runtime changes."; + setError(message); return false; } - return workspaceStore.reloadWorkspaceEngine(); }; const systemState = createSystemState({ @@ -5756,6 +5781,7 @@ export default function App() { refreshSoulData: (options?: { force?: boolean }) => refreshSoulData(options).catch(() => undefined), runSoulPrompt, activeWorkspaceRoot: workspaceStore.activeWorkspaceRoot().trim(), + isRemoteWorkspace: workspaceStore.activeWorkspaceDisplay().workspaceType === "remote", refreshSkills: (options?: { force?: boolean }) => refreshSkills(options).catch(() => undefined), refreshHubSkills: (options?: { force?: boolean }) => refreshHubSkills(options).catch(() => undefined), refreshPlugins: (scopeOverride?: PluginScope) => diff --git a/packages/app/src/app/components/add-mcp-modal.tsx b/packages/app/src/app/components/add-mcp-modal.tsx index 82ca44433..dd3431e8e 100644 --- a/packages/app/src/app/components/add-mcp-modal.tsx +++ b/packages/app/src/app/components/add-mcp-modal.tsx @@ -10,6 +10,7 @@ export type AddMcpModalProps = { onClose: () => void; onAdd: (entry: McpDirectoryInfo) => void; busy: boolean; + isRemoteWorkspace: boolean; language: Language; }; @@ -20,6 +21,7 @@ export default function AddMcpModal(props: AddMcpModalProps) { const [serverType, setServerType] = createSignal<"remote" | "local">("remote"); const [url, setUrl] = createSignal(""); const [command, setCommand] = createSignal(""); + const [oauthRequired, setOauthRequired] = createSignal(false); const [error, setError] = createSignal(null); const reset = () => { @@ -27,6 +29,7 @@ export default function AddMcpModal(props: AddMcpModalProps) { setServerType("remote"); setUrl(""); setCommand(""); + setOauthRequired(false); setError(null); }; @@ -56,7 +59,7 @@ export default function AddMcpModal(props: AddMcpModalProps) { description: "", type: "remote", url: trimmedUrl, - oauth: true, + oauth: oauthRequired(), }); } else { const trimmedCommand = command().trim(); @@ -129,25 +132,43 @@ export default function AddMcpModal(props: AddMcpModalProps) { + +
{tr("mcp.remote_workspace_url_hint")}
+
- setUrl(e.currentTarget.value)} - /> +
+ setUrl(e.currentTarget.value)} + /> + +
diff --git a/packages/app/src/app/components/mcp-auth-modal.tsx b/packages/app/src/app/components/mcp-auth-modal.tsx index 148049c60..b1c4c7c36 100644 --- a/packages/app/src/app/components/mcp-auth-modal.tsx +++ b/packages/app/src/app/components/mcp-auth-modal.tsx @@ -341,6 +341,10 @@ export default function McpAuthModal(props: McpAuthModalProps) { const handleReloadAndRetry = async () => { if (!props.onReloadEngine) return; + if (props.isRemoteWorkspace && typeof window !== "undefined") { + const proceed = window.confirm(translate("mcp.auth.reload_remote_confirm")); + if (!proceed) return; + } await props.onReloadEngine(); startAuth(true); }; diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index 1312a38db..2cdc0e546 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -167,6 +167,7 @@ export type DashboardViewProps = { refreshSoulData: (options?: { force?: boolean }) => void; runSoulPrompt: (prompt: string) => void; activeWorkspaceRoot: string; + isRemoteWorkspace: boolean; refreshSkills: (options?: { force?: boolean }) => void; refreshHubSkills: (options?: { force?: boolean }) => void; refreshPlugins: (scopeOverride?: PluginScope) => void; @@ -1215,6 +1216,7 @@ export default function DashboardView(props: DashboardViewProps) { setDashboardTab={props.setTab} busy={props.busy} activeWorkspaceRoot={props.activeWorkspaceRoot} + isRemoteWorkspace={props.isRemoteWorkspace} refreshMcpServers={props.refreshMcpServers} mcpServers={props.mcpServers} mcpStatus={props.mcpStatus} diff --git a/packages/app/src/app/pages/extensions.tsx b/packages/app/src/app/pages/extensions.tsx index 90bc7988c..b4a931d97 100644 --- a/packages/app/src/app/pages/extensions.tsx +++ b/packages/app/src/app/pages/extensions.tsx @@ -128,6 +128,7 @@ export default function ExtensionsView(props: ExtensionsViewProps) { showHeader={false} busy={props.busy} activeWorkspaceRoot={props.activeWorkspaceRoot} + isRemoteWorkspace={props.isRemoteWorkspace} mcpServers={props.mcpServers} mcpStatus={props.mcpStatus} mcpLastUpdatedAt={props.mcpLastUpdatedAt} diff --git a/packages/app/src/app/pages/mcp.tsx b/packages/app/src/app/pages/mcp.tsx index af7c3b9ef..83e92e1a1 100644 --- a/packages/app/src/app/pages/mcp.tsx +++ b/packages/app/src/app/pages/mcp.tsx @@ -33,6 +33,7 @@ import { currentLocale, t, type Language } from "../../i18n"; export type McpViewProps = { busy: boolean; activeWorkspaceRoot: string; + isRemoteWorkspace: boolean; showHeader?: boolean; mcpServers: McpServerEntry[]; mcpStatus: string | null; @@ -686,6 +687,7 @@ export default function McpView(props: McpViewProps) { onClose={() => setAddMcpModalOpen(false)} onAdd={(entry) => props.connectMcp(entry)} busy={props.busy} + isRemoteWorkspace={props.isRemoteWorkspace} language={locale()} /> diff --git a/packages/app/src/i18n/locales/en.ts b/packages/app/src/i18n/locales/en.ts index 734498a1a..5fd7b3966 100644 --- a/packages/app/src/i18n/locales/en.ts +++ b/packages/app/src/i18n/locales/en.ts @@ -444,6 +444,8 @@ export default { "mcp.server_command": "Command", "mcp.server_command_placeholder": "npx -y @modelcontextprotocol/server-sequential-thinking", "mcp.server_command_hint": "The shell command to start the server.", + "mcp.oauth_optional_label": "Requires OAuth sign-in", + "mcp.remote_workspace_url_hint": "Remote workers connect fastest with URL-based MCP servers.", "mcp.add_server_button": "Add server", "mcp.name_required": "Enter a server name.", "mcp.url_or_command_required": "Enter a URL for remote or a command for local servers.", @@ -463,12 +465,13 @@ export default { "mcp.auth.already_connected": "Already Connected", "mcp.auth.already_connected_description": "{server} is already authenticated and ready to use.", "mcp.auth.configured_previously": "The MCP may have been configured globally or in a previous session. You can close this modal and start using the MCP tools right away.", - "mcp.auth.reload_engine_retry": "Reload engine and retry", + "mcp.auth.reload_engine_retry": "Apply changes and retry", "mcp.auth.retry_now": "Retry Now", "mcp.auth.retry": "Retry", "mcp.auth.reload_before_oauth": "Reload the engine to finish setting up this MCP before starting OAuth.", - "mcp.auth.reload_notice": "Finish setup by reloading the engine to activate this MCP. This is a required step, not an error.", + "mcp.auth.reload_notice": "For this to take effect, OpenWork needs to refresh the worker service. This can interrupt a running session.", "mcp.auth.reload_blocked": "Reload is paused while a session is running. Stop the run to finish setup.", + "mcp.auth.reload_remote_confirm": "For this to take effect, OpenWork needs to refresh the worker service. This might stop your running session. Continue?", "mcp.auth.reload_needed": "Finish setup by reloading the engine, then try connecting again.", "mcp.auth.manual_finish_title": "Remote server?", "mcp.auth.manual_finish_hint": "Paste the callback URL (localhost:19876) or just the code to finish connecting.", diff --git a/packages/app/src/i18n/locales/zh.ts b/packages/app/src/i18n/locales/zh.ts index a81cc15c2..722088c5f 100644 --- a/packages/app/src/i18n/locales/zh.ts +++ b/packages/app/src/i18n/locales/zh.ts @@ -381,6 +381,8 @@ export default { "mcp.server_command": "命令", "mcp.server_command_placeholder": "npx -y @modelcontextprotocol/server-sequential-thinking", "mcp.server_command_hint": "启动服务器的 shell 命令。", + "mcp.oauth_optional_label": "需要 OAuth 登录", + "mcp.remote_workspace_url_hint": "远程工作区建议优先使用 URL 类型的 MCP 服务器。", "mcp.add_server_button": "添加服务器", "mcp.name_required": "请输入服务器名称。", "mcp.url_or_command_required": "远程服务器需要 URL,本地服务器需要命令。", @@ -434,7 +436,10 @@ export default { "mcp.auth.already_connected": "已连接", "mcp.auth.already_connected_description": "{server} 已通过身份验证,可以正常使用。", "mcp.auth.configured_previously": "该 MCP 可能在全局或之前的会话中已配置。您可以关闭此弹窗,立即开始使用 MCP 工具。", - "mcp.auth.reload_engine_retry": "重新加载引擎并重试", + "mcp.auth.reload_engine_retry": "应用更改并重试", + "mcp.auth.reload_notice": "要使更改生效,OpenWork 需要刷新 worker 服务。这可能会中断正在运行的会话。", + "mcp.auth.reload_blocked": "会话运行中,暂时无法刷新。请先停止运行后再完成设置。", + "mcp.auth.reload_remote_confirm": "要使更改生效,OpenWork 需要刷新 worker 服务。这可能会停止您正在运行的会话。是否继续?", "mcp.auth.retry_now": "立即重试", "mcp.auth.retry": "重试", "mcp.auth.step1_title": "正在打开您的浏览器", diff --git a/packages/server/src/mcp.remote-connect.e2e.test.ts b/packages/server/src/mcp.remote-connect.e2e.test.ts new file mode 100644 index 000000000..8a5f28c50 --- /dev/null +++ b/packages/server/src/mcp.remote-connect.e2e.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { addMcp, listMcp, removeMcp } from "./mcp.js"; + +describe("mcp remote connect flow", () => { + test("adds, lists, and removes a remote MCP without OAuth", async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "openwork-mcp-remote-e2e-")); + + try { + const added = await addMcp(workspaceRoot, "simple-remote", { + type: "remote", + url: "https://example.com/mcp", + enabled: true, + }); + expect(added.action).toBe("added"); + + const listedAfterAdd = await listMcp(workspaceRoot); + const item = listedAfterAdd.find((entry) => entry.name === "simple-remote"); + expect(item).toBeDefined(); + expect(item?.config).toEqual({ + type: "remote", + url: "https://example.com/mcp", + enabled: true, + }); + expect(item?.source).toBe("config.project"); + + const configText = await readFile(join(workspaceRoot, "opencode.jsonc"), "utf8"); + expect(configText).toContain("\"simple-remote\""); + expect(configText).toContain("\"https://example.com/mcp\""); + + const removed = await removeMcp(workspaceRoot, "simple-remote"); + expect(removed).toBe(true); + + const listedAfterRemove = await listMcp(workspaceRoot); + expect(listedAfterRemove.some((entry) => entry.name === "simple-remote")).toBe(false); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 0a49e2c33..f4a9c0251 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -2447,10 +2447,21 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens: addRoute(routes, "POST", "/workspace/:id/engine/reload", "client", async (ctx) => { const workspace = await resolveWorkspace(config, ctx.params.id); - throw new ApiError(410, "engine_reload_deprecated", "OpenWork-managed engine reload is disabled", { + requireClientScope(ctx, "collaborator"); + + await reloadOpencodeEngine(workspace); + + await recordAudit(workspace.path, { + id: shortId(), workspaceId: workspace.id, - guidance: "Use OpenCode hot reload instead", + actor: ctx.actor ?? { type: "remote" }, + action: "engine.reload", + target: workspace.baseUrl ?? "opencode", + summary: "Reloaded workspace engine", + timestamp: Date.now(), }); + + return jsonResponse({ ok: true, reloadedAt: Date.now() }); }); addRoute(routes, "GET", "/workspace/:id/inbox", "client", async (ctx) => { diff --git a/pr/mcp-remote-e2e/mcp-oauth-e2e-success.png b/pr/mcp-remote-e2e/mcp-oauth-e2e-success.png new file mode 100644 index 000000000..9dfa71b01 Binary files /dev/null and b/pr/mcp-remote-e2e/mcp-oauth-e2e-success.png differ