mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -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) =>
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "正在打开您的浏览器",
|
||||
|
||||
43
packages/server/src/mcp.remote-connect.e2e.test.ts
Normal file
43
packages/server/src/mcp.remote-connect.e2e.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
BIN
pr/mcp-remote-e2e/mcp-oauth-e2e-success.png
Normal file
BIN
pr/mcp-remote-e2e/mcp-oauth-e2e-success.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Reference in New Issue
Block a user