feat(mcp): add OAuth support to MCP install flow

Squash merge PR #1583.

This merge commit was created by an AI agent (OpenHands) on behalf of Graham Neubig.

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Graham Neubig
2026-07-07 12:03:36 +02:00
committed by GitHub
co-authored by openhands
parent d2cfffb7ef
commit c552545926
85 changed files with 5713 additions and 835 deletions
+31 -1
View File
@@ -139,6 +139,30 @@ jobs:
run: |
echo "agent_server_version=$(node -p "require('./config/defaults.json').versions.agentServer")" >> "$GITHUB_OUTPUT"
- name: Resolve linked SDK PR
id: linked_sdk
if: github.event_name == 'pull_request'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
BODY=$(gh api "/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" --jq '.body // ""')
SDK_PR=$(
printf '%s\n' "$BODY" \
| grep -Eo 'OpenHands/software-agent-sdk(/pull/|#)[0-9]+' \
| head -n1 \
| grep -Eo '[0-9]+$' \
|| true
)
if [ -z "$SDK_PR" ]; then
echo "No linked software-agent-sdk PR found in PR description."
exit 0
fi
SDK_REF=$(gh api "/repos/OpenHands/software-agent-sdk/pulls/${SDK_PR}" --jq '.head.ref')
SDK_SHA=$(gh api "/repos/OpenHands/software-agent-sdk/pulls/${SDK_PR}" --jq '.head.sha')
echo "Using software-agent-sdk PR #${SDK_PR} branch: ${SDK_REF} (${SDK_SHA})"
echo "git_ref=${SDK_SHA}" >> "$GITHUB_OUTPUT"
# ── Wait for Docker workflow (pull_request trigger only) ────────────
# When triggered by pull_request, the Docker image may still be
# building. Poll the Docker workflow until it completes for this SHA.
@@ -256,9 +280,14 @@ jobs:
- name: Install openhands-sdk (for mock LLM server)
env:
AGENT_SERVER_VERSION: ${{ steps.defaults.outputs.agent_server_version }}
SDK_GIT_REF: ${{ steps.linked_sdk.outputs.git_ref }}
run: |
uv venv .mock-llm-venv
uv pip install -p .mock-llm-venv "openhands-sdk==$AGENT_SERVER_VERSION"
if [ -n "$SDK_GIT_REF" ]; then
uv pip install -p .mock-llm-venv "git+https://github.com/OpenHands/software-agent-sdk@${SDK_GIT_REF}#subdirectory=openhands-sdk"
else
uv pip install -p .mock-llm-venv "openhands-sdk==$AGENT_SERVER_VERSION"
fi
- name: Verify mock LLM server starts
run: |
@@ -292,6 +321,7 @@ jobs:
MOCK_LLM_PYTHON: .mock-llm-venv/bin/python3
MOCK_LLM_DOCKER_IMAGE: ${{ steps.image.outputs.tag }}
MOCK_LLM_DOCKER_GLOBAL_TIMEOUT_MS: 1200000
OH_AGENT_SERVER_GIT_REF: ${{ steps.linked_sdk.outputs.git_ref }}
run: |
set +e
MARKER_DIR=".mock-llm-markers"
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
- name: Install openhands-sdk (for mock LLM server)
env:
AGENT_SERVER_VERSION: ${{ steps.defaults.outputs.agent_server_version }}
# Pin agent-client-protocol to the range openhands-sdk 1.31.1 is
# Pin agent-client-protocol to the range openhands-sdk 1.32.0 is
# compatible with. acp 0.11.0 reordered the ACP prompt() args and
# breaks the SDK's ACP client; the mock ACP server runs from this
# venv, so keep it on the same acp version as the agent-server.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

+601
View File
@@ -0,0 +1,601 @@
import { chromium } from "@playwright/test";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const FRONTEND_URL = process.env.FRONTEND_URL ?? "http://127.0.0.1:3101";
const BACKEND_URL = process.env.BACKEND_URL ?? "http://127.0.0.1:18110";
const SESSION_API_KEY =
process.env.SESSION_API_KEY ?? "codex-mcp-gif-isolated-key";
const MOCK_LLM_URL = process.env.MOCK_LLM_URL ?? "http://127.0.0.1:19999";
const MOCK_MODEL = "openai/mock-test-model";
const MOCK_LLM_API_KEY = "mock-api-key-for-testing";
const RUN_TAG =
process.env.RUN_TAG ??
new Date()
.toISOString()
.replace(/[-:TZ.]/g, "")
.slice(0, 14);
const ROOT = path.resolve(import.meta.dirname, "..");
const PR_DIR = path.resolve(import.meta.dirname);
const flows = [
{
id: "a1",
title: "a1: Local backend + unauthenticated weather MCP",
serverName: "a1_weather",
url: "http://127.0.0.1:19200/mcp",
auth: { mode: "None" },
toolName: "weather_forecast",
toolArgs: { city: "Pittsburgh" },
prompt: "Use the weather MCP to get the Pittsburgh forecast.",
finalText: "A1 local weather MCP complete.",
successToken: "weather_fixture_success",
},
{
id: "a2",
title: "a2: Local backend + bearer-token MCP",
serverName: "a2_elevenlabs",
url: "http://127.0.0.1:19201/mcp",
auth: { mode: "Bearer token", token: "elevenlabs-test-token" },
toolName: "elevenlabs_voice_note",
toolArgs: { text: "Canvas demo voice note" },
prompt: "Use the elevenlabs MCP to draft a voice note.",
finalText: "A2 bearer-token MCP complete.",
successToken: "elevenlabs_fixture_success",
},
{
id: "a3",
title: "a3: Local backend + header-authenticated MCP",
serverName: "a3_datadog",
url: "http://127.0.0.1:19202/mcp",
auth: {
mode: "Header",
headers: "DD-API-KEY=datadog-api-key\nDD-APPLICATION-KEY=datadog-app-key",
},
toolName: "datadog_metric_snapshot",
toolArgs: { service: "canvas-api" },
prompt: "Use the datadog MCP to inspect canvas-api metrics.",
finalText: "A3 header-auth MCP complete.",
successToken: "datadog_fixture_success",
},
{
id: "a4",
title: "a4: Local backend + OAuth client-id/secret MCP",
serverName: "a4_notion",
url: "http://127.0.0.1:19203/mcp",
auth: {
mode: "OAuth",
clientAuthMethod: "Client secret POST",
clientId: "notion-client",
clientSecret: "notion-secret",
scopes: "read:mock",
},
toolName: "notion_page_lookup",
toolArgs: { title: "Roadmap" },
prompt: "Use the notion MCP to look up the Roadmap page.",
finalText: "A4 OAuth client-secret MCP complete.",
successToken: "notion_fixture_success",
},
{
id: "a5",
title: "a5: Local backend + dynamic OAuth MCP",
serverName: "a5_linear",
url: "http://127.0.0.1:19204/mcp",
auth: { mode: "OAuth", scopes: "read:mock write:mock" },
toolName: "linear_issue_summary",
toolArgs: { issue_key: "LIN-42" },
prompt: "Use the linear MCP to summarize issue LIN-42.",
finalText: "A5 dynamic OAuth MCP complete.",
successToken: "linear_fixture_success",
},
];
async function adminPost(pathname, body = undefined) {
const response = await fetch(`${MOCK_LLM_URL}${pathname}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(
`Mock LLM ${pathname} failed: ${response.status} ${await response.text()}`,
);
}
return response.json().catch(() => ({}));
}
async function registerTrajectory(flow) {
await adminPost("/admin/reset");
await adminPost("/admin/trajectory/register", {
name: flow.id,
turns: [
{ tool_call: { name: flow.toolName, arguments: flow.toolArgs } },
{
text: `${flow.finalText} The MCP observation contains ${flow.successToken}.`,
},
],
});
await adminPost("/admin/trajectory/activate", { name: flow.id });
}
async function setupPage() {
const browser = await chromium.launch({
headless: true,
args: [
"--disable-dev-shm-usage",
"--disable-gpu",
"--renderer-process-limit=1",
"--single-process",
"--no-zygote",
],
});
const context = await browser.newContext({
baseURL: FRONTEND_URL,
viewport: { width: 960, height: 600 },
deviceScaleFactor: 1,
});
await context.addInitScript(
({ apiKey, backendUrl }) => {
window.localStorage.setItem("analytics-consent", "false");
window.localStorage.setItem("openhands-telemetry-consent", "denied");
window.localStorage.setItem("openhands-telemetry-first-use", "true");
window.localStorage.setItem("openhands-onboarded", "1");
const active = { backendId: "default-local", orgId: null };
window.localStorage.setItem(
"openhands-backends",
JSON.stringify([
{
id: "default-local",
name: "Local",
host: backendUrl,
apiKey,
kind: "local",
},
]),
);
window.localStorage.setItem(
"openhands-active-backend",
JSON.stringify(active),
);
window.sessionStorage.setItem(
"openhands-active-backend",
JSON.stringify(active),
);
window.open = (url) => {
const mount = () => {
const frame = document.createElement("iframe");
frame.setAttribute("data-testid", "mcp-oauth-hidden-popup");
frame.style.position = "fixed";
frame.style.width = "1px";
frame.style.height = "1px";
frame.style.opacity = "0";
frame.style.pointerEvents = "none";
frame.style.left = "-10px";
frame.style.bottom = "-10px";
document.body.appendChild(frame);
if (url && url !== "about:blank") frame.src = String(url);
return frame;
};
let frame = document.body ? mount() : null;
const ensureFrame = () => {
if (!frame) frame = mount();
return frame;
};
return {
closed: false,
close() {
this.closed = true;
frame?.remove();
},
get location() {
return ensureFrame().contentWindow?.location;
},
set location(value) {
ensureFrame().src = String(value);
},
focus() {},
};
};
},
{ apiKey: SESSION_API_KEY, backendUrl: BACKEND_URL },
);
const page = await context.newPage();
await page.route("**/*", async (route) => {
const request = route.request();
const url = new URL(request.url());
const isBackend =
url.origin === new URL(BACKEND_URL).origin ||
(url.origin === FRONTEND_URL && url.pathname.startsWith("/api/"));
if (!isBackend) {
await route.continue();
return;
}
await route.continue({
headers: {
...request.headers(),
"X-Session-API-Key": SESSION_API_KEY,
},
});
});
return { browser, context, page };
}
async function waitForTestId(page, testId, timeout = 30_000) {
await page.getByTestId(testId).waitFor({ state: "visible", timeout });
}
async function dismissBlockingModals(page) {
try {
const form = page.getByTestId("telemetry-consent-form");
await form.waitFor({ state: "visible", timeout: 2_000 });
await page.getByTestId("confirm-telemetry-preferences").click();
await form.waitFor({ state: "hidden", timeout: 5_000 });
} catch {}
try {
const form = page.getByTestId("user-capture-consent-form");
await form.waitFor({ state: "visible", timeout: 2_000 });
await form.getByRole("button", { name: "Confirm preferences" }).click();
await form.waitFor({ state: "hidden", timeout: 5_000 });
} catch {}
try {
const skip = page.getByTestId("onboarding-skip");
await skip.waitFor({ state: "visible", timeout: 2_000 });
await skip.click();
await page.getByTestId("onboarding-modal").waitFor({
state: "hidden",
timeout: 5_000,
});
} catch {}
try {
const close = page.getByTestId("onboarding-hello-close");
await close.waitFor({ state: "visible", timeout: 2_000 });
await close.click();
await page.getByTestId("onboarding-modal").waitFor({
state: "hidden",
timeout: 5_000,
});
} catch {}
}
async function gotoPage(page, pathname) {
let lastError;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await page.goto(pathname, {
waitUntil: "domcontentloaded",
timeout: 45_000,
});
return;
} catch (error) {
lastError = error;
await page.waitForTimeout(1_000 * attempt).catch(() => {});
}
}
throw lastError;
}
async function selectDropdownOption(page, comboboxLabel, optionText) {
const combobox = page.getByRole("combobox", { name: comboboxLabel });
await combobox.waitFor({ state: "visible", timeout: 10_000 });
await combobox.click();
try {
await combobox.fill("");
} catch {}
const option = page.getByRole("option", { name: optionText });
await option.waitFor({ state: "visible", timeout: 10_000 });
await option.click();
}
async function setChatInput(page, text) {
await page.evaluate((inputText) => {
const el = document.querySelector('[data-testid="chat-input"]');
if (!(el instanceof HTMLElement)) {
throw new Error("chat-input not found");
}
el.focus();
el.textContent = inputText;
el.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: inputText,
inputType: "insertText",
}),
);
}, text);
}
async function waitForOutputText(page, text, timeout = 90_000) {
const started = Date.now();
while (Date.now() - started < timeout) {
const found = await page.evaluate((needle) => {
const selectors = [
'[data-testid="agent-message"]',
'[data-testid="environment-message"]',
'[data-testid="model-messages"]',
'[data-testid="event-group"]',
'[data-testid="error-message-banner"]',
];
return selectors.some((selector) =>
Array.from(document.querySelectorAll(selector)).some((node) =>
node.textContent?.includes(needle),
),
);
}, text);
if (found) return;
await page.waitForTimeout(1_000);
}
throw new Error(`Timed out waiting for output text: ${text}`);
}
function frameDir(flowId) {
return path.join(PR_DIR, "frames", flowId);
}
async function capture(page, flowId, label) {
const dir = frameDir(flowId);
fs.mkdirSync(dir, { recursive: true });
const count = fs
.readdirSync(dir)
.filter((name) => name.endsWith(".png")).length;
const file = path.join(
dir,
`${String(count + 1).padStart(3, "0")}-${label}.png`,
);
await page.screenshot({ path: file, fullPage: false });
return file;
}
function makeGif(flowId) {
const frames = fs
.readdirSync(frameDir(flowId))
.filter((name) => name.endsWith(".png"))
.sort()
.map((name) => path.join(frameDir(flowId), name));
const out = path.join(PR_DIR, `${flowId}.gif`);
const script = String.raw`
from PIL import Image
import sys
out = sys.argv[1]
paths = sys.argv[2:]
imgs = []
for path in paths:
img = Image.open(path).convert("RGB")
max_width = 960
if img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.Resampling.LANCZOS)
imgs.append(img.convert("P", palette=Image.Palette.ADAPTIVE, colors=128))
durations = [900] * len(imgs)
if durations:
durations[-1] = 1800
imgs[0].save(out, save_all=True, append_images=imgs[1:], duration=durations, loop=0, optimize=True)
`;
const result = spawnSync("python", ["-c", script, out, ...frames], {
cwd: ROOT,
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(`GIF creation failed for ${flowId}: ${result.stderr}`);
}
return out;
}
async function ensureLlmSettings() {
const profileName = `mcp-gif-${RUN_TAG}`;
const saveResponse = await fetch(
`${BACKEND_URL}/api/profiles/${encodeURIComponent(profileName)}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Session-API-Key": SESSION_API_KEY,
},
body: JSON.stringify({
llm: {
model: MOCK_MODEL,
api_key: MOCK_LLM_API_KEY,
base_url: MOCK_LLM_URL,
},
include_secrets: true,
}),
},
);
if (!saveResponse.ok) {
throw new Error(
`POST /api/profiles failed: ${saveResponse.status} ${await saveResponse.text()}`,
);
}
const activateResponse = await fetch(
`${BACKEND_URL}/api/profiles/${encodeURIComponent(profileName)}/activate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Session-API-Key": SESSION_API_KEY,
},
},
);
if (!activateResponse.ok) {
throw new Error(
`POST /api/profiles/activate failed: ${activateResponse.status} ${await activateResponse.text()}`,
);
}
const response = await fetch(`${BACKEND_URL}/api/settings`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-Session-API-Key": SESSION_API_KEY,
},
body: JSON.stringify({
agent_settings_diff: {
llm: {
model: MOCK_MODEL,
api_key: MOCK_LLM_API_KEY,
base_url: MOCK_LLM_URL,
},
},
}),
});
if (!response.ok) {
throw new Error(
`PATCH /api/settings failed: ${response.status} ${await response.text()}`,
);
}
}
async function installMcpServer(page, context, flow) {
const flowUrl =
flow.auth.mode === "OAuth"
? flow.url
: `${flow.url}?run=${RUN_TAG}-${flow.id}`;
const flowName = `${flow.serverName}_${RUN_TAG}`;
await gotoPage(page, "/mcp");
await dismissBlockingModals(page);
await waitForTestId(page, "mcp-page");
await capture(page, flow.id, "mcp-page");
await page.getByTestId("mcp-add-custom-server").click();
await waitForTestId(page, "mcp-custom-editor");
await capture(page, flow.id, "custom-modal");
await selectDropdownOption(page, /Server Type/i, /^SHTTP$/i);
await page.getByTestId("server-name-input").fill(flowName);
await page.getByTestId("url-input").fill(flowUrl);
await capture(page, flow.id, "server-url");
if (flow.auth.mode !== "None") {
await selectDropdownOption(page, /Authentication/i, flow.auth.mode);
}
if (flow.auth.mode === "Bearer token") {
await page.getByTestId("api-key-input").fill(flow.auth.token);
}
if (flow.auth.mode === "Header") {
await page.getByTestId("headers-input").fill(flow.auth.headers);
}
if (flow.auth.mode === "OAuth") {
if (flow.auth.clientAuthMethod) {
await selectDropdownOption(
page,
/OAuth client auth/i,
flow.auth.clientAuthMethod,
);
}
if (flow.auth.clientId) {
await page.getByTestId("oauth-client-id-input").fill(flow.auth.clientId);
}
if (flow.auth.clientSecret) {
await page
.getByTestId("oauth-client-secret-input")
.fill(flow.auth.clientSecret);
}
if (flow.auth.scopes) {
await page.getByTestId("oauth-scopes-input").fill(flow.auth.scopes);
}
}
await capture(page, flow.id, "auth-filled");
const useRealPopup = process.env.MCP_GIF_REAL_POPUP === "1";
let popupPromise = null;
if (flow.auth.mode === "OAuth" && useRealPopup) {
popupPromise = context
.waitForEvent("page", { timeout: 10_000 })
.catch(() => null);
}
await page.getByTestId("submit-button").click();
if (flow.auth.mode === "OAuth" && !useRealPopup) {
await page.waitForTimeout(3_000);
await capture(page, flow.id, "oauth-submitted");
}
const popup = popupPromise ? await popupPromise : null;
if (popup) {
await popup
.waitForLoadState("domcontentloaded", { timeout: 30_000 })
.catch(() => {});
if (!popup.isClosed()) {
await popup.waitForTimeout(1_000).catch(() => {});
}
if (!popup.isClosed()) {
await capture(popup, flow.id, "oauth-popup").catch(() => {});
}
}
try {
await page.getByTestId("mcp-custom-editor").waitFor({
state: "hidden",
timeout: flow.auth.mode === "OAuth" ? 60_000 : 30_000,
});
} catch (error) {
await capture(page, flow.id, "install-still-open").catch(() => {});
const modalText = await page
.getByTestId("mcp-custom-editor")
.innerText()
.catch(() => "");
throw new Error(
`MCP install modal did not close for ${flow.id}. Text:\n${modalText}`,
{ cause: error },
);
}
await waitForTestId(page, "mcp-installed-list");
await capture(page, flow.id, "installed");
if (popup && !popup.isClosed()) {
await popup.close().catch(() => {});
}
}
async function runConversation(page, flow) {
await registerTrajectory(flow);
await gotoPage(page, "/");
await dismissBlockingModals(page);
await waitForTestId(page, "home-chat-launcher");
await capture(page, flow.id, "home");
await setChatInput(page, flow.prompt);
await capture(page, flow.id, "prompt");
await page.getByTestId("submit-button").click();
await page.waitForURL(/\/conversations\/.+/, { timeout: 30_000 });
await capture(page, flow.id, "conversation-started");
await waitForOutputText(page, flow.successToken, 90_000);
await capture(page, flow.id, "tool-observation");
await waitForOutputText(page, flow.finalText, 90_000);
await capture(page, flow.id, "final-reply");
}
async function main() {
fs.mkdirSync(PR_DIR, { recursive: true });
const selectedIds = new Set(
(process.env.FLOW_IDS ?? "")
.split(",")
.map((id) => id.trim())
.filter(Boolean),
);
const selectedFlows =
selectedIds.size === 0
? flows
: flows.filter((flow) => selectedIds.has(flow.id));
if (selectedFlows.length === 0) {
throw new Error(`No matching flows for FLOW_IDS=${process.env.FLOW_IDS}`);
}
const { browser, context, page } = await setupPage();
try {
await ensureLlmSettings();
for (const flow of selectedFlows) {
fs.rmSync(frameDir(flow.id), { recursive: true, force: true });
console.log(`Capturing ${flow.title}`);
await installMcpServer(page, context, flow);
await runConversation(page, flow);
const gif = makeGif(flow.id);
console.log(`Wrote ${path.relative(ROOT, gif)}`);
}
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+758
View File
@@ -0,0 +1,758 @@
import { chromium } from "@playwright/test";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const FRONTEND_URL = process.env.FRONTEND_URL ?? "http://127.0.0.1:3101";
const LOCAL_BACKEND_URL = process.env.BACKEND_URL ?? "http://127.0.0.1:18110";
const LOCAL_SESSION_API_KEY =
process.env.SESSION_API_KEY ?? "codex-mcp-gif-isolated-key";
const CLOUD_BACKEND_URL =
process.env.CLOUD_BACKEND_URL ?? "https://app.all-hands.dev";
const ALLOW_PERSONAL_CLOUD_SETTINGS_MUTATION =
process.env.ALLOW_PERSONAL_CLOUD_SETTINGS_MUTATION === "1";
const FIXTURE_PUBLIC_URL =
process.env.FIXTURE_PUBLIC_URL ?? "https://statusquo-dr-ohmcp.ngrok-free.app";
const MOCK_LLM_ADMIN_URL = process.env.MOCK_LLM_URL ?? "http://127.0.0.1:19999";
const MOCK_LLM_BASE_URL =
process.env.MOCK_LLM_BASE_URL ?? `${FIXTURE_PUBLIC_URL}/mock-llm`;
const MOCK_MODEL = "openai/mock-test-model";
const MOCK_LLM_API_KEY = "mock-api-key-for-testing";
const RUN_TAG =
process.env.RUN_TAG ??
new Date()
.toISOString()
.replace(/[-:TZ.]/g, "")
.slice(0, 14);
const PR_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(PR_DIR, "..");
const LLM_ENV_FILE =
process.env.LLM_ENV_FILE ??
(process.env.HOME ? path.join(process.env.HOME, ".env") : "");
function readDotEnv(file) {
if (!file || !fs.existsSync(file)) return {};
return Object.fromEntries(
fs
.readFileSync(file, "utf8")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
.map((line) => line.replace(/^export\s+/, ""))
.map((line) => {
const eq = line.indexOf("=");
if (eq === -1) return null;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
return [key, value];
})
.filter(Boolean),
);
}
const envFileValues = readDotEnv(LLM_ENV_FILE);
function configuredEnv(name) {
return process.env[name] ?? envFileValues[name];
}
const CONFIGURED_LLM_MODEL = configuredEnv("LLM_MODEL");
const CONFIGURED_LLM_API_KEY = configuredEnv("LLM_API_KEY");
const CONFIGURED_LLM_BASE_URL = configuredEnv("LLM_BASE_URL");
const USE_CONFIGURED_LLM = Boolean(
CONFIGURED_LLM_MODEL || CONFIGURED_LLM_API_KEY || CONFIGURED_LLM_BASE_URL,
);
if (USE_CONFIGURED_LLM && (!CONFIGURED_LLM_MODEL || !CONFIGURED_LLM_API_KEY)) {
throw new Error("Configured LLM requires LLM_MODEL and LLM_API_KEY.");
}
const CAPTURE_LLM_MODEL = CONFIGURED_LLM_MODEL ?? MOCK_MODEL;
const CAPTURE_LLM_API_KEY = CONFIGURED_LLM_API_KEY ?? MOCK_LLM_API_KEY;
const CAPTURE_LLM_BASE_URL = CONFIGURED_LLM_BASE_URL ?? MOCK_LLM_BASE_URL;
const flows = [
{
id: "b1",
title: "b1: Cloud backend + unauthenticated weather MCP",
serverName: "b1_weather",
url: `${FIXTURE_PUBLIC_URL}/weather/mcp`,
auth: { mode: "None" },
toolName: "weather_forecast",
toolArgs: { city: "Pittsburgh" },
prompt: "Use the weather MCP to get the Pittsburgh forecast.",
finalText: "B1 cloud weather MCP complete.",
successToken: "weather_fixture_success",
},
{
id: "b2",
title: "b2: Cloud backend + bearer-token MCP",
serverName: "b2_elevenlabs",
url: `${FIXTURE_PUBLIC_URL}/elevenlabs/mcp`,
auth: { mode: "Bearer token", token: "elevenlabs-test-token" },
toolName: "elevenlabs_voice_note",
toolArgs: { text: "Canvas demo voice note" },
prompt: "Use the elevenlabs MCP to draft a voice note.",
finalText: "B2 cloud bearer-token MCP complete.",
successToken: "elevenlabs_fixture_success",
},
{
id: "b3",
title: "b3: Cloud backend + header-authenticated MCP",
serverName: "b3_datadog",
url: `${FIXTURE_PUBLIC_URL}/datadog/mcp`,
auth: {
mode: "Header",
headers: "DD-API-KEY=datadog-api-key\nDD-APPLICATION-KEY=datadog-app-key",
},
toolName: "datadog_metric_snapshot",
toolArgs: { service: "canvas-api" },
prompt: "Use the datadog MCP to inspect canvas-api metrics.",
finalText: "B3 cloud header-auth MCP complete.",
successToken: "datadog_fixture_success",
},
{
id: "b4",
title: "b4: Cloud backend + OAuth client-id/secret MCP",
serverName: "b4_notion",
url: `${FIXTURE_PUBLIC_URL}/notion/mcp`,
auth: {
mode: "OAuth",
clientAuthMethod: "Client secret POST",
clientId: "notion-client",
clientSecret: "notion-secret",
scopes: "read:mock",
},
toolName: "notion_page_lookup",
toolArgs: { title: "Roadmap" },
prompt: "Use the notion MCP to look up the Roadmap page.",
finalText: "B4 cloud OAuth client-secret MCP complete.",
successToken: "notion_fixture_success",
},
{
id: "b5",
title: "b5: Cloud backend + dynamic OAuth MCP",
serverName: "b5_linear",
url: `${FIXTURE_PUBLIC_URL}/linear/mcp`,
auth: { mode: "OAuth", scopes: "read:mock write:mock" },
toolName: "linear_issue_summary",
toolArgs: { issue_key: "LIN-42" },
prompt: "Use the linear MCP to summarize issue LIN-42.",
finalText: "B5 cloud dynamic OAuth MCP complete.",
successToken: "linear_fixture_success",
},
];
function readCloudApiKey() {
if (process.env.OPENHANDS_CLOUD_API_KEY)
return process.env.OPENHANDS_CLOUD_API_KEY;
if (process.env.OPENHANDS_API_KEY) return process.env.OPENHANDS_API_KEY;
const tokenFile = "/tmp/openhands-cloud-token.json";
if (fs.existsSync(tokenFile)) {
const token = JSON.parse(fs.readFileSync(tokenFile, "utf8"));
return token.access_token ?? token.api_key ?? token.token;
}
throw new Error(
"Missing cloud token. Set OPENHANDS_CLOUD_API_KEY or authorize device flow first.",
);
}
const CLOUD_API_KEY = readCloudApiKey();
function serverNameForFlow(flow) {
return `${flow.serverName}_${RUN_TAG}`;
}
function modelToolNameForFlow(flow) {
return `${serverNameForFlow(flow)}_${flow.toolName}`;
}
function conversationPrompt(flow) {
if (!USE_CONFIGURED_LLM) return flow.prompt;
return [
`Call the MCP tool named ${modelToolNameForFlow(flow)} with these JSON arguments: ${JSON.stringify(flow.toolArgs)}.`,
"Do not answer from memory. Wait for the MCP tool observation.",
`After the tool observation, reply with exactly this full line: ${flow.finalText} The MCP observation contains ${flow.successToken}.`,
].join(" ");
}
async function adminPost(pathname, body = undefined) {
const response = await fetch(`${MOCK_LLM_ADMIN_URL}${pathname}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(
`Mock LLM ${pathname} failed: ${response.status} ${await response.text()}`,
);
}
return response.json().catch(() => ({}));
}
async function registerTrajectory(flow) {
if (USE_CONFIGURED_LLM) return;
await adminPost("/admin/reset");
await adminPost("/admin/trajectory/register", {
name: flow.id,
turns: [
{ text: flow.title },
{
tool_call: {
name: modelToolNameForFlow(flow),
arguments: flow.toolArgs,
},
},
{
text: `${flow.finalText} The MCP observation contains ${flow.successToken}.`,
},
],
});
await adminPost("/admin/trajectory/activate", { name: flow.id });
}
async function cloudApi(pathname, options = {}) {
const response = await fetch(`${CLOUD_BACKEND_URL}${pathname}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${CLOUD_API_KEY}`,
...(options.headers ?? {}),
},
});
const text = await response.text();
let payload;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok) {
throw new Error(
`Cloud ${pathname} failed: ${response.status} ${
typeof payload === "string" ? payload : JSON.stringify(payload)
}`,
);
}
return payload;
}
async function ensureCloudSettings() {
const cloudUrl = new URL(CLOUD_BACKEND_URL);
if (
cloudUrl.hostname === "app.all-hands.dev" &&
!ALLOW_PERSONAL_CLOUD_SETTINGS_MUTATION
) {
throw new Error(
"Refusing to mutate personal OpenHands Cloud settings. Use a feature deploy/dedicated Cloud backend, or set ALLOW_PERSONAL_CLOUD_SETTINGS_MUTATION=1 for this capture run.",
);
}
await cloudApi("/api/v1/settings", {
method: "POST",
body: JSON.stringify({
agent_settings_diff: {
agent_kind: "openhands",
agent: "CodeActAgent",
llm: {
model: CAPTURE_LLM_MODEL,
api_key: CAPTURE_LLM_API_KEY,
base_url: CAPTURE_LLM_BASE_URL,
},
},
conversation_settings_diff: {
max_iterations: 50,
},
}),
});
}
async function cleanupCloudMcpServers() {
const settings = await cloudApi("/api/v1/settings", { method: "GET" });
const currentConfig =
settings.agent_settings?.mcp_config ?? settings.mcp_config ?? {};
if (!currentConfig || typeof currentConfig !== "object") return;
const hasMcpServersWrapper =
currentConfig.mcpServers &&
typeof currentConfig.mcpServers === "object" &&
!Array.isArray(currentConfig.mcpServers);
const currentServers = hasMcpServersWrapper
? currentConfig.mcpServers
: currentConfig;
const flowPrefixes = flows.map((flow) => `${flow.serverName}_`);
const filtered = Object.fromEntries(
Object.entries(currentServers).filter(
([name]) =>
!name.endsWith(`_${RUN_TAG}`) &&
!flowPrefixes.some((prefix) => name.startsWith(prefix)),
),
);
if (Object.keys(filtered).length === Object.keys(currentServers).length)
return;
const nextConfig = hasMcpServersWrapper ? { mcpServers: filtered } : filtered;
await cloudApi("/api/v1/settings", {
method: "POST",
body: JSON.stringify({
agent_settings_diff: {
mcp_config: Object.keys(filtered).length > 0 ? nextConfig : null,
},
}),
});
}
async function setupPage() {
const browser = await chromium.launch({
headless: true,
args: [
"--disable-dev-shm-usage",
"--disable-gpu",
"--renderer-process-limit=1",
"--single-process",
"--no-zygote",
],
});
const context = await browser.newContext({
baseURL: FRONTEND_URL,
viewport: { width: 960, height: 600 },
deviceScaleFactor: 1,
});
await context.addInitScript(
({ localApiKey, localBackendUrl, cloudApiKey, cloudBackendUrl }) => {
window.localStorage.setItem("analytics-consent", "false");
window.localStorage.setItem("openhands-telemetry-consent", "denied");
window.localStorage.setItem("openhands-telemetry-first-use", "true");
window.localStorage.setItem("openhands-onboarded", "1");
const active = { backendId: "cloud-openhands", orgId: null };
window.localStorage.setItem(
"openhands-backends",
JSON.stringify([
{
id: "default-local",
name: "Local OAuth helper",
host: localBackendUrl,
apiKey: localApiKey,
kind: "local",
},
{
id: "cloud-openhands",
name: "OpenHands Cloud",
host: cloudBackendUrl,
apiKey: cloudApiKey,
kind: "cloud",
},
]),
);
window.localStorage.setItem(
"openhands-active-backend",
JSON.stringify(active),
);
window.sessionStorage.setItem(
"openhands-active-backend",
JSON.stringify(active),
);
window.open = (url) => {
const mount = () => {
const frame = document.createElement("iframe");
frame.setAttribute("data-testid", "mcp-oauth-hidden-popup");
frame.style.position = "fixed";
frame.style.width = "1px";
frame.style.height = "1px";
frame.style.opacity = "0";
frame.style.pointerEvents = "none";
frame.style.left = "-10px";
frame.style.bottom = "-10px";
document.body.appendChild(frame);
if (url && url !== "about:blank") frame.src = String(url);
return frame;
};
let frame = document.body ? mount() : null;
const ensureFrame = () => {
if (!frame) frame = mount();
return frame;
};
return {
closed: false,
close() {
this.closed = true;
frame?.remove();
},
get location() {
return ensureFrame().contentWindow?.location;
},
set location(value) {
ensureFrame().src = String(value);
},
focus() {},
};
};
},
{
localApiKey: LOCAL_SESSION_API_KEY,
localBackendUrl: LOCAL_BACKEND_URL,
cloudApiKey: CLOUD_API_KEY,
cloudBackendUrl: CLOUD_BACKEND_URL,
},
);
const page = await context.newPage();
await page.route("**/*", async (route) => {
const request = route.request();
const url = new URL(request.url());
const isLocalBackend =
url.origin === new URL(LOCAL_BACKEND_URL).origin ||
(url.origin === FRONTEND_URL && url.pathname.startsWith("/api/"));
if (!isLocalBackend) {
await route.continue();
return;
}
await route.continue({
headers: {
...request.headers(),
"X-Session-API-Key": LOCAL_SESSION_API_KEY,
},
});
});
return { browser, context, page };
}
async function waitForTestId(page, testId, timeout = 30_000) {
await page.getByTestId(testId).waitFor({ state: "visible", timeout });
}
async function dismissBlockingModals(page) {
try {
const form = page.getByTestId("telemetry-consent-form");
await form.waitFor({ state: "visible", timeout: 2_000 });
await page.getByTestId("confirm-telemetry-preferences").click();
await form.waitFor({ state: "hidden", timeout: 5_000 });
} catch {}
try {
const form = page.getByTestId("user-capture-consent-form");
await form.waitFor({ state: "visible", timeout: 2_000 });
await form.getByRole("button", { name: "Confirm preferences" }).click();
await form.waitFor({ state: "hidden", timeout: 5_000 });
} catch {}
try {
const skip = page.getByTestId("onboarding-skip");
await skip.waitFor({ state: "visible", timeout: 2_000 });
await skip.click();
await page.getByTestId("onboarding-modal").waitFor({
state: "hidden",
timeout: 5_000,
});
} catch {}
try {
const close = page.getByTestId("onboarding-hello-close");
await close.waitFor({ state: "visible", timeout: 2_000 });
await close.click();
await page.getByTestId("onboarding-modal").waitFor({
state: "hidden",
timeout: 5_000,
});
} catch {}
}
async function gotoPage(page, pathname) {
let lastError;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await page.goto(pathname, {
waitUntil: "domcontentloaded",
timeout: 45_000,
});
return;
} catch (error) {
lastError = error;
await page.waitForTimeout(1_000 * attempt).catch(() => {});
}
}
throw lastError;
}
async function selectDropdownOption(page, comboboxLabel, optionText) {
const combobox = page.getByRole("combobox", { name: comboboxLabel });
await combobox.waitFor({ state: "visible", timeout: 10_000 });
await combobox.click();
try {
await combobox.fill("");
} catch {}
const option = page.getByRole("option", { name: optionText });
await option.waitFor({ state: "visible", timeout: 10_000 });
await option.click();
}
async function setChatInput(page, text) {
await page.evaluate((inputText) => {
const el = document.querySelector('[data-testid="chat-input"]');
if (!(el instanceof HTMLElement)) {
throw new Error("chat-input not found");
}
el.focus();
el.textContent = inputText;
el.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: inputText,
inputType: "insertText",
}),
);
}, text);
}
function currentConversationId(page) {
try {
const pathname = new URL(page.url()).pathname;
const match = pathname.match(/^\/conversations\/([^/]+)/);
if (!match || match[1].startsWith("task-")) return null;
return match[1];
} catch {
return null;
}
}
async function cloudEventHistoryContains(conversationId, text) {
if (!conversationId) return false;
const params = new URLSearchParams({
limit: "100",
sort_order: "TIMESTAMP",
});
const page = await cloudApi(
`/api/v1/conversation/${conversationId}/events/search?${params.toString()}`,
{ method: "GET" },
);
return JSON.stringify(page?.items ?? []).includes(text);
}
async function waitForOutputText(page, text, timeout = 180_000) {
const started = Date.now();
let lastCloudCheck = 0;
while (Date.now() - started < timeout) {
try {
const found = await page.evaluate((needle) => {
const selectors = [
'[data-testid="agent-message"]',
'[data-testid="environment-message"]',
'[data-testid="model-messages"]',
'[data-testid="event-group"]',
'[data-testid="error-message-banner"]',
];
return selectors.some((selector) =>
Array.from(document.querySelectorAll(selector)).some((node) =>
node.textContent?.includes(needle),
),
);
}, text);
if (found) return;
} catch {}
if (Date.now() - lastCloudCheck > 2_000) {
lastCloudCheck = Date.now();
try {
if (
await cloudEventHistoryContains(currentConversationId(page), text)
) {
return;
}
} catch {}
}
await page.waitForTimeout(1_000);
}
throw new Error(`Timed out waiting for output text: ${text}`);
}
function frameDir(flowId) {
return path.join(PR_DIR, "frames", flowId);
}
async function capture(page, flowId, label) {
const dir = frameDir(flowId);
fs.mkdirSync(dir, { recursive: true });
const count = fs
.readdirSync(dir)
.filter((name) => name.endsWith(".png")).length;
const file = path.join(
dir,
`${String(count + 1).padStart(3, "0")}-${label}.png`,
);
await page.screenshot({ path: file, fullPage: false, timeout: 90_000 });
return file;
}
function makeGif(flowId) {
const frames = fs
.readdirSync(frameDir(flowId))
.filter((name) => name.endsWith(".png"))
.sort()
.map((name) => path.join(frameDir(flowId), name));
const out = path.join(PR_DIR, `${flowId}.gif`);
const script = String.raw`
from PIL import Image
import sys
out = sys.argv[1]
paths = sys.argv[2:]
imgs = []
for path in paths:
img = Image.open(path).convert("RGB")
max_width = 960
if img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.Resampling.LANCZOS)
imgs.append(img.convert("P", palette=Image.Palette.ADAPTIVE, colors=128))
durations = [900] * len(imgs)
if durations:
durations[-1] = 1800
imgs[0].save(out, save_all=True, append_images=imgs[1:], duration=durations, loop=0, optimize=True)
`;
const result = spawnSync("python", ["-c", script, out, ...frames], {
cwd: ROOT,
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(`GIF creation failed for ${flowId}: ${result.stderr}`);
}
return out;
}
async function installMcpServer(page, flow) {
const flowUrl =
flow.auth.mode === "OAuth"
? flow.url
: `${flow.url}?run=${RUN_TAG}-${flow.id}`;
const flowName = serverNameForFlow(flow);
await gotoPage(page, "/mcp");
await dismissBlockingModals(page);
await waitForTestId(page, "mcp-page", 60_000);
await capture(page, flow.id, "mcp-page");
await page.getByTestId("mcp-add-custom-server").click();
await waitForTestId(page, "mcp-custom-editor");
await capture(page, flow.id, "custom-modal");
await selectDropdownOption(page, /Server Type/i, /^SHTTP$/i);
await page.getByTestId("server-name-input").fill(flowName);
await page.getByTestId("url-input").fill(flowUrl);
await capture(page, flow.id, "server-url");
if (flow.auth.mode !== "None") {
await selectDropdownOption(page, /Authentication/i, flow.auth.mode);
}
if (flow.auth.mode === "Bearer token") {
await page.getByTestId("api-key-input").fill(flow.auth.token);
}
if (flow.auth.mode === "Header") {
await page.getByTestId("headers-input").fill(flow.auth.headers);
}
if (flow.auth.mode === "OAuth") {
if (flow.auth.clientAuthMethod) {
await selectDropdownOption(
page,
/OAuth client auth/i,
flow.auth.clientAuthMethod,
);
}
if (flow.auth.clientId) {
await page.getByTestId("oauth-client-id-input").fill(flow.auth.clientId);
}
if (flow.auth.clientSecret) {
await page
.getByTestId("oauth-client-secret-input")
.fill(flow.auth.clientSecret);
}
if (flow.auth.scopes) {
await page.getByTestId("oauth-scopes-input").fill(flow.auth.scopes);
}
}
await capture(page, flow.id, "auth-filled");
await page.getByTestId("submit-button").click();
if (flow.auth.mode === "OAuth") {
await page.waitForTimeout(3_000);
await capture(page, flow.id, "oauth-submitted");
}
try {
await page.getByTestId("mcp-custom-editor").waitFor({
state: "hidden",
timeout: flow.auth.mode === "OAuth" ? 90_000 : 45_000,
});
} catch (error) {
await capture(page, flow.id, "install-still-open").catch(() => {});
const modalText = await page
.getByTestId("mcp-custom-editor")
.innerText()
.catch(() => "");
throw new Error(
`MCP install modal did not close for ${flow.id}. Text:\n${modalText}`,
{ cause: error },
);
}
await waitForTestId(page, "mcp-installed-list");
await capture(page, flow.id, "installed");
}
async function runConversation(page, flow) {
await registerTrajectory(flow);
await gotoPage(page, "/");
await dismissBlockingModals(page);
await waitForTestId(page, "home-chat-launcher", 60_000);
await capture(page, flow.id, "home");
await setChatInput(page, conversationPrompt(flow));
await capture(page, flow.id, "prompt");
await page.getByTestId("submit-button").click();
await page.waitForURL(/\/conversations\/.+/, { timeout: 45_000 });
await capture(page, flow.id, "conversation-provisioning");
await page.waitForFunction(
() => /^\/conversations\/(?!task-).+/.test(window.location.pathname),
undefined,
{ timeout: 180_000 },
);
await waitForTestId(page, "chat-input", 180_000);
await capture(page, flow.id, "conversation-started");
await waitForOutputText(page, flow.successToken, 240_000);
await capture(page, flow.id, "tool-observation");
await waitForOutputText(page, flow.finalText, 120_000);
await capture(page, flow.id, "final-reply");
}
async function main() {
fs.mkdirSync(PR_DIR, { recursive: true });
const selectedIds = new Set(
(process.env.FLOW_IDS ?? "")
.split(",")
.map((id) => id.trim())
.filter(Boolean),
);
const selectedFlows =
selectedIds.size === 0
? flows
: flows.filter((flow) => selectedIds.has(flow.id));
if (selectedFlows.length === 0) {
throw new Error(`No matching flows for FLOW_IDS=${process.env.FLOW_IDS}`);
}
await ensureCloudSettings();
await cleanupCloudMcpServers();
const { browser, page } = await setupPage();
try {
for (const flow of selectedFlows) {
fs.rmSync(frameDir(flow.id), { recursive: true, force: true });
console.log(`Capturing ${flow.title}`);
await installMcpServer(page, flow);
await runConversation(page, flow);
const gif = makeGif(flow.id);
console.log(`Wrote ${path.relative(ROOT, gif)}`);
}
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+782
View File
@@ -0,0 +1,782 @@
import { chromium } from "@playwright/test";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const FRONTEND_URL = process.env.FRONTEND_URL ?? "http://127.0.0.1:3101";
const BACKEND_URL = process.env.BACKEND_URL ?? "http://127.0.0.1:18110";
const SESSION_API_KEY =
process.env.SESSION_API_KEY ?? "codex-mcp-gif-isolated-key";
const MOCK_LLM_URL = process.env.MOCK_LLM_URL ?? "http://127.0.0.1:19999";
const HUB_URL = process.env.HUB_URL ?? "http://localhost:8081";
const HUB_API_URL = process.env.HUB_API_URL ?? "http://127.0.0.1:8081";
const HUB_MCP_URL = process.env.HUB_MCP_URL ?? "http://127.0.0.1:8081/api/mcp";
const HUB_TOKEN = process.env.HUB_TOKEN ?? "dev-token";
const FIXTURE_DOCKER_HOST = process.env.FIXTURE_DOCKER_HOST ?? "172.18.0.1";
const MOCK_MODEL = "openai/mock-test-model";
const MOCK_LLM_API_KEY = "mock-api-key-for-testing";
const RUN_TAG =
process.env.RUN_TAG ??
new Date()
.toISOString()
.replace(/[-:TZ.]/g, "")
.slice(0, 14);
const PR_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(PR_DIR, "..");
const flows = [
{
id: "c1",
title: "c1: Integrations hub + unauthenticated weather MCP",
integrationKey: `c1_weather_${RUN_TAG}`,
displayName: `C1 Weather ${RUN_TAG}`,
hubMode: "custom",
fixtureUrl: `http://${FIXTURE_DOCKER_HOST}:19200/mcp`,
credential: null,
toolName: "weather_forecast",
toolArgs: { city: "Pittsburgh" },
prompt:
"Use the integrations hub weather MCP to get the Pittsburgh forecast.",
finalText: "C1 integrations hub weather MCP complete.",
successToken: "weather_fixture_success",
},
{
id: "c2",
title: "c2: Integrations hub + bearer-token MCP",
integrationKey: `c2_elevenlabs_${RUN_TAG}`,
displayName: `C2 ElevenLabs ${RUN_TAG}`,
hubMode: "custom",
fixtureUrl: `http://${FIXTURE_DOCKER_HOST}:19201/mcp`,
credential: { bearerToken: "elevenlabs-test-token" },
toolName: "elevenlabs_voice_note",
toolArgs: { text: "Canvas demo voice note" },
prompt: "Use the integrations hub elevenlabs MCP to draft a voice note.",
finalText: "C2 integrations hub bearer-token MCP complete.",
successToken: "elevenlabs_fixture_success",
},
{
id: "c3",
title: "c3: Integrations hub + header-authenticated MCP",
integrationKey: `c3_datadog_${RUN_TAG}`,
displayName: `C3 Datadog ${RUN_TAG}`,
hubMode: "custom",
fixtureUrl: `http://${FIXTURE_DOCKER_HOST}:19202/mcp`,
credential: { headerName: "DD-API-KEY", headerSecret: "datadog-api-key" },
toolName: "datadog_metric_snapshot",
toolArgs: { service: "canvas-api" },
prompt:
"Use the integrations hub datadog MCP to inspect canvas-api metrics.",
finalText: "C3 integrations hub header-auth MCP complete.",
successToken: "datadog_fixture_success",
},
{
id: "c4",
title: "c4: Integrations hub + OAuth client-id/secret MCP",
integrationKey: `c4-notion-oauth-${RUN_TAG}`,
displayName: `C4 Notion OAuth ${RUN_TAG}`,
hubMode: "managed-oauth",
fixtureUrl: `http://${FIXTURE_DOCKER_HOST}:19203/mcp`,
managedConnector: {
description: "Static client-id and client-secret OAuth MCP fixture.",
oauthClientId: "notion-client",
oauthClientSecret: "notion-secret",
oauthConfig: {
authorizationUrl: `http://${FIXTURE_DOCKER_HOST}:19203/authorize`,
tokenUrl: `http://${FIXTURE_DOCKER_HOST}:19203/token`,
scopes: ["read:mock"],
pkce: true,
clientAuthentication: "body",
},
},
toolName: "notion_page_lookup",
toolArgs: { title: "Roadmap" },
prompt: "Use the integrations hub notion MCP to look up the Roadmap page.",
finalText: "C4 integrations hub OAuth client-secret MCP complete.",
successToken: "notion_fixture_success",
},
{
id: "c5",
title: "c5: Integrations hub + dynamic OAuth MCP",
integrationKey: `c5-linear-dynamic-${RUN_TAG}`,
displayName: `C5 Linear Dynamic ${RUN_TAG}`,
hubMode: "managed-oauth",
fixtureUrl: `http://${FIXTURE_DOCKER_HOST}:19204/mcp`,
managedConnector: {
description: "Dynamic OAuth client registration MCP fixture.",
oauthConfig: {
authorizationUrl: `http://${FIXTURE_DOCKER_HOST}:19204/authorize`,
tokenUrl: `http://${FIXTURE_DOCKER_HOST}:19204/token`,
registrationUrl: `http://${FIXTURE_DOCKER_HOST}:19204/register`,
scopes: ["read:mock"],
pkce: true,
clientAuthentication: "none",
},
},
toolName: "linear_issue_summary",
toolArgs: { issue_key: "LIN-42" },
prompt: "Use the integrations hub linear MCP to summarize issue LIN-42.",
finalText: "C5 integrations hub dynamic OAuth MCP complete.",
successToken: "linear_fixture_success",
},
];
async function adminPost(pathname, body = undefined) {
const response = await fetch(`${MOCK_LLM_URL}${pathname}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(
`Mock LLM ${pathname} failed: ${response.status} ${await response.text()}`,
);
}
return response.json().catch(() => ({}));
}
async function registerTrajectory(flow) {
const hubToolName = `${flow.integrationKey}__${flow.toolName}`;
await adminPost("/admin/reset");
await adminPost("/admin/trajectory/register", {
name: flow.id,
turns: [
{ tool_call: { name: hubToolName, arguments: flow.toolArgs } },
{
text: `${flow.finalText} The MCP observation contains ${flow.successToken}.`,
},
],
});
await adminPost("/admin/trajectory/activate", { name: flow.id });
}
async function setupPage() {
const browser = await chromium.launch({
headless: true,
args: [
"--disable-dev-shm-usage",
"--disable-gpu",
"--renderer-process-limit=1",
"--single-process",
"--no-zygote",
],
});
const context = await browser.newContext({
viewport: { width: 960, height: 600 },
deviceScaleFactor: 1,
});
await context.addInitScript(
({ apiKey, backendUrl, hubToken }) => {
window.localStorage.setItem("analytics-consent", "false");
window.localStorage.setItem("openhands-telemetry-consent", "denied");
window.localStorage.setItem("openhands-telemetry-first-use", "true");
window.localStorage.setItem("openhands-onboarded", "1");
window.localStorage.setItem("integrations-hub-openhands-token", hubToken);
const active = { backendId: "default-local", orgId: null };
window.localStorage.setItem(
"openhands-backends",
JSON.stringify([
{
id: "default-local",
name: "Local",
host: backendUrl,
apiKey,
kind: "local",
},
]),
);
window.localStorage.setItem(
"openhands-active-backend",
JSON.stringify(active),
);
window.sessionStorage.setItem(
"openhands-active-backend",
JSON.stringify(active),
);
},
{
apiKey: SESSION_API_KEY,
backendUrl: BACKEND_URL,
hubToken: HUB_TOKEN,
},
);
const page = await context.newPage();
await page.route("**/*", async (route) => {
const request = route.request();
const url = new URL(request.url());
const backendOrigin = new URL(BACKEND_URL).origin;
const frontendOrigin = new URL(FRONTEND_URL).origin;
const hubOrigin = new URL(HUB_URL).origin;
const isCanvasBackend =
url.origin === backendOrigin ||
(url.origin === frontendOrigin && url.pathname.startsWith("/api/"));
const isHubBackend =
url.origin === hubOrigin && url.pathname.startsWith("/api/");
if (!isCanvasBackend && !isHubBackend) {
await route.continue();
return;
}
await route.continue({
headers: {
...request.headers(),
...(isCanvasBackend ? { "X-Session-API-Key": SESSION_API_KEY } : {}),
...(isHubBackend ? { Authorization: `Bearer ${HUB_TOKEN}` } : {}),
},
});
});
return { browser, context, page };
}
async function waitForTestId(page, testId, timeout = 30_000) {
await page.getByTestId(testId).waitFor({ state: "visible", timeout });
}
async function dismissBlockingModals(page) {
try {
const form = page.getByTestId("telemetry-consent-form");
await form.waitFor({ state: "visible", timeout: 2_000 });
await page.getByTestId("confirm-telemetry-preferences").click();
await form.waitFor({ state: "hidden", timeout: 5_000 });
} catch {}
try {
const form = page.getByTestId("user-capture-consent-form");
await form.waitFor({ state: "visible", timeout: 2_000 });
await form.getByRole("button", { name: "Confirm preferences" }).click();
await form.waitFor({ state: "hidden", timeout: 5_000 });
} catch {}
try {
const skip = page.getByTestId("onboarding-skip");
await skip.waitFor({ state: "visible", timeout: 2_000 });
await skip.click();
await page.getByTestId("onboarding-modal").waitFor({
state: "hidden",
timeout: 5_000,
});
} catch {}
try {
const close = page.getByTestId("onboarding-hello-close");
await close.waitFor({ state: "visible", timeout: 2_000 });
await close.click();
await page.getByTestId("onboarding-modal").waitFor({
state: "hidden",
timeout: 5_000,
});
} catch {}
}
async function gotoPage(page, url) {
let lastError;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
return;
} catch (error) {
lastError = error;
await page.waitForTimeout(1_000 * attempt).catch(() => {});
}
}
throw lastError;
}
async function selectDropdownOption(page, comboboxLabel, optionText) {
const combobox = page.getByRole("combobox", { name: comboboxLabel });
await combobox.waitFor({ state: "visible", timeout: 10_000 });
await combobox.click();
try {
await combobox.fill("");
} catch {}
const option = page.getByRole("option", { name: optionText });
await option.waitFor({ state: "visible", timeout: 10_000 });
await option.click();
}
async function setChatInput(page, text) {
await page.evaluate((inputText) => {
const el = document.querySelector('[data-testid="chat-input"]');
if (!(el instanceof HTMLElement)) {
throw new Error("chat-input not found");
}
el.focus();
el.textContent = inputText;
el.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: inputText,
inputType: "insertText",
}),
);
}, text);
}
async function waitForBodyText(page, text, timeout = 60_000) {
await page.locator("body").filter({ hasText: text }).waitFor({ timeout });
}
async function waitForOutputText(page, text, timeout = 90_000) {
const started = Date.now();
while (Date.now() - started < timeout) {
const found = await page.evaluate((needle) => {
const selectors = [
'[data-testid="agent-message"]',
'[data-testid="environment-message"]',
'[data-testid="model-messages"]',
'[data-testid="event-group"]',
'[data-testid="error-message-banner"]',
];
return selectors.some((selector) =>
Array.from(document.querySelectorAll(selector)).some((node) =>
node.textContent?.includes(needle),
),
);
}, text);
if (found) return;
await page.waitForTimeout(1_000);
}
throw new Error(`Timed out waiting for output text: ${text}`);
}
function frameDir(flowId) {
return path.join(PR_DIR, "frames", flowId);
}
async function capture(page, flowId, label) {
const dir = frameDir(flowId);
fs.mkdirSync(dir, { recursive: true });
const count = fs
.readdirSync(dir)
.filter((name) => name.endsWith(".png")).length;
const file = path.join(
dir,
`${String(count + 1).padStart(3, "0")}-${label}.png`,
);
await page.screenshot({ path: file, fullPage: false, timeout: 90_000 });
return file;
}
function makeGif(flowId) {
const frames = fs
.readdirSync(frameDir(flowId))
.filter((name) => name.endsWith(".png"))
.sort()
.map((name) => path.join(frameDir(flowId), name));
const out = path.join(PR_DIR, `${flowId}.gif`);
const script = String.raw`
from PIL import Image
import sys
out = sys.argv[1]
paths = sys.argv[2:]
imgs = []
for path in paths:
img = Image.open(path).convert("RGB")
max_width = 960
if img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.Resampling.LANCZOS)
imgs.append(img.convert("P", palette=Image.Palette.ADAPTIVE, colors=128))
durations = [900] * len(imgs)
if durations:
durations[-1] = 1800
imgs[0].save(out, save_all=True, append_images=imgs[1:], duration=durations, loop=0, optimize=True)
`;
const result = spawnSync("python", ["-c", script, out, ...frames], {
cwd: ROOT,
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(`GIF creation failed for ${flowId}: ${result.stderr}`);
}
return out;
}
async function ensureLlmSettings() {
const profileName = `mcp-gif-c-${RUN_TAG}`;
const saveResponse = await fetch(
`${BACKEND_URL}/api/profiles/${encodeURIComponent(profileName)}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Session-API-Key": SESSION_API_KEY,
},
body: JSON.stringify({
llm: {
model: MOCK_MODEL,
api_key: MOCK_LLM_API_KEY,
base_url: MOCK_LLM_URL,
},
include_secrets: true,
}),
},
);
if (!saveResponse.ok) {
throw new Error(
`POST /api/profiles failed: ${saveResponse.status} ${await saveResponse.text()}`,
);
}
const activateResponse = await fetch(
`${BACKEND_URL}/api/profiles/${encodeURIComponent(profileName)}/activate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Session-API-Key": SESSION_API_KEY,
},
},
);
if (!activateResponse.ok) {
throw new Error(
`POST /api/profiles/activate failed: ${activateResponse.status} ${await activateResponse.text()}`,
);
}
const response = await fetch(`${BACKEND_URL}/api/settings`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-Session-API-Key": SESSION_API_KEY,
},
body: JSON.stringify({
agent_settings_diff: {
agent_kind: "openhands",
agent: "CodeActAgent",
llm: {
model: MOCK_MODEL,
api_key: MOCK_LLM_API_KEY,
base_url: MOCK_LLM_URL,
},
},
}),
});
if (!response.ok) {
throw new Error(
`PATCH /api/settings failed: ${response.status} ${await response.text()}`,
);
}
}
async function cleanupStaleHubMcpServers() {
const settingsResponse = await fetch(`${BACKEND_URL}/api/settings`, {
headers: { "X-Session-API-Key": SESSION_API_KEY },
});
if (!settingsResponse.ok) {
throw new Error(
`GET /api/settings failed: ${settingsResponse.status} ${await settingsResponse.text()}`,
);
}
const settings = await settingsResponse.json();
const currentConfig = settings.agent_settings?.mcp_config ?? {};
const staleNames = Object.keys(currentConfig).filter((name) =>
/^c[1-5]_hub_\d+/.test(name),
);
if (staleNames.length === 0) {
return;
}
const response = await fetch(`${BACKEND_URL}/api/settings`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-Session-API-Key": SESSION_API_KEY,
},
body: JSON.stringify({
agent_settings_diff: {
mcp_config: Object.fromEntries(staleNames.map((name) => [name, null])),
},
}),
});
if (!response.ok) {
throw new Error(
`PATCH /api/settings mcp_config failed: ${response.status} ${await response.text()}`,
);
}
}
async function hubApi(pathname, options = {}) {
const response = await fetch(`${HUB_API_URL}${pathname}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${HUB_TOKEN}`,
...(options.headers ?? {}),
},
});
const text = await response.text();
let payload;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok) {
throw new Error(
`Hub ${pathname} failed: ${response.status} ${
typeof payload === "string" ? payload : JSON.stringify(payload)
}`,
);
}
return payload;
}
async function ensureManagedConnector(flow) {
if (flow.hubMode !== "managed-oauth") return;
await hubApi("/api/managed-connectors", {
method: "POST",
body: JSON.stringify({
slug: flow.integrationKey,
name: flow.displayName,
description: flow.managedConnector.description,
categories: ["QA"],
authModes: ["oauth2"],
authStrategy: "oauth2",
provider: "mcp",
serverUrl: flow.fixtureUrl,
oauthConfigured: true,
oauthClientId: flow.managedConnector.oauthClientId,
oauthClientSecret: flow.managedConnector.oauthClientSecret,
oauthConfig: flow.managedConnector.oauthConfig,
credentialLabel: `Connect ${flow.displayName}`,
credentialPlaceholder: `Connect ${flow.displayName}`,
credentialHelp: "Use the fixture OAuth server.",
tools: [],
enabled: true,
}),
}).catch(async (error) => {
if (!String(error.message).includes("409")) throw error;
});
}
async function getHubAgentKey() {
const response = await hubApi("/api/user/agent-key", {
method: "GET",
});
if (!response?.apiKey) {
throw new Error(
`Hub agent key response missing apiKey: ${JSON.stringify(response)}`,
);
}
return response.apiKey;
}
async function addCustomHubIntegration(page, flow) {
await gotoPage(page, `${HUB_URL}/integrations?showIntegrationWizard=1`);
await waitForBodyText(page, "Integrations");
await capture(page, flow.id, "hub-integrations");
const customButton = page.getByRole("button", {
name: /Use a custom MCP server URL instead/i,
});
await customButton.waitFor({ state: "visible", timeout: 20_000 });
await customButton.click();
await page.getByPlaceholder("my-mcp").waitFor({ state: "visible" });
await capture(page, flow.id, "hub-custom-form");
await page.getByPlaceholder("my-mcp").fill(flow.integrationKey);
await page.getByPlaceholder("My MCP").fill(flow.displayName);
await page.getByPlaceholder("https://example.com/mcp").fill(flow.fixtureUrl);
if (flow.credential) {
await page.getByText("Add", { exact: true }).click();
await page.getByPlaceholder("OAuth or bearer token").waitFor({
state: "visible",
timeout: 10_000,
});
}
if (flow.credential?.bearerToken) {
await page
.getByPlaceholder("OAuth or bearer token")
.fill(flow.credential.bearerToken);
}
if (flow.credential?.headerSecret) {
await page
.getByPlaceholder("Secret used with a header")
.fill(flow.credential.headerSecret);
await page.getByPlaceholder("X-API-Key").fill(flow.credential.headerName);
}
await capture(page, flow.id, "hub-custom-filled");
await page.getByRole("button", { name: "Discover tools" }).click();
await waitForBodyText(page, flow.toolName, 60_000);
await capture(page, flow.id, "hub-tools-discovered");
await page
.getByRole("button", { name: /Add integration|Create integration/i })
.click();
await waitForBodyText(page, flow.displayName, 60_000);
await waitForBodyText(page, flow.toolName, 60_000);
await capture(page, flow.id, "hub-integration-created");
}
async function addManagedOauthIntegration(page, flow) {
await ensureManagedConnector(flow);
await gotoPage(
page,
`${HUB_URL}/integrations?showIntegrationWizard=1&managedConnector=${flow.integrationKey}`,
);
await waitForBodyText(page, flow.displayName, 30_000);
await capture(page, flow.id, "hub-managed-form");
const connectButton = page.getByRole("button", { name: /^Connect$/ }).first();
await connectButton.waitFor({ state: "visible", timeout: 20_000 });
await connectButton.click();
await page.waitForURL(/\/integrations/, { timeout: 60_000 });
await waitForBodyText(page, "Connected", 60_000);
await capture(page, flow.id, "hub-oauth-connected");
await page.getByRole("button", { name: "Discover tools" }).click();
await waitForBodyText(page, flow.toolName, 60_000);
await capture(page, flow.id, "hub-tools-discovered");
await page
.getByRole("button", { name: /Add integration|Create integration/i })
.click();
await waitForBodyText(page, flow.displayName, 60_000);
await waitForBodyText(page, flow.toolName, 60_000);
await capture(page, flow.id, "hub-integration-created");
}
async function showAgentConnection(page, flow) {
await gotoPage(page, `${HUB_URL}/agent-connection`);
await waitForBodyText(page, "Agent Connection", 30_000);
await capture(page, flow.id, "hub-agent-connection");
const revealButton = page.getByRole("button", { name: /Reveal/i }).first();
if (await revealButton.isVisible().catch(() => false)) {
await revealButton.click();
await page.waitForTimeout(500);
await capture(page, flow.id, "hub-agent-key-revealed");
}
}
async function installHubMcpInCanvas(page, flow, hubAgentKey) {
const serverName = `${flow.id}_hub_${RUN_TAG}`;
await gotoPage(page, `${FRONTEND_URL}/mcp`);
await dismissBlockingModals(page);
await waitForTestId(page, "mcp-page", 180_000);
await capture(page, flow.id, "canvas-mcp-page");
await page.getByTestId("mcp-add-custom-server").click({ noWaitAfter: true });
await waitForTestId(page, "mcp-custom-editor");
await capture(page, flow.id, "canvas-custom-modal");
await page.getByTestId("server-type-dropdown").fill("SHTTP");
await page.getByTestId("server-type-dropdown").press("Enter");
await page.getByTestId("server-name-input").fill(serverName);
await page
.getByTestId("url-input")
.fill(`${HUB_MCP_URL}?run=${RUN_TAG}-${flow.id}`);
await selectDropdownOption(page, /Authentication/i, "Bearer token");
await page.getByTestId("api-key-input").fill(hubAgentKey);
await capture(page, flow.id, "canvas-hub-auth-filled");
await page
.getByTestId("mcp-custom-editor")
.getByRole("button", { name: "Add Server" })
.click({ noWaitAfter: true });
try {
await page.getByTestId("mcp-custom-editor").waitFor({
state: "hidden",
timeout: 60_000,
});
} catch (error) {
await capture(page, flow.id, "canvas-install-still-open").catch(() => {});
const modalText = await page
.getByTestId("mcp-custom-editor")
.innerText()
.catch(() => "");
throw new Error(
`Hub MCP install modal did not close for ${flow.id}. Text:\n${modalText}`,
{ cause: error },
);
}
await waitForTestId(page, "mcp-installed-list");
await capture(page, flow.id, "canvas-hub-installed");
}
async function runConversation(page, flow) {
await registerTrajectory(flow);
await waitForTestId(page, "conversation-panel-new-thread-picker", 60_000);
await page.getByTestId("conversation-panel-new-thread-picker").click();
await waitForTestId(page, "launch-no-workspace", 30_000);
await capture(page, flow.id, "new-chat-menu");
const conversationResponsePromise = page.waitForResponse((response) => {
return (
response.url().startsWith(`${BACKEND_URL}/api/conversations`) &&
response.request().method() === "POST" &&
response.status() >= 200 &&
response.status() < 300
);
});
await page.getByTestId("launch-no-workspace").click({ noWaitAfter: true });
const conversationResponse = await conversationResponsePromise;
const conversation = await conversationResponse.json();
const conversationShortId = String(conversation.id).slice(0, 5);
await waitForBodyText(page, `Conversation ${conversationShortId}`, 60_000);
await page.keyboard.press("Escape");
const createdConversationCard = page
.getByTestId("conversation-card")
.filter({ hasText: `Conversation ${conversationShortId}` })
.first();
await createdConversationCard.waitFor({ state: "visible", timeout: 30_000 });
await capture(page, flow.id, "conversation-card-created");
await createdConversationCard.dispatchEvent("click");
try {
await page.waitForURL(/\/conversations\/.+/, { timeout: 10_000 });
} catch {
await gotoPage(page, `${FRONTEND_URL}/conversations/${conversation.id}`);
}
await waitForTestId(page, "chat-input", 180_000);
await capture(page, flow.id, "conversation-ready");
await setChatInput(page, flow.prompt);
await capture(page, flow.id, "prompt");
await page.getByTestId("submit-button").click({ noWaitAfter: true });
await capture(page, flow.id, "conversation-started");
await waitForOutputText(page, flow.successToken, 90_000);
await capture(page, flow.id, "tool-observation");
await waitForOutputText(page, flow.finalText, 90_000);
await capture(page, flow.id, "final-reply");
}
async function main() {
fs.mkdirSync(PR_DIR, { recursive: true });
const selectedIds = new Set(
(process.env.FLOW_IDS ?? "")
.split(",")
.map((id) => id.trim())
.filter(Boolean),
);
const selectedFlows =
selectedIds.size === 0
? flows
: flows.filter((flow) => selectedIds.has(flow.id));
if (selectedFlows.length === 0) {
throw new Error(`No matching flows for FLOW_IDS=${process.env.FLOW_IDS}`);
}
const { browser, page } = await setupPage();
try {
await ensureLlmSettings();
await cleanupStaleHubMcpServers();
const hubAgentKey = await getHubAgentKey();
for (const flow of selectedFlows) {
fs.rmSync(frameDir(flow.id), { recursive: true, force: true });
console.log(`Capturing ${flow.title}`);
if (flow.hubMode === "custom") {
await addCustomHubIntegration(page, flow);
} else {
await addManagedOauthIntegration(page, flow);
}
await showAgentConnection(page, flow);
await installHubMcpInCanvas(page, flow, hubAgentKey);
await runConversation(page, flow);
const gif = makeGif(flow.id);
console.log(`Wrote ${path.relative(ROOT, gif)}`);
}
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""FastMCP fixture servers for Agent Canvas authenticated-MCP GIF capture."""
from __future__ import annotations
import argparse
from typing import Any
from urllib.parse import urlparse
from fastmcp import FastMCP
from fastmcp.server.auth import DebugTokenVerifier
from fastmcp.server.auth.auth import ClientRegistrationOptions
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
from fastmcp.server.dependencies import (
CurrentAccessToken,
CurrentHeaders,
)
from mcp.server.auth.provider import AccessToken
from mcp.shared.auth import (
InvalidRedirectUriError,
OAuthClientInformationFull,
)
from pydantic import AnyUrl
BEARER_TOKEN = "elevenlabs-test-token"
DATADOG_API_KEY = "datadog-api-key"
DATADOG_APP_KEY = "datadog-app-key"
STATIC_CLIENT_ID = "notion-client"
STATIC_CLIENT_SECRET = "notion-secret"
OAUTH_SCOPES = "read:mock write:mock"
class LocalhostRedirectClient(OAuthClientInformationFull):
"""Static OAuth client that accepts FastMCP's generated localhost callback."""
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
if redirect_uri is None:
raise InvalidRedirectUriError("redirect_uri is required for this fixture")
parsed = urlparse(str(redirect_uri))
if (
parsed.scheme == "http"
and parsed.hostname in {
"127.0.0.1",
"localhost",
"::1",
}
and (
parsed.path == "/callback"
or parsed.path.startswith("/api/oauth/")
)
):
return redirect_uri
raise InvalidRedirectUriError(
f"Redirect URI '{redirect_uri}' is not an Agent Canvas callback"
)
class StaticClientOAuthProvider(InMemoryOAuthProvider):
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
if client_id == STATIC_CLIENT_ID:
return LocalhostRedirectClient(
client_id=STATIC_CLIENT_ID,
client_secret=STATIC_CLIENT_SECRET,
client_secret_expires_at=0,
token_endpoint_auth_method="client_secret_post",
redirect_uris=[AnyUrl("http://127.0.0.1/callback")],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
scope=OAUTH_SCOPES,
client_name="Mock Notion Static OAuth",
)
return await super().get_client(client_id)
async def authorize(
self,
client: OAuthClientInformationFull,
params: Any,
) -> str:
if client.client_id == STATIC_CLIENT_ID and client.client_id not in self.clients:
self.clients[client.client_id] = client
return await super().authorize(client, params)
def build_weather_server() -> FastMCP:
mcp = FastMCP("Mock Weather MCP")
@mcp.tool
def weather_forecast(city: str) -> str:
"""Return a deterministic mock forecast."""
return f"weather_fixture_success city={city} forecast=sunny-72F"
return mcp
def build_elevenlabs_server() -> FastMCP:
auth = DebugTokenVerifier(validate=lambda token: token == BEARER_TOKEN)
mcp = FastMCP("Mock ElevenLabs MCP", auth=auth)
@mcp.tool
def elevenlabs_voice_note(
text: str,
token: AccessToken = CurrentAccessToken(),
) -> str:
"""Return a deterministic mock voice note id."""
return (
"elevenlabs_fixture_success "
f"client={token.client_id} voice_id=mock-voice text={text}"
)
return mcp
def build_datadog_server() -> FastMCP:
mcp = FastMCP("Mock Datadog MCP")
@mcp.tool
def datadog_metric_snapshot(
service: str,
headers: dict[str, str] = CurrentHeaders(),
) -> str:
"""Validate Datadog-style headers and return a metric sample."""
normalized = {key.lower(): value for key, value in headers.items()}
if normalized.get("dd-api-key") != DATADOG_API_KEY:
return "datadog_fixture_error missing-or-invalid-dd-api-key"
if (
"dd-application-key" in normalized
and normalized.get("dd-application-key") != DATADOG_APP_KEY
):
return "datadog_fixture_error missing-or-invalid-dd-application-key"
return f"datadog_fixture_success service={service} cpu=0.37 errors=0"
return mcp
def build_static_oauth_server(base_url: str) -> FastMCP:
# The local install-time OAuth helper used for GIF capture does not
# consistently forward the requested scope parameter. Keep the fixture
# focused on proving OAuth token acquisition and MCP tool use rather than
# failing before the tool call on a missing demo scope.
auth = StaticClientOAuthProvider(base_url=base_url)
mcp = FastMCP("Mock Notion Static OAuth MCP", auth=auth)
@mcp.tool
def notion_page_lookup(
title: str,
token: AccessToken = CurrentAccessToken(),
) -> str:
"""Return a deterministic mock Notion page lookup."""
scopes = ",".join(token.scopes)
return (
"notion_fixture_success "
f"client={token.client_id} scopes={scopes} title={title}"
)
return mcp
def build_dynamic_oauth_server(base_url: str) -> FastMCP:
auth = InMemoryOAuthProvider(
base_url=base_url,
client_registration_options=ClientRegistrationOptions(
enabled=True,
valid_scopes=["read:mock", "write:mock"],
default_scopes=["read:mock"],
),
)
mcp = FastMCP("Mock Linear Dynamic OAuth MCP", auth=auth)
@mcp.tool
def linear_issue_summary(
issue_key: str,
token: AccessToken = CurrentAccessToken(),
) -> str:
"""Return a deterministic mock Linear issue summary."""
scopes = ",".join(token.scopes)
return (
"linear_fixture_success "
f"client={token.client_id} scopes={scopes} issue={issue_key}"
)
return mcp
def build_server(kind: str, base_url: str) -> FastMCP:
if kind == "weather":
return build_weather_server()
if kind == "elevenlabs":
return build_elevenlabs_server()
if kind == "datadog":
return build_datadog_server()
if kind == "notion":
return build_static_oauth_server(base_url)
if kind == "linear":
return build_dynamic_oauth_server(base_url)
raise ValueError(f"Unsupported fixture kind: {kind}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"kind",
choices=["weather", "elevenlabs", "datadog", "notion", "linear"],
)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--base-url")
parser.add_argument("--port", type=int, required=True)
parser.add_argument("--path", default="/mcp")
args = parser.parse_args()
base_url = args.base_url or f"http://{args.host}:{args.port}"
server = build_server(args.kind, base_url)
server.run(
transport="http",
host=args.host,
port=args.port,
path=args.path,
stateless_http=True,
show_banner=False,
)
if __name__ == "__main__":
main()
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Path-routing proxy for public MCP fixture capture.
One ngrok tunnel points at this proxy. Paths are routed by prefix:
- /weather/* -> weather fixture
- /elevenlabs/* -> bearer-token fixture
- /datadog/* -> header-auth fixture
- /notion/* -> static OAuth fixture
- /linear/* -> dynamic OAuth fixture
- /mock-llm/* -> scripted mock LLM
"""
from __future__ import annotations
import argparse
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit, urlunsplit
import requests
HOP_BY_HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
}
def _target_map() -> dict[str, str]:
return {
"weather": os.environ.get("MCP_WEATHER_TARGET", "http://127.0.0.1:19200"),
"elevenlabs": os.environ.get(
"MCP_ELEVENLABS_TARGET", "http://127.0.0.1:19201"
),
"datadog": os.environ.get("MCP_DATADOG_TARGET", "http://127.0.0.1:19202"),
"notion": os.environ.get("MCP_NOTION_TARGET", "http://127.0.0.1:19203"),
"linear": os.environ.get("MCP_LINEAR_TARGET", "http://127.0.0.1:19204"),
"mock-llm": os.environ.get("MOCK_LLM_TARGET", "http://127.0.0.1:19999"),
}
class ProxyHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self) -> None:
self._proxy()
def do_POST(self) -> None:
self._proxy()
def do_PUT(self) -> None:
self._proxy()
def do_PATCH(self) -> None:
self._proxy()
def do_DELETE(self) -> None:
self._proxy()
def do_OPTIONS(self) -> None:
self._proxy()
def log_message(self, fmt: str, *args: object) -> None:
print(f"{self.client_address[0]} - {fmt % args}", flush=True)
def _proxy(self) -> None:
split = urlsplit(self.path)
parts = [part for part in split.path.split("/") if part]
if not parts:
self._send_text(200, "fixture proxy ok\n")
return
prefix = parts[0]
targets = _target_map()
if (
len(parts) >= 4
and prefix == ".well-known"
and parts[1] == "oauth-protected-resource"
and parts[2] in targets
):
self._send_oauth_resource_metadata(parts[2])
return
upstream_path: str | None = None
if (
len(parts) >= 3
and prefix == ".well-known"
and parts[1] in {"oauth-authorization-server", "openid-configuration"}
and parts[2] in targets
):
prefix = parts[2]
upstream_path = "/" + "/".join(parts[:2])
if prefix in {"authorize", "token", "register"}:
oauth_prefix = self._oauth_prefix_from_resource(split.query)
if oauth_prefix in targets:
prefix = oauth_prefix
upstream_path = split.path
target_base = targets.get(prefix)
if not target_base:
self._send_text(404, f"unknown fixture prefix: {prefix}\n")
return
if upstream_path is None:
upstream_path = "/" + "/".join(parts[1:])
if upstream_path == "/":
upstream_path = "/"
target = urlunsplit(
(
urlsplit(target_base).scheme,
urlsplit(target_base).netloc,
upstream_path,
split.query,
"",
)
)
body = None
content_length = self.headers.get("Content-Length")
if content_length:
body = self.rfile.read(int(content_length))
request_headers = {
key: value
for key, value in self.headers.items()
if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host"
}
try:
with requests.request(
self.command,
target,
headers=request_headers,
data=body,
allow_redirects=False,
stream=True,
timeout=(10, 300),
) as response:
self.send_response(response.status_code)
for key, value in response.headers.items():
if key.lower() in HOP_BY_HOP_HEADERS:
continue
self.send_header(key, value)
self.end_headers()
for chunk in response.iter_content(chunk_size=64 * 1024):
if chunk:
self.wfile.write(chunk)
except requests.RequestException as exc:
self._send_text(502, f"upstream request failed: {exc}\n")
def _send_text(self, status: int, text: str) -> None:
data = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _public_origin(self) -> str:
host = self.headers.get("X-Forwarded-Host") or self.headers.get("Host")
proto = self.headers.get("X-Forwarded-Proto")
if not proto:
proto = "https" if host and "ngrok-free.app" in host else "http"
return f"{proto}://{host}"
def _send_json(self, status: int, value: object) -> None:
data = json.dumps(value).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _send_oauth_resource_metadata(self, kind: str) -> None:
origin = self._public_origin()
self._send_json(
200,
{
"resource": f"{origin}/{kind}/mcp",
"authorization_servers": [f"{origin}/{kind}"],
},
)
@staticmethod
def _oauth_prefix_from_resource(query: str) -> str | None:
resources = parse_qs(query).get("resource", [])
for resource in resources:
parts = [part for part in urlsplit(resource).path.split("/") if part]
if len(parts) >= 2 and parts[-1] == "mcp":
return parts[-2]
return None
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=19300)
args = parser.parse_args()
server = ThreadingHTTPServer((args.host, args.port), ProxyHandler)
print(f"fixture proxy listening on http://{args.host}:{args.port}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+5 -5
View File
@@ -179,7 +179,7 @@ you are running inside of — NOT the automation backend.
- `regressions/` — CSS isolation, event pagination, workspace persistence (`mock-llm-ui-regressions.spec.ts`). Always included in selective runs.
- **Selective test execution**: `test-mapping.json` maps source paths to test subdirectories. The `scripts/resolve-affected-tests.mjs` script reads the PR's changed files and outputs which test directories to run. Four resolution modes: (1) changed files match specific `mappings` → run only those subdirs + `regressions`; (2) changed mock-LLM spec files → run the containing feature subdirectory + `regressions`, so test-only PRs that add new specs still execute the new tests; (3) changed files match `runAllSources` patterns (cross-cutting files like `src/api/agent-server-adapter.ts`, `package.json`, shared test helpers, or `test-mapping.json`) or are unmapped `src/` files → run full suite (`__ALL__`); (4) changed files are outside the E2E-relevant tree (docs, specs) → nothing; the workflow still starts so required checks do not remain pending, but the heavy test job is skipped by its internal change detector. The CI workflow's "Resolve affected test directories" step runs the script and passes the result to Playwright; `workflow_dispatch` always runs the full suite.
- Tests run serially (`workers: 1`, `mode: "serial"` per describe block). Each spec is self-contained (configures its own LLM profile, resets mock LLM in `afterEach`). The `afterEach` hook resets the mock LLM to its default trajectory so subsequent specs start fresh even when a preceding test fails.
- CI workflow: `.github/workflows/mock-llm-e2e.yml` runs on PR commits (opened, synchronize, reopened) and on manual dispatch. It intentionally does **not** use `pull_request.paths` filters, because path-skipped workflows can leave required checks pending. Instead a lightweight `detect-pr-changes` job marks the heavy `mock-llm-e2e` job skipped-success for PRs that only touch docs, specs, or other non-stack files. Relevant paths are `src/**`, `public/**`, `scripts/**`, `bin/**`, `config/**`, `tests/e2e/mock-llm/**`, `tests/e2e/support/**`, `package.json`, `package-lock.json`, build/TS configs, styling configs, and the workflow file itself. `workflow_dispatch` is unaffected by path filters and always runs. The workflow builds the frontend, starts the mock LLM server, runs the tests, and posts a PR comment with results. `render-mock-llm-report.mjs` keeps only the heading/summary/commit links visible and wraps the full test table in a `<details><summary>Details</summary>` block. `upsert-pr-comment.mjs` deletes any older comment for the same mock-LLM job (matched by hidden marker or the legacy bot-authored heading) before posting the latest report, so same-job comments do not accumulate. The PR comment marks tests from newly added spec files with a 🆕 badge; the "Detect newly added spec files" step queries the GitHub API for files with `status == "added"` matching `tests/e2e/mock-llm/**/*.spec.ts`, and passes them as `--new-files` to `render-mock-llm-report.mjs`. The summary line shows the count of new tests (e.g. `🆕 2 new`). The same detection and comment replacement flow are wired into the Docker E2E workflow (`mock-llm-docker-e2e.yml`).
- CI workflow: `.github/workflows/mock-llm-e2e.yml` runs on PR commits (opened, synchronize, reopened) and on manual dispatch. It intentionally does **not** use `pull_request.paths` filters, because path-skipped workflows can leave required checks pending. Instead a lightweight `detect-pr-changes` job marks the heavy `mock-llm-e2e` job skipped-success for PRs that only touch docs, specs, or other non-stack files. Relevant paths are `src/**`, `public/**`, `scripts/**`, `bin/**`, `config/**`, `tests/e2e/mock-llm/**`, `tests/e2e/support/**`, `package.json`, `package-lock.json`, build/TS configs, styling configs, and the workflow file itself. `workflow_dispatch` is unaffected by path filters and always runs. For cross-repo SDK changes, the workflow scans the PR description for an `OpenHands/software-agent-sdk/pull/<number>` link; when present, it installs the mock LLM server's `openhands-sdk` package from that SDK PR branch and runs the agent-server through `OH_AGENT_SERVER_GIT_REF` so the frontend and backend schemas match. The workflow builds the frontend, starts the mock LLM server, runs the tests, and posts a PR comment with results. `render-mock-llm-report.mjs` keeps only the heading/summary/commit links visible and wraps the full test table in a `<details><summary>Details</summary>` block. `upsert-pr-comment.mjs` deletes any older comment for the same mock-LLM job (matched by hidden marker or the legacy bot-authored heading) before posting the latest report, so same-job comments do not accumulate. The PR comment marks tests from newly added spec files with a 🆕 badge; the "Detect newly added spec files" step queries the GitHub API for files with `status == "added"` matching `tests/e2e/mock-llm/**/*.spec.ts`, and passes them as `--new-files` to `render-mock-llm-report.mjs`. The summary line shows the count of new tests (e.g. `🆕 2 new`). The same detection and comment replacement flow are wired into the Docker E2E workflow (`mock-llm-docker-e2e.yml`).
- The custom `DoneMarkerReporter` writes `.mock-llm-markers/.tests-done` after all tests complete (before webServer teardown) so the CI wrapper can detect completion and kill the lingering teardown process.
### Docker Image Testing (Shared Specs)
@@ -194,7 +194,7 @@ you are running inside of — NOT the automation backend.
- **Docker image**: Set `MOCK_LLM_DOCKER_IMAGE` to the image tag (default: `ghcr.io/openhands/agent-canvas:latest`). The container is started with `--rm --network host` and a unique `--name` for cleanup.
- **State isolation**: The Docker container uses its internal state directory (no host mount needed for tests). Each test run starts a fresh container.
- **Skill test volume mounts**: Tests that create files the agent-server needs to read (skill repos, user skills) require Docker volume mounts because the container has an isolated filesystem. The Docker config mounts `.tmp/mock-llm-skill-repos/` → `/tmp/mock-llm-skill-repos/` for project skills and `.tmp/mock-llm-user-skills/` → `/home/openhands/.openhands/skills/` for user skills. Env vars `MOCK_LLM_SKILL_REPOS_CONTAINER_DIR` and `MOCK_LLM_USER_SKILLS_HOST_DIR` tell `skill-test-helpers.ts` which paths to use for agent-server API registration vs. host-side file operations.
- CI workflow: `.github/workflows/mock-llm-docker-e2e.yml` has three triggers — all pull the already-built image from GHCR (no rebuild): (1) `workflow_run` fires automatically after the `Docker` workflow completes on main (no path filter — always validates the published image); (2) `pull_request` fires on PR commits (opened, synchronize, reopened) without `paths:` filters, then its lightweight `detect-pr-changes` job skips the heavy Docker E2E job for docs-only/non-stack PRs while still producing a completed required-check context; (3) `workflow_dispatch` accepts a custom `docker_image` input (always runs). The image tag is derived from the commit SHA (`ghcr.io/openhands/agent-canvas:sha-<short>-amd64`). Fork PRs are skipped (no GHCR push). Report artifacts go to `test-results-mock-llm-docker/` and `playwright-report-mock-llm-docker/`.
- CI workflow: `.github/workflows/mock-llm-docker-e2e.yml` has three triggers — all pull the already-built image from GHCR (no rebuild): (1) `workflow_run` fires automatically after the `Docker` workflow completes on main (no path filter — always validates the published image); (2) `pull_request` fires on PR commits (opened, synchronize, reopened) without `paths:` filters, then its lightweight `detect-pr-changes` job skips the heavy Docker E2E job for docs-only/non-stack PRs while still producing a completed required-check context; (3) `workflow_dispatch` accepts a custom `docker_image` input (always runs). The image tag is derived from the commit SHA (`ghcr.io/openhands/agent-canvas:sha-<short>-amd64`). Fork PRs are skipped (no GHCR push). When the PR description links an `OpenHands/software-agent-sdk` PR, the Docker E2E job installs the host-side mock LLM SDK package from that SDK PR branch and exposes the same branch as `OH_AGENT_SERVER_GIT_REF` for partial-stack specs. Report artifacts go to `test-results-mock-llm-docker/` and `playwright-report-mock-llm-docker/`.
## Debugging E2E Test Failures
@@ -517,10 +517,10 @@ When adding code that needs a new string, decide up front which rule it falls un
- `scripts/dev-safe.mjs` uses `uvx` for temporary agent-server installation — no permanent `uv tool install` needed. Environment variables (highest precedence first):
- `OH_AGENT_SERVER_LOCAL_PATH` — absolute path to a local `software-agent-sdk` checkout. Runs the local checkout via `uvx` with `--with-editable` for `openhands-sdk`/`openhands-tools`/`openhands-workspace` and `--reinstall` for `openhands-agent-server`, so SDK edits are picked up on restart. Highest precedence.
- `OH_AGENT_SERVER_GIT_REF` — git commit SHA or branch name (takes precedence over version)
- `OH_AGENT_SERVER_VERSION` — specific PyPI version (e.g., "1.31.1")
- `OH_AGENT_SERVER_VERSION` — specific PyPI version (e.g., "1.32.0")
- `OH_SECRET_KEY` — secret key for settings encryption; auto-generated and persisted to `~/.openhands/agent-canvas/secret-key.txt` on first run (same file Docker uses), ensuring dev mode and Docker share the same key when both mount the same `~/.openhands` directory. Override with the env var to pin a specific key.
- `SESSION_API_KEY` / `OH_SESSION_API_KEYS_0` / `VITE_SESSION_API_KEY` — session API key for agent-server authentication; auto-generated using `crypto.randomBytes(32)` if not set, passed to both agent-server (`OH_SESSION_API_KEYS_0`) and frontend (`VITE_SESSION_API_KEY`)
- Default: released PyPI version `1.31.1` for agent-server SDK libraries
- Default: released PyPI version `1.32.0` for agent-server SDK libraries
- Security: `scripts/dev-safe.mjs` and `scripts/dev-with-automation.mjs` auto-generate random API keys when needed and persist the defaults so static builds, localStorage, and restarted services stay in sync:
- `SESSION_API_KEY` — 64-character hex (256-bit) for agent-server API authentication; persisted at `~/.openhands/agent-canvas/session-api-key.txt` unless overridden via env var
@@ -643,7 +643,7 @@ When adding code that needs a new string, decide up front which rule it falls un
- CI workflow: a `Read defaults from config/defaults.json` step uses `node -p` to extract values into `$GITHUB_OUTPUT`.
- Dockerfile ARG defaults are kept as fallbacks for local `docker build` without the CI workflow; CI always passes `--build-arg` overrides from the JSON.
- To bump a version, edit `config/defaults.json` only — the JS scripts, Docker build, and CI workflow all derive their values from it.
- Docker all-in-one image: `.github/workflows/docker.yml` builds and publishes `ghcr.io/openhands/agent-canvas` — a combined image that bundles the agent-server (from `ghcr.io/openhands/agent-server`), the automation server (`openhands-automation` via pip), and the agent-canvas frontend (static build). The Dockerfile lives at `docker/Dockerfile`, the entrypoint at `docker/entrypoint.sh`. The workflow structure mirrors the SDK repo's `server.yml`: a `build-and-push-image` matrix job (2 × arch: amd64 on `ubuntu-24.04`, arm64 on `ubuntu-24.04-arm`) pushes arch-suffixed tags, then `merge-manifests` creates multi-arch manifests via `docker buildx imagetools create`, then `consolidate-build-info` aggregates artifacts, and `update-pr-description` updates the PR body (using `<!-- AGENT_CANVAS_DOCKER_START -->` / `<!-- AGENT_CANVAS_DOCKER_END -->` markers). The workflow triggers on push to main, `v*` tags (releases), PRs, and `workflow_dispatch`. On release tags it also pushes semver tags (e.g. `1.2.3`, `1.2`, `1`, `latest`). Fork PRs are skipped (no GHCR auth). The image exposes port 8000 as a unified entry point: `/api/automation/*` → automation (:18001), `/api/*` → agent-server (:18000), `/*` → static frontend. The Dockerfile accepts a `VITE_APP_ENV` build arg (default empty → staging PostHog key); the CI workflow passes `VITE_APP_ENV=production` only for tagged releases (`refs/tags/v*`), so PR and main-branch images use the staging key while release images use the production key, matching the `build:lib` npm path. The entrypoint auto-generates **both** the session API key and `OH_SECRET_KEY` (persisted to `~/.openhands/agent-canvas/session-api-key.txt` and `secret-key.txt` respectively) when none is provided, so the image runs secure by default. Users can override either via env var (`OH_SECRET_KEY`, `SESSION_API_KEY` / `OH_SESSION_API_KEYS_0`). `scripts/dev-safe.mjs` uses the same `secret-key.txt` file, so dev mode and Docker share the same key when both use the same `~/.openhands` directory.
- Docker all-in-one image: `.github/workflows/docker.yml` builds and publishes `ghcr.io/openhands/agent-canvas` — a combined image that bundles the agent-server (from `ghcr.io/openhands/agent-server`), the automation server (`openhands-automation` via pip), and the agent-canvas frontend (static build). The Dockerfile lives at `docker/Dockerfile`, the entrypoint at `docker/entrypoint.sh`. The workflow structure mirrors the SDK repo's `server.yml`: a `build-and-push-image` matrix job (2 × arch: amd64 on `ubuntu-24.04`, arm64 on `ubuntu-24.04-arm`) pushes arch-suffixed tags, then `merge-manifests` creates multi-arch manifests via `docker buildx imagetools create`, then `consolidate-build-info` aggregates artifacts, and `update-pr-description` updates the PR body (using `<!-- AGENT_CANVAS_DOCKER_START -->` / `<!-- AGENT_CANVAS_DOCKER_END -->` markers). The workflow triggers on push to main, `v*` tags (releases), PRs, and `workflow_dispatch`. On release tags it also pushes semver tags (e.g. `1.2.3`, `1.2`, `1`, `latest`). Fork PRs are skipped (no GHCR auth). On PRs that link an `OpenHands/software-agent-sdk` PR in the description, the Docker workflow uses that SDK PR's published branch image (`ghcr.io/openhands/agent-server:<branch-with-slashes-as-dashes>-python`) as the agent-server base image unless a `workflow_dispatch` input explicitly overrides it. The image exposes port 8000 as a unified entry point: `/api/automation/*` → automation (:18001), `/api/*` → agent-server (:18000), `/*` → static frontend. The Dockerfile accepts a `VITE_APP_ENV` build arg (default empty → staging PostHog key); the CI workflow passes `VITE_APP_ENV=production` only for tagged releases (`refs/tags/v*`), so PR and main-branch images use the staging key while release images use the production key, matching the `build:lib` npm path. The entrypoint auto-generates **both** the session API key and `OH_SECRET_KEY` (persisted to `~/.openhands/agent-canvas/session-api-key.txt` and `secret-key.txt` respectively) when none is provided, so the image runs secure by default. Users can override either via env var (`OH_SECRET_KEY`, `SESSION_API_KEY` / `OH_SESSION_API_KEYS_0`). `scripts/dev-safe.mjs` uses the same `secret-key.txt` file, so dev mode and Docker share the same key when both use the same `~/.openhands` directory.
- Spec files live under `specs/`. Spec IDs are stable — never renumber. Mark deprecated specs with ~~strikethrough~~. Tag implementation code and tests with `// @spec BM-002 — Short title` comments so specs are grep-able across the codebase (`grep -rn '@spec BM-' src/ __tests__/`). Place the comment on the line immediately above the relevant code block or test. When multiple tests cover the same spec, use `it.each` if the test structure is identical.
+2 -4
View File
@@ -1265,16 +1265,14 @@ describe("buildStartConversationRequest — ACP discriminator", () => {
acp_server: "claude-code",
acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
mcp_config: {
mcpServers: {
fetch: { command: "uvx", args: ["mcp-server-fetch"] },
},
fetch: { command: "uvx", args: ["mcp-server-fetch"] },
},
},
},
}) as { agent_settings: { mcp_config?: unknown } };
expect(payload.agent_settings.mcp_config).toEqual({
mcpServers: { fetch: { command: "uvx", args: ["mcp-server-fetch"] } },
fetch: { command: "uvx", args: ["mcp-server-fetch"] },
});
});
+1 -1
View File
@@ -175,7 +175,7 @@ describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () =>
});
});
it("preserves a null mcp_config so clearing MCP config still round-trips", async () => {
it("preserves a null mcp_config so clearing MCP servers still round-trips", async () => {
// Arrange: mcp_config: null is an intentional "clear" signal, not an error.
vi.mocked(axios.request).mockResolvedValue({ data: {} });
@@ -5,6 +5,7 @@ import SettingsService, {
} from "#/api/settings-service/settings-service.api";
import * as activeStore from "#/api/backend-registry/active-store";
import type { MCPServerConfig } from "#/types/mcp-server";
import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config";
// vi.mock factories are hoisted before imports, so spy functions must be
// created with vi.hoisted() to be in scope inside the factory.
@@ -117,7 +118,7 @@ describe("McpService.testServer", () => {
// Exact match also guards that non-catalog servers get no `tool_call`.
expect(mockTestServer).toHaveBeenCalledWith({
server: {
type: "stdio",
transport: "stdio",
command: "npx",
args: ["-y", "@my/mcp-server"],
env: { API_KEY: "secret" },
@@ -221,14 +222,14 @@ describe("McpService.testServer", () => {
// Redacted-secret round-trip for the edit flow
//
// The MCP page reads settings with redacted secrets, so unchanged env
// values arrive as the literal "<redacted>" placeholder. The service swaps
// values arrive as the literal redaction placeholder. The service swaps
// them for the stored values in encrypted form (decrypted server-side) so
// the test exercises the real credentials.
// -------------------------------------------------------------------------
const REDACTED_SLACK_SERVER: MCPServerConfig = {
...SLACK_SERVER,
env: { SLACK_TEAM_ID: "T01", SLACK_BOT_TOKEN: "<redacted>" },
env: { SLACK_TEAM_ID: "T01", SLACK_BOT_TOKEN: REDACTED_MCP_SECRET_VALUE },
};
it("substitutes redacted env values with encrypted stored values", async () => {
@@ -236,9 +237,7 @@ describe("McpService.testServer", () => {
vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
agent_settings: {
mcp_config: {
mcpServers: {
slack: { env: { SLACK_BOT_TOKEN: "gAAAAA-encrypted-token" } },
},
slack: { env: { SLACK_BOT_TOKEN: "gAAAAA-encrypted-token" } },
},
},
} as unknown as SettingsApiResponse);
@@ -274,7 +273,10 @@ describe("McpService.testServer", () => {
expect(mockTestServer).toHaveBeenCalledWith(
expect.objectContaining({
server: expect.objectContaining({
env: { SLACK_TEAM_ID: "T01", SLACK_BOT_TOKEN: "<redacted>" },
env: {
SLACK_TEAM_ID: "T01",
SLACK_BOT_TOKEN: REDACTED_MCP_SECRET_VALUE,
},
}),
}),
);
+131 -22
View File
@@ -268,10 +268,10 @@ describe("SettingsService", () => {
it("pre-clears mcp_config before writing the new value on the local backend", async () => {
// The agent-server PATCH applies agent_settings_diff via deep-merge,
// which cannot remove name-keyed entries from mcp_config.mcpServers.
// which cannot remove name-keyed entries from mcp_config.
// saveSettings must compensate by sending a {mcp_config: null} PATCH
// first so the follow-up PATCH effectively replaces the field. Without
// this, deleting a server leaves stale mcpServers keys behind and
// this, deleting a server leaves stale MCP server keys behind and
// shifted indices produce duplicate entries.
const patchBodies: Array<Record<string, unknown>> = [];
server.use(
@@ -287,7 +287,7 @@ describe("SettingsService", () => {
await SettingsService.saveSettings({
agent_settings_diff: {
mcp_config: { mcpServers: { only: { url: "https://x.example" } } },
mcp_config: { only: { url: "https://x.example" } },
},
});
@@ -295,7 +295,7 @@ describe("SettingsService", () => {
{ agent_settings_diff: { mcp_config: null } },
{
agent_settings_diff: {
mcp_config: { mcpServers: { only: { url: "https://x.example" } } },
mcp_config: { only: { url: "https://x.example" } },
},
},
]);
@@ -358,7 +358,7 @@ describe("SettingsService", () => {
await SettingsService.saveSettings({
agent_settings_diff: {
mcp_config: { mcpServers: { only: { url: "https://x.example" } } },
mcp_config: { only: { url: "https://x.example" } },
},
});
@@ -368,7 +368,121 @@ describe("SettingsService", () => {
});
expect(mockSaveCloudSettings).toHaveBeenNthCalledWith(2, {
agent_settings_diff: {
mcp_config: { mcpServers: { only: { url: "https://x.example" } } },
mcp_config: { only: { url: "https://x.example" } },
},
});
});
it("does not pre-clear cloud mcp_config when rotating a redacted MCP key", async () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
mockFetchCloudSettings.mockResolvedValue({
agent_settings: {
mcp_config: {
integrations_hub: {
url: "https://integrations.staging.all-hands.dev/api/mcp",
headers: { Authorization: "**********" },
},
},
},
});
await SettingsService.saveSettings({
agent_settings_diff: {
mcp_config: {
integrations_hub: {
url: "https://integrations.staging.all-hands.dev/api/mcp",
auth: { strategy: "bearer", value: "new-key" },
},
},
},
});
expect(mockSaveCloudSettings).toHaveBeenCalledTimes(1);
expect(mockSaveCloudSettings).toHaveBeenCalledWith({
agent_settings_diff: {
mcp_config: {
integrations_hub: {
url: "https://integrations.staging.all-hands.dev/api/mcp",
headers: { Authorization: "Bearer new-key" },
},
},
},
});
});
it("converts bearer MCP auth to headers when saving mcp_config to cloud", async () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
await SettingsService.saveSettings({
agent_settings_diff: {
mcp_config: {
elevenlabs: {
transport: "http",
url: "https://mcp.example.com/mcp",
auth: { strategy: "bearer", value: "elevenlabs-test-token" },
},
},
},
});
expect(mockSaveCloudSettings).toHaveBeenCalledTimes(2);
expect(mockSaveCloudSettings).toHaveBeenNthCalledWith(2, {
agent_settings_diff: {
mcp_config: {
elevenlabs: {
transport: "http",
url: "https://mcp.example.com/mcp",
headers: {
Authorization: "Bearer elevenlabs-test-token",
},
},
},
},
});
});
it("converts OAuth token state to headers when saving mcp_config to cloud", async () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
await SettingsService.saveSettings({
agent_settings_diff: {
mcp_config: {
notion: {
transport: "http",
url: "https://mcp.example.com/mcp",
auth: {
strategy: "oauth2",
authentication: {
type: "oauth",
client_auth_method: "client_secret_post",
},
state: {
tokens: {
access_token: "oauth-access-token",
},
},
},
},
},
},
});
expect(mockSaveCloudSettings).toHaveBeenCalledTimes(2);
expect(mockSaveCloudSettings).toHaveBeenNthCalledWith(2, {
agent_settings_diff: {
mcp_config: {
notion: {
transport: "http",
url: "https://mcp.example.com/mcp",
headers: {
Authorization: "Bearer oauth-access-token",
},
},
},
},
});
});
@@ -381,9 +495,7 @@ describe("SettingsService", () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
const previousMcpConfig = {
mcpServers: { existing: { url: "https://old.example" } },
};
const previousMcpConfig = { existing: { url: "https://old.example" } };
mockFetchCloudSettings.mockResolvedValue({
agent_settings: { mcp_config: previousMcpConfig },
});
@@ -397,12 +509,11 @@ describe("SettingsService", () => {
if (mcp === null) return Promise.resolve(undefined); // pre-clear
// The full payload from the user contains the *new* mcp_config.
// Distinguish it from the rollback (which writes the previous
// value) by object identity on mcpServers.
// value) by object identity on the server map.
if (
mcp &&
typeof mcp === "object" &&
"mcpServers" in (mcp as Record<string, unknown>) &&
(mcp as { mcpServers: Record<string, unknown> }).mcpServers.new
(mcp as Record<string, unknown>).new
) {
return Promise.reject(new Error("validation failed"));
}
@@ -413,7 +524,7 @@ describe("SettingsService", () => {
await expect(
SettingsService.saveSettings({
agent_settings_diff: {
mcp_config: { mcpServers: { new: { url: "https://new.example" } } },
mcp_config: { new: { url: "https://new.example" } },
},
}),
).rejects.toThrow("validation failed");
@@ -434,7 +545,7 @@ describe("SettingsService", () => {
// Same scenario as the cloud test but for the local agent-server
// path. We assert the rollback PATCH is the final request observed.
const patchBodies: Array<Record<string, unknown>> = [];
const previousMcpServers = {
const previousMcpConfig = {
existing: { url: "https://old.example" },
};
let getCount = 0;
@@ -442,7 +553,7 @@ describe("SettingsService", () => {
http.get("*/api/settings", () => {
getCount += 1;
return HttpResponse.json({
agent_settings: { mcp_config: { mcpServers: previousMcpServers } },
agent_settings: { mcp_config: previousMcpConfig },
conversation_settings: {},
llm_api_key_is_set: false,
});
@@ -459,8 +570,7 @@ describe("SettingsService", () => {
if (
mcp &&
typeof mcp === "object" &&
"mcpServers" in (mcp as Record<string, unknown>) &&
(mcp as { mcpServers: Record<string, unknown> }).mcpServers.new
(mcp as Record<string, unknown>).new
) {
return HttpResponse.json(
{ error: "validation failed" },
@@ -478,7 +588,7 @@ describe("SettingsService", () => {
await expect(
SettingsService.saveSettings({
agent_settings_diff: {
mcp_config: { mcpServers: { new: { url: "https://new.example" } } },
mcp_config: { new: { url: "https://new.example" } },
},
}),
).rejects.toBeDefined();
@@ -489,7 +599,7 @@ describe("SettingsService", () => {
// Final PATCH is the rollback, restoring the previous mcp_config.
const last = patchBodies[patchBodies.length - 1];
expect(last).toEqual({
agent_settings_diff: { mcp_config: { mcpServers: previousMcpServers } },
agent_settings_diff: { mcp_config: previousMcpConfig },
});
// And we must have done the pre-clear too.
expect(patchBodies[0]).toEqual({
@@ -517,7 +627,7 @@ describe("SettingsService", () => {
await expect(
SettingsService.saveSettings({
agent_settings_diff: {
mcp_config: { mcpServers: { new: { url: "https://new.example" } } },
mcp_config: { new: { url: "https://new.example" } },
},
}),
).rejects.toThrow("validation failed");
@@ -540,8 +650,7 @@ describe("SettingsService", () => {
const isNewWrite =
!!mcp &&
typeof mcp === "object" &&
"mcpServers" in (mcp as Record<string, unknown>) &&
!!(mcp as { mcpServers: Record<string, unknown> }).mcpServers.new;
!!(mcp as Record<string, unknown>).new;
expect(isPreClear || isNewWrite).toBe(true);
}
});
@@ -103,11 +103,9 @@ function settingsWithMcpConfig(mcp_config: unknown) {
function settingsWithGithubMcp() {
return settingsWithMcpConfig({
mcpServers: {
github: {
url: GITHUB_HOSTED_MCP_URL,
auth: "github-token",
},
github: {
url: GITHUB_HOSTED_MCP_URL,
auth: { strategy: "bearer", value: "github-token" },
},
});
}
@@ -120,7 +118,7 @@ describe("recommended automations", () => {
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
mockUseSettings.mockReturnValue({
data: settingsWithMcpConfig({ mcpServers: {} }),
data: settingsWithMcpConfig({}),
});
// Pre-flight connectivity test must pass so save mutations are reached.
vi.spyOn(McpService, "testServer").mockResolvedValue({
@@ -21,17 +21,26 @@ const EDIT_STDIO_SERVER: MCPServerConfig = {
args: ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"],
};
const EDIT_OAUTH_SERVER: MCPServerConfig = {
id: "shttp-0",
type: "shttp",
name: "superhuman_mail",
url: "https://mcp.mail.superhuman.com/mcp",
auth: {
strategy: "oauth2",
authentication: { type: "oauth", client_auth_method: "none" },
},
};
function buildSettingsWithMcp(overrides: Partial<Settings> = {}): Settings {
return {
...MOCK_DEFAULT_USER_SETTINGS,
agent_settings: {
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
mcp_config: {
mcpServers: {
github: {
command: "docker",
args: ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"],
},
github: {
command: "docker",
args: ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"],
},
},
},
@@ -70,6 +79,22 @@ function EditEditorOnceSettingsLoaded({ onClose }: { onClose: () => void }) {
);
}
function EditOAuthEditorOnceSettingsLoaded({
onClose,
}: {
onClose: () => void;
}) {
const { data } = useSettings();
if (!data) return null;
return (
<CustomServerEditor
server={EDIT_OAUTH_SERVER}
existingServers={[EDIT_OAUTH_SERVER]}
onClose={onClose}
/>
);
}
function renderWith(ui: React.ReactNode) {
return render(ui, {
wrapper: ({ children }) => (
@@ -209,4 +234,56 @@ describe("CustomServerEditor", () => {
),
);
});
it("persists OAuth state returned by the connection test when editing", async () => {
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
buildSettingsWithMcp({
agent_settings: {
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
mcp_config: {
superhuman_mail: {
url: "https://mcp.mail.superhuman.com/mcp",
transport: "http",
auth: {
strategy: "oauth2",
authentication: { type: "oauth", client_auth_method: "none" },
},
},
},
},
}),
);
vi.spyOn(McpService, "authorizeOAuth").mockResolvedValue({
ok: true,
tools: [],
oauth_state: {
tokens: { access_token: "gAAAAencrypted-access-token" },
token_expires_at: 12345,
},
});
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
renderWith(<EditOAuthEditorOnceSettingsLoaded onClose={vi.fn()} />);
await screen.findByTestId("mcp-custom-editor");
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1));
const sent = (saveSpy.mock.calls[0][0] as Record<string, unknown>)
.agent_settings_diff as {
mcp_config: Record<string, unknown>;
};
expect(sent.mcp_config).toMatchObject({
superhuman_mail: {
auth: {
strategy: "oauth2",
state: {
tokens: { access_token: "gAAAAencrypted-access-token" },
token_expires_at: 12345,
},
},
},
});
});
});
@@ -69,9 +69,9 @@ describe("InstallServerModal", () => {
const [payload] = saveSpy.mock.calls[0];
const sentMcpConfig = (payload as Record<string, unknown>)
.agent_settings_diff as {
mcp_config: { mcpServers: Record<string, unknown> };
mcp_config: Record<string, unknown>;
};
expect(sentMcpConfig.mcp_config.mcpServers).toMatchObject({
expect(sentMcpConfig.mcp_config).toMatchObject({
slack: {
command: "npx",
args: ["-y", "@zencoderai/slack-mcp-server"],
@@ -110,9 +110,9 @@ describe("InstallServerModal", () => {
await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1));
const sent = (saveSpy.mock.calls[0][0] as Record<string, unknown>)
.agent_settings_diff as {
mcp_config: { mcpServers: Record<string, unknown> };
mcp_config: Record<string, unknown>;
};
expect(sent.mcp_config.mcpServers).toMatchObject({
expect(sent.mcp_config).toMatchObject({
tavily: {
command: "npx",
args: ["-y", "tavily-mcp"],
@@ -204,6 +204,216 @@ describe("InstallServerModal", () => {
await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1));
});
it("persists OAuth state returned by the connection test when installing", async () => {
const entry: MarketplaceEntry = {
id: "synthetic-oauth",
name: "Synthetic OAuth",
description: "Synthetic OAuth entry.",
iconBg: "#000000",
connectionOptions: [
{
id: "oauth",
provider: "mcp",
transport: {
kind: "shttp",
url: "https://mcp.example.com/mcp",
},
auth: {
strategy: "oauth2",
oauth: { clientAuthentication: "none" },
},
},
],
};
vi.spyOn(McpService, "authorizeOAuth").mockResolvedValue({
ok: true,
tools: [],
oauth_state: {
tokens: { access_token: "gAAAAencrypted-access-token" },
token_expires_at: 12345,
},
});
const getSpy = vi
.spyOn(SettingsService, "getSettings")
.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
renderWith(<InstallServerModal entry={entry} onClose={vi.fn()} />);
await screen.findByTestId("mcp-install-modal");
await waitFor(() => expect(getSpy).toHaveBeenCalled());
fireEvent.click(screen.getByTestId("mcp-install-submit"));
await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1));
const sent = (saveSpy.mock.calls[0][0] as Record<string, unknown>)
.agent_settings_diff as {
mcp_config: Record<string, unknown>;
};
expect(sent.mcp_config).toMatchObject({
synthetic_oauth: {
url: "https://mcp.example.com/mcp",
auth: {
strategy: "oauth2",
state: {
tokens: { access_token: "gAAAAencrypted-access-token" },
token_expires_at: 12345,
},
},
},
});
});
it("installs header-field remote servers with tagged header auth", async () => {
const entry = {
id: "datadog-style",
name: "Datadog-style Server",
description: "Remote MCP server that authenticates via two headers.",
iconBg: "#632CA6",
connectionOptions: [
{
id: "api",
provider: "mcp",
transport: {
kind: "shttp",
url: "https://mcp.example.com/mcp",
headerFields: [
{
key: "DD-API-KEY",
label: "Datadog API key",
type: "password",
required: true,
},
{
key: "DD-APPLICATION-KEY",
label: "Datadog Application key",
type: "password",
required: true,
},
],
},
auth: { strategy: "none" },
},
],
} as unknown as MarketplaceEntry;
const testSpy = vi
.spyOn(McpService, "testServer")
.mockResolvedValue({ ok: true, tools: [] });
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
renderWith(<InstallServerModal entry={entry} onClose={vi.fn()} />);
await screen.findByTestId("mcp-install-modal");
await waitFor(() => expect(SettingsService.getSettings).toHaveBeenCalled());
expect(
screen.queryByTestId("mcp-install-field-api_key"),
).not.toBeInTheDocument();
fireEvent.change(screen.getByTestId("mcp-install-field-DD-API-KEY"), {
target: { value: "dd-api-secret" },
});
fireEvent.change(
screen.getByTestId("mcp-install-field-DD-APPLICATION-KEY"),
{ target: { value: "dd-app-secret" } },
);
fireEvent.click(screen.getByTestId("mcp-install-submit"));
await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1));
expect(testSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: "shttp",
url: "https://mcp.example.com/mcp",
auth: {
strategy: "header",
headers: {
"DD-API-KEY": "dd-api-secret",
"DD-APPLICATION-KEY": "dd-app-secret",
},
},
}),
);
const sent = (saveSpy.mock.calls[0][0] as Record<string, unknown>)
.agent_settings_diff as {
mcp_config: Record<string, unknown>;
};
expect(sent.mcp_config).toMatchObject({
datadog_style: {
url: "https://mcp.example.com/mcp",
auth: {
strategy: "header",
headers: {
"DD-API-KEY": "dd-api-secret",
"DD-APPLICATION-KEY": "dd-app-secret",
},
},
},
});
});
it("uses the user-edited URL when the transport opts into urlEditable", async () => {
const entry = {
id: "datadog-style",
name: "Datadog-style Server",
description: "Remote MCP server with a site-specific URL.",
iconBg: "#632CA6",
connectionOptions: [
{
id: "api",
provider: "mcp",
transport: {
kind: "shttp",
url: "https://mcp.example.com/mcp",
urlEditable: true,
headerFields: [
{
key: "DD-API-KEY",
label: "Datadog API key",
type: "password",
required: true,
},
],
},
auth: { strategy: "none" },
},
],
} as unknown as MarketplaceEntry;
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
renderWith(<InstallServerModal entry={entry} onClose={vi.fn()} />);
await screen.findByTestId("mcp-install-modal");
await waitFor(() => expect(SettingsService.getSettings).toHaveBeenCalled());
const urlInput = screen.getByTestId(
"mcp-install-field-url",
) as HTMLInputElement;
expect(urlInput).not.toBeDisabled();
fireEvent.change(urlInput, {
target: { value: "https://mcp.us5.example.com/v1/mcp" },
});
fireEvent.change(screen.getByTestId("mcp-install-field-DD-API-KEY"), {
target: { value: "dd-api-secret" },
});
fireEvent.click(screen.getByTestId("mcp-install-submit"));
await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1));
const sent = (saveSpy.mock.calls[0][0] as Record<string, unknown>)
.agent_settings_diff as {
mcp_config: Record<string, unknown>;
};
expect(sent.mcp_config).toMatchObject({
datadog_style: {
url: "https://mcp.us5.example.com/v1/mcp",
auth: {
strategy: "header",
headers: { "DD-API-KEY": "dd-api-secret" },
},
},
});
});
it("installs Linear over streamable HTTP with the api key as a bearer credential", async () => {
// Arrange: the marketplace serves the patched Linear entry (shttp
// /mcp endpoint, bearer auth) — the UI must never touch the removed
@@ -239,20 +449,20 @@ describe("InstallServerModal", () => {
expect.objectContaining({
type: "shttp",
url: "https://mcp.linear.app/mcp",
api_key: "lin_api_secret",
auth: { strategy: "bearer", value: "lin_api_secret" },
}),
);
const sent = (saveSpy.mock.calls[0][0] as Record<string, unknown>)
.agent_settings_diff as {
mcp_config: { mcpServers: Record<string, unknown> };
mcp_config: Record<string, unknown>;
};
// Remote installs are now keyed by the catalog slug ("linear") rather
// than the auto-generated "shttp" fallback, so the server is
// referenceable by name in mcp_server_refs.
expect(sent.mcp_config.mcpServers).toMatchObject({
expect(sent.mcp_config).toMatchObject({
linear: {
url: "https://mcp.linear.app/mcp",
headers: { Authorization: "Bearer lin_api_secret" },
auth: { strategy: "bearer", value: "lin_api_secret" },
},
});
});
@@ -278,7 +488,6 @@ describe("InstallServerModal", () => {
const submit = screen.getByTestId("mcp-install-submit");
// Assert: Cancel precedes the dominant Install action in DOM order.
// eslint-disable-next-line no-bitwise
expect(
cancel.compareDocumentPosition(submit) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
@@ -27,7 +27,7 @@ describe("MCPServerForm validation", () => {
// Fill required fields
fireEvent.change(screen.getByTestId("name-input"), {
target: { value: "my-server" },
target: { value: "my_server" },
});
fireEvent.change(screen.getByTestId("command-input"), {
target: { value: "npx" },
@@ -70,7 +70,7 @@ describe("MCPServerForm validation", () => {
);
fireEvent.change(screen.getByTestId("server-name-input"), {
target: { value: "my-search" },
target: { value: "my_search" },
});
fireEvent.change(screen.getByTestId("url-input"), {
target: { value: "https://api.example.com" },
@@ -81,11 +81,87 @@ describe("MCPServerForm validation", () => {
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).toMatchObject({
type: "sse",
name: "my-search",
name: "my_search",
url: "https://api.example.com",
});
});
it("rejects hyphenated server names because they become tool prefixes", () => {
const onSubmit = vi.fn();
render(
<MCPServerForm
mode="add"
server={{ id: "tmp", type: "shttp" }}
existingServers={[]}
onSubmit={onSubmit}
onCancel={noop}
/>,
);
fireEvent.change(screen.getByTestId("server-name-input"), {
target: { value: "integrations-hub" },
});
fireEvent.change(screen.getByTestId("url-input"), {
target: { value: "https://api.example.com/mcp" },
});
fireEvent.click(screen.getByTestId("submit-button"));
expect(
screen.getByText("SETTINGS$MCP_ERROR_NAME_INVALID"),
).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it("submits header authentication as a tagged auth credential", () => {
const onSubmit = vi.fn();
render(
<MCPServerForm
mode="edit"
server={{
id: "shttp-0",
type: "shttp",
name: "datadog",
url: "https://api.example.com/mcp",
auth: {
strategy: "header",
headers: {
"DD-API-KEY": "",
"DD-APPLICATION-KEY": "",
},
},
}}
existingServers={[]}
onSubmit={onSubmit}
onCancel={noop}
/>,
);
fireEvent.change(screen.getByTestId("headers-input"), {
target: {
value: "DD-API-KEY=dd-api\nDD-APPLICATION-KEY=dd-app",
},
});
fireEvent.click(screen.getByTestId("submit-button"));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).toMatchObject({
type: "shttp",
name: "datadog",
url: "https://api.example.com/mcp",
auth: {
strategy: "header",
headers: {
"DD-API-KEY": "dd-api",
"DD-APPLICATION-KEY": "dd-app",
},
},
});
});
it("rejects an sse/shttp server name with unsafe characters", () => {
const onSubmit = vi.fn();
@@ -139,6 +215,47 @@ describe("MCPServerForm validation", () => {
expect(onSubmit.mock.calls[0][0].name).toBeUndefined();
});
it("preserves OAuth state when editing a remote OAuth server", () => {
const onSubmit = vi.fn();
const oauthState = {
tokens: {
access_token: "**********",
refresh_token: "**********",
},
};
render(
<MCPServerForm
mode="edit"
server={{
id: "shttp-0",
type: "shttp",
name: "superhuman_mail",
url: "https://mcp.mail.superhuman.com/mcp",
auth: {
strategy: "oauth2",
authentication: { type: "oauth", client_auth_method: "none" },
state: oauthState,
},
}}
existingServers={[]}
onSubmit={onSubmit}
onCancel={noop}
/>,
);
fireEvent.click(screen.getByTestId("submit-button"));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).toMatchObject({
auth: {
strategy: "oauth2",
authentication: { type: "oauth", client_auth_method: "none" },
state: oauthState,
},
});
});
it("rejects duplicate URLs across sse/shttp types", () => {
const onSubmit = vi.fn();
@@ -3,6 +3,7 @@ import { AUTOMATION_CATALOG } from "@openhands/extensions/automations";
import { INTEGRATION_CATALOG } from "@openhands/extensions/integrations";
import {
getDefaultMcpTransport,
getInstallableMcpConnectionOption,
getMcpMarketplaceCatalog,
} from "#/utils/mcp-marketplace-utils";
@@ -37,16 +38,15 @@ describe("OpenHands extensions catalogs", () => {
const catalog = getMcpMarketplaceCatalog(INTEGRATION_CATALOG);
const linear = catalog.find((entry) => entry.id === "linear")!;
expect(getDefaultMcpTransport(linear)).toEqual({
const mcpOption = getInstallableMcpConnectionOption(linear)!;
expect(mcpOption.transport).toEqual({
kind: "shttp",
url: "https://mcp.linear.app/mcp",
apiKeyOptional: true,
});
expect(linear.docsUrl).toBe("https://linear.app/docs/mcp");
const mcpOption = linear.connectionOptions.find(
(option) => option.transport?.kind === "shttp",
);
expect(mcpOption?.auth.strategy).toBe("bearer");
expect(mcpOption.auth.strategy).toBe("bearer");
expect(
linear.connectionOptions.some((option) => option.transport?.kind === "sse"),
).toBe(false);
@@ -14,7 +14,7 @@ const SHTTP_SERVER: MCPServerConfig = {
id: "shttp-1",
type: "shttp",
url: "https://mcp.example.com/mcp",
api_key: "secret-key",
auth: { strategy: "bearer", value: "secret-key" },
};
describe("useTestMcpServer", () => {
@@ -36,17 +36,15 @@ describe("useUpdateMcpServer - stdio credential preservation", () => {
it("saves the encrypted stdio env, not the redacted placeholder, when a stdio server is renamed", async () => {
// The redacted settings the editor reads from still carry the original name
// ("old-name") and redacted env. The user renames to "new-name" and leaves
// the secret env value as "<redacted>".
// ("old_name") and redacted env. The user renames to "new_name" and leaves
// the secret env value as the redaction placeholder.
useSettingsMock.mockReturnValue({
data: {
agent_settings: {
mcp_config: {
mcpServers: {
"old-name": {
command: "npx",
env: { API_KEY: REDACTED_MCP_SECRET_VALUE },
},
old_name: {
command: "npx",
env: { API_KEY: REDACTED_MCP_SECRET_VALUE },
},
},
},
@@ -56,11 +54,9 @@ describe("useUpdateMcpServer - stdio credential preservation", () => {
vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
agent_settings: {
mcp_config: {
mcpServers: {
"old-name": {
command: "npx",
env: { API_KEY: "gAAAAA-encrypted-api-key" },
},
old_name: {
command: "npx",
env: { API_KEY: "gAAAAA-encrypted-api-key" },
},
},
},
@@ -75,7 +71,7 @@ describe("useUpdateMcpServer - stdio credential preservation", () => {
server: {
id: "stdio-0",
type: "stdio",
name: "new-name",
name: "new_name",
command: "npx",
env: { API_KEY: REDACTED_MCP_SECRET_VALUE },
},
@@ -88,11 +84,9 @@ describe("useUpdateMcpServer - stdio credential preservation", () => {
.agent_settings_diff as Record<string, unknown> | undefined;
const savedSdkConfig = savedDiff?.mcp_config;
expect(savedSdkConfig).toMatchObject({
mcpServers: {
"new-name": {
command: "npx",
env: { API_KEY: "gAAAAA-encrypted-api-key" },
},
new_name: {
command: "npx",
env: { API_KEY: "gAAAAA-encrypted-api-key" },
},
});
// The literal placeholder must never round-trip into the saved config.
+11 -7
View File
@@ -20,6 +20,11 @@ const EXACT_SEMVER_PATTERN =
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
describe("package library metadata", () => {
const ALLOWED_STACK_PIN_DEPS = new Set([
"@openhands/extensions",
"@openhands/typescript-client",
]);
it("publishes the agent-canvas package entrypoints", () => {
expect(packageJson.name).toBe("@openhands/agent-canvas");
expect(packageJson.main).toBe("./dist/index.cjs");
@@ -64,11 +69,6 @@ describe("package library metadata", () => {
it("does not use git dependencies except approved stack pins", () => {
const GIT_DEP_PATTERN =
/^(git[+:]|github:|bitbucket:|gitlab:|[a-zA-Z0-9_-]+\/)/;
const ALLOWED_GIT_DEPS = new Set([
"@openhands/extensions",
"@openhands/typescript-client",
]);
const allDeps = {
...packageJson.dependencies,
...packageJson.devDependencies,
@@ -77,7 +77,7 @@ describe("package library metadata", () => {
const violations = Object.entries(allDeps)
.filter(
([name, version]) =>
GIT_DEP_PATTERN.test(version) && !ALLOWED_GIT_DEPS.has(name),
GIT_DEP_PATTERN.test(version) && !ALLOWED_STACK_PIN_DEPS.has(name),
)
.map(([name, version]) => `${name}: ${version}`);
@@ -93,7 +93,11 @@ describe("package library metadata", () => {
const violations = Object.entries(allDepsBySection).flatMap(
([section, dependencies]) =>
Object.entries(dependencies ?? {})
.filter(([, version]) => !EXACT_SEMVER_PATTERN.test(version))
.filter(
([name, version]) =>
!EXACT_SEMVER_PATTERN.test(version) &&
!ALLOWED_STACK_PIN_DEPS.has(name),
)
.map(([name, version]) => `${section}.${name}: ${version}`),
);
+25 -37
View File
@@ -134,11 +134,9 @@ describe("MCPPage", () => {
agent_settings: {
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
mcp_config: {
mcpServers: {
acme_internal: {
command: "npx",
args: ["-y", "@acme/internal-mcp-server"],
},
acme_internal: {
command: "npx",
args: ["-y", "@acme/internal-mcp-server"],
},
},
},
@@ -221,12 +219,10 @@ describe("MCPPage", () => {
agent_settings: {
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
mcp_config: {
mcpServers: {
slack: {
command: "npx",
args: ["-y", "@zencoderai/slack-mcp-server"],
env: { SLACK_BOT_TOKEN: "xoxb-abc", SLACK_TEAM_ID: "T01" },
},
slack: {
command: "npx",
args: ["-y", "@zencoderai/slack-mcp-server"],
env: { SLACK_BOT_TOKEN: "xoxb-abc", SLACK_TEAM_ID: "T01" },
},
},
},
@@ -247,12 +243,10 @@ describe("MCPPage", () => {
agent_settings: {
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
mcp_config: {
mcpServers: {
slack: {
command: "npx",
args: ["-y", "@zencoderai/slack-mcp-server"],
env: { SLACK_BOT_TOKEN: "xoxb-abc", SLACK_TEAM_ID: "T01" },
},
slack: {
command: "npx",
args: ["-y", "@zencoderai/slack-mcp-server"],
env: { SLACK_BOT_TOKEN: "xoxb-abc", SLACK_TEAM_ID: "T01" },
},
},
},
@@ -286,11 +280,9 @@ describe("MCPPage", () => {
agent_settings: {
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
mcp_config: {
mcpServers: {
github: {
url: "https://api.githubcopilot.com/mcp/",
auth: "github_pat_test",
},
github: {
url: "https://api.githubcopilot.com/mcp/",
auth: { strategy: "bearer", value: "github_pat_test" },
},
},
},
@@ -318,12 +310,10 @@ describe("MCPPage", () => {
agent_settings: {
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
mcp_config: {
mcpServers: {
tavily: {
command: "npx",
args: ["-y", "tavily-mcp"],
env: { TAVILY_API_KEY: "tvly-secret" },
},
tavily: {
command: "npx",
args: ["-y", "tavily-mcp"],
env: { TAVILY_API_KEY: "tvly-secret" },
},
},
},
@@ -352,12 +342,10 @@ describe("MCPPage", () => {
agent_settings: {
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
mcp_config: {
mcpServers: {
slack: {
command: "npx",
args: ["-y", "@zencoderai/slack-mcp-server"],
env: { SLACK_BOT_TOKEN: "xoxb-old", SLACK_TEAM_ID: "T01" },
},
slack: {
command: "npx",
args: ["-y", "@zencoderai/slack-mcp-server"],
env: { SLACK_BOT_TOKEN: "xoxb-old", SLACK_TEAM_ID: "T01" },
},
},
},
@@ -393,15 +381,15 @@ describe("MCPPage", () => {
await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1));
const sent = (saveSpy.mock.calls[0][0] as Record<string, unknown>)
.agent_settings_diff as {
mcp_config: { mcpServers: Record<string, unknown> };
mcp_config: Record<string, unknown>;
};
// The original Slack stdio entry is preserved and the new stdio
// install is suffixed rather than overwriting it.
expect(Object.keys(sent.mcp_config.mcpServers).sort()).toEqual([
expect(Object.keys(sent.mcp_config).sort()).toEqual([
"slack",
"slack_1",
]);
expect(sent.mcp_config.mcpServers).toMatchObject({
expect(sent.mcp_config).toMatchObject({
slack: { env: { SLACK_BOT_TOKEN: "xoxb-old" } },
slack_1: { env: { SLACK_BOT_TOKEN: "xoxb-new", SLACK_TEAM_ID: "T02" } },
});
+5 -5
View File
@@ -389,18 +389,18 @@ describe("buildAgentServerCommand", () => {
// Defaults to the released PyPI version with all SDK packages pinned to same version
expect(cmd.args).toEqual([
"--from",
"openhands-agent-server==1.31.1",
"openhands-agent-server==1.32.0",
"--with",
"openhands-sdk==1.31.1",
"openhands-sdk==1.32.0",
"--with",
"openhands-tools==1.31.1",
"openhands-tools==1.32.0",
"--with",
"openhands-workspace==1.31.1",
"openhands-workspace==1.32.0",
"--with",
"agent-client-protocol<0.11",
"agent-server",
]);
expect(cmd.source).toBe("PyPI (1.31.1, default)");
expect(cmd.source).toBe("PyPI (1.32.0, default)");
});
it("uses specific PyPI version when OH_AGENT_SERVER_VERSION is set with all packages pinned", () => {
+154 -168
View File
@@ -18,7 +18,7 @@ describe("toSdkMcpConfig", () => {
const out = toSdkMcpConfig(config);
expect(out).not.toBeNull();
expect(Object.keys(out!.mcpServers)).toEqual(["sse", "shttp", "myname"]);
expect(Object.keys(out!)).toEqual(["sse", "shttp", "myname"]);
});
it("only suffixes when the same base actually collides", () => {
@@ -37,7 +37,7 @@ describe("toSdkMcpConfig", () => {
const out = toSdkMcpConfig(config);
expect(Object.keys(out!.mcpServers)).toEqual([
expect(Object.keys(out!)).toEqual([
"sse",
"sse_1",
"sse_2",
@@ -61,7 +61,7 @@ describe("toSdkMcpConfig", () => {
const out = toSdkMcpConfig(config);
expect(out!.mcpServers).toMatchObject({
expect(out!).toMatchObject({
sse: { url: "https://x" },
sse_1: { url: "https://y" },
shttp: { url: "https://z" },
@@ -83,7 +83,7 @@ describe("toSdkMcpConfig", () => {
const out = toSdkMcpConfig(config);
expect(Object.keys(out!.mcpServers)).toEqual(["tool", "tool_1", "other"]);
expect(Object.keys(out!)).toEqual(["tool", "tool_1", "other"]);
});
it("falls back to a 'stdio' base when a stdio entry has no name", () => {
@@ -98,25 +98,42 @@ describe("toSdkMcpConfig", () => {
const out = toSdkMcpConfig(config);
expect(Object.keys(out!.mcpServers)).toEqual(["stdio", "stdio_1"]);
expect(Object.keys(out!)).toEqual(["stdio", "stdio_1"]);
});
it("uses a user-given name as the sse/shttp dict key", () => {
const config: MCPConfig = {
sse_servers: [{ name: "my-search", url: "https://sse.example" }],
shttp_servers: [{ name: "my-docs", url: "https://shttp.example" }],
sse_servers: [{ name: "my_search", url: "https://sse.example" }],
shttp_servers: [{ name: "my_docs", url: "https://shttp.example" }],
stdio_servers: [],
};
const out = toSdkMcpConfig(config);
expect(Object.keys(out!.mcpServers)).toEqual(["my-search", "my-docs"]);
expect(out!.mcpServers["my-search"]).toMatchObject({
expect(Object.keys(out!)).toEqual(["my_search", "my_docs"]);
expect(out!.my_search).toMatchObject({
url: "https://sse.example",
transport: "sse",
});
});
it("normalizes MCP server names before using them as SDK keys", () => {
const config: MCPConfig = {
sse_servers: [
{ name: "integrations-hub", url: "https://hub.example/mcp" },
],
shttp_servers: [],
stdio_servers: [{ name: "docs-server", command: "npx" }],
};
const out = toSdkMcpConfig(config);
expect(Object.keys(out!)).toEqual([
"integrations_hub",
"docs_server",
]);
});
it("falls back to the base name for unnamed sse/shttp entries", () => {
const config: MCPConfig = {
sse_servers: [{ name: "named", url: "https://a" }, { url: "https://b" }],
@@ -126,7 +143,7 @@ describe("toSdkMcpConfig", () => {
const out = toSdkMcpConfig(config);
expect(Object.keys(out!.mcpServers)).toEqual(["named", "sse", "shttp"]);
expect(Object.keys(out!)).toEqual(["named", "sse", "shttp"]);
});
it("de-dups colliding user-given sse/shttp names with a suffix", () => {
@@ -141,30 +158,65 @@ describe("toSdkMcpConfig", () => {
const out = toSdkMcpConfig(config);
expect(Object.keys(out!.mcpServers)).toEqual(["search", "search_1"]);
expect(Object.keys(out!)).toEqual(["search", "search_1"]);
});
it("round-trips a user-given sse/shttp name through parse → write", () => {
const persisted = {
mcpServers: {
"my-search": { url: "https://x", transport: "sse" },
"my-docs": { url: "https://y" },
},
my_search: { url: "https://x", transport: "sse" },
my_docs: { url: "https://y" },
};
const parsed = parseMcpConfig(persisted);
expect(parsed.sse_servers).toEqual([
{ name: "my-search", url: "https://x" },
{ name: "my_search", url: "https://x" },
]);
expect(parsed.shttp_servers).toEqual([
{ name: "my-docs", url: "https://y" },
{ name: "my_docs", url: "https://y" },
]);
const written = toSdkMcpConfig(parsed);
expect(Object.keys(written!.mcpServers).sort()).toEqual([
"my-docs",
"my-search",
expect(Object.keys(written!).sort()).toEqual(["my_docs", "my_search"]);
});
it("parses cloud SDK MCPConfig wrapper shape", () => {
const persisted = {
mcpServers: {
"cloud-weather": {
url: "https://weather.example/mcp",
transport: "http",
},
"cloud-files": {
command: "uvx",
args: ["mcp-server-files"],
},
},
};
const parsed = parseMcpConfig(persisted);
expect(parsed.shttp_servers).toEqual([
{ name: "cloud-weather", url: "https://weather.example/mcp" },
]);
expect(parsed.stdio_servers).toEqual([
{
name: "cloud-files",
command: "uvx",
args: ["mcp-server-files"],
},
]);
});
it("does not unwrap a valid server named mcpServers", () => {
const parsed = parseMcpConfig({
mcpServers: {
url: "https://meta.example/mcp",
},
});
expect(parsed.shttp_servers).toEqual([
{ name: "mcpServers", url: "https://meta.example/mcp" },
]);
});
@@ -173,12 +225,10 @@ describe("toSdkMcpConfig", () => {
// must leave `name` unset — otherwise the auto key would become a
// sticky, user-facing name on the next edit.
const persisted = {
mcpServers: {
sse: { url: "https://a", transport: "sse" },
sse_1: { url: "https://b", transport: "sse" },
shttp: { url: "https://c" },
shttp_2: { url: "https://d" },
},
sse: { url: "https://a", transport: "sse" },
sse_1: { url: "https://b", transport: "sse" },
shttp: { url: "https://c" },
shttp_2: { url: "https://d" },
};
const parsed = parseMcpConfig(persisted);
@@ -199,11 +249,19 @@ describe("toSdkMcpConfig", () => {
).toBeNull();
});
it("serializes remote API keys as authorization headers for SDK/ACP forwarding", () => {
it("serializes remote API keys through the FastMCP auth field", () => {
const config: MCPConfig = {
sse_servers: [{ url: "https://sse.example", api_key: "sse-secret" }],
sse_servers: [
{
url: "https://sse.example",
auth: { strategy: "bearer", value: "sse-secret" },
},
],
shttp_servers: [
{ url: "https://shttp.example", api_key: "shttp-secret" },
{
url: "https://shttp.example",
auth: { strategy: "bearer", value: "shttp-secret" },
},
],
stdio_servers: [],
};
@@ -211,57 +269,35 @@ describe("toSdkMcpConfig", () => {
const out = toSdkMcpConfig(config);
expect(out).toEqual({
mcpServers: {
sse: {
url: "https://sse.example",
transport: "sse",
headers: { Authorization: "Bearer sse-secret" },
},
shttp: {
url: "https://shttp.example",
headers: { Authorization: "Bearer shttp-secret" },
},
sse: {
url: "https://sse.example",
transport: "sse",
auth: { strategy: "bearer", value: "sse-secret" },
},
shttp: {
url: "https://shttp.example",
transport: "http",
auth: { strategy: "bearer", value: "shttp-secret" },
},
});
});
it("round-trips persisted authorization headers back to frontend api_key fields", () => {
const persisted = {
mcpServers: {
shttp: {
url: "https://shttp.example",
headers: { Authorization: "Bearer shttp-secret" },
},
},
};
const parsed = parseMcpConfig(persisted);
expect(parsed.shttp_servers).toEqual([
{ url: "https://shttp.example", api_key: "shttp-secret" },
]);
});
it("keeps names stable across a parse → write round trip", () => {
// Simulates loading the user's persisted settings, parsing them,
// and re-serializing on save (which is what happens on every edit).
// The keys must not drift between trips.
const persisted = {
mcpServers: {
sse: { url: "https://x", transport: "sse" },
sse_1: { url: "https://y", transport: "sse" },
shttp: { url: "https://z" },
github: { command: "/bin/gh" },
},
sse: { url: "https://x", transport: "sse" },
sse_1: { url: "https://y", transport: "sse" },
shttp: { url: "https://z" },
github: { command: "/bin/gh" },
};
const parsed = parseMcpConfig(persisted);
const written = toSdkMcpConfig(parsed);
expect(written).not.toBeNull();
expect(Object.keys(written!.mcpServers).sort()).toEqual(
Object.keys(persisted.mcpServers).sort(),
);
expect(Object.keys(written!).sort()).toEqual(Object.keys(persisted).sort());
});
it("does not bump the suffix on a stdio name when an sse server is added", () => {
@@ -279,130 +315,80 @@ describe("toSdkMcpConfig", () => {
stdio_servers: [{ name: "myname", command: "/bin/run" }],
};
const out1 = toSdkMcpConfig(before)!.mcpServers;
const out2 = toSdkMcpConfig(after)!.mcpServers;
const out1 = toSdkMcpConfig(before)!;
const out2 = toSdkMcpConfig(after)!;
expect("myname" in out1).toBe(true);
expect("myname" in out2).toBe(true);
});
});
describe("parseMcpConfig — deprecated Linear SSE migration", () => {
// Linear removed its MCP SSE transport; persisted configs that still
// point at https://mcp.linear.app/sse must be rewritten to streamable
// HTTP at https://mcp.linear.app/mcp.
it("migrates a legacy Linear SSE server to the /mcp endpoint", () => {
// Arrange
describe("parseMcpConfig / toSdkMcpConfig — auth: oauth round-trip", () => {
it("round-trips auth metadata and state for remote OAuth servers", () => {
const persisted = {
mcpServers: {
sse: { url: "https://mcp.linear.app/sse", transport: "sse" },
},
};
// Act
const parsed = parseMcpConfig(persisted);
// Assert
expect(parsed.sse_servers).toEqual([]);
expect(parsed.shttp_servers).toEqual([
{ url: "https://mcp.linear.app/mcp" },
]);
});
it("preserves the api key and tolerates a trailing slash when migrating", () => {
// Arrange
const persisted = {
mcpServers: {
sse: {
url: "https://mcp.linear.app/sse/",
transport: "sse",
auth: "lin_api_secret",
superhuman_mail: {
url: "https://mcp.mail.superhuman.com/mcp",
transport: "http",
auth: {
strategy: "oauth2",
authentication: {
type: "oauth",
client_auth_method: "none",
},
state: {
tokens: { access_token: "gAAAAencrypted-access-token" },
token_expires_at: 12345,
},
},
},
};
// Act
const parsed = parseMcpConfig(persisted);
const roundTripped = toSdkMcpConfig(parseMcpConfig(persisted));
// Assert
expect(parsed.shttp_servers).toEqual([
{ url: "https://mcp.linear.app/mcp", api_key: "lin_api_secret" },
]);
});
it("leaves non-Linear SSE servers untouched", () => {
// Arrange
const persisted = {
mcpServers: {
sse: { url: "https://other.example/sse", transport: "sse" },
expect(roundTripped).toEqual({
superhuman_mail: {
url: "https://mcp.mail.superhuman.com/mcp",
transport: "http",
auth: {
strategy: "oauth2",
authentication: {
type: "oauth",
client_auth_method: "none",
},
state: {
tokens: { access_token: "gAAAAencrypted-access-token" },
token_expires_at: 12345,
},
},
},
};
// Act
const parsed = parseMcpConfig(persisted);
// Assert
expect(parsed.sse_servers).toEqual([{ url: "https://other.example/sse" }]);
expect(parsed.shttp_servers).toEqual([]);
});
it("keeps a hand-added /mcp entry instead of duplicating it on migration", () => {
// Arrange: legacy SSE install plus a manual /mcp entry that already
// carries its own credential — the manual entry must win.
const persisted = {
mcpServers: {
sse: { url: "https://mcp.linear.app/sse", transport: "sse" },
shttp: { url: "https://mcp.linear.app/mcp", auth: "manual_key" },
},
};
// Act
const parsed = parseMcpConfig(persisted);
// Assert
expect(parsed.shttp_servers).toEqual([
{ url: "https://mcp.linear.app/mcp", api_key: "manual_key" },
]);
});
it("persists the migration as an shttp server on the next save", () => {
// Arrange: the exact broken state from the field — the server key
// "sse" is what produced the rejected `sse_save_comment` tool name.
const persisted = {
mcpServers: {
sse: { url: "https://mcp.linear.app/sse", transport: "sse" },
},
};
// Act: parse (load) then serialize (what every MCP save does).
const written = toSdkMcpConfig(parseMcpConfig(persisted));
// Assert: re-persisted under the shttp key with no transport field,
// so the backend connects via streamable HTTP to the new endpoint.
expect(written).toEqual({
mcpServers: { shttp: { url: "https://mcp.linear.app/mcp" } },
});
});
it("persists migrated legacy Linear credentials as authorization headers", () => {
it("keeps private_key_jwt OAuth client authentication metadata", () => {
const persisted = {
mcpServers: {
sse: {
url: "https://mcp.linear.app/sse",
transport: "sse",
auth: "lin_api_secret",
oauth: {
url: "https://mcp.example.com/mcp",
transport: "http",
auth: {
strategy: "oauth2",
authentication: {
type: "oauth",
client_auth_method: "private_key_jwt",
},
},
},
};
const written = toSdkMcpConfig(parseMcpConfig(persisted));
expect(written).toEqual({
mcpServers: {
shttp: {
url: "https://mcp.linear.app/mcp",
headers: { Authorization: "Bearer lin_api_secret" },
expect(toSdkMcpConfig(parseMcpConfig(persisted))).toEqual({
oauth: {
url: "https://mcp.example.com/mcp",
transport: "http",
auth: {
strategy: "oauth2",
authentication: {
type: "oauth",
client_auth_method: "private_key_jwt",
},
},
},
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { flattenMcpConfig } from "#/utils/mcp-installed-servers";
import type { MCPConfig } from "#/types/settings";
describe("flattenMcpConfig", () => {
it("preserves OAuth metadata and state for installed remote servers", () => {
const config: MCPConfig = {
sse_servers: [],
stdio_servers: [],
shttp_servers: [
{
name: "superhuman-mail",
url: "https://mcp.mail.superhuman.com/mcp",
auth: {
strategy: "oauth2",
authentication: {
type: "oauth",
client_auth_method: "none",
},
state: {
tokens: {
access_token: "gAAAAencrypted-access-token",
},
},
},
},
],
};
expect(flattenMcpConfig(config)).toEqual([
{
id: "shttp-0",
type: "shttp",
name: "superhuman-mail",
url: "https://mcp.mail.superhuman.com/mcp",
headers: undefined,
timeout: undefined,
auth: {
strategy: "oauth2",
authentication: {
type: "oauth",
client_auth_method: "none",
},
state: {
tokens: {
access_token: "gAAAAencrypted-access-token",
},
},
},
},
]);
});
});
+27 -1
View File
@@ -97,7 +97,7 @@ describe("getInstallableMcpConnectionOption", () => {
expect(option?.transport.kind).toBe("stdio");
});
it("returns undefined for an OAuth-only entry (no locally installable option)", () => {
it("returns undefined for provider OAuth entries without a local MCP auth contract", () => {
const oauthOnlyEntry: Parameters<
typeof getInstallableMcpConnectionOption
>[0] = {
@@ -118,6 +118,32 @@ describe("getInstallableMcpConnectionOption", () => {
expect(option).toBeUndefined();
});
it("returns MCP-server-managed OAuth options", () => {
const oauthOnlyEntry: Parameters<
typeof getInstallableMcpConnectionOption
>[0] = {
...slackEntry,
id: "oauth-only",
connectionOptions: [
{
id: "oauth",
provider: "mcp",
auth: {
strategy: "oauth2",
oauth: { clientAuthentication: "none" },
},
transport: { kind: "shttp", url: "https://example.com/mcp" },
} as Parameters<
typeof getInstallableMcpConnectionOption
>[0]["connectionOptions"][number],
],
};
const option = getInstallableMcpConnectionOption(oauthOnlyEntry);
expect(option).toBeDefined();
expect(option?.auth.strategy).toBe("oauth2");
expect(option?.transport.kind).toBe("shttp");
});
it("returns undefined when the entry has no MCP connection options", () => {
const noOptionsEntry: Parameters<
typeof getInstallableMcpConnectionOption
+1 -1
View File
@@ -183,7 +183,7 @@ describe("sdk settings schema helpers", () => {
});
it("treats empty object value as equivalent to null default (mcp_config serializer artifact)", () => {
// The backend serialises mcp_config=None as {} via a custom Pydantic
// The backend serializes absent mcp_config as {} via a custom Pydantic
// serializer, but the schema default is null. The view should stay
// "basic" because an empty object is semantically the same as null.
const withEmptyMcpConfig: Settings = {
+3 -3
View File
@@ -2,9 +2,9 @@
"_comment": "Single source of truth for version pins, ports, paths, and defaults shared across the npm and Docker install paths. Read by scripts/dev-safe.mjs, scripts/dev-with-automation.mjs, docker/entrypoint.sh (via generated defaults.env), and .github/workflows/docker.yml.",
"versions": {
"agentServer": "1.31.1",
"agentServer": "1.32.0",
"agentCanvas": "1.1.0",
"automation": "1.1.2"
"automation": "1.1.3"
},
"compatibility": {
@@ -12,7 +12,7 @@
},
"constraints": {
"_comment": "Temporary upper bound on the agent-client-protocol (acp) transitive dependency. openhands-sdk 1.31.1 requires agent-client-protocol>=0.10.1 with no upper bound, but acp 0.11.0 reordered the ACP prompt() arguments and breaks the SDK's ACP client (a PromptRequest validation error surfaces as 'ACP error: 2 validation errors for PromptRequest'). Pin acp <0.11 until a fixed openhands-sdk ships. Consumed by scripts/dev-safe.mjs (agent-server uvx install) and .github/workflows/mock-llm-e2e.yml (mock ACP server venv).",
"_comment": "Temporary upper bound on the agent-client-protocol (acp) transitive dependency. openhands-sdk 1.32.0 requires agent-client-protocol>=0.10.1 with no upper bound, but acp 0.11.0 reordered the ACP prompt() arguments and breaks the SDK's ACP client (a PromptRequest validation error surfaces as 'ACP error: 2 validation errors for PromptRequest'). Pin acp <0.11 until a fixed openhands-sdk ships. Consumed by scripts/dev-safe.mjs (agent-server uvx install) and .github/workflows/mock-llm-e2e.yml (mock ACP server venv).",
"agentClientProtocol": "agent-client-protocol<0.11"
},
+9
View File
@@ -70,6 +70,7 @@ RUN node -e " \
FROM ${AGENT_SERVER_IMAGE} AS final
ARG AUTOMATION_VERSION
ARG AGENT_SERVER_SDK_GIT_REF=""
ARG OPENHANDS_BUILD_GIT_SHA=unknown
ARG OPENHANDS_BUILD_GIT_REF=unknown
@@ -100,6 +101,14 @@ RUN if command -v apt-get >/dev/null 2>&1; then \
RUN uv pip install --system "openhands-automation==${AUTOMATION_VERSION}" 2>/dev/null \
|| pip install --no-cache-dir "openhands-automation==${AUTOMATION_VERSION}"
RUN if [ -n "${AGENT_SERVER_SDK_GIT_REF}" ]; then \
BASE_GIT_URL="git+https://github.com/OpenHands/software-agent-sdk@${AGENT_SERVER_SDK_GIT_REF}" && \
uv pip install --system --force-reinstall \
"${BASE_GIT_URL}#subdirectory=openhands-sdk" \
"${BASE_GIT_URL}#subdirectory=openhands-tools" \
"${BASE_GIT_URL}#subdirectory=openhands-workspace"; \
fi
# Copy the frontend build output.
# react-router.config.ts unpacks build/client/ into build/ for non-Vercel builds.
COPY --from=frontend-build /build/build /opt/agent-canvas/frontend
+1
View File
@@ -111,6 +111,7 @@ fi
export OPENHANDS_AUTOMATION_API_KEY="${OPENHANDS_AUTOMATION_API_KEY:-${EFFECTIVE_SESSION_KEY}}"
export AUTOMATION_LOCAL_API_KEY="${AUTOMATION_LOCAL_API_KEY:-${EFFECTIVE_SESSION_KEY}}"
export AUTOMATION_AGENT_SERVER_API_KEY="${AUTOMATION_AGENT_SERVER_API_KEY:-${EFFECTIVE_SESSION_KEY}}"
export OPENHANDS_REMOTE_WS_READY_REQUIRED="${OPENHANDS_REMOTE_WS_READY_REQUIRED:-false}"
# AGENT_SERVER_URL — needed by automation sandbox callbacks.
export AGENT_SERVER_URL="${AGENT_SERVER_URL:-http://127.0.0.1:${AGENT_SERVER_PORT}}"
+12 -14
View File
@@ -12,8 +12,8 @@
"@heroui/react": "2.8.10",
"@microlink/react-json-view": "1.31.20",
"@monaco-editor/react": "4.7.0",
"@openhands/extensions": "0.7.0",
"@openhands/typescript-client": "1.28.0",
"@openhands/extensions": "0.8.0",
"@openhands/typescript-client": "1.32.0",
"@react-router/node": "7.17.0",
"@react-router/serve": "7.17.0",
"@tailwindcss/vite": "4.2.4",
@@ -3469,9 +3469,9 @@
"license": "MIT"
},
"node_modules/@openhands/extensions": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.7.0.tgz",
"integrity": "sha512-p0gY8bBR5Kmyu9I+/YYy0MlvO5D92KQYbocv3/W1Foy/fkcJv92iCultz66Vczpr42lzl+mc0FJ1/xO9/1+JSQ==",
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.8.0.tgz",
"integrity": "sha512-R3P39FkePK1Ew8eGnWAvcpsaDtcbgDdGRSe9F1PVVPdTVLqdx6rb7xqAWw4nqEr/E/m8b54hIE8ajSLluS5b3A==",
"engines": {
"node": ">=18.20.0"
},
@@ -3482,21 +3482,19 @@
}
},
"node_modules/@openhands/typescript-client": {
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/@openhands/typescript-client/-/typescript-client-1.28.0.tgz",
"integrity": "sha512-hp/Uk3+8z4DexPtNTl6yhdZkGE6e+ZnCoL+Z7bVhIrBN2QuxLy/nc7pO7+JliXO9ZPuh9N3+DLQPfdVZvsKZqg==",
"license": "MIT",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/@openhands/typescript-client/-/typescript-client-1.32.0.tgz",
"integrity": "sha512-q7J6yNOPu9QB/1KP35xw5QRucqpcez8eCnrDuFmVFRfwqvcqfvD6DbHl5xvgTZNV/AnLGujHH9xA6DIkf8xrFw==",
"dependencies": {
"@openrouter/sdk": "^0.12.35",
"@openrouter/sdk": "^0.13.24",
"ws": "^8.20.0"
}
},
"node_modules/@openrouter/sdk": {
"version": "0.12.79",
"resolved": "https://registry.npmjs.org/@openrouter/sdk/-/sdk-0.12.79.tgz",
"integrity": "sha512-0ZpwtnuHh3/B1piW9kHCUIQy6PAsaK/vjFdZuHxmCdAenCyUNsLA2mFpmfHNWRNb+bOO3yBc4IALa264UyzmBA==",
"version": "0.13.24",
"resolved": "https://registry.npmjs.org/@openrouter/sdk/-/sdk-0.13.24.tgz",
"integrity": "sha512-E/gdhaUl9UKn1ngm+kw21ybnqB5iAAwbxnsDzE2iTZzQCbR7J0REZa65HJY8TxYwpaOs3rLpiHyi4w8LNKpvmQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
+2 -2
View File
@@ -23,8 +23,8 @@
"@heroui/react": "2.8.10",
"@microlink/react-json-view": "1.31.20",
"@monaco-editor/react": "4.7.0",
"@openhands/extensions": "0.7.0",
"@openhands/typescript-client": "1.28.0",
"@openhands/extensions": "0.8.0",
"@openhands/typescript-client": "1.32.0",
"@react-router/node": "7.17.0",
"@react-router/serve": "7.17.0",
"@tailwindcss/vite": "4.2.4",
+6 -6
View File
@@ -19,7 +19,7 @@
*
* Usage:
* node scripts/check-sdk-version-sync.mjs
* EXPECTED_SDK_VERSION=1.31.1 node scripts/check-sdk-version-sync.mjs
* EXPECTED_SDK_VERSION=1.32.0 node scripts/check-sdk-version-sync.mjs
* node scripts/check-sdk-version-sync.mjs --check-pypi
*
* Environment variables:
@@ -78,7 +78,7 @@ Triggering from other repos:
-H "Authorization: token \$GITHUB_TOKEN" \\
-H "Accept: application/vnd.github.v3+json" \\
https://api.github.com/repos/OpenHands/agent-canvas/dispatches \\
-d '{"event_type": "sdk-version-check", "client_payload": {"version": "1.31.1"}}'
-d '{"event_type": "sdk-version-check", "client_payload": {"version": "1.32.0"}}'
`);
process.exit(0);
}
@@ -260,9 +260,9 @@ async function fetchPyPIDependencies(packageName, version) {
* Parse PyPI requires_dist array and extract SDK package versions
*
* PyPI returns dependencies in PEP 508 format like:
* "openhands-sdk>=1.31.1,<2.0.0"
* "openhands-tools==1.31.1"
* "openhands-workspace (>=1.31.1)"
* "openhands-sdk>=1.32.0,<2.0.0"
* "openhands-tools==1.32.0"
* "openhands-workspace (>=1.32.0)"
*/
function parseSdkVersionsFromRequiresDist(requiresDist) {
const versions = {};
@@ -276,7 +276,7 @@ function parseSdkVersionsFromRequiresDist(requiresDist) {
}
// Extract the version number - look for patterns like:
// ">=1.31.1", "==1.31.1", "(>=1.31.1)", "~=1.31.1"
// ">=1.32.0", "==1.32.0", "(>=1.32.0)", "~=1.32.0"
// After the package name and before any comma or closing paren
const versionPattern = /[><=~!]+\s*([0-9]+(?:\.[0-9]+)*)/;
const match = dep.match(versionPattern);
@@ -38,7 +38,12 @@ const IDENTICAL_VALUE_ALLOWLIST = new Set([
'SETTINGS$CLOUD_SETTINGS_LINK',
'SETTINGS$GITHUB',
'SETTINGS$GITLAB',
'SETTINGS$MCP_AUTH_MODE_OAUTH',
'SETTINGS$MCP_DEFAULT_CONFIG',
'SETTINGS$MCP_HEADERS_PLACEHOLDER',
'SETTINGS$MCP_OAUTH_CLIENT_ID_PLACEHOLDER',
'SETTINGS$MCP_OAUTH_CLIENT_SECRET_PLACEHOLDER',
'SETTINGS$MCP_OAUTH_SCOPES_PLACEHOLDER',
'SETTINGS$MCP_SERVER_TYPE_SHTTP',
'SETTINGS$MCP_SERVER_TYPE_SSE',
'SETTINGS$MCP_SERVER_TYPE_STDIO',
+2 -2
View File
@@ -48,7 +48,7 @@ const LOCAL_AGENT_SERVER_SUBDIRS = [
"openhands-workspace",
];
const DEFAULT_AGENT_SERVER_VERSION = SHARED_DEFAULTS.versions.agentServer;
// Temporary transitive-dep pin: openhands-sdk 1.31.1 leaves agent-client-protocol
// Temporary transitive-dep pin: openhands-sdk 1.32.0 leaves agent-client-protocol
// unbounded (>=0.10.1), but acp 0.11.0 reordered the ACP prompt() args and breaks
// the SDK's ACP client. Hold acp <0.11 until a fixed SDK ships. See config/defaults.json.
const AGENT_CLIENT_PROTOCOL_CONSTRAINT =
@@ -399,7 +399,7 @@ export function validateFrontendDependencies(
* edits are picked up without a manual reinstall. The agent-server itself
* is rebuilt from local source on each invocation (--reinstall).
* - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name
* - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.31.1")
* - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.32.0")
*
* If none are set, defaults to the released version specified by
* DEFAULT_AGENT_SERVER_VERSION. Set OH_AGENT_SERVER_GIT_REF to use a
+4
View File
@@ -748,6 +748,8 @@ function startAgentServer(config) {
const agentServerEnv = {
...buildAgentServerEnv(safeConfig),
...buildAgentServerAutomationEnv(config),
OPENHANDS_REMOTE_WS_READY_REQUIRED:
process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false",
// Ensure the agent-server uses the resolved key from config. This is
// LOCAL_BACKEND_API_KEY when set, or the auto-generated persisted key.
OH_SESSION_API_KEYS_0: config.sessionApiKey,
@@ -803,6 +805,8 @@ function startAutomationBackend(config) {
// Force UTF-8 for all Python file I/O (same reason as agent-server;
// see buildAgentServerEnv in dev-safe.mjs).
PYTHONUTF8: "1",
OPENHANDS_REMOTE_WS_READY_REQUIRED:
process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false",
// The URL the automation backend itself uses to call the
// agent-server's REST API (tarball upload + bash dispatch).
//
+13 -19
View File
@@ -26,13 +26,11 @@ describe("buildStartConversationRequest", () => {
api_key: "gAAAAAencrypted-llm-api-key",
},
mcp_config: {
mcpServers: {
linear: {
url: "https://mcp.linear.app/mcp",
transport: "http",
headers: {
Authorization: encryptedValue,
},
linear: {
url: "https://mcp.linear.app/mcp",
transport: "http",
headers: {
Authorization: encryptedValue,
},
},
},
@@ -58,13 +56,11 @@ describe("buildStartConversationRequest", () => {
acp_command: ["codex-acp"],
acp_model: "gpt-5.5/medium",
mcp_config: {
mcpServers: {
linear: {
url: "https://mcp.linear.app/mcp",
transport: "http",
headers: {
Authorization: encryptedValue,
},
linear: {
url: "https://mcp.linear.app/mcp",
transport: "http",
headers: {
Authorization: encryptedValue,
},
},
},
@@ -90,11 +86,9 @@ describe("buildStartConversationRequest", () => {
acp_command: ["codex-acp"],
acp_model: "gpt-5.5/medium",
mcp_config: {
mcpServers: {
publicDocs: {
url: "https://docs.example.com/mcp",
transport: "http",
},
publicDocs: {
url: "https://docs.example.com/mcp",
transport: "http",
},
},
};
+20 -17
View File
@@ -441,22 +441,25 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function hasEncryptedString(value: unknown): boolean {
if (typeof value === "string") {
return value.startsWith(FERNET_TOKEN_PREFIX);
}
if (Array.isArray(value)) {
return value.some(hasEncryptedString);
}
if (isPlainRecord(value)) {
return Object.values(value).some(hasEncryptedString);
}
return false;
}
function hasEncryptedMcpSecrets(mcpConfig: unknown): boolean {
if (!isPlainRecord(mcpConfig) || !isPlainRecord(mcpConfig.mcpServers)) {
if (!isPlainRecord(mcpConfig)) {
return false;
}
return Object.values(mcpConfig.mcpServers).some((server) => {
if (!isPlainRecord(server)) return false;
return ["env", "headers"].some((key) => {
const values = server[key];
if (!isPlainRecord(values)) return false;
return Object.values(values).some(
(value) =>
typeof value === "string" && value.startsWith(FERNET_TOKEN_PREFIX),
);
});
});
return Object.values(mcpConfig).some(hasEncryptedString);
}
function getConversationConfirmationPolicy(
@@ -709,12 +712,12 @@ function buildConfiguredAcpAgentSettings(
}
}
// ``mcp_config`` is a *shared* field (not in ACP_SETTINGS_KEYS): forward it
// ``mcp_config`` is a shared field (not in ACP_SETTINGS_KEYS): forward it
// so the ACP subprocess connects to the configured MCP servers at session
// creation. Only include it when it actually carries servers — an empty or
// malformed value is dropped rather than sending ``mcp_config: {}``.
const mcpConfig = toRecord(agentSettings.mcp_config);
if (Object.keys(mcpConfig).length > 0 && "mcpServers" in mcpConfig) {
if (Object.keys(mcpConfig).length > 0) {
payload.mcp_config = mcpConfig;
}
@@ -779,7 +782,7 @@ function buildConfiguredOpenHandsAgentSettings(
}
const mcpConfig = toRecord(agentSettings.mcp_config);
if (Object.keys(mcpConfig).length === 0 || !("mcpServers" in mcpConfig)) {
if (Object.keys(mcpConfig).length === 0) {
delete agentSettings.mcp_config;
}
@@ -932,8 +935,8 @@ export function buildStartConversationRequest(
// conversation start. Non-ACP conversations need it for encrypted LLM keys.
// ACP normally carries provider credentials as LookupSecrets, so avoid
// forcing a cipher on fresh ACP-only backends. The exception is MCP:
// encrypted settings round-trip mcp_config.env/headers as Fernet tokens,
// and ACP forwards that mcp_config directly to the subprocess.
// encrypted settings round-trip mcp_config secrets as Fernet tokens,
// and ACP forwards mcp_config directly to the subprocess.
if (
options.secretsEncrypted &&
(!acpMode || hasEncryptedMcpSecrets(agentSettings.mcp_config))
+4 -2
View File
@@ -1,5 +1,4 @@
import {
type MCPConfig,
type Provider,
type Settings,
type SettingsValue,
@@ -46,7 +45,7 @@ type CloudSettingsResponse = {
remote_runtime_resource_factor?: number | null;
max_budget_per_task?: number | null;
provider_tokens_set?: Partial<Record<Provider, string | null>>;
mcp_config?: MCPConfig;
mcp_config?: Record<string, SettingsValue>;
disabled_skills?: string[];
agent_settings?: Record<string, SettingsValue> | null;
conversation_settings?: Record<string, SettingsValue> | null;
@@ -91,6 +90,9 @@ function deriveAgentSettings(
if (Object.keys(condenser).length > 0) agent.condenser = condenser;
if (typeof flat.agent === "string") agent.agent = flat.agent;
if (flat.mcp_config && Object.keys(flat.mcp_config).length > 0) {
agent.mcp_config = flat.mcp_config;
}
return agent;
}
@@ -13,15 +13,13 @@ describe("substituteRedactedMcpCredentials", () => {
it("preserves encrypted stdio env when the server is renamed", async () => {
// Regression: renaming a stdio server left a redacted env value unchanged,
// so the lookup by the new display name missed the stored entry and the
// literal "<redacted>" placeholder overwrote the stored encrypted secret.
// literal redaction placeholder overwrote the stored encrypted secret.
vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
agent_settings: {
mcp_config: {
mcpServers: {
"old-name": {
command: "npx",
env: { API_KEY: "gAAAAA-encrypted-api-key" },
},
"old-name": {
command: "npx",
env: { API_KEY: "gAAAAA-encrypted-api-key" },
},
},
},
@@ -45,10 +43,8 @@ describe("substituteRedactedMcpCredentials", () => {
vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
agent_settings: {
mcp_config: {
mcpServers: {
alpha: { command: "npx", env: { TOKEN: "gAAAAA-alpha-token" } },
beta: { command: "npx", env: { TOKEN: "gAAAAA-beta-token" } },
},
alpha: { command: "npx", env: { TOKEN: "gAAAAA-alpha-token" } },
beta: { command: "npx", env: { TOKEN: "gAAAAA-beta-token" } },
},
},
} as unknown as SettingsApiResponse);
@@ -68,11 +64,9 @@ describe("substituteRedactedMcpCredentials", () => {
vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
agent_settings: {
mcp_config: {
mcpServers: {
"my-server": {
command: "npx",
env: { API_KEY: "gAAAAA-encrypted", REGION: "us-east-1" },
},
"my-server": {
command: "npx",
env: { API_KEY: "gAAAAA-encrypted", REGION: "us-east-1" },
},
},
},
@@ -112,7 +106,7 @@ describe("substituteRedactedMcpCredentials", () => {
it("keeps the placeholder when the stored stdio entry is missing", async () => {
vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
agent_settings: { mcp_config: { mcpServers: {} } },
agent_settings: { mcp_config: {} },
} as unknown as SettingsApiResponse);
const result = await substituteRedactedMcpCredentials({
@@ -125,4 +119,63 @@ describe("substituteRedactedMcpCredentials", () => {
expect(result.env).toEqual({ API_KEY: REDACTED_MCP_SECRET_VALUE });
});
it("replaces redacted OAuth state with the encrypted stored subtree", async () => {
vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
agent_settings: {
mcp_config: {
"superhuman-mail": {
url: "https://mcp.mail.superhuman.com/mcp",
auth: {
strategy: "oauth2",
state: {
tokens: {
access_token: "gAAAAA-encrypted-access-token",
refresh_token: "gAAAAA-encrypted-refresh-token",
},
client_info: {
client_id: "superhuman-client",
client_secret: "gAAAAA-encrypted-client-secret",
},
},
},
},
},
},
} as unknown as SettingsApiResponse);
const result = await substituteRedactedMcpCredentials({
id: "shttp-0",
type: "shttp",
name: "superhuman-mail",
url: "https://mcp.mail.superhuman.com/mcp",
auth: {
strategy: "oauth2",
state: {
tokens: {
access_token: REDACTED_MCP_SECRET_VALUE,
refresh_token: REDACTED_MCP_SECRET_VALUE,
},
client_info: {
client_id: "superhuman-client",
client_secret: REDACTED_MCP_SECRET_VALUE,
},
},
},
});
expect(result.auth).toEqual({
strategy: "oauth2",
state: {
tokens: {
access_token: "gAAAAA-encrypted-access-token",
refresh_token: "gAAAAA-encrypted-refresh-token",
},
client_info: {
client_id: "superhuman-client",
client_secret: "gAAAAA-encrypted-client-secret",
},
},
});
});
});
+24 -15
View File
@@ -1,15 +1,20 @@
import SettingsService from "#/api/settings-service/settings-service.api";
import { isMcpAuthCredential } from "#/types/mcp-auth";
import type { MCPServerConfig } from "#/types/mcp-server";
import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config";
import {
hasRedactedMcpSecretLeaf,
REDACTED_MCP_SECRET_VALUE,
} from "#/utils/mcp-config";
type StoredMcpServer = {
url?: unknown;
transport?: unknown;
env?: unknown;
auth?: unknown;
headers?: unknown;
};
type StoredMcpServers = Record<string, StoredMcpServer>;
type StoredMcpConfig = Record<string, StoredMcpServer>;
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
@@ -57,7 +62,7 @@ const stdioIndexFromId = (id: string | undefined): number | undefined => {
const findStoredStdioByIndex = (
id: string | undefined,
storedServers: StoredMcpServers,
storedServers: StoredMcpConfig,
): StoredMcpServer | undefined => {
const index = stdioIndexFromId(id);
if (index === undefined) return undefined;
@@ -69,7 +74,7 @@ const findStoredStdioByIndex = (
const findStoredServer = (
server: MCPServerConfig,
storedServers: StoredMcpServers,
storedServers: StoredMcpConfig,
): StoredMcpServer | undefined => {
if (server.type === "stdio") {
// Prefer the positional id: a rename changes the display name (the stored
@@ -98,28 +103,30 @@ async function fetchEncryptedStoredServer(
): Promise<StoredMcpServer | undefined> {
const response = await SettingsService.fetchSettingsFromApi("encrypted");
const mcpConfig = response.agent_settings?.mcp_config;
if (!isRecord(mcpConfig) || !isRecord(mcpConfig.mcpServers)) {
if (!isRecord(mcpConfig)) {
return undefined;
}
return findStoredServer(server, mcpConfig.mcpServers as StoredMcpServers);
return findStoredServer(server, mcpConfig as StoredMcpConfig);
}
/**
* The MCP editor sees redacted settings (`<redacted>`). When the user leaves
* The MCP editor sees redacted settings (`**********`). When the user leaves
* a secret unchanged, replace that placeholder with the stored encrypted
* env/header value so tests and saves round-trip the real credential without
* exposing plaintext in the browser.
* env/header/OAuth state value so tests and saves round-trip the real
* credential without exposing plaintext in the browser.
*/
export async function substituteRedactedMcpCredentials(
server: MCPServerConfig,
): Promise<MCPServerConfig> {
const redactedStdioEnv =
server.type === "stdio" && hasRedactedValue(server.env);
const redactedRemoteApiKey =
const redactedRemoteAuth =
(server.type === "sse" || server.type === "shttp") &&
server.api_key === REDACTED_MCP_SECRET_VALUE;
hasRedactedMcpSecretLeaf(server.auth);
if (!redactedStdioEnv && !redactedRemoteApiKey) return server;
if (!redactedStdioEnv && !redactedRemoteAuth) {
return server;
}
try {
const stored = await fetchEncryptedStoredServer(server);
@@ -139,9 +146,11 @@ export async function substituteRedactedMcpCredentials(
return { ...server, env };
}
const headers = stringRecord(stored.headers);
if (!headers) return server;
return { ...server, api_key: undefined, headers };
if (!redactedRemoteAuth) return server;
if (isMcpAuthCredential(stored.auth)) {
return { ...server, auth: stored.auth };
}
return server;
} catch {
return server;
}
+168 -15
View File
@@ -6,15 +6,19 @@ import {
} from "../backend-registry/active-store";
import SettingsService from "../settings-service/settings-service.api";
import McpService from "./mcp-service.api";
import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config";
vi.mock("@openhands/typescript-client/clients", () => ({
MCPClient: vi.fn(),
}));
const testServer = vi.fn();
const startOAuth = vi.fn();
const getOAuthStatus = vi.fn();
const submitOAuthCallback = vi.fn();
const close = vi.fn();
const encryptedAuthorization = "gAAAAAencrypted-authorization-header";
const encryptedAuth = "gAAAAAencrypted-auth";
describe("McpService.testServer", () => {
beforeEach(() => {
@@ -32,26 +36,42 @@ describe("McpService.testServer", () => {
vi.mocked(MCPClient).mockImplementation(function MockMCPClient() {
return {
testServer,
startOAuth,
getOAuthStatus,
submitOAuthCallback,
close,
} as unknown as MCPClient;
} as unknown as typeof MCPClient);
testServer.mockResolvedValue({ ok: true, tools: [] });
startOAuth.mockResolvedValue({
ok: true,
job_id: "job-1",
authorization_url: "https://auth.example/authorize",
});
getOAuthStatus.mockResolvedValue({
ok: true,
status: "succeeded",
job_id: "job-1",
tools: ["search_mail"],
});
submitOAuthCallback.mockResolvedValue({
ok: true,
status: "succeeded",
job_id: "job-1",
tools: ["search_mail"],
});
});
it("tests stored remote MCP credentials as encrypted headers, not redacted api_key text", async () => {
it("tests stored remote MCP credentials as encrypted auth, not redacted text", async () => {
vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
llm_api_key_is_set: false,
conversation_settings: {},
agent_settings: {
mcp_config: {
mcpServers: {
linear: {
url: "https://mcp.linear.app/mcp",
transport: "http",
headers: {
Authorization: encryptedAuthorization,
},
},
linear: {
url: "https://mcp.linear.app/mcp",
transport: "http",
auth: { strategy: "bearer", value: encryptedAuth },
},
},
},
@@ -62,7 +82,7 @@ describe("McpService.testServer", () => {
type: "shttp",
name: "linear",
url: "https://mcp.linear.app/mcp",
api_key: "<redacted>",
auth: { strategy: "bearer", value: REDACTED_MCP_SECRET_VALUE },
});
expect(SettingsService.fetchSettingsFromApi).toHaveBeenCalledWith(
@@ -72,14 +92,147 @@ describe("McpService.testServer", () => {
expect(testServer.mock.calls[0][0]).toMatchObject({
name: "linear",
server: {
type: "shttp",
transport: "http",
url: "https://mcp.linear.app/mcp",
headers: {
Authorization: encryptedAuthorization,
},
auth: { strategy: "bearer", value: encryptedAuth },
},
});
expect(testServer.mock.calls[0][0].server).not.toHaveProperty("api_key");
expect(testServer.mock.calls[0][0].server).not.toHaveProperty("headers");
expect(close).toHaveBeenCalledTimes(1);
});
it("forwards explicit OAuth authentication metadata to the MCP test endpoint", async () => {
await McpService.testServer({
id: "shttp-0",
type: "shttp",
name: "superhuman-mail",
url: "https://mcp.mail.superhuman.com/mcp",
auth: {
strategy: "oauth2",
authentication: {
type: "oauth",
client_auth_method: "none",
},
state: {
tokens: {
access_token: "gAAAAexisting-access-token",
},
},
},
});
expect(testServer).toHaveBeenCalledTimes(1);
expect(testServer.mock.calls[0][0]).toMatchObject({
name: "superhuman-mail",
server: {
transport: "http",
url: "https://mcp.mail.superhuman.com/mcp",
auth: {
strategy: "oauth2",
authentication: {
type: "oauth",
client_auth_method: "none",
},
state: {
tokens: {
access_token: "gAAAAexisting-access-token",
},
},
},
},
timeout: 120,
});
expect(close).toHaveBeenCalledTimes(1);
});
it("returns OAuth state captured by the MCP test endpoint", async () => {
testServer.mockResolvedValueOnce({
ok: true,
tools: ["search_mail"],
oauth_state: {
tokens: {
access_token: "gAAAAencrypted-access-token",
},
token_expires_at: 12345,
},
});
const result = await McpService.testServer({
id: "shttp-0",
type: "shttp",
name: "superhuman-mail",
url: "https://mcp.mail.superhuman.com/mcp",
auth: {
strategy: "oauth2",
authentication: {
type: "oauth",
client_auth_method: "none",
},
},
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("expected successful MCP test");
expect(result.oauth_state).toMatchObject({
tokens: {
access_token: "gAAAAencrypted-access-token",
},
token_expires_at: 12345,
});
});
it("starts OAuth through the TypeScript MCP client", async () => {
const result = await McpService.startOAuth({
id: "shttp-0",
type: "shttp",
name: "superhuman-mail",
url: "https://mcp.mail.superhuman.com/mcp",
auth: {
strategy: "oauth2",
authentication: {
type: "oauth",
client_auth_method: "none",
},
},
});
expect(result.job_id).toBe("job-1");
expect(startOAuth).toHaveBeenCalledTimes(1);
expect(startOAuth.mock.calls[0][0]).toMatchObject({
name: "superhuman-mail",
server: {
transport: "http",
url: "https://mcp.mail.superhuman.com/mcp",
auth: {
strategy: "oauth2",
authentication: {
type: "oauth",
client_auth_method: "none",
},
},
},
timeout: 120,
});
expect(close).toHaveBeenCalledTimes(1);
});
it("submits OAuth callback through the TypeScript MCP client", async () => {
await McpService.submitOAuthCallback(
"job/1",
"http://localhost:1234/callback?code=abc",
);
expect(submitOAuthCallback).toHaveBeenCalledWith("job/1", {
callback_url: "http://localhost:1234/callback?code=abc",
});
expect(close).toHaveBeenCalledTimes(1);
});
it("gets OAuth status through the TypeScript MCP client", async () => {
await McpService.getOAuthStatus("job/1");
expect(getOAuthStatus).toHaveBeenCalledWith("job/1");
expect(close).toHaveBeenCalledTimes(1);
});
});
+234 -24
View File
@@ -1,21 +1,46 @@
import { MCPClient } from "@openhands/typescript-client/clients";
import type {
MCPServerSpec,
MCPTestRequest,
} from "@openhands/typescript-client";
import type { MCPTestRequest } from "@openhands/typescript-client";
import { getAgentServerClientOptions } from "../agent-server-client-options";
import { getActiveBackend } from "../backend-registry/active-store";
import {
getActiveBackend,
getRegisteredBackends,
} from "../backend-registry/active-store";
import { getCredentialValidationForServer } from "#/utils/mcp-credential-validation";
import type { MCPAuthCredential } from "#/types/mcp-auth";
import type {
ExtendedMCPTestResponse,
MCPOAuthStartResponse,
MCPOAuthStatusResponse,
MCPServerConfig,
} from "#/types/mcp-server";
import { substituteRedactedMcpCredentials } from "./mcp-redacted-credentials";
function toMcpServerSpec(server: MCPServerConfig): MCPServerSpec {
const OAUTH_MCP_TEST_TIMEOUT_SECONDS = 120;
type MCPTestServer = {
transport?: "stdio" | "http" | "sse";
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
auth?: MCPAuthCredential;
};
interface ExtendedMCPTestRequest {
server: MCPTestServer;
name?: string;
timeout?: number;
tool_call?: {
name: string;
arguments: Record<string, unknown>;
};
}
function toMcpServer(server: MCPServerConfig): MCPTestServer {
if (server.type === "stdio") {
return {
type: "stdio",
transport: "stdio",
command: server.command!,
...(server.args?.length && { args: server.args }),
...(server.env &&
@@ -23,14 +48,90 @@ function toMcpServerSpec(server: MCPServerConfig): MCPServerSpec {
};
}
return {
type: server.type,
transport: server.type === "sse" ? "sse" : "http",
url: server.url!,
...(server.headers &&
Object.keys(server.headers).length > 0 && { headers: server.headers }),
...(server.api_key ? { api_key: server.api_key } : {}),
} as MCPServerSpec;
...(server.auth ? { auth: server.auth } : {}),
};
}
function getMcpTestTimeout(server: MCPServerConfig): number | undefined {
if (server.auth?.strategy !== "oauth2") return server.timeout;
return OAUTH_MCP_TEST_TIMEOUT_SECONDS;
}
async function buildMcpTestRequest(
server: MCPServerConfig,
): Promise<ExtendedMCPTestRequest> {
const validation = getCredentialValidationForServer(server);
const serverSpec = toMcpServer(
await substituteRedactedMcpCredentials(server),
);
const timeout = getMcpTestTimeout(server);
return {
server: serverSpec,
...(server.name ? { name: server.name } : {}),
...(timeout !== undefined ? { timeout } : {}),
...(validation ? { tool_call: validation.toolCall } : {}),
};
}
function getMcpProbeOptions(): { host: string; apiKey?: string } {
const active = getActiveBackend().backend;
if (active.kind === "local") {
const { host, apiKey } = getAgentServerClientOptions();
return { host, ...(apiKey ? { apiKey } : {}) };
}
const localBackend = getRegisteredBackends().find(
(backend) => backend.kind === "local" && backend.host,
);
if (localBackend) {
return {
host: localBackend.host.replace(/\/+$/, ""),
...(localBackend.apiKey ? { apiKey: localBackend.apiKey } : {}),
};
}
throw new Error("OAuth authorization requires a reachable local backend.");
}
function createMcpProbeClient(): MCPClient {
const { host, apiKey } = getMcpProbeOptions();
return new MCPClient({
host,
...(apiKey ? { apiKey } : {}),
timeout: OAUTH_MCP_TEST_TIMEOUT_SECONDS * 1000 + 5000,
});
}
function oauthStatusToTestResponse(
status: MCPOAuthStatusResponse,
): ExtendedMCPTestResponse {
if (status.status === "succeeded") {
return {
ok: true,
tools: status.tools ?? [],
...(status.tool_result !== undefined && {
tool_result: status.tool_result,
}),
...(status.oauth_state !== undefined && {
oauth_state: status.oauth_state,
}),
};
}
return {
ok: false,
error: status.error || "OAuth authorization did not complete",
error_kind: status.error_kind || "unknown",
};
}
const sleep = (ms: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, ms);
});
class McpService {
static async testServer(
server: MCPServerConfig,
@@ -49,23 +150,10 @@ class McpService {
return { ok: true, tools: [] };
}
const validation = getCredentialValidationForServer(server);
const serverSpec = toMcpServerSpec(
await substituteRedactedMcpCredentials(server),
);
const { host, apiKey } = getAgentServerClientOptions();
const client = new MCPClient({ host, ...(apiKey ? { apiKey } : {}) });
try {
// `tool_call` / `tool_result` aren't in the published client types
// yet; the client POSTs the request object and returns the response
// body as-is, so the extra fields round-trip at runtime. Old agent
// servers ignore `tool_call` and return no `tool_result`, in which
// case the response passes through uninterpreted (legacy behavior).
const request = {
server: serverSpec,
...(server.name ? { name: server.name } : {}),
...(server.timeout ? { timeout: server.timeout } : {}),
...(validation ? { tool_call: validation.toolCall } : {}),
};
const request = await buildMcpTestRequest(server);
const result = (await client.testServer(
request as MCPTestRequest,
)) as ExtendedMCPTestResponse;
@@ -84,6 +172,128 @@ class McpService {
client.close();
}
}
static async startOAuth(
server: MCPServerConfig,
): Promise<MCPOAuthStartResponse> {
const client = createMcpProbeClient();
try {
return await McpService.startOAuthWithClient(client, server);
} finally {
client.close();
}
}
static async getOAuthStatus(jobId: string): Promise<MCPOAuthStatusResponse> {
const client = createMcpProbeClient();
try {
return await McpService.getOAuthStatusWithClient(client, jobId);
} finally {
client.close();
}
}
static async submitOAuthCallback(
jobId: string,
callbackUrl: string,
): Promise<MCPOAuthStatusResponse> {
const client = createMcpProbeClient();
try {
return await McpService.submitOAuthCallbackWithClient(
client,
jobId,
callbackUrl,
);
} finally {
client.close();
}
}
static async authorizeOAuth(
server: MCPServerConfig,
): Promise<ExtendedMCPTestResponse> {
const popup = window.open("about:blank", "_blank");
const client = createMcpProbeClient();
try {
const start = await McpService.startOAuthWithClient(client, server);
if (!start.ok || !start.job_id || !start.authorization_url) {
popup?.close();
return {
ok: false,
error: start.error || "Could not start OAuth authorization",
error_kind: start.error_kind || "unknown",
};
}
let status = await McpService.getOAuthStatusWithClient(
client,
start.job_id,
);
for (let attempt = 0; attempt < 20; attempt += 1) {
if (status.status === "succeeded" || status.status === "failed") {
popup?.close();
return oauthStatusToTestResponse(status);
}
if (status.callback_ready) break;
await sleep(250);
status = await McpService.getOAuthStatusWithClient(
client,
start.job_id,
);
}
if (popup) {
popup.location.href = start.authorization_url;
}
for (
let attempt = 0;
attempt < OAUTH_MCP_TEST_TIMEOUT_SECONDS;
attempt += 1
) {
await sleep(1000);
status = await McpService.getOAuthStatusWithClient(
client,
start.job_id,
);
if (status.status === "succeeded" || status.status === "failed") {
popup?.close();
return oauthStatusToTestResponse(status);
}
}
return {
ok: false,
error: "OAuth authorization timed out",
error_kind: "timeout",
};
} finally {
client.close();
}
}
private static async startOAuthWithClient(
client: MCPClient,
server: MCPServerConfig,
): Promise<MCPOAuthStartResponse> {
const request = await buildMcpTestRequest(server);
return client.startOAuth(request as MCPTestRequest);
}
private static async getOAuthStatusWithClient(
client: MCPClient,
jobId: string,
): Promise<MCPOAuthStatusResponse> {
return client.getOAuthStatus(jobId);
}
private static async submitOAuthCallbackWithClient(
client: MCPClient,
jobId: string,
callbackUrl: string,
): Promise<MCPOAuthStatusResponse> {
return client.submitOAuthCallback(jobId, { callback_url: callbackUrl });
}
}
export default McpService;
+131 -13
View File
@@ -1,6 +1,11 @@
import { SettingsClient } from "@openhands/typescript-client/clients";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings, SettingsSchema, SettingsValue } from "#/types/settings";
import {
getSdkMcpServerMap,
hasRedactedMcpSecretLeaf,
stringRecord,
} from "#/utils/mcp-config";
import { getActiveBackend } from "../backend-registry/active-store";
import {
fetchCloudConversationSettingsSchema,
@@ -173,6 +178,104 @@ const clearCache = () => {
settingsCache = { redacted: null, encrypted: null, timestamp: 0 };
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
const basicAuthHeader = (username: string, password: string): string => {
const token = btoa(`${username}:${password}`);
return `Basic ${token}`;
};
const headersFromMcpAuth = (
auth: Record<string, unknown>,
): Record<string, string> | null => {
switch (auth.strategy) {
case "none":
return {};
case "api_key": {
if (typeof auth.value !== "string" || !auth.value) return null;
const header =
typeof auth.header_name === "string" && auth.header_name
? auth.header_name
: "Authorization";
const value =
header === "Authorization" ? `Bearer ${auth.value}` : auth.value;
return { [header]: value };
}
case "bearer":
if (typeof auth.value !== "string" || !auth.value) return null;
return { Authorization: `Bearer ${auth.value}` };
case "basic":
if (
typeof auth.username !== "string" ||
typeof auth.password !== "string"
) {
return null;
}
return { Authorization: basicAuthHeader(auth.username, auth.password) };
case "header":
return stringRecord(auth.headers) ?? {};
case "oauth2": {
const tokens = isRecord(auth.state) ? auth.state.tokens : undefined;
if (!isRecord(tokens) || typeof tokens.access_token !== "string") {
return null;
}
return { Authorization: `Bearer ${tokens.access_token}` };
}
default:
return null;
}
};
const cloudCompatibleMcpConfig = (value: unknown): unknown => {
if (!isRecord(value)) return value;
const hasWrapper = isRecord(value.mcpServers);
const serverMap: Record<string, unknown> = hasWrapper
? (value.mcpServers as Record<string, unknown>)
: value;
const converted = Object.fromEntries(
Object.entries(serverMap).map(([name, server]) => {
if (!isRecord(server) || !isRecord(server.auth)) return [name, server];
const authHeaders = headersFromMcpAuth(server.auth);
if (authHeaders === null) return [name, server];
const nextServer = { ...server };
const existingHeaders = stringRecord(server.headers) ?? {};
const mergedHeaders = { ...existingHeaders, ...authHeaders };
delete nextServer.auth;
if (Object.keys(mergedHeaders).length > 0) {
nextServer.headers = mergedHeaders;
} else {
delete nextServer.headers;
}
return [name, nextServer];
}),
);
return hasWrapper ? { ...value, mcpServers: converted } : converted;
};
const hasRedactedMcpSecrets = (mcpConfig: unknown): boolean => {
const servers = getSdkMcpServerMap(mcpConfig);
if (!servers) return false;
return Object.values(servers).some(
(server) =>
hasRedactedMcpSecretLeaf(isRecord(server) ? server.auth : undefined) ||
hasRedactedMcpSecretLeaf(isRecord(server) ? server.headers : undefined) ||
hasRedactedMcpSecretLeaf(isRecord(server) ? server.env : undefined),
);
};
const removesMcpServer = (previous: unknown, next: unknown): boolean => {
const previousServers = getSdkMcpServerMap(previous);
const nextServers = getSdkMcpServerMap(next);
if (!previousServers || !nextServers) return false;
return Object.keys(previousServers).some((name) => !(name in nextServers));
};
/**
* Transform API response into Settings object with derived fields.
*/
@@ -256,10 +359,6 @@ const syncDerivedSettings = (settings: Partial<Settings>): Settings => {
if (typeof condenser?.max_size === "number") {
merged.condenser_max_size = condenser.max_size;
}
if (agentSettings.mcp_config) {
merged.mcp_config = agentSettings.mcp_config as Settings["mcp_config"];
}
if (typeof conversationSettings.confirmation_mode === "boolean") {
merged.confirmation_mode = conversationSettings.confirmation_mode;
}
@@ -439,7 +538,7 @@ class SettingsService {
// The backend applies ``agent_settings_diff`` by deep-merging it into the
// existing ``agent_settings`` dict (see SDK
// ``openhands.agent_server.persistence.models._deep_merge``). That works
// for scalar fields but is wrong for ``mcp_config.mcpServers``, which is
// for scalar fields but is wrong for ``mcp_config``, which is
// a name-keyed map: a diff that omits a server cannot remove it (stale
// key stays), and a diff whose key indices shift (e.g. after deleting
// index 0, the second server is renumbered) leaves the original keys
@@ -461,14 +560,14 @@ class SettingsService {
// The pre-clear is destructive: if the follow-up write fails after the
// clear succeeds, the user's MCP config is left empty. Snapshot the
// previous value (in raw SDK shape, NOT the GUI's parsed MCPConfig)
// previous value (in raw SDK-native shape, NOT the GUI's parsed MCPConfig)
// before pre-clearing so we can attempt a best-effort rollback. The
// original write error is always re-thrown to the caller regardless
// of rollback success — the GUI's react-query mutations surface that
// as an error toast so the user knows to retry.
//
// Snapshot must be the SDK shape (``{ mcpServers: { name: cfg }}``)
// because that is what the backend expects on the rollback PATCH.
// Snapshot must be the SDK-native server map because that is what the
// backend expects on the rollback PATCH.
// ``SettingsService.getSettings`` returns a GUI Settings object whose
// ``mcp_config`` is typed as the parsed frontend MCPConfig and
// defaults to empty arrays when nothing is installed, so it is not
@@ -493,6 +592,19 @@ class SettingsService {
}
}
// Cloud settings may redact MCP env/header/auth secrets without an
// encrypted exposure mode. A pre-clear before a non-delete update can
// permanently erase unchanged secrets; for updates that keep all existing
// server keys, rely on the backend deep-merge to preserve redacted leaves
// while applying edited fields and newly typed credentials.
const shouldUseCloudMergePatchForRedactedMcpSecrets =
isCloud &&
needsMcpPreClear &&
hasRedactedMcpSecrets(mcpConfigSnapshot) &&
!removesMcpServer(mcpConfigSnapshot, agentDiff?.mcp_config);
const shouldPreClearMcpConfig =
needsMcpPreClear && !shouldUseCloudMergePatchForRedactedMcpSecrets;
if (isCloud) {
const hasCloudWork =
!!payload.agent_settings_diff ||
@@ -501,7 +613,7 @@ class SettingsService {
if (!hasCloudWork) {
return true;
}
if (needsMcpPreClear) {
if (shouldPreClearMcpConfig) {
await withRetry(() =>
saveCloudSettings({
agent_settings_diff: { mcp_config: null },
@@ -513,7 +625,13 @@ class SettingsService {
// is called from tests with an exact-shape assertion).
const cloudPayload: Parameters<typeof saveCloudSettings>[0] = {};
if (payload.agent_settings_diff) {
cloudPayload.agent_settings_diff = payload.agent_settings_diff;
cloudPayload.agent_settings_diff = { ...payload.agent_settings_diff };
if ("mcp_config" in cloudPayload.agent_settings_diff) {
cloudPayload.agent_settings_diff.mcp_config =
cloudCompatibleMcpConfig(
cloudPayload.agent_settings_diff.mcp_config,
) as SettingsValue;
}
}
if (payload.conversation_settings_diff) {
cloudPayload.conversation_settings_diff =
@@ -528,7 +646,7 @@ class SettingsService {
try {
await withRetry(() => saveCloudSettings(cloudPayload));
} catch (err) {
if (needsMcpPreClear && mcpConfigSnapshot) {
if (shouldPreClearMcpConfig && mcpConfigSnapshot) {
// Best-effort rollback. We deliberately do not wrap in withRetry:
// the user's session is already in a degraded state and we want
// to surface the original error promptly. Swallowing the restore
@@ -555,7 +673,7 @@ class SettingsService {
if (!hasLocalDiffs) {
return true;
}
if (needsMcpPreClear) {
if (shouldPreClearMcpConfig) {
await withRetry(() =>
new SettingsClient(getAgentServerClientOptions()).updateSettings({
agent_settings_diff: { mcp_config: null },
@@ -569,7 +687,7 @@ class SettingsService {
),
);
} catch (err) {
if (needsMcpPreClear && mcpConfigSnapshot) {
if (shouldPreClearMcpConfig && mcpConfigSnapshot) {
// See cloud branch above for rationale.
try {
await new SettingsClient(
@@ -73,7 +73,7 @@ export function RecommendedAutomationsLauncher({
const completedInstallRef = useRef(false);
const launchInFlightRef = useRef(false);
const installedMcpServers = useMemo(
const installedMcpConfig = useMemo(
() =>
flattenMcpConfig(parseMcpConfig(settings?.agent_settings?.mcp_config)),
[settings?.agent_settings?.mcp_config],
@@ -134,9 +134,9 @@ export function RecommendedAutomationsLauncher({
const getMissingEntries = useCallback(
(automation: RecommendedAutomation) =>
getRequiredEntries(automation).filter(
(entry) => !findInstalledEntryMatch(entry, installedMcpServers),
(entry) => !findInstalledEntryMatch(entry, installedMcpConfig),
),
[installedMcpServers],
[installedMcpConfig],
);
const proceedWithLocalLaunch = (automation: RecommendedAutomation) => {
@@ -225,7 +225,7 @@ export function RecommendedAutomationsLauncher({
<>
<RecommendedAutomationsSection
backendKind={activeBackend.backend.kind}
installedServers={installedMcpServers}
installedServers={installedMcpConfig}
query={query}
onSelect={handleSelectAutomation}
scrollableGrid={scrollableGrid}
@@ -52,7 +52,8 @@ export function HomeChatLauncher() {
const [selectedPlugins, setSelectedPlugins] = useState<PluginSpec[]>([]);
const [isPluginPickerOpen, setIsPluginPickerOpen] = useState(false);
const { mutate: createConversation, isPending } = useCreateConversation();
const { mutateAsync: createConversation, isPending } =
useCreateConversation();
const isCreatingElsewhere = useIsCreatingConversation();
const isCreating = isPending || isCreatingElsewhere;
const { isConfigured: isLlmConfigured, isLoading: isLlmConfigLoading } =
@@ -127,8 +128,9 @@ export function HomeChatLauncher() {
TOAST_OPTIONS,
);
createConversation(variables, {
onSuccess: async (data) => {
void (async () => {
try {
const data = await createConversation(variables);
toast.dismiss(toastId);
try {
sessionStorage.removeItem(HOME_PROMPT_DRAFT_KEY);
@@ -198,12 +200,11 @@ export function HomeChatLauncher() {
}
navigate(`/conversations/${targetConversationId}`);
},
onError: (error) => {
} catch (error) {
toast.dismiss(toastId);
displayErrorToast(error instanceof Error ? error.message : null);
},
});
}
})();
};
// Without this wrapper a `/model NAME` typed here would become the first
@@ -14,7 +14,11 @@ import { useUpdateMcpServer } from "#/hooks/mutation/use-update-mcp-server";
import { useDeleteMcpServer } from "#/hooks/mutation/use-delete-mcp-server";
import { useTestMcpServer } from "#/hooks/mutation/use-test-mcp-server";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { ExtendedMCPTestFailure, MCPServerConfig } from "#/types/mcp-server";
import {
ExtendedMCPTestFailure,
ExtendedMCPTestResponse,
MCPServerConfig,
} from "#/types/mcp-server";
import {
displayErrorToast,
displaySuccessToast,
@@ -22,6 +26,7 @@ import {
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { cn } from "#/utils/utils";
import { modalTitleLgClassName } from "#/utils/modal-classes";
import McpService from "#/api/mcp-service/mcp-service.api";
interface CustomServerEditorProps {
server: MCPServerConfig;
@@ -52,6 +57,9 @@ export function CustomServerEditor({
reset: resetTest,
} = useTestMcpServer();
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
const [oauthTestResult, setOauthTestResult] =
React.useState<ExtendedMCPTestResponse | null>(null);
const [isOauthTesting, setIsOauthTesting] = React.useState(false);
// The MCP connectivity-test endpoint only exists on the local agent-server.
// For cloud backends `McpService.testServer` short-circuits with a synthetic
@@ -62,7 +70,8 @@ export function CustomServerEditor({
const isEditing = !!server.id;
const isPending = isAdding || isUpdating || isDeleting;
const isDismissBlocked = isPending || isTesting || showDeleteConfirm;
const isDismissBlocked =
isPending || isTesting || isOauthTesting || showDeleteConfirm;
const makeTestErrorMessage = (failure: ExtendedMCPTestFailure): string => {
switch (failure.error_kind) {
@@ -78,15 +87,16 @@ export function CustomServerEditor({
};
const testMessage: TestMessage | null = React.useMemo(() => {
if (!testResult) return null;
if (testResult.ok) {
const result = oauthTestResult ?? testResult;
if (!result) return null;
if (result.ok) {
return {
ok: true,
text: t(I18nKey.MCP$TEST_SUCCESS, { count: testResult.tools.length }),
text: t(I18nKey.MCP$TEST_SUCCESS, { count: result.tools.length }),
};
}
return { ok: false, text: makeTestErrorMessage(testResult) };
}, [testResult, t]);
return { ok: false, text: makeTestErrorMessage(result) };
}, [oauthTestResult, testResult, t]);
// Shared error handler so both add and update surface backend errors
// as a toast instead of failing silently — previously these calls
@@ -99,19 +109,58 @@ export function CustomServerEditor({
const handleSubmit = (payload: MCPServerConfig) => {
resetTest();
setOauthTestResult(null);
if (payload.auth?.strategy === "oauth2") {
setIsOauthTesting(true);
void McpService.authorizeOAuth(payload)
.then((result) => {
setOauthTestResult(result);
if (!result.ok) return;
const serverToSave = result.oauth_state
? {
...payload,
auth: { ...payload.auth!, state: result.oauth_state },
}
: payload;
if (isEditing) {
updateMcpServer(
{ serverId: server.id, server: serverToSave },
{ onSuccess: onClose, onError: handleError },
);
} else {
addMcpServer(serverToSave, {
onSuccess: onClose,
onError: handleError,
});
}
})
.catch(handleError)
.finally(() => setIsOauthTesting(false));
return;
}
testServer(payload, {
onSuccess: (result) => {
if (!result.ok) {
// Test failed — modal stays open, error shown via testMessage.
return;
}
const serverToSave =
result.oauth_state && payload.auth?.strategy === "oauth2"
? {
...payload,
auth: { ...payload.auth, state: result.oauth_state },
}
: payload;
if (isEditing) {
updateMcpServer(
{ serverId: server.id, server: payload },
{ serverId: server.id, server: serverToSave },
{ onSuccess: onClose, onError: handleError },
);
} else {
addMcpServer(payload, { onSuccess: onClose, onError: handleError });
addMcpServer(serverToSave, {
onSuccess: onClose,
onError: handleError,
});
}
},
onError: handleError,
@@ -119,6 +168,15 @@ export function CustomServerEditor({
};
const handleTestClick = (payload: MCPServerConfig) => {
setOauthTestResult(null);
if (payload.auth?.strategy === "oauth2" && !isCloudBackend) {
setIsOauthTesting(true);
void McpService.authorizeOAuth(payload)
.then(setOauthTestResult)
.catch(handleError)
.finally(() => setIsOauthTesting(false));
return;
}
testServer(payload);
};
@@ -173,7 +231,7 @@ export function CustomServerEditor({
onDelete={isEditing ? () => setShowDeleteConfirm(true) : undefined}
isActionDisabled={isPending}
onTest={isCloudBackend ? undefined : handleTestClick}
isTestPending={isTesting}
isTestPending={isTesting || isOauthTesting}
testMessage={isCloudBackend ? null : testMessage}
/>
</div>
@@ -14,16 +14,20 @@ import type {
} from "@openhands/extensions/integrations";
import { McpLogoBadge } from "#/components/features/mcp-logo-badge";
import { ExtendedMCPTestFailure, MCPServerConfig } from "#/types/mcp-server";
import type { MCPAuthCredential } from "#/types/mcp-auth";
import { useAddMcpServer } from "#/hooks/mutation/use-add-mcp-server";
import { useTestMcpServer } from "#/hooks/mutation/use-test-mcp-server";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import {
getMcpOAuthAuthenticationConfig,
getInstallableMcpConnectionOption,
type McpMarketplaceConnectionOption,
} from "#/utils/mcp-marketplace-utils";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { useSaveFieldsAsSecrets } from "#/hooks/mutation/use-save-fields-as-secrets";
import { modalTitleLgClassName } from "#/utils/modal-classes";
import McpService from "#/api/mcp-service/mcp-service.api";
import { toMcpServerName } from "#/utils/mcp-server-name";
/**
* Renders a helperText string as React nodes, converting any `[text](url)`
@@ -76,7 +80,13 @@ function optionNeedsCredentialField(
if (option?.transport.kind !== "shttp" && option?.transport.kind !== "sse") {
return false;
}
return ["api_key", "bearer", "basic"].includes(option.auth.strategy);
return ["api_key", "bearer"].includes(option.auth.strategy);
}
function isOAuthOption(
option: McpMarketplaceConnectionOption | undefined,
): boolean {
return !!option && option.auth.strategy === "oauth2";
}
function isCredentialOptional(option: McpMarketplaceConnectionOption): boolean {
@@ -86,6 +96,15 @@ function isCredentialOptional(option: McpMarketplaceConnectionOption): boolean {
return option.auth.apiKeyOptional ?? option.transport.apiKeyOptional ?? false;
}
function getRemoteHeaderFields(
option: McpMarketplaceConnectionOption | undefined,
): MarketplaceField[] {
if (option?.transport.kind !== "shttp" && option?.transport.kind !== "sse") {
return [];
}
return option.transport.headerFields ?? [];
}
function makeInitialState(entry: MarketplaceEntry): FieldState {
const values: Record<string, string> = {};
const savedAsSecret: Record<string, boolean> = {};
@@ -100,11 +119,18 @@ function makeInitialState(entry: MarketplaceEntry): FieldState {
for (const field of template.argFields ?? []) {
values[field.key] = "";
}
} else if (optionNeedsCredentialField(option)) {
values.api_key = "";
if (option?.auth.credentialSecretName) {
savedAsSecret.api_key =
option.auth.saveCredentialAsSecretByDefault ?? false;
} else if (template?.kind === "shttp" || template?.kind === "sse") {
values.url = template.url;
for (const field of getRemoteHeaderFields(option)) {
values[field.key] = "";
savedAsSecret[field.key] = field.type === "password";
}
if (optionNeedsCredentialField(option)) {
values.api_key = "";
if (option?.auth.credentialSecretName) {
savedAsSecret.api_key =
option.auth.saveCredentialAsSecretByDefault ?? false;
}
}
}
return { values, errors: {}, savedAsSecret };
@@ -136,10 +162,12 @@ export function InstallServerModal({
const [globalError, setGlobalError] = React.useState<string | null>(null);
const [isFinalizingInstall, setIsFinalizingInstall] = React.useState(false);
const [isAuthorizingOAuth, setIsAuthorizingOAuth] = React.useState(false);
const option = getInstallableMcpConnectionOption(entry);
const template = option?.transport;
const isPending = isTesting || isAdding || isFinalizingInstall;
const isPending =
isTesting || isAuthorizingOAuth || isAdding || isFinalizingInstall;
const setValue = (key: string, value: string) => {
setState((prev) => ({
@@ -185,7 +213,14 @@ export function InstallServerModal({
);
}
if (template?.kind === "shttp" || template?.kind === "sse") {
return saveHostedCredentialAsSecret();
return Promise.all([
saveHostedCredentialAsSecret(),
saveFieldsAsSecrets(
getRemoteHeaderFields(option),
stateRef.current.values,
stateRef.current.savedAsSecret,
),
]).then(() => undefined);
}
return Promise.resolve();
};
@@ -204,6 +239,46 @@ export function InstallServerModal({
};
const submitServer = (payload: MCPServerConfig) => {
if (payload.auth?.strategy === "oauth2") {
setIsAuthorizingOAuth(true);
void McpService.authorizeOAuth(payload)
.then((result) => {
if (!result.ok) {
setGlobalError(makeTestErrorMessage(result));
return;
}
const serverToSave = result.oauth_state
? {
...payload,
auth: { ...payload.auth!, state: result.oauth_state },
}
: payload;
addMcpServer(serverToSave, {
onSuccess: () => {
displaySuccessToast(t(I18nKey.MCP$INSTALL_SUCCESS));
setIsFinalizingInstall(true);
void (async () => {
try {
await saveSelectedSecrets();
} finally {
onSuccess?.(entry);
onClose();
}
})();
},
onError: (err: unknown) => {
const message = retrieveAxiosErrorMessage(err as AxiosError);
setGlobalError(message || t(I18nKey.ERROR$GENERIC));
},
});
})
.catch((err: unknown) => {
const message = retrieveAxiosErrorMessage(err as AxiosError);
setGlobalError(message || t(I18nKey.ERROR$GENERIC));
})
.finally(() => setIsAuthorizingOAuth(false));
return;
}
testMcpServer(payload, {
onSuccess: (result) => {
if (!result.ok) {
@@ -211,7 +286,14 @@ export function InstallServerModal({
// Modal stays open — do NOT call onClose.
return;
}
addMcpServer(payload, {
const serverToSave =
result.oauth_state && payload.auth?.strategy === "oauth2"
? {
...payload,
auth: { ...payload.auth, state: result.oauth_state },
}
: payload;
addMcpServer(serverToSave, {
onSuccess: () => {
displaySuccessToast(t(I18nKey.MCP$INSTALL_SUCCESS));
setIsFinalizingInstall(true);
@@ -250,23 +332,83 @@ export function InstallServerModal({
}
if (!option) return;
const apiKey = state.values.api_key?.trim() ?? "";
const url = template.urlEditable
? (state.values.url?.trim() ?? "")
: template.url;
const oauthMode = isOAuthOption(option);
const needsCredential = optionNeedsCredentialField(option);
if (needsCredential && !isCredentialOptional(option) && !apiKey) {
setState((prev) => ({
...prev,
errors: { api_key: t(I18nKey.MCP$ERROR_FIELD_REQUIRED) },
}));
const headerFields = getRemoteHeaderFields(option);
const headerErrors: Record<string, string | null> = {};
if (!url) {
headerErrors.url = t(I18nKey.SETTINGS$MCP_ERROR_URL_REQUIRED);
} else {
try {
const parsedUrl = new URL(url);
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
headerErrors.url = t(I18nKey.SETTINGS$MCP_ERROR_URL_INVALID_PROTOCOL);
}
} catch {
headerErrors.url = t(I18nKey.SETTINGS$MCP_ERROR_URL_INVALID);
}
}
for (const field of headerFields) {
if (field.required && !(state.values[field.key] ?? "").trim()) {
headerErrors[field.key] = t(I18nKey.MCP$ERROR_FIELD_REQUIRED);
}
}
if (
!oauthMode &&
needsCredential &&
!isCredentialOptional(option) &&
!apiKey
) {
headerErrors.api_key = t(I18nKey.MCP$ERROR_FIELD_REQUIRED);
}
if (Object.values(headerErrors).some(Boolean)) {
setState((prev) => ({ ...prev, errors: headerErrors }));
return;
}
const oauthAuthentication = oauthMode
? getMcpOAuthAuthenticationConfig(option)
: undefined;
const fieldHeaders = Object.fromEntries(
headerFields
.map((field) => [field.key, state.values[field.key]?.trim() ?? ""])
.filter(([, value]) => value),
);
const hasFieldHeaders = Object.keys(fieldHeaders).length > 0;
let auth: MCPAuthCredential | undefined;
if (oauthMode) {
auth = {
strategy: "oauth2",
...(oauthAuthentication && { authentication: oauthAuthentication }),
};
} else if (needsCredential && apiKey) {
auth =
option.auth.strategy === "api_key"
? {
strategy: "api_key",
value: apiKey,
...(option.auth.apiKeyHeaderName && {
header_name: option.auth.apiKeyHeaderName,
}),
}
: { strategy: "bearer", value: apiKey };
} else if (hasFieldHeaders) {
auth = { strategy: "header", headers: fieldHeaders };
}
const payload: MCPServerConfig = {
id: `${template.kind}-${uuidv4()}`,
type: template.kind,
// Name remote servers after the catalog slug (e.g. "github") so they
// get a referenceable mcp_config key instead of the auto-generated
// "sse"/"shttp" fallback. Stdio installs already carry serverName.
name: entry.id,
url: template.url,
...(needsCredential && apiKey && { api_key: apiKey }),
// get a referenceable, LLM-tool-safe mcp_config key instead of the
// auto-generated "sse"/"shttp" fallback. Stdio installs already carry
// serverName from the catalog.
name: toMcpServerName(entry.id),
url,
...(auth && { auth }),
...(hasFieldHeaders &&
auth?.strategy !== "header" && { headers: fieldHeaders }),
};
submitServer(payload);
};
@@ -329,9 +471,11 @@ export function InstallServerModal({
const renderFields = () => {
if (template?.kind === "shttp" || template?.kind === "sse") {
const oauthMode = isOAuthOption(option);
const shouldRenderCredential = optionNeedsCredentialField(option);
const apiKeyOptional = option ? isCredentialOptional(option) : false;
const credentialSecretName = option?.auth.credentialSecretName;
const headerFields = getRemoteHeaderFields(option);
return (
<>
<SettingsInput
@@ -339,12 +483,60 @@ export function InstallServerModal({
name="url"
type="url"
label={t(I18nKey.SETTINGS$MCP_URL)}
value={template.url}
onChange={() => {}}
isDisabled
value={state.values.url ?? template.url}
onChange={(value) => setValue("url", value)}
isDisabled={!template.urlEditable}
className="w-full"
/>
{shouldRenderCredential ? (
{state.errors.url && (
<p className="text-xs text-red-500">{state.errors.url}</p>
)}
{headerFields.map((field) => (
<div key={field.key} className="flex flex-col gap-1">
<SettingsInput
testId={`mcp-install-field-${field.key}`}
name={field.key}
type={field.type === "password" ? "password" : "text"}
label={field.label}
value={state.values[field.key] ?? ""}
onChange={(v) => setValue(field.key, v)}
placeholder={field.placeholder}
required={field.required}
showOptionalTag={!field.required}
className="w-full"
/>
{field.helperText && (
<p className="text-xs text-tertiary-alt">
{renderHelperText(field.helperText)}
</p>
)}
{state.errors[field.key] && (
<p className="text-xs text-red-500">
{state.errors[field.key]}
</p>
)}
{field.key in state.savedAsSecret && (
<SaveAsSecretToggle
fieldKey={field.key}
checked={state.savedAsSecret[field.key] ?? false}
onToggle={(v) => toggleSecret(field.key, v)}
/>
)}
</div>
))}
{oauthMode ? (
<div
data-testid="mcp-install-oauth-info"
className="flex flex-col gap-2 p-3 rounded-lg border border-[var(--oh-border)] bg-base-tertiary"
>
<p className="text-sm text-secondary-light">
{t(I18nKey.MCP$OAUTH_CONNECT_INFO)}
</p>
<p className="text-xs text-tertiary-alt">
{t(I18nKey.MCP$OAUTH_CONNECT_HINT)}
</p>
</div>
) : shouldRenderCredential ? (
<div className="flex flex-col gap-1">
<SettingsInput
testId="mcp-install-field-api_key"
@@ -523,7 +715,7 @@ export function InstallServerModal({
isDisabled={isPending}
testId="mcp-install-submit"
>
{isTesting
{isTesting || isAuthorizingOAuth
? t(I18nKey.MCP$VERIFYING)
: isAdding || isFinalizingInstall
? t(I18nKey.SETTINGS$SAVING)
@@ -8,9 +8,20 @@ import { BrandButton } from "../brand-button";
import { OptionalTag } from "../optional-tag";
import { cn } from "#/utils/utils";
import { formControlMultilineFieldClassName } from "#/utils/form-control-classes";
import {
isValidMcpServerName,
MCP_SERVER_NAME_PATTERN,
} from "#/utils/mcp-server-name";
import type {
MCPAuthCredential,
MCPAuthenticationConfig,
MCPOAuthClientAuthMethod,
} from "#/types/mcp-auth";
import type { MCPServerConfig } from "#/types/mcp-server";
type MCPServerType = "sse" | "stdio" | "shttp";
type RemoteAuthMode = "none" | "bearer" | "header" | "oauth2";
type OAuthClientAuthMethodOption = "auto" | MCPOAuthClientAuthMethod;
export interface TestMessage {
ok: boolean;
@@ -46,6 +57,23 @@ export function MCPServerForm({
const [serverType, setServerType] = React.useState<MCPServerType>(
server?.type || "sse",
);
const [authMode, setAuthMode] = React.useState<RemoteAuthMode>(() => {
if (server?.auth?.strategy === "oauth2") return "oauth2";
if (server?.auth?.strategy === "header") return "header";
if (
server?.auth?.strategy === "bearer" ||
server?.auth?.strategy === "api_key"
) {
return "bearer";
}
return "none";
});
const [oauthClientAuthMethod, setOAuthClientAuthMethod] =
React.useState<OAuthClientAuthMethodOption>(() =>
server?.auth?.strategy === "oauth2"
? (server.auth.authentication?.client_auth_method ?? "auto")
: "auto",
);
const [error, setError] = React.useState<string | null>(null);
const formRef = React.useRef<HTMLFormElement>(null);
@@ -54,6 +82,28 @@ export function MCPServerForm({
{ key: "stdio", label: t(I18nKey.SETTINGS$MCP_SERVER_TYPE_STDIO) },
{ key: "shttp", label: t(I18nKey.SETTINGS$MCP_SERVER_TYPE_SHTTP) },
];
const authModeOptions = [
{ key: "none", label: t(I18nKey.SETTINGS$MCP_AUTH_MODE_NONE) },
{ key: "bearer", label: t(I18nKey.SETTINGS$MCP_AUTH_MODE_BEARER) },
{ key: "header", label: t(I18nKey.SETTINGS$MCP_AUTH_MODE_HEADER) },
{ key: "oauth2", label: t(I18nKey.SETTINGS$MCP_AUTH_MODE_OAUTH) },
];
const oauthClientAuthMethodOptions = [
{ key: "auto", label: t(I18nKey.SETTINGS$MCP_OAUTH_CLIENT_AUTH_AUTO) },
{ key: "none", label: t(I18nKey.SETTINGS$MCP_OAUTH_CLIENT_AUTH_NONE) },
{
key: "client_secret_post",
label: t(I18nKey.SETTINGS$MCP_OAUTH_CLIENT_AUTH_SECRET_POST),
},
{
key: "client_secret_basic",
label: t(I18nKey.SETTINGS$MCP_OAUTH_CLIENT_AUTH_SECRET_BASIC),
},
{
key: "private_key_jwt",
label: t(I18nKey.SETTINGS$MCP_OAUTH_CLIENT_AUTH_PRIVATE_KEY_JWT),
},
];
const validateUrl = (url: string): string | null => {
if (!url) return t(I18nKey.SETTINGS$MCP_ERROR_URL_REQUIRED);
@@ -70,7 +120,7 @@ export function MCPServerForm({
const validateName = (name: string): string | null => {
if (!name) return t(I18nKey.SETTINGS$MCP_ERROR_NAME_REQUIRED);
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
if (!isValidMcpServerName(name)) {
return t(I18nKey.SETTINGS$MCP_ERROR_NAME_INVALID);
}
return null;
@@ -128,6 +178,26 @@ export function MCPServerForm({
return null;
};
const validateRemoteAuth = (formData: FormData): string | null => {
if (authMode === "header") {
const headerString = formData.get("headers")?.toString() || "";
if (!headerString.trim())
return t(I18nKey.SETTINGS$MCP_ERROR_HEADER_REQUIRED);
return validateEnvFormat(headerString);
}
if (authMode === "oauth2") {
const clientId = formData.get("oauth_client_id")?.toString().trim();
const clientSecret = formData
.get("oauth_client_secret")
?.toString()
.trim();
if (clientSecret && !clientId) {
return t(I18nKey.SETTINGS$MCP_ERROR_OAUTH_SECRET_REQUIRES_ID);
}
}
return null;
};
const validateTimeout = (timeoutStr: string): string | null => {
if (!timeoutStr.trim()) return null; // Optional field
@@ -177,7 +247,7 @@ export function MCPServerForm({
// key (and the reference used in mcp_server_refs), so hold it to the
// same safe-identifier rule as stdio names.
const name = formData.get("name")?.toString().trim() || "";
if (name && !/^[a-zA-Z0-9_-]+$/.test(name)) {
if (name && !isValidMcpServerName(name)) {
return t(I18nKey.SETTINGS$MCP_ERROR_NAME_INVALID);
}
@@ -188,7 +258,7 @@ export function MCPServerForm({
if (timeoutError) return timeoutError;
}
return null;
return validateRemoteAuth(formData);
}
if (serverType === "stdio") {
@@ -216,13 +286,72 @@ export function MCPServerForm({
return env;
};
const formatEnvironmentVariables = (env?: Record<string, string>): string => {
const formatEnvironmentVariables = (
env?: Record<string, string> | null,
): string => {
if (!env) return "";
return Object.entries(env)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
};
const editableAuthValue = (auth: MCPAuthCredential | undefined): string => {
if (auth?.strategy === "bearer" || auth?.strategy === "api_key") {
return auth.value ?? "";
}
return "";
};
const editableHeaderValue = (auth: MCPAuthCredential | undefined): string => {
if (auth?.strategy !== "header") return "";
return formatEnvironmentVariables(auth.headers);
};
const oauthAuthentication =
server?.auth?.strategy === "oauth2"
? server.auth.authentication
: undefined;
const oauthState =
server?.auth?.strategy === "oauth2" ? server.auth.state : undefined;
const authFromFormData = (
formData: FormData,
): MCPAuthCredential | undefined => {
if (authMode === "none") return undefined;
if (authMode === "bearer") {
const value = formData.get("api_key")?.toString().trim();
if (!value) return undefined;
if (server?.auth?.strategy === "api_key") {
return { ...server.auth, value };
}
return { strategy: "bearer", value };
}
if (authMode === "header") {
const headers = parseEnvironmentVariables(
formData.get("headers")?.toString() || "",
);
return { strategy: "header", headers };
}
const scopes = formData.get("oauth_scopes")?.toString().trim();
const clientId = formData.get("oauth_client_id")?.toString().trim();
const clientSecret = formData.get("oauth_client_secret")?.toString().trim();
const authentication: MCPAuthenticationConfig = {
type: "oauth",
...(oauthClientAuthMethod !== "auto" && {
client_auth_method: oauthClientAuthMethod,
}),
...(scopes && { scopes }),
...(clientId && { client_id: clientId }),
...(clientSecret && { client_secret: clientSecret }),
};
return {
strategy: "oauth2",
authentication,
...(oauthState && { state: oauthState }),
};
};
const buildConfig = (formData: FormData): MCPServerConfig => {
const baseConfig = {
id: server?.id || `${serverType}-${Date.now()}`,
@@ -232,14 +361,14 @@ export function MCPServerForm({
if (serverType === "sse" || serverType === "shttp") {
const name = formData.get("name")?.toString().trim();
const url = formData.get("url")?.toString().trim();
const apiKey = formData.get("api_key")?.toString().trim();
const timeoutStr = formData.get("timeout")?.toString().trim();
const auth = authFromFormData(formData);
const serverConfig: MCPServerConfig = {
...baseConfig,
...(name && { name }),
url: url!,
...(apiKey && { api_key: apiKey }),
...(auth && { auth }),
};
// Only add timeout for SHTTP servers
@@ -312,6 +441,7 @@ export function MCPServerForm({
data-testid={formTestId}
onSubmit={handleSubmit}
className="flex flex-col items-start gap-6"
noValidate
>
{mode === "add" && (
<SettingsDropdownInput
@@ -342,7 +472,8 @@ export function MCPServerForm({
showOptionalTag
defaultValue={server?.name || ""}
// eslint-disable-next-line i18next/no-literal-string -- example value, not translatable
placeholder="my-search-server"
placeholder="my_search_server"
pattern={MCP_SERVER_NAME_PATTERN.source}
/>
<SettingsInput
@@ -357,17 +488,107 @@ export function MCPServerForm({
placeholder="https://api.example.com"
/>
<SettingsInput
testId="api-key-input"
name="api_key"
type="password"
label={t(I18nKey.SETTINGS$MCP_API_KEY)}
className="w-full min-w-0"
showOptionalTag
defaultValue={server?.api_key || ""}
placeholder={t(I18nKey.SETTINGS$MCP_API_KEY_PLACEHOLDER)}
<SettingsDropdownInput
testId="auth-mode-dropdown"
name="auth-mode"
label={t(I18nKey.SETTINGS$MCP_AUTHENTICATION)}
items={authModeOptions}
selectedKey={authMode}
onSelectionChange={(key) => setAuthMode(key as RemoteAuthMode)}
onInputChange={() => {}}
isClearable={false}
allowsCustomValue={false}
wrapperClassName="w-full min-w-0"
/>
{authMode === "bearer" && (
<SettingsInput
testId="api-key-input"
name="api_key"
type="password"
label={t(I18nKey.SETTINGS$MCP_API_KEY)}
className="w-full min-w-0"
required
defaultValue={editableAuthValue(server?.auth)}
placeholder={t(I18nKey.SETTINGS$MCP_API_KEY_PLACEHOLDER)}
/>
)}
{authMode === "header" && (
<label className="flex flex-col gap-2.5 w-full min-w-0">
<span className="text-sm">{t(I18nKey.SETTINGS$MCP_HEADERS)}</span>
<textarea
data-testid="headers-input"
name="headers"
rows={4}
defaultValue={editableHeaderValue(server?.auth)}
placeholder={t(I18nKey.SETTINGS$MCP_HEADERS_PLACEHOLDER)}
className={cn(
formControlMultilineFieldClassName,
"resize-none placeholder:italic",
"disabled:bg-[var(--oh-surface-raised)] disabled:border-[var(--oh-border-subtle)]",
)}
/>
</label>
)}
{authMode === "oauth2" && (
<>
<SettingsDropdownInput
testId="oauth-client-auth-method-dropdown"
name="oauth_client_auth_method"
label={t(I18nKey.SETTINGS$MCP_OAUTH_CLIENT_AUTH)}
items={oauthClientAuthMethodOptions}
selectedKey={oauthClientAuthMethod}
onSelectionChange={(key) =>
setOAuthClientAuthMethod(key as OAuthClientAuthMethodOption)
}
onInputChange={() => {}}
isClearable={false}
allowsCustomValue={false}
wrapperClassName="w-full min-w-0"
/>
<SettingsInput
testId="oauth-client-id-input"
name="oauth_client_id"
type="text"
label={t(I18nKey.SETTINGS$MCP_OAUTH_CLIENT_ID)}
className="w-full min-w-0"
showOptionalTag
defaultValue={oauthAuthentication?.client_id || ""}
placeholder={t(
I18nKey.SETTINGS$MCP_OAUTH_CLIENT_ID_PLACEHOLDER,
)}
/>
<SettingsInput
testId="oauth-client-secret-input"
name="oauth_client_secret"
type="password"
label={t(I18nKey.SETTINGS$MCP_OAUTH_CLIENT_SECRET)}
className="w-full min-w-0"
showOptionalTag
defaultValue={oauthAuthentication?.client_secret || ""}
placeholder={t(
I18nKey.SETTINGS$MCP_OAUTH_CLIENT_SECRET_PLACEHOLDER,
)}
/>
<SettingsInput
testId="oauth-scopes-input"
name="oauth_scopes"
type="text"
label={t(I18nKey.SETTINGS$MCP_OAUTH_SCOPES)}
className="w-full min-w-0"
showOptionalTag
defaultValue={
Array.isArray(oauthAuthentication?.scopes)
? oauthAuthentication.scopes.join(" ")
: oauthAuthentication?.scopes || ""
}
placeholder={t(I18nKey.SETTINGS$MCP_OAUTH_SCOPES_PLACEHOLDER)}
/>
</>
)}
{serverType === "shttp" && (
<SettingsInput
testId="timeout-input"
@@ -396,8 +617,8 @@ export function MCPServerForm({
required
defaultValue={server?.name || ""}
// eslint-disable-next-line i18next/no-literal-string -- example value, not translatable
placeholder="my-mcp-server"
pattern="^[a-zA-Z0-9_-]+$"
placeholder="my_mcp_server"
pattern={MCP_SERVER_NAME_PATTERN.source}
/>
<SettingsInput
@@ -1,18 +1,7 @@
import { Pencil, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
interface MCPServerConfig {
id: string;
type: "sse" | "stdio" | "shttp";
name?: string;
url?: string;
api_key?: string;
timeout?: number;
command?: string;
args?: string[];
env?: Record<string, string>;
}
import type { MCPServerConfig } from "#/types/mcp-server";
export function MCPServerListItem({
server,
@@ -2,18 +2,7 @@ import { useTranslation } from "react-i18next";
import { MCPServerListItem } from "./mcp-server-list-item";
import { I18nKey } from "#/i18n/declaration";
import { extensionModuleEmptyStateClassName } from "#/utils/extension-module-card-classes";
interface MCPServerConfig {
id: string;
type: "sse" | "stdio" | "shttp";
name?: string;
url?: string;
api_key?: string;
timeout?: number;
command?: string;
args?: string[];
env?: Record<string, string>;
}
import type { MCPServerConfig } from "#/types/mcp-server";
interface MCPServerListProps {
servers: MCPServerConfig[];
+11 -39
View File
@@ -1,28 +1,17 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPConfig } from "#/types/settings";
import type { MCPServerConfig } from "#/types/mcp-server";
import {
MCPSHTTPServer,
MCPConfig,
MCPSSEServer,
MCPStdioServer,
} from "#/types/settings";
import { parseMcpConfig, toSdkMcpConfig } from "#/utils/mcp-config";
parseMcpConfig,
toMcpShttpServer,
toMcpSseServer,
toMcpStdioServer,
toSdkMcpConfig,
} from "#/utils/mcp-config";
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
type MCPServerType = "sse" | "stdio" | "shttp";
interface MCPServerConfig {
type: MCPServerType;
name?: string;
url?: string;
api_key?: string;
timeout?: number;
command?: string;
args?: string[];
env?: Record<string, string>;
}
export function useAddMcpServer() {
const queryClient = useQueryClient();
const { data: settings } = useSettings();
@@ -40,28 +29,11 @@ export function useAddMcpServer() {
};
if (server.type === "sse") {
const sseServer: MCPSSEServer = {
...(server.name && { name: server.name }),
url: server.url!,
...(server.api_key && { api_key: server.api_key }),
};
newConfig.sse_servers.push(sseServer);
newConfig.sse_servers.push(toMcpSseServer(server));
} else if (server.type === "stdio") {
const stdioServer: MCPStdioServer = {
name: server.name!,
command: server.command!,
...(server.args && { args: server.args }),
...(server.env && { env: server.env }),
};
newConfig.stdio_servers.push(stdioServer);
newConfig.stdio_servers.push(toMcpStdioServer(server));
} else if (server.type === "shttp") {
const shttpServer: MCPSHTTPServer = {
...(server.name && { name: server.name }),
url: server.url!,
...(server.api_key && { api_key: server.api_key }),
...(server.timeout !== undefined && { timeout: server.timeout }),
};
newConfig.shttp_servers.push(shttpServer);
newConfig.shttp_servers.push(toMcpShttpServer(server));
}
await SettingsService.saveSettings({
+11 -31
View File
@@ -1,14 +1,15 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import {
MCPSHTTPServer,
MCPConfig,
MCPSSEServer,
MCPStdioServer,
} from "#/types/settings";
import { MCPConfig } from "#/types/settings";
import { MCPServerConfig } from "#/types/mcp-server";
import { parseMcpConfig, toSdkMcpConfig } from "#/utils/mcp-config";
import {
parseMcpConfig,
toMcpShttpServer,
toMcpSseServer,
toMcpStdioServer,
toSdkMcpConfig,
} from "#/utils/mcp-config";
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
import { substituteRedactedMcpCredentials } from "#/api/mcp-service/mcp-redacted-credentials";
@@ -38,32 +39,11 @@ export function useUpdateMcpServer() {
const index = parseInt(indexStr, 10);
if (serverType === "sse") {
const sseServer: MCPSSEServer = {
...(serverToSave.name && { name: serverToSave.name }),
url: serverToSave.url!,
...(serverToSave.api_key && { api_key: serverToSave.api_key }),
...(serverToSave.headers && { headers: serverToSave.headers }),
};
newConfig.sse_servers[index] = sseServer;
newConfig.sse_servers[index] = toMcpSseServer(serverToSave);
} else if (serverType === "stdio") {
const stdioServer: MCPStdioServer = {
name: serverToSave.name!,
command: serverToSave.command!,
...(serverToSave.args && { args: serverToSave.args }),
...(serverToSave.env && { env: serverToSave.env }),
};
newConfig.stdio_servers[index] = stdioServer;
newConfig.stdio_servers[index] = toMcpStdioServer(serverToSave);
} else if (serverType === "shttp") {
const shttpServer: MCPSHTTPServer = {
...(serverToSave.name && { name: serverToSave.name }),
url: serverToSave.url!,
...(serverToSave.api_key && { api_key: serverToSave.api_key }),
...(serverToSave.headers && { headers: serverToSave.headers }),
...(serverToSave.timeout !== undefined && {
timeout: serverToSave.timeout,
}),
};
newConfig.shttp_servers[index] = shttpServer;
newConfig.shttp_servers[index] = toMcpShttpServer(serverToSave);
}
await SettingsService.saveSettings({
+6 -4
View File
@@ -64,6 +64,11 @@ const normalizeSettingsResponse = (settings: Partial<Settings>): Settings => {
>),
};
const mcpConfig =
agentSettings.mcp_config !== undefined
? parseMcpConfig(agentSettings.mcp_config)
: (settings.mcp_config ?? DEFAULT_SETTINGS.mcp_config);
return {
...DEFAULT_SETTINGS,
...settings,
@@ -96,10 +101,7 @@ const normalizeSettingsResponse = (settings: Partial<Settings>): Settings => {
condenser_max_size:
pickFirstNumber(lookupNested(agentSettings, "condenser.max_size")) ??
DEFAULT_SETTINGS.condenser_max_size,
mcp_config: parseMcpConfig(
settings.mcp_config ??
(agentSettings.mcp_config as typeof settings.mcp_config),
),
mcp_config: mcpConfig,
search_api_key: settings.search_api_key || "",
email: settings.email || "",
git_user_name: settings.git_user_name || DEFAULT_SETTINGS.git_user_name,
+391
View File
@@ -22218,6 +22218,363 @@
"uk": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}",
"ca": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}"
},
"SETTINGS$MCP_AUTHENTICATION": {
"en": "Authentication",
"ja": "認証",
"zh-CN": "身份验证",
"zh-TW": "驗證",
"ko-KR": "인증",
"no": "Autentisering",
"it": "Autenticazione",
"pt": "Autenticação",
"es": "Autenticación",
"ar": "المصادقة",
"fr": "Authentification",
"tr": "Kimlik doğrulama",
"de": "Authentifizierung",
"uk": "Автентифікація",
"ca": "Autenticació"
},
"SETTINGS$MCP_AUTH_MODE_NONE": {
"en": "None",
"ja": "なし",
"zh-CN": "无",
"zh-TW": "無",
"ko-KR": "없음",
"no": "Ingen",
"it": "Nessuno",
"pt": "Nenhum",
"es": "Ninguno",
"ar": "بدون",
"fr": "Aucun",
"tr": "Yok",
"de": "Keine",
"uk": "Немає",
"ca": "Cap"
},
"SETTINGS$MCP_AUTH_MODE_BEARER": {
"en": "Bearer token",
"ja": "ベアラートークン",
"zh-CN": "Bearer 令牌",
"zh-TW": "Bearer 權杖",
"ko-KR": "베어러 토큰",
"no": "Bearer-token",
"it": "Token bearer",
"pt": "Token bearer",
"es": "Token bearer",
"ar": "رمز حامل",
"fr": "Jeton bearer",
"tr": "Bearer token",
"de": "Bearer-Token",
"uk": "Токен носія",
"ca": "Token bearer"
},
"SETTINGS$MCP_AUTH_MODE_HEADER": {
"en": "Header",
"ja": "ヘッダー",
"zh-CN": "标头",
"zh-TW": "標頭",
"ko-KR": "헤더",
"no": "Header",
"it": "Intestazione",
"pt": "Cabeçalho",
"es": "Encabezado",
"ar": "ترويسة",
"fr": "En-tête",
"tr": "Üst bilgi",
"de": "Header",
"uk": "Заголовок",
"ca": "Capçalera"
},
"SETTINGS$MCP_AUTH_MODE_OAUTH": {
"en": "OAuth",
"ja": "OAuth",
"zh-CN": "OAuth",
"zh-TW": "OAuth",
"ko-KR": "OAuth",
"no": "OAuth",
"it": "OAuth",
"pt": "OAuth",
"es": "OAuth",
"ar": "OAuth",
"fr": "OAuth",
"tr": "OAuth",
"de": "OAuth",
"uk": "OAuth",
"ca": "OAuth"
},
"SETTINGS$MCP_HEADERS": {
"en": "Headers",
"ja": "ヘッダー",
"zh-CN": "标头",
"zh-TW": "標頭",
"ko-KR": "헤더",
"no": "Headere",
"it": "Intestazioni",
"pt": "Cabeçalhos",
"es": "Encabezados",
"ar": "ترويسات",
"fr": "En-têtes",
"tr": "Üst bilgiler",
"de": "Header",
"uk": "Заголовки",
"ca": "Capçaleres"
},
"SETTINGS$MCP_HEADERS_PLACEHOLDER": {
"en": "X-API-Key=value\nX-Application-Key=value",
"ja": "X-API-Key=value\nX-Application-Key=value",
"zh-CN": "X-API-Key=value\nX-Application-Key=value",
"zh-TW": "X-API-Key=value\nX-Application-Key=value",
"ko-KR": "X-API-Key=value\nX-Application-Key=value",
"no": "X-API-Key=value\nX-Application-Key=value",
"it": "X-API-Key=value\nX-Application-Key=value",
"pt": "X-API-Key=value\nX-Application-Key=value",
"es": "X-API-Key=value\nX-Application-Key=value",
"ar": "X-API-Key=value\nX-Application-Key=value",
"fr": "X-API-Key=value\nX-Application-Key=value",
"tr": "X-API-Key=value\nX-Application-Key=value",
"de": "X-API-Key=value\nX-Application-Key=value",
"uk": "X-API-Key=value\nX-Application-Key=value",
"ca": "X-API-Key=value\nX-Application-Key=value"
},
"SETTINGS$MCP_OAUTH_CLIENT_AUTH": {
"en": "OAuth client auth",
"ja": "OAuth クライアント認証",
"zh-CN": "OAuth 客户端认证",
"zh-TW": "OAuth 用戶端驗證",
"ko-KR": "OAuth 클라이언트 인증",
"no": "OAuth-klientautentisering",
"it": "Autenticazione client OAuth",
"pt": "Autenticação de cliente OAuth",
"es": "Autenticación de cliente OAuth",
"ar": "مصادقة عميل OAuth",
"fr": "Authentification client OAuth",
"tr": "OAuth istemci kimlik doğrulaması",
"de": "OAuth-Client-Authentifizierung",
"uk": "Автентифікація клієнта OAuth",
"ca": "Autenticació de client OAuth"
},
"SETTINGS$MCP_OAUTH_CLIENT_AUTH_AUTO": {
"en": "Auto",
"ja": "自動",
"zh-CN": "自动",
"zh-TW": "自動",
"ko-KR": "자동",
"no": "Auto",
"it": "Automatico",
"pt": "Automático",
"es": "Automático",
"ar": "تلقائي",
"fr": "Auto",
"tr": "Otomatik",
"de": "Auto",
"uk": "Авто",
"ca": "Automàtic"
},
"SETTINGS$MCP_OAUTH_CLIENT_AUTH_NONE": {
"en": "None",
"ja": "なし",
"zh-CN": "无",
"zh-TW": "無",
"ko-KR": "없음",
"no": "Ingen",
"it": "Nessuno",
"pt": "Nenhum",
"es": "Ninguno",
"ar": "بدون",
"fr": "Aucun",
"tr": "Yok",
"de": "Keine",
"uk": "Немає",
"ca": "Cap"
},
"SETTINGS$MCP_OAUTH_CLIENT_AUTH_SECRET_POST": {
"en": "Client secret POST",
"ja": "クライアントシークレット POST",
"zh-CN": "客户端密钥 POST",
"zh-TW": "用戶端密碼 POST",
"ko-KR": "클라이언트 시크릿 POST",
"no": "Client secret POST",
"it": "Client secret POST",
"pt": "Client secret POST",
"es": "Client secret POST",
"ar": "سر العميل POST",
"fr": "Client secret POST",
"tr": "Client secret POST",
"de": "Client-Secret POST",
"uk": "Секрет клієнта POST",
"ca": "Client secret POST"
},
"SETTINGS$MCP_OAUTH_CLIENT_AUTH_SECRET_BASIC": {
"en": "Client secret basic",
"ja": "クライアントシークレット basic",
"zh-CN": "客户端密钥 basic",
"zh-TW": "用戶端密碼 basic",
"ko-KR": "클라이언트 시크릿 basic",
"no": "Client secret basic",
"it": "Client secret basic",
"pt": "Client secret basic",
"es": "Client secret basic",
"ar": "سر العميل basic",
"fr": "Client secret basic",
"tr": "Client secret basic",
"de": "Client-Secret basic",
"uk": "Секрет клієнта basic",
"ca": "Client secret basic"
},
"SETTINGS$MCP_OAUTH_CLIENT_AUTH_PRIVATE_KEY_JWT": {
"en": "Private key JWT",
"ja": "プライベートキー JWT",
"zh-CN": "私钥 JWT",
"zh-TW": "私鑰 JWT",
"ko-KR": "개인 키 JWT",
"no": "Private key JWT",
"it": "Private key JWT",
"pt": "Private key JWT",
"es": "Private key JWT",
"ar": "مفتاح خاص JWT",
"fr": "Clé privée JWT",
"tr": "Private key JWT",
"de": "Private Key JWT",
"uk": "Приватний ключ JWT",
"ca": "Private key JWT"
},
"SETTINGS$MCP_OAUTH_CLIENT_ID": {
"en": "OAuth client ID",
"ja": "OAuth クライアント ID",
"zh-CN": "OAuth 客户端 ID",
"zh-TW": "OAuth 用戶端 ID",
"ko-KR": "OAuth 클라이언트 ID",
"no": "OAuth-klient-ID",
"it": "ID client OAuth",
"pt": "ID de cliente OAuth",
"es": "ID de cliente OAuth",
"ar": "معرف عميل OAuth",
"fr": "ID client OAuth",
"tr": "OAuth istemci kimliği",
"de": "OAuth-Client-ID",
"uk": "ID клієнта OAuth",
"ca": "ID de client OAuth"
},
"SETTINGS$MCP_OAUTH_CLIENT_ID_PLACEHOLDER": {
"en": "client-id",
"ja": "client-id",
"zh-CN": "client-id",
"zh-TW": "client-id",
"ko-KR": "client-id",
"no": "client-id",
"it": "client-id",
"pt": "client-id",
"es": "client-id",
"ar": "client-id",
"fr": "client-id",
"tr": "client-id",
"de": "client-id",
"uk": "client-id",
"ca": "client-id"
},
"SETTINGS$MCP_OAUTH_CLIENT_SECRET": {
"en": "OAuth client secret",
"ja": "OAuth クライアントシークレット",
"zh-CN": "OAuth 客户端密钥",
"zh-TW": "OAuth 用戶端密碼",
"ko-KR": "OAuth 클라이언트 시크릿",
"no": "OAuth-klienthemmelighet",
"it": "Client secret OAuth",
"pt": "Client secret OAuth",
"es": "Client secret OAuth",
"ar": "سر عميل OAuth",
"fr": "Client secret OAuth",
"tr": "OAuth istemci gizli anahtarı",
"de": "OAuth-Client-Secret",
"uk": "Секрет клієнта OAuth",
"ca": "Client secret OAuth"
},
"SETTINGS$MCP_OAUTH_CLIENT_SECRET_PLACEHOLDER": {
"en": "client-secret",
"ja": "client-secret",
"zh-CN": "client-secret",
"zh-TW": "client-secret",
"ko-KR": "client-secret",
"no": "client-secret",
"it": "client-secret",
"pt": "client-secret",
"es": "client-secret",
"ar": "client-secret",
"fr": "client-secret",
"tr": "client-secret",
"de": "client-secret",
"uk": "client-secret",
"ca": "client-secret"
},
"SETTINGS$MCP_OAUTH_SCOPES": {
"en": "OAuth scopes",
"ja": "OAuth スコープ",
"zh-CN": "OAuth 范围",
"zh-TW": "OAuth 範圍",
"ko-KR": "OAuth 범위",
"no": "OAuth-scopes",
"it": "Scope OAuth",
"pt": "Escopos OAuth",
"es": "Ámbitos de OAuth",
"ar": "نطاقات OAuth",
"fr": "Portées OAuth",
"tr": "OAuth kapsamları",
"de": "OAuth-Scopes",
"uk": "Області OAuth",
"ca": "Àmbits OAuth"
},
"SETTINGS$MCP_OAUTH_SCOPES_PLACEHOLDER": {
"en": "read write",
"ja": "read write",
"zh-CN": "read write",
"zh-TW": "read write",
"ko-KR": "read write",
"no": "read write",
"it": "read write",
"pt": "read write",
"es": "read write",
"ar": "read write",
"fr": "read write",
"tr": "read write",
"de": "read write",
"uk": "read write",
"ca": "read write"
},
"SETTINGS$MCP_ERROR_HEADER_REQUIRED": {
"en": "Header authentication requires a header",
"ja": "ヘッダー認証にはヘッダーが必要です",
"zh-CN": "标头身份验证需要标头",
"zh-TW": "標頭驗證需要標頭",
"ko-KR": "헤더 인증에는 헤더가 필요합니다",
"no": "Header-autentisering krever en header",
"it": "L'autenticazione tramite intestazione richiede un'intestazione",
"pt": "A autenticação por cabeçalho requer um cabeçalho",
"es": "La autenticación por encabezado requiere un encabezado",
"ar": "تتطلب مصادقة الترويسة ترويسة",
"fr": "L'authentification par en-tête nécessite un en-tête",
"tr": "Üst bilgi kimlik doğrulaması bir üst bilgi gerektirir",
"de": "Header-Authentifizierung erfordert einen Header",
"uk": "Автентифікація заголовком потребує заголовка",
"ca": "L'autenticació per capçalera requereix una capçalera"
},
"SETTINGS$MCP_ERROR_OAUTH_SECRET_REQUIRES_ID": {
"en": "OAuth client secret requires a client ID",
"ja": "OAuth クライアントシークレットにはクライアント ID が必要です",
"zh-CN": "OAuth 客户端密钥需要客户端 ID",
"zh-TW": "OAuth 用戶端密碼需要用戶端 ID",
"ko-KR": "OAuth 클라이언트 시크릿에는 클라이언트 ID가 필요합니다",
"no": "OAuth-klienthemmelighet krever en klient-ID",
"it": "Il client secret OAuth richiede un ID client",
"pt": "O client secret OAuth requer um ID de cliente",
"es": "El client secret de OAuth requiere un ID de cliente",
"ar": "سر عميل OAuth يتطلب معرف عميل",
"fr": "Le client secret OAuth nécessite un ID client",
"tr": "OAuth istemci gizli anahtarı bir istemci kimliği gerektirir",
"de": "OAuth-Client-Secret erfordert eine Client-ID",
"uk": "Секрет клієнта OAuth потребує ID клієнта",
"ca": "El client secret OAuth requereix un ID de client"
},
"SETTINGS": {
"en": "Environment variables must follow KEY=value format",
"ja": "A server with this URL already exists for the selected type",
@@ -33420,5 +33777,39 @@
"de": "An diese Unterhaltung sind keine Plugins angehängt.",
"uk": "До цієї розмови не додано жодного плагіна.",
"ca": "No hi ha cap connector adjuntat a aquesta conversa."
},
"MCP$OAUTH_CONNECT_INFO": {
"en": "This server uses OAuth for authentication. Click Install to connect — you will be redirected to authorize access.",
"ja": "このサーバーは認証にOAuthを使用します。「インストール」をクリックして接続してください。アクセスを承認するようにリダイレクトされます。",
"zh-CN": "此服务器使用 OAuth 进行身份验证。点击\"安装\"进行连接 — 您将被重定向以授权访问。",
"zh-TW": "此伺服器使用 OAuth 進行身分驗證。點擊「安裝」進行連線 — 您將被重新導向以授權存取。",
"ko-KR": "이 서버는 인증에 OAuth를 사용합니다. \"설치\"를 클릭하여 연결하세요 — 액세스 권한 부여를 위해 리디렉션됩니다.",
"no": "Denne serveren bruker OAuth for autentisering. Klikk Installer for å koble til — du vil bli omdirigert for å godkjenne tilgang.",
"it": "Questo server utilizza OAuth per l'autenticazione. Fai clic su Installa per connetterti — verrai reindirizzato per autorizzare l'accesso.",
"pt": "Este servidor usa OAuth para autenticação. Clique em Instalar para conectar — você será redirecionado para autorizar o acesso.",
"es": "Este servidor utiliza OAuth para la autenticación. Haz clic en Instalar para conectarte — serás redirigido para autorizar el acceso.",
"ar": "يستخدم هذا الخادم OAuth للمصادقة. انقر على \"تثبيت\" للاتصال — سيتم إعادة توجيهك لتخويل الوصول.",
"fr": "Ce serveur utilise OAuth pour l'authentification. Cliquez sur Installer pour vous connecter — vous serez redirigé pour autoriser l'accès.",
"tr": "Bu sunucu kimlik doğrulama için OAuth kullanır. Bağlanmak için Yükle'ye tıklayın — erişimi yetkilendirmek için yönlendirileceksiniz.",
"de": "Dieser Server verwendet OAuth für die Authentifizierung. Klicken Sie auf Installieren, um eine Verbindung herzustellen — Sie werden zur Autorisierung des Zugriffs weitergeleitet.",
"uk": "Цей сервер використовує OAuth для автентифікації. Натисніть «Встановити», щоб підключитися — ви будете перенаправлені для авторизації доступу.",
"ca": "Aquest servidor utilitza OAuth per a l'autenticació. Feu clic a Instal·la per connectar-vos — se us redirigirà per autoritzar l'accés."
},
"MCP$OAUTH_CONNECT_HINT": {
"en": "No API key needed. The server handles the OAuth flow automatically.",
"ja": "APIキーは不要です。サーバーがOAuthフローを自動的に処理します。",
"zh-CN": "无需 API 密钥。服务器会自动处理 OAuth 流程。",
"zh-TW": "無需 API 金鑰。伺服器會自動處理 OAuth 流程。",
"ko-KR": "API 키가 필요하지 않습니다. 서버가 OAuth 흐름을 자동으로 처리합니다.",
"no": "Ingen API-nøkkel nødvendig. Serveren håndterer OAuth-flyten automatisk.",
"it": "Nessuna chiave API necessaria. Il server gestisce automaticamente il flusso OAuth.",
"pt": "Nenhuma chave de API necessária. O servidor gerencia o fluxo OAuth automaticamente.",
"es": "No se necesita clave de API. El servidor maneja el flujo de OAuth automáticamente.",
"ar": "لا مطلوب مفتاح API. يتعامل الخادم مع تدفق OAuth تلقائيًا.",
"fr": "Aucune clé API requise. Le serveur gère le flux OAuth automatiquement.",
"tr": "API anahtarı gerekmez. Sunucu OAuth akışını otomatik olarak yönetir.",
"de": "Kein API-Schlüssel erforderlich. Der Server übernimmt den OAuth-Fluss automatisch.",
"uk": "Ключ API не потрібен. Сервер автоматично обробляє процес OAuth.",
"ca": "No cal cap clau d'API. El servidor gestiona el flux d'OAuth automàticament."
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ import {
// No ACP guard here (unlike `/settings` and `/settings/condenser`): MCP
// servers configured via `agent_settings.mcp_config` are now forwarded to
// the ACP subprocess at session creation, so this page is meaningful for
// both OpenHands and ACP agents. The same editor and `mcp_config` storage
// both OpenHands and ACP agents. The same editor and MCP settings storage
// drive both kinds.
export default function MCPPage() {
+2 -6
View File
@@ -36,7 +36,7 @@ export const DEFAULT_SETTINGS: Settings = {
git_user_email: "openhands@all-hands.dev",
agent_settings_schema: null,
agent_settings: {
schema_version: 1,
schema_version: 6,
agent_kind: "openhands",
agent: "CodeActAgent",
llm: {
@@ -51,11 +51,7 @@ export const DEFAULT_SETTINGS: Settings = {
enable_iterative_refinement: false,
},
enable_sub_agents: false,
mcp_config: {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
},
mcp_config: {},
},
conversation_settings_schema: null,
conversation_settings: {
+63
View File
@@ -0,0 +1,63 @@
export type MCPOAuthClientAuthMethod =
| "none"
| "client_secret_post"
| "client_secret_basic"
| "private_key_jwt";
export type MCPJsonValue =
| boolean
| number
| string
| null
| MCPJsonValue[]
| { [key: string]: MCPJsonValue };
export interface MCPOAuthAuthenticationConfig {
type: "oauth";
client_auth_method?: MCPOAuthClientAuthMethod | null;
scopes?: string | string[] | null;
client_name?: string | null;
client_metadata_url?: string | null;
client_id?: string | null;
client_secret?: string | null;
additional_client_metadata?: Record<string, MCPJsonValue> | null;
}
export type MCPAuthenticationConfig = MCPOAuthAuthenticationConfig;
export interface MCPOAuthState {
tokens?: Record<string, MCPJsonValue> | null;
client_info?: Record<string, MCPJsonValue> | null;
token_expires_at?: number | null;
}
export type MCPAuthCredential =
| { strategy: "none" }
| { strategy: "api_key"; value?: string | null; header_name?: string | null }
| { strategy: "bearer"; value?: string | null }
| { strategy: "basic"; username: string; password?: string | null }
| { strategy: "header"; headers?: Record<string, string> | null }
| {
strategy: "oauth2";
authentication?: MCPAuthenticationConfig | null;
state?: MCPOAuthState | null;
};
export const MCP_AUTH_STRATEGIES = [
"none",
"api_key",
"bearer",
"basic",
"header",
"oauth2",
] as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
export const isMcpAuthCredential = (
value: unknown,
): value is MCPAuthCredential =>
isRecord(value) &&
typeof value.strategy === "string" &&
(MCP_AUTH_STRATEGIES as readonly string[]).includes(value.strategy);
+24 -13
View File
@@ -1,9 +1,5 @@
// Shared MCPServerConfig shape used by the MCP page UI components.
//
// Historically each component duplicated this interface. Centralizing
// it here keeps the marketplace utilities, hooks, and form in sync.
import type { MCPTestFailureKind } from "@openhands/typescript-client";
import type { MCPAuthCredential, MCPOAuthState } from "./mcp-auth";
export type MCPServerType = "sse" | "stdio" | "shttp";
@@ -12,21 +8,14 @@ export interface MCPServerConfig {
type: MCPServerType;
name?: string;
url?: string;
api_key?: string;
headers?: Record<string, string>;
timeout?: number;
command?: string;
args?: string[];
env?: Record<string, string>;
auth?: MCPAuthCredential;
}
// Extensions of the published `@openhands/typescript-client` MCP test
// types (frozen at the released version). The agent server's
// /api/mcp/test additionally accepts a `tool_call` (a read-only tool to
// invoke so credentials get exercised) and reports its outcome in
// `tool_result`; the service layer maps an interpreted credential
// failure to the GUI-local `"credentials"` error kind.
export interface MCPTestToolCall {
name: string;
arguments: Record<string, unknown>;
@@ -43,6 +32,7 @@ export interface ExtendedMCPTestSuccess {
ok: true;
tools: string[];
tool_result?: MCPTestToolResult | null;
oauth_state?: MCPOAuthState | null;
}
export interface ExtendedMCPTestFailure {
@@ -54,3 +44,24 @@ export interface ExtendedMCPTestFailure {
export type ExtendedMCPTestResponse =
| ExtendedMCPTestSuccess
| ExtendedMCPTestFailure;
export interface MCPOAuthStartResponse {
ok: boolean;
job_id?: string | null;
authorization_url?: string | null;
error?: string | null;
error_kind?: MCPTestFailureKind | null;
}
export interface MCPOAuthStatusResponse {
ok: boolean;
status: "pending" | "authorizing" | "succeeded" | "failed";
job_id: string;
authorization_url?: string | null;
callback_ready?: boolean;
tools?: string[] | null;
tool_result?: MCPTestToolResult | null;
oauth_state?: MCPOAuthState | null;
error?: string | null;
error_kind?: MCPTestFailureKind | null;
}
+7 -4
View File
@@ -1,3 +1,5 @@
import type { MCPAuthCredential } from "./mcp-auth";
export const ProviderOptions = {
github: "github",
gitlab: "gitlab",
@@ -17,8 +19,8 @@ export type ProviderToken = {
export type MCPSSEServer = {
name?: string;
url: string;
api_key?: string;
headers?: Record<string, string>;
auth?: MCPAuthCredential;
};
export type MCPStdioServer = {
@@ -31,9 +33,9 @@ export type MCPStdioServer = {
export type MCPSHTTPServer = {
name?: string;
url: string;
api_key?: string;
headers?: Record<string, string>;
timeout?: number;
auth?: MCPAuthCredential;
};
export type MCPConfig = {
@@ -120,8 +122,9 @@ export type SettingsScope = "personal";
* LLM-driven Agent. The other agent_settings fields (``llm``, ``condenser``,
* ``mcp_config``, ``tools``) apply.
* - ``"acp"``: the conversation is driven by an external ACP subprocess
* (Claude Code / Codex / Gemini CLI / Custom). The LLM / condenser / MCP
* settings are inert; ``acp_command`` / ``acp_args`` / ``acp_model`` /
* (Claude Code / Codex / Gemini CLI / Custom). The LLM / condenser
* settings are inert; ``mcp_config`` and ``acp_command`` / ``acp_args`` /
* ``acp_model`` /
* ``acp_server`` apply instead. Provider credentials are supplied through the
* Secrets panel (``request.secrets``), never through a per-agent env channel.
*/
+1 -1
View File
@@ -12,7 +12,7 @@ import { queryClient } from "#/query-client-config";
* The ACP sub-agent owns its own LLM and condenser, so the canvas-side
* surfaces that configure those concepts (``/settings``,
* ``/settings/condenser``) have nothing useful to do while ACP is active.
* (``/mcp`` is intentionally *not* guarded: ``mcp_config`` is now forwarded
* (``/mcp`` is intentionally *not* guarded: ``mcp_config`` is forwarded
* to the ACP subprocess at session creation.) Doing the redirect in a
* ``clientLoader`` (instead of a per-route ``useEffect``) prevents the
* one-frame flash of the old content before the guard fires.
+227 -108
View File
@@ -5,6 +5,14 @@ import {
MCPStdioServer,
SettingsValue,
} from "#/types/settings";
import type {
MCPAuthCredential,
MCPAuthenticationConfig,
MCPJsonValue,
MCPOAuthState,
} from "#/types/mcp-auth";
import type { MCPServerConfig } from "#/types/mcp-server";
import { toMcpServerName } from "#/utils/mcp-server-name";
const EMPTY_MCP_CONFIG: MCPConfig = {
sse_servers: [],
@@ -12,29 +20,10 @@ const EMPTY_MCP_CONFIG: MCPConfig = {
shttp_servers: [],
};
export const REDACTED_MCP_SECRET_VALUE = "<redacted>";
export const REDACTED_MCP_SECRET_VALUE = "**********";
const LINEAR_DEPRECATED_SSE_URL = "https://mcp.linear.app/sse";
const LINEAR_SHTTP_URL = "https://mcp.linear.app/mcp";
/**
* Linear removed its MCP SSE transport (the /sse endpoint rejects every
* call since 2026-04-08). Detect persisted configs that still point at
* the dead endpoint so they can be migrated to streamable HTTP at the
* /mcp replacement. Matches only the exact deprecated URL (tolerating a
* trailing slash or query string) — nothing else is rewritten.
*/
function isDeprecatedLinearSse(
url: string,
transport: string | undefined,
): boolean {
if (transport !== "sse") return false;
const normalized = url.split("?")[0].replace(/\/+$/, "");
return normalized === LINEAR_DEPRECATED_SSE_URL;
}
type SdkMcpServerConfig = Record<string, SettingsValue>;
type SdkMcpConfig = { mcpServers: Record<string, SdkMcpServerConfig> };
type SdkMcpServerConfig = Record<string, SettingsValue | MCPAuthCredential>;
type SdkMcpConfig = Record<string, SdkMcpServerConfig>;
/**
* The dict key for an SSE/SHTTP server doubles as its display name once a
@@ -52,118 +41,256 @@ function userGivenServerName(
return autoGenerated.test(serverName) ? undefined : serverName;
}
function apiKeyFromAuthorizationHeader(value: unknown): string | undefined {
if (Array.isArray(value)) {
return value
.map(apiKeyFromAuthorizationHeader)
.find((apiKey) => apiKey !== undefined);
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
export function getSdkMcpServerMap(
value: unknown,
): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const mcpServers = value.mcpServers;
if (
isRecord(mcpServers) &&
!("url" in mcpServers) &&
!("command" in mcpServers)
) {
return mcpServers;
}
if (typeof value !== "string" || value.length === 0) return undefined;
const bearer = value.match(/^Bearer\s+(.+)$/i);
return bearer ? bearer[1] : value;
return value;
}
function apiKeyFromServerConfig(
export function stringRecord(
value: unknown,
): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
const entries = Object.entries(value).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
export function hasRedactedMcpSecretLeaf(value: unknown): boolean {
if (value === REDACTED_MCP_SECRET_VALUE) return true;
if (Array.isArray(value)) return value.some(hasRedactedMcpSecretLeaf);
if (isRecord(value))
return Object.values(value).some(hasRedactedMcpSecretLeaf);
return false;
}
function getOAuthState(value: unknown): MCPOAuthState | undefined {
if (!isRecord(value)) return undefined;
const state: MCPOAuthState = {};
if (isRecord(value.tokens)) {
state.tokens = value.tokens as NonNullable<MCPOAuthState["tokens"]>;
}
if (isRecord(value.client_info)) {
state.client_info = value.client_info as NonNullable<
MCPOAuthState["client_info"]
>;
}
if (
typeof value.token_expires_at === "number" ||
value.token_expires_at === null
) {
state.token_expires_at = value.token_expires_at;
}
return Object.keys(state).length > 0 ? state : undefined;
}
function normalizeTaggedAuthCredential(
value: unknown,
): MCPAuthCredential | undefined {
if (!isRecord(value)) return undefined;
switch (value.strategy) {
case "none":
return { strategy: "none" };
case "api_key":
if (typeof value.value !== "string") return undefined;
return {
strategy: "api_key",
value: value.value,
...(typeof value.header_name === "string" && {
header_name: value.header_name,
}),
};
case "bearer":
if (typeof value.value !== "string") return undefined;
return { strategy: "bearer", value: value.value };
case "basic":
if (
typeof value.username !== "string" ||
typeof value.password !== "string"
) {
return undefined;
}
return {
strategy: "basic",
username: value.username,
password: value.password,
};
case "header": {
const headers = stringRecord(value.headers);
return headers ? { strategy: "header", headers } : undefined;
}
case "oauth2": {
const authentication = getAuthenticationConfig(value.authentication);
const state = getOAuthState(value.state);
return {
strategy: "oauth2",
...(authentication && { authentication }),
...(state && { state }),
};
}
default:
return undefined;
}
}
function authCredentialFromServerConfig(
serverConfig: Record<string, unknown>,
): string | undefined {
const headers = serverConfig.headers;
const authorization =
headers && typeof headers === "object"
? ((headers as Record<string, unknown>).Authorization ??
(headers as Record<string, unknown>).authorization)
: undefined;
const headerApiKey = apiKeyFromAuthorizationHeader(authorization);
if (headerApiKey) return headerApiKey;
const auth = serverConfig.auth;
return typeof auth === "string" && auth !== "oauth" ? auth : undefined;
): MCPAuthCredential | undefined {
return normalizeTaggedAuthCredential(serverConfig.auth);
}
function getAuthorizationHeaders(apiKey: string | undefined) {
if (!apiKey) return {};
function getRemoteSecretFields(entry: {
auth?: MCPAuthCredential;
headers?: Record<string, string>;
}): Partial<SdkMcpServerConfig> {
const fields: Partial<SdkMcpServerConfig> = {};
if (entry.auth && !hasRedactedMcpSecretLeaf(entry.auth)) {
fields.auth = entry.auth;
}
if (entry.headers && Object.keys(entry.headers).length > 0) {
fields.headers = entry.headers;
}
return fields;
}
function getRemoteServerFields(server: MCPServerConfig) {
return {
headers: {
Authorization: `Bearer ${apiKey}`,
},
...(server.name && { name: server.name }),
url: server.url!,
...(server.headers && { headers: server.headers }),
...(server.auth && { auth: server.auth }),
};
}
function getRemoteCredentialFields(entry: {
api_key?: string;
headers?: Record<string, string>;
}): Partial<SdkMcpServerConfig> {
if (entry.api_key && entry.api_key !== REDACTED_MCP_SECRET_VALUE) {
return getAuthorizationHeaders(entry.api_key);
export function toMcpSseServer(server: MCPServerConfig): MCPSSEServer {
return getRemoteServerFields(server);
}
export function toMcpShttpServer(server: MCPServerConfig): MCPSHTTPServer {
return {
...getRemoteServerFields(server),
...(server.timeout !== undefined && { timeout: server.timeout }),
};
}
export function toMcpStdioServer(server: MCPServerConfig): MCPStdioServer {
return {
name: server.name!,
command: server.command!,
...(server.args && { args: server.args }),
...(server.env && { env: server.env }),
};
}
function getAuthenticationConfig(
value: unknown,
): MCPAuthenticationConfig | undefined {
if (!value || typeof value !== "object") return undefined;
const record = value as Record<string, unknown>;
if (record.type !== "oauth") return undefined;
const authentication: MCPAuthenticationConfig = { type: "oauth" };
if (
record.client_auth_method === "none" ||
record.client_auth_method === "client_secret_post" ||
record.client_auth_method === "client_secret_basic" ||
record.client_auth_method === "private_key_jwt"
) {
authentication.client_auth_method = record.client_auth_method;
}
if (entry.headers && Object.keys(entry.headers).length > 0) {
return { headers: entry.headers };
if (typeof record.scopes === "string") {
authentication.scopes = record.scopes;
} else if (
Array.isArray(record.scopes) &&
record.scopes.every((scope) => typeof scope === "string")
) {
authentication.scopes = record.scopes;
}
return {};
if (typeof record.client_name === "string") {
authentication.client_name = record.client_name;
}
if (typeof record.client_metadata_url === "string") {
authentication.client_metadata_url = record.client_metadata_url;
}
if (typeof record.client_id === "string") {
authentication.client_id = record.client_id;
}
if (typeof record.client_secret === "string") {
authentication.client_secret = record.client_secret;
}
if (
record.additional_client_metadata &&
typeof record.additional_client_metadata === "object" &&
!Array.isArray(record.additional_client_metadata)
) {
authentication.additional_client_metadata =
record.additional_client_metadata as Record<string, MCPJsonValue>;
}
return authentication;
}
/**
* Parse an SDK mcp_config value ({ mcpServers: { ... } }) and convert it
* Parse the SDK-native ``agent_settings.mcp_config`` value and convert it
* to the frontend MCPConfig format used by UI components.
*/
export function parseMcpConfig(value: unknown): MCPConfig {
if (!value || typeof value !== "object") {
return { ...EMPTY_MCP_CONFIG };
}
const obj = value as Record<string, unknown>;
if (
!("mcpServers" in obj) ||
!obj.mcpServers ||
typeof obj.mcpServers !== "object"
) {
const mcpConfig = getSdkMcpServerMap(value);
if (!mcpConfig) {
return { ...EMPTY_MCP_CONFIG };
}
const sseServers: (string | MCPSSEServer)[] = [];
const stdioServers: MCPStdioServer[] = [];
const shttpServers: (string | MCPSHTTPServer)[] = [];
// Legacy Linear SSE entries rewritten to the /mcp endpoint. Collected
// separately and merged after the loop so an existing hand-added /mcp
// entry (with its own api_key/timeout) wins over the migrated one.
const migratedShttpServers: MCPSHTTPServer[] = [];
const mcpServers = obj.mcpServers as Record<string, Record<string, unknown>>;
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
if (!serverConfig || typeof serverConfig !== "object") continue;
for (const [serverName, serverConfig] of Object.entries(mcpConfig)) {
if (!isRecord(serverConfig)) continue;
const url = serverConfig.url as string | undefined;
if (url) {
const transport = serverConfig.transport as string | undefined;
const apiKey = apiKeyFromServerConfig(serverConfig);
const auth = authCredentialFromServerConfig(serverConfig);
const headers = stringRecord(serverConfig.headers);
if (isDeprecatedLinearSse(url, transport)) {
const server: MCPSHTTPServer = { url: LINEAR_SHTTP_URL };
if (apiKey) server.api_key = apiKey;
migratedShttpServers.push(server);
} else if (transport === "sse") {
if (transport === "sse") {
const name = userGivenServerName(serverName, "sse");
const server: MCPSSEServer = { url };
if (name) server.name = name;
if (apiKey) server.api_key = apiKey;
if (auth) server.auth = auth;
if (headers) server.headers = headers;
sseServers.push(server);
} else {
const name = userGivenServerName(serverName, "shttp");
const server: MCPSHTTPServer = { url };
if (name) server.name = name;
if (apiKey) server.api_key = apiKey;
if (serverConfig.timeout != null) {
server.timeout = serverConfig.timeout as number;
}
if (auth) server.auth = auth;
if (headers) server.headers = headers;
shttpServers.push(server);
}
} else {
if (typeof serverConfig.command !== "string") continue;
const stdioServer: MCPStdioServer = {
name: serverName,
command: serverConfig.command as string,
command: serverConfig.command,
};
if (serverConfig.args) {
stdioServer.args = serverConfig.args as string[];
@@ -175,15 +302,6 @@ export function parseMcpConfig(value: unknown): MCPConfig {
}
}
const normalizeUrl = (u: string) => u.replace(/\/+$/, "");
for (const migrated of migratedShttpServers) {
const alreadyPresent = shttpServers.some((entry) => {
const entryUrl = typeof entry === "string" ? entry : entry.url;
return normalizeUrl(entryUrl) === normalizeUrl(migrated.url);
});
if (!alreadyPresent) shttpServers.push(migrated);
}
return {
sse_servers: sseServers,
stdio_servers: stdioServers,
@@ -192,8 +310,8 @@ export function parseMcpConfig(value: unknown): MCPConfig {
}
/**
* Convert the frontend MCPConfig format back to the SDK { mcpServers: { ... } }
* shape expected by agent_settings.mcp_config on the backend.
* Convert the frontend MCPConfig format back to the SDK-native server map
* expected by agent_settings.mcp_config on the backend.
*
* Names are only suffixed (``_1``, ``_2``, …) when an earlier entry has
* already claimed the bare base name. We intentionally do NOT use a single
@@ -204,13 +322,14 @@ export function parseMcpConfig(value: unknown): MCPConfig {
* unrelated entries keep their human-meaningful names stable across edits.
*/
export function toSdkMcpConfig(config: MCPConfig): SdkMcpConfig | null {
const mcpServers: Record<string, SdkMcpServerConfig> = {};
const mcpConfig: SdkMcpConfig = {};
const reserve = (base: string): string => {
if (!(base in mcpServers)) return base;
const safeBase = toMcpServerName(base);
if (!(safeBase in mcpConfig)) return safeBase;
let i = 1;
while (`${base}_${i}` in mcpServers) i += 1;
return `${base}_${i}`;
while (`${safeBase}_${i}` in mcpConfig) i += 1;
return `${safeBase}_${i}`;
};
for (const entry of config.sse_servers) {
@@ -221,24 +340,24 @@ export function toSdkMcpConfig(config: MCPConfig): SdkMcpConfig | null {
} else {
name = entry.name;
server.url = entry.url;
Object.assign(server, getRemoteCredentialFields(entry));
Object.assign(server, getRemoteSecretFields(entry));
}
server.transport = "sse";
mcpServers[reserve(name || "sse")] = server;
mcpConfig[reserve(name || "sse")] = server;
}
for (const entry of config.shttp_servers) {
const server: SdkMcpServerConfig = {};
const server: SdkMcpServerConfig = { transport: "http" };
let name: string | undefined;
if (typeof entry === "string") {
server.url = entry;
} else {
name = entry.name;
server.url = entry.url;
Object.assign(server, getRemoteCredentialFields(entry));
Object.assign(server, getRemoteSecretFields(entry));
if (entry.timeout != null) server.timeout = entry.timeout;
}
mcpServers[reserve(name || "shttp")] = server;
mcpConfig[reserve(name || "shttp")] = server;
}
for (const entry of config.stdio_servers) {
@@ -247,8 +366,8 @@ export function toSdkMcpConfig(config: MCPConfig): SdkMcpConfig | null {
};
if (entry.args) server.args = entry.args;
if (entry.env) server.env = entry.env;
mcpServers[reserve(entry.name || "stdio")] = server;
mcpConfig[reserve(entry.name || "stdio")] = server;
}
return Object.keys(mcpServers).length > 0 ? { mcpServers } : null;
return Object.keys(mcpConfig).length > 0 ? mcpConfig : null;
}
+2 -2
View File
@@ -8,8 +8,8 @@ export function flattenMcpConfig(config: MCPConfig): MCPServerConfig[] {
type: "sse" as const,
name: typeof server === "object" ? server.name : undefined,
url: typeof server === "string" ? server : server.url,
api_key: typeof server === "object" ? server.api_key : undefined,
headers: typeof server === "object" ? server.headers : undefined,
auth: typeof server === "object" ? server.auth : undefined,
})),
...config.stdio_servers.map((server, index) => ({
id: `stdio-${index}`,
@@ -24,9 +24,9 @@ export function flattenMcpConfig(config: MCPConfig): MCPServerConfig[] {
type: "shttp" as const,
name: typeof server === "object" ? server.name : undefined,
url: typeof server === "string" ? server : server.url,
api_key: typeof server === "object" ? server.api_key : undefined,
headers: typeof server === "object" ? server.headers : undefined,
timeout: typeof server === "object" ? server.timeout : undefined,
auth: typeof server === "object" ? server.auth : undefined,
})),
];
}
+53 -4
View File
@@ -1,8 +1,13 @@
import { MCPServerConfig } from "#/types/mcp-server";
import type {
MCPAuthenticationConfig,
MCPOAuthClientAuthMethod,
} from "#/types/mcp-auth";
import type {
IntegrationAuthConfig,
IntegrationCatalogEntry as MarketplaceEntry,
IntegrationConnectionOption,
IntegrationOAuthConfig,
IntegrationTransport,
} from "@openhands/extensions/integrations";
@@ -40,10 +45,22 @@ export function getDefaultMcpConnectionOption(
function isLocallyInstallableMcpOption(
option: McpMarketplaceConnectionOption,
): boolean {
// The local install modal writes static MCP server config. OAuth options
// describe hosted redirect flows, so prefer an API/stdio fallback when one
// exists and leave OAuth as the default connection for hosted integrations.
return option.auth.strategy !== "oauth2";
if (option.auth.strategy !== "oauth2") return true;
const oauth = option.auth.oauth;
if (!oauth) return false;
// Local agent-server installs only support OAuth flows initiated by the MCP
// server itself through fastmcp. Catalog entries that specify provider OAuth
// endpoints still need the hosted integration-auth flow and should not be
// exposed as locally installable static MCP configs.
return (
!oauth.authorizationUrl &&
!oauth.tokenUrl &&
!oauth.registrationUrl &&
!oauth.additionalAuthorizationParams &&
!oauth.additionalTokenParams
);
}
export function getInstallableMcpConnectionOption(
@@ -58,6 +75,38 @@ export function getDefaultMcpTransport(
return getDefaultMcpConnectionOption(entry)?.transport;
}
function toMcpOAuthClientAuthMethod(
value: IntegrationOAuthConfig["clientAuthentication"] | undefined,
): MCPOAuthClientAuthMethod | undefined {
switch (value) {
case "none":
return "none";
case "body":
return "client_secret_post";
case "basic":
return "client_secret_basic";
default:
return undefined;
}
}
export function getMcpOAuthAuthenticationConfig(
option: McpMarketplaceConnectionOption,
): MCPAuthenticationConfig | undefined {
if (option.auth.strategy !== "oauth2") return undefined;
const authentication: MCPAuthenticationConfig = { type: "oauth" };
const clientAuthMethod = toMcpOAuthClientAuthMethod(
option.auth.oauth?.clientAuthentication,
);
if (clientAuthMethod) {
authentication.client_auth_method = clientAuthMethod;
}
if (option.auth.oauth?.scopes?.length) {
authentication.scopes = option.auth.oauth.scopes;
}
return Object.keys(authentication).length > 1 ? authentication : undefined;
}
export function getMcpMarketplaceCatalog(
catalog: MarketplaceEntry[],
): MarketplaceEntry[] {
+15
View File
@@ -0,0 +1,15 @@
export const MCP_SERVER_NAME_PATTERN = /^[a-zA-Z0-9_]+$/;
export function isValidMcpServerName(name: string): boolean {
return MCP_SERVER_NAME_PATTERN.test(name);
}
export function toMcpServerName(value: string, fallback = "mcp"): string {
const normalized = value
.trim()
.replace(/[^a-zA-Z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
return normalized || fallback;
}
@@ -126,7 +126,7 @@ test.describe("preset automation → slash command conversation", () => {
}
}
await resetMockLLM(request).catch(() => {});
// Clear any MCP config so subsequent tests start clean
// Clear any MCP servers so subsequent tests start clean
await request
.patch(`${BACKEND_URL}/api/settings`, {
headers: {
@@ -164,14 +164,12 @@ test.describe("preset automation → slash command conversation", () => {
data: {
agent_settings_diff: {
mcp_config: {
mcpServers: {
slack: {
command: "echo",
args: ["dummy-slack-mcp"],
env: {
SLACK_BOT_TOKEN: "xoxb-test-token",
SLACK_TEAM_ID: "T0000000000",
},
slack: {
command: "echo",
args: ["dummy-slack-mcp"],
env: {
SLACK_BOT_TOKEN: "xoxb-test-token",
SLACK_TEAM_ID: "T0000000000",
},
},
},
@@ -251,9 +249,9 @@ test.describe("preset automation → slash command conversation", () => {
}) => {
await ensureMockLLMProfile(page);
// Explicitly clear the MCP config left by test 1.
// Setting mcp_config to null removes it entirely (an empty {} or
// { mcpServers: {} } is treated as a no-op partial merge).
// Explicitly clear the MCP servers left by test 1.
// Setting mcp_config to null removes it entirely (an empty {} is treated
// as a no-op partial merge).
const clearResp = await request.patch(`${BACKEND_URL}/api/settings`, {
headers: {
"X-Session-API-Key": SESSION_API_KEY,
@@ -270,11 +268,10 @@ test.describe("preset automation → slash command conversation", () => {
headers: { "X-Session-API-Key": SESSION_API_KEY },
});
const settings = await settingsResp.json();
const mcpConfig = settings?.agent_settings?.mcp_config;
const servers = mcpConfig?.mcpServers ?? {};
const servers = settings?.agent_settings?.mcp_config ?? {};
expect(
Object.keys(servers).length,
`MCP servers should be empty, got: ${JSON.stringify(mcpConfig).slice(0, 200)}`,
`MCP servers should be empty, got: ${JSON.stringify(servers).slice(0, 200)}`,
).toBe(0);
await setupTrajectory(request);
@@ -9,13 +9,13 @@
*
* Verifies:
* 1. The MCP page renders with the GitHub marketplace card visible
* 2. Clicking the card opens the install modal with the hosted endpoint
* 2. Clicking the add control opens the install modal with the hosted endpoint
* 3. Filling in the PAT and submitting succeeds (with mocked test endpoint)
* 4. After install the GitHub server appears in the installed list
* 5. The installed server can be deleted via the UI
*/
import { test, expect } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import {
BACKEND_URL,
SESSION_API_KEY,
@@ -31,13 +31,24 @@ const GITHUB_HOSTED_MCP_URL = "https://api.githubcopilot.com/mcp/";
test.describe.configure({ mode: "serial" });
async function openGitHubInstallModal(page: Page) {
const modal = page.getByTestId("mcp-install-modal");
await expect(async () => {
await page.getByTestId("mcp-marketplace-toggle-github").click();
await expect(modal).toBeVisible({ timeout: 1_500 });
}).toPass({ timeout: 10_000 });
return modal;
}
test.describe("MCP GitHub server install flow", () => {
test.beforeEach(async ({ page }) => {
await seedLocalStorage(page);
});
test.afterEach(async ({ request }) => {
// Clear any MCP config so subsequent tests start clean
// Clear any MCP servers so subsequent tests start clean
await request
.patch(`${BACKEND_URL}/api/settings`, {
headers: {
@@ -69,7 +80,7 @@ test.describe("MCP GitHub server install flow", () => {
await expect(githubCard).toContainText("GitHub");
});
test("step 2: clicking GitHub card opens the install modal with correct fields", async ({
test("step 2: clicking GitHub add control opens the install modal with correct fields", async ({
page,
}) => {
await routeSessionApiKey(page);
@@ -77,12 +88,7 @@ test.describe("MCP GitHub server install flow", () => {
await dismissAnalyticsModal(page);
await waitForTestId(page, "mcp-marketplace-grid");
// Click the GitHub marketplace card
await page.getByTestId("mcp-marketplace-card-github").click();
// The install modal should appear
const modal = page.getByTestId("mcp-install-modal");
await expect(modal).toBeVisible({ timeout: 5_000 });
const modal = await openGitHubInstallModal(page);
// Verify the modal is for the GitHub entry
await expect(modal).toHaveAttribute("data-marketplace-id", "github");
@@ -120,10 +126,7 @@ test.describe("MCP GitHub server install flow", () => {
await dismissAnalyticsModal(page);
await waitForTestId(page, "mcp-marketplace-grid");
// Click the GitHub card to open the install modal
await page.getByTestId("mcp-marketplace-card-github").click();
const modal = page.getByTestId("mcp-install-modal");
await expect(modal).toBeVisible({ timeout: 5_000 });
const modal = await openGitHubInstallModal(page);
// Fill in the PAT — SettingsInput puts data-testid on the <input> directly
const patInput = page.getByTestId("mcp-install-field-api_key");
@@ -157,13 +160,9 @@ test.describe("MCP GitHub server install flow", () => {
// mcp_server_refs — not the auto-generated "shttp" fallback. The settings
// API redacts persisted secrets, so the raw PAT must not be readable after
// installation.
const mcpServers = mcpConfig?.mcpServers ?? mcpConfig?.shttp_servers;
expect(mcpServers).toBeTruthy();
expect(mcpServers?.github).toMatchObject({
expect(mcpConfig?.github).toMatchObject({
url: GITHUB_HOSTED_MCP_URL,
headers: {
Authorization: "<redacted>",
},
auth: { strategy: "api_key", value: "**********" },
});
});
@@ -179,13 +178,9 @@ test.describe("MCP GitHub server install flow", () => {
data: {
agent_settings_diff: {
mcp_config: {
mcpServers: {
github: {
url: GITHUB_HOSTED_MCP_URL,
headers: {
Authorization: `Bearer ${FAKE_PAT}`,
},
},
github: {
url: GITHUB_HOSTED_MCP_URL,
auth: { strategy: "api_key", value: FAKE_PAT },
},
},
},
@@ -205,13 +200,11 @@ test.describe("MCP GitHub server install flow", () => {
const serverItem = installedList.getByTestId("mcp-server-item").first();
await expect(serverItem).toBeVisible();
// The installed card has a CirclePlusCheckToggle — clicking it while
// isSelected=true triggers onDelete. Find and click the toggle button
// inside the server card (it shows a checkmark icon).
const toggleButton = serverItem.locator(
'[data-testid^="mcp-installed-toggle-"]',
);
await toggleButton.click();
await serverItem.click();
await expect(page.getByTestId("mcp-custom-editor")).toBeVisible({
timeout: 5_000,
});
await page.getByTestId("mcp-custom-editor-delete").click();
// A confirmation modal should appear — click confirm
const confirmButton = page.getByTestId("confirm-button");
@@ -230,8 +223,7 @@ test.describe("MCP GitHub server install flow", () => {
expect(settingsResp.ok()).toBe(true);
const settings = await settingsResp.json();
const mcpConfig = settings?.agent_settings?.mcp_config;
const mcpServers = mcpConfig?.mcpServers;
const githubStillPresent = mcpServers?.github != null;
const githubStillPresent = mcpConfig?.github != null;
expect(githubStillPresent).toBe(false);
});
});
@@ -20,7 +20,7 @@
* - Valid credentials connect successfully.
* - Older agent servers that omit `tool_result` behave exactly as before.
* - The Edit modal's "Test connection" verifies the *stored* credentials —
* it never tests the literal `<redacted>` placeholder the browser sees.
* it never tests the literal redaction placeholder the browser sees.
*/
import {
@@ -47,7 +47,7 @@ const SLACK_TOOL_CALL = {
// Placeholder the settings API substitutes for secret env values when read
// without X-Expose-Secrets (the MCP page's mode). The whole point of the fix
// is that the test request must NOT carry this value.
const REDACTED = "<redacted>";
const REDACTED = "**********";
/** The JSON body the GUI POSTs to `/api/mcp/test` (captured via interception). */
interface McpTestRequestBody {
@@ -82,12 +82,12 @@ async function getSettings(request: APIRequestContext) {
return resp.json();
}
/** Detect the Slack server in persisted settings regardless of config shape. */
/** Detect the Slack server in persisted settings. */
function hasSlackServer(settings: unknown): boolean {
const cfg = (settings as { agent_settings?: { mcp_config?: unknown } })
const servers = (settings as { agent_settings?: { mcp_config?: unknown } })
?.agent_settings?.mcp_config;
if (!cfg) return false;
return JSON.stringify(cfg).includes("@zencoderai/slack-mcp-server");
if (!servers) return false;
return JSON.stringify(servers).includes("@zencoderai/slack-mcp-server");
}
async function patchMcpConfig(request: APIRequestContext, mcpConfig: unknown) {
@@ -107,12 +107,10 @@ async function installSlackViaAPI(
env: Record<string, string>,
) {
await patchMcpConfig(request, {
mcpServers: {
slack: {
command: "npx",
args: ["-y", "@zencoderai/slack-mcp-server"],
env,
},
slack: {
command: "npx",
args: ["-y", "@zencoderai/slack-mcp-server"],
env,
},
});
}
@@ -310,7 +308,7 @@ test.describe("MCP Test Connection credential verification (Slack)", () => {
}) => {
// A custom server is not in the marketplace catalog → no credential probe.
await patchMcpConfig(request, {
mcpServers: { "my-custom": { url: "https://custom.example.test/mcp" } },
my_custom: { url: "https://custom.example.test/mcp" },
});
await routeSessionApiKey(page);
@@ -62,7 +62,6 @@ class MockACPAgent(acp.Agent):
self,
cwd: str,
additional_directories=None,
mcp_servers=None,
**kwargs,
) -> acp.NewSessionResponse:
print(f"[mock-acp] new_session cwd={cwd}", file=sys.stderr, flush=True)
+101 -15
View File
@@ -184,13 +184,17 @@ class MockLLMHandler(BaseHTTPRequestHandler):
raw = response.raw_response.model_dump()
if body.get("stream"):
self._send_streaming(raw)
stream_options = body.get("stream_options") or {}
self._send_streaming(
raw, include_usage=bool(stream_options.get("include_usage"))
)
else:
self._send_json(200, raw)
def _send_streaming(self, raw: dict):
def _send_streaming(self, raw: dict, include_usage: bool = False):
"""SSE streaming: emit content chunk + finish chunk + [DONE]."""
choice = raw["choices"][0]
message = choice["message"]
base = {
"id": raw["id"],
"object": "chat.completion.chunk",
@@ -198,28 +202,110 @@ class MockLLMHandler(BaseHTTPRequestHandler):
"model": raw["model"],
}
content_chunk = {
**base,
"choices": [
{"index": 0, "delta": choice["message"], "finish_reason": None}
],
}
finish_reason = "stop"
tool_calls = message.get("tool_calls") or []
chunks = []
if tool_calls:
finish_reason = "tool_calls"
chunks.append(
{
**base,
"choices": [
{
"index": 0,
"delta": {"role": message.get("role", "assistant")},
"finish_reason": None,
}
],
}
)
for i, tool_call in enumerate(tool_calls):
function = tool_call.get("function", {})
chunks.append(
{
**base,
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [
{
"index": i,
"id": tool_call.get("id"),
"type": tool_call.get("type", "function"),
"function": {
"name": function.get("name"),
"arguments": "",
},
}
]
},
"finish_reason": None,
}
],
}
)
if function.get("arguments"):
chunks.append(
{
**base,
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [
{
"index": i,
"function": {
"arguments": function["arguments"]
},
}
]
},
"finish_reason": None,
}
],
}
)
else:
chunks.append(
{
**base,
"choices": [
{
"index": 0,
"delta": {
"role": message.get("role", "assistant"),
"content": message.get("content") or "",
},
"finish_reason": None,
}
],
}
)
finish_chunk = {
**base,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
"choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}],
}
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
for chunk in [content_chunk, finish_chunk]:
for chunk in [*chunks, finish_chunk]:
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
if include_usage:
usage_chunk = {
**base,
"choices": [],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
}
self.wfile.write(f"data: {json.dumps(usage_chunk)}\n\n".encode())
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
@@ -542,7 +542,23 @@ export async function activateProfileViaUI(page: Page, profileName: string) {
await waitForTestId(page, "profile-actions-menu");
const setActive = page.getByTestId("profile-set-active");
if (await setActive.isEnabled()) {
const activationResponse = page.waitForResponse(
(response) => {
const request = response.request();
if (request.method() !== "POST") return false;
const pathname = new URL(response.url()).pathname;
return (
decodeURIComponent(pathname) ===
`/api/profiles/${profileName}/activate`
);
},
{ timeout: 10_000 },
);
await setActive.click();
expect(
(await activationResponse).ok(),
`Activating profile "${profileName}" should return 2xx`,
).toBe(true);
} else {
// Already active
await page.keyboard.press("Escape");