feat(mcp): simplify remote MCP setup for remote workspaces (#747)

* feat(mcp): simplify remote MCP setup and cover with e2e test

* fix(mcp): apply remote runtime changes before OAuth retry
This commit is contained in:
ben
2026-03-05 22:52:22 -08:00
committed by GitHub
parent ef7b2e4d43
commit 2e3d4d8f03
11 changed files with 135 additions and 17 deletions

View File

@@ -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) =>

View File

@@ -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<string | null>(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) {
</button>
<button
type="button"
disabled={props.isRemoteWorkspace}
class={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
serverType() === "local"
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
}`}
onClick={() => setServerType("local")}
} ${props.isRemoteWorkspace ? "opacity-50 cursor-not-allowed" : ""}`}
onClick={() => {
if (props.isRemoteWorkspace) return;
setServerType("local");
}}
>
{tr("mcp.type_local_cmd")}
</button>
</div>
<Show when={props.isRemoteWorkspace}>
<div class="mt-2 text-[11px] text-dls-secondary">{tr("mcp.remote_workspace_url_hint")}</div>
</Show>
</div>
<Show when={serverType() === "remote"}>
<TextInput
label={tr("mcp.server_url")}
placeholder={tr("mcp.server_url_placeholder")}
value={url()}
onInput={(e) => setUrl(e.currentTarget.value)}
/>
<div class="space-y-3">
<TextInput
label={tr("mcp.server_url")}
placeholder={tr("mcp.server_url_placeholder")}
value={url()}
onInput={(e) => setUrl(e.currentTarget.value)}
/>
<label class="flex items-center gap-2 text-xs text-dls-secondary">
<input
type="checkbox"
class="h-4 w-4 rounded border border-dls-border"
checked={oauthRequired()}
onChange={(event) => setOauthRequired(event.currentTarget.checked)}
/>
{tr("mcp.oauth_optional_label")}
</label>
</div>
</Show>
<Show when={serverType() === "local"}>

View File

@@ -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);
};

View File

@@ -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}

View File

@@ -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}

View File

@@ -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()}
/>
</section>

View File

@@ -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.",

View File

@@ -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": "正在打开您的浏览器",

View File

@@ -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 });
}
});
});

View File

@@ -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) => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB