EN / field notes OpenHands feature map

OpenHands / F17

Customize hub and MCP servers

Customize groups MCP Servers, Skills, Plugins and Apps behind one sidebar entry. From 1024 px up /customize goes straight to /mcp; below that (phones and tablets) it is a list hub. The MCP page lets a user browse a library of catalog integrations, install one through a modal (credentials masked, optionally also saved as secrets), hand-author custom SSE, Streamable HTTP or STDIO servers, test connections, check each installed server's health, enable or disable, edit and delete servers. Enabled servers are offered to every new conversation's agent.

30 mapped behaviors · 30 recipes and supporting checks · source snapshot 9 October 2026
From upstream main at 8793c111. Read the maintained source.

How to get to it

  • Sidebar Customize (sidebar-skills-link); on desktop it lands on /mcp. On a phone, open the drawer (sidebar-mobile-menu-toggle) first; it lands on the hub.
  • Direct URLs /customize and /mcp.
  • Command menu (Control+k/Meta+k): Customize (to /customize, then /mcp) and MCP servers (to /mcp). The menu itself is F02.
  • Customize sub-navigation row MCP Servers (sidebar-extensions-/mcp), desktop aside or phone hub. Pinning Customize as home is F02.
  • The onboarding checklist item Connect an MCP integration links to /mcp and is ticked once a server is configured, before any conversation uses it (F02).
  • On /mcp: a library card or its + toggle (install modal), Add custom server (mcp-add-custom-server), an installed card (editor) and its health-row buttons.

Before you start

Start with the common launch and health checks, then follow this family’s preconditions in order. Recipes share the fixtures and state named below.

Preconditions:

  • Baseline state (launched, doctored, onboard --skip done) on a fresh run, so nothing is installed yet (control-openhands api GET /api/settings --pick agent_settings.mcp_config is {}).
  • uvx and npx on the machine. F17.agent-uses-server, F17.custom-add-remote's agent check and the disabled-server check need an active LLM profile (control-openhands llm preset deepseek).
  • TLS-intercepting proxy (this sandbox): MCP stdio servers start with a minimal environment (HOME, PATH, no proxy or CA variables), so without trust settings uvx/npx cannot download packages and catalog STDIO installs fail. launch now seeds the run's private HOME when SSL_CERT_FILE or NODE_EXTRA_CA_CERTS points at a CA bundle: check with cat "$OH_VERIFY_RUN/private/home/.config/uv/uv.toml" (native-tls = true) and cat "$OH_VERIFY_RUN/private/home/.npmrc" (cafile=<bundle>). Only if they are missing, write them once: mkdir -p "$OH_VERIFY_RUN/private/home/.config/uv", printf 'native-tls = true\n' > "$OH_VERIFY_RUN/private/home/.config/uv/uv.toml" and printf 'cafile=/root/.ccr/ca-bundle.crt\n' > "$OH_VERIFY_RUN/private/home/.npmrc" (use your proxy's CA bundle path). F17.install-error moves them aside for one step. Custom servers can also pass UV_NATIVE_TLS=true in their env. Skip all of this on a machine with direct internet.
  • F17.custom-add-remote needs a local Streamable HTTP MCP server (arrange, not proof; no fixture verb exists for it yet): from a shell, cd "$OH_VERIFY_RUN/workspace" && (PORT=38417 nohup npx -y @modelcontextprotocol/server-everything streamableHttp > "$OH_VERIFY_RUN/private/qa-shttp.log" 2>&1 &), pick any free port, and wait until the log says MCP Streamable HTTP Server listening on port 38417. Stop it in Cleanup by its own PID (pgrep -f 'server-everything streamableHttp' lists it; never pkill -f).
  • F17.cloud-nav and F17.native-git-tabs need an OpenHands Cloud backend (with git providers configured); not available here.
  • F17.stored-secret-reuse and F17.single-request-mutations use control-openhands fixture mcp-server --name qa-vault; it prints the <node> binary and <server.mjs> path the recipes substitute.
  • F17.credential-probe sends a dummy token to GitHub's hosted MCP endpoint and, through the catalog's npx stdio server, to Slack; both need the network. Behind the TLS-intercepting proxy the stdio server's own HTTPS call fails unless it gets the proxy CA (NODE_EXTRA_CA_CERTS), which only the custom editor can pass.
  • Never type real credentials: every key below is a dummy.

Behavior inventory

30 stable behavior IDs and their expected behavior
  • F17.customize-redirect on desktop, the sidebar Customize link, the URL /customize and the command-menu items Customize and MCP servers all land on /mcp. Read recipe ↓
  • F17.desktop-subnav from 1024 px up, Customize pages show a left aside headed Customize with MCP Servers, Skills, Plugins and Apps, the active row marked, and the "synced from" backend badge. Read recipe ↓
  • F17.mobile-hub below 1024 px (phones and tablets), /customize is a hub with the same four rows and badge; at phone width detail pages show a Back chevron to the hub; widening the window to 1024 px or more on /customize redirects to /mcp. Read recipe ↓
  • F17.breakpoint-sweep at 767, 768, 820, 1023, 1024 and 1440 px, /customize is the hub below 1024 px and the desktop aside from 1024 px up, and the MCP page keeps Add custom server, a search box wider than 150 px and no horizontal overflow. Read recipe ↓
  • F17.cloud-nav with a Cloud backend active, Plugins and Apps are hidden and Skills becomes an external link to <cloud host>/settings/skills (blocked here). Read recipe ↓
  • F17.page-states /mcp shows the Model Context Protocol (MCP) header, Add custom server, the toolbar, an Installed section (empty-state copy when nothing is installed) and the Library grid, without page errors. Read recipe ↓
  • F17.search one search box filters the library and the installed list (catalog name, description and args count); no match shows No matches for your search. in each section; the X clears it. Read recipe ↓
  • F17.section-filter the All / Installed / Library dropdown shows only the chosen sections. Read recipe ↓
  • F17.library-catalog library cards (logo, name, transport, description, + toggle) open the install modal by click, + toggle or Enter; the modal closes with Cancel, X or Escape. Read recipe ↓
  • F17.install-remote a remote (HTTP) entry shows its fixed URL read-only and a type="password" credential field that is required. Read recipe ↓
  • F17.install-oauth an entry whose server runs OAuth itself shows the OAuth info panel instead of a key field. Read recipe ↓
  • F17.install-unsupported entries the local backend cannot install (provider-OAuth only, 12 today such as Sentry) should be hidden or explain themselves; today their modal is empty and Install does nothing (fail). Read recipe ↓
  • F17.install-args an entry with argument fields (Filesystem's Paths (space separated)) requires them and appends each whitespace-separated token to the stored command's args. Read recipe ↓
  • F17.install-stdio a STDIO entry shows its read-only command; Install shows Verifying…, Saving..., toasts MCP server saved. and adds a card with the catalog name and description. Read recipe ↓
  • F17.install-error a failed install test keeps the modal open with a red error and saves nothing. Read recipe ↓
  • F17.save-as-secret password credential fields carry Also save as secret (checked by default, with a tooltip); after install the value is listed in Settings → Secrets. Read recipe ↓
  • F17.custom-validation the custom editor refuses bad input with an inline message (URL required/invalid/wrong protocol, name invalid or duplicate, command required or with spaces, env/header not KEY=value, header required, OAuth secret without client ID, timeout out of range). A malformed header line reuses the environment-variable message (fail). Read recipe ↓
  • F17.test-connection Test connection in the editor shows Verifying…, then a green Connected — N tool(s) available or a red categorized error; Add/Save tests first and stays open on failure. Read recipe ↓
  • F17.custom-add Add Server saves and closes; the card is listed after a reload. Read recipe ↓
  • F17.custom-add-remote a custom Streamable HTTP (SHTTP) server with a Bearer token connects, saves as an HTTP card showing its URL, reopens with the token masked, and its tools reach the agent as <server>_<tool>. Read recipe ↓
  • F17.server-health each card's health row runs Test connection (Checking connection…), then shows a verdict with a coloured dot; a failure is red, worded for the transport (a STDIO command that cannot start, a remote server that cannot be reached) with the Agent Server's detail, and offers Retry. Read recipe ↓
  • F17.enable-disable the card toggle disables a server without removing its configuration; the state persists and a disabled server is withheld from new conversations. Read recipe ↓
  • F17.custom-edit clicking a card opens Edit MCP server prefilled (secrets shown as **********); Save tests and persists the change. Read recipe ↓
  • F17.stored-secret-reuse a saved server's env values and tokens come back from the settings API and in the editor as **********; the editor's Test connection swaps each placeholder for the stored encrypted value (never the placeholder text, never the plaintext) and Save leaves untouched secrets out of its sparse patch, so a Save from the edit form that keeps them changes only the edited fields. Read recipe ↓
  • F17.single-request-mutations adding, editing or deleting one server sends exactly one POST, PATCH or DELETE to /api/settings/mcp/<name> and leaves sibling servers and their stored credentials untouched. Read recipe ↓
  • F17.credential-probe a catalog entry whose server lists its tools with any credentials (Slack) also gets a read-only credential probe during Install and Test connection: a failed probe reads Credential check failed: <provider error> and saves nothing (Known failure, reproduced 2026-10-08: any errored probe call, a network fetch failed included, is worded as a credential failure; #18161). A hosted server that rejects the token at connect (GitHub) never reaches a probe and reads Connection failed: … 401 Unauthorized …, also saving nothing (Known failure, reproduced 2026-10-08: the Agent Server's POST /api/mcp/test returns the 401 as error_kind unknown, so the modal cannot call it a credential failure; OpenHands/software-agent-sdk#5607); a server the catalog does not know gets no probe. Read recipe ↓
  • F17.delete-server Delete in the editor asks for confirmation; Cancel keeps the server, Confirm removes it with MCP server removed. Read recipe ↓
  • F17.native-git-tabs on a Cloud backend, GitHub/GitLab/Bitbucket offer a recommended native-integration tab next to the MCP tab (blocked here). Read recipe ↓
  • F17.agent-uses-server a conversation started after the save can call the server's tools. Read recipe ↓
  • F17.phone the MCP page, the install modal and the custom editor fit a 390 px viewport. Read recipe ↓

Readable recipes

Read each script from top to bottom. Code is copied from the map; prose gives the action, expected observation, and conditions. <id>, <run> and similar placeholders stand for values from your own run. Short forms such as browser count continue the same control-openhands invocation; they are kept as documented.

Expected observations describe the recipe’s contract. Captures below selected recipes show representative real states from this snapshot; they do not mark every mapped behavior as passed. Follow cleanup before moving to another family.

Desktop entry points #

  1. Do
    control-openhands browser goto /conversations
  2. Check
    control-openhands browser click 'testid=sidebar-skills-link' --expect-url '/mcp(\?|$)'
  3. Check
    control-openhands browser count 'testid=mcp-page'

    (1).

  4. Do
    control-openhands browser goto /customize
  5. Wait
    control-openhands browser wait-url '/mcp(\?|$)'
  6. Note
    Command menu: run
  7. Do
    control-openhands browser goto /conversations

    (the browser is on /mcp, where the URL wait would pass at once),

  8. Do
    control-openhands browser press Control+k
  9. Do
    control-openhands browser type 'testid=command-menu >> role=combobox' MCP
  10. Do
    control-openhands browser press Enter
  11. Wait
    control-openhands browser wait-url '/mcp(\?|$)'
  12. Note
    repeat with the term Customize (its only option reads Customize Browse skills, plugins, and integrations.).
  13. Note
    Every path ends on /mcp.

Desktop sub-navigation #

  1. Note
    On /mcp run
  2. Check
    control-openhands browser text 'testid=extensions-navbar-desktop'
  3. Note
    : Customize, MCP Servers, Skills, Plugins, Apps, then These settings are synced from Local backend (<base url>).
  4. Check
    control-openhands browser attr 'testid=sidebar-extensions-/mcp' aria-current

    is page.

  5. Check
    control-openhands browser click 'testid=sidebar-extensions-/skills' --expect-url '/skills(\?|$)'
  6. Note
    that row's aria-current becomes page.
  7. Note
    Do the same for /plugins and /apps, then return with
  8. Check
    control-openhands browser click 'testid=sidebar-extensions-/mcp' --expect-url '/mcp(\?|$)'
  9. Do
    control-openhands browser screenshot 'testid=extensions-navbar-desktop' --feature F17.desktop-subnav --name subnav

    shows the MCP Servers row highlighted.

Phone hub #

  1. Do
    control-openhands browser viewport phone
  2. Do
    control-openhands browser goto /customize
  3. Check
    control-openhands browser text 'testid=extensions-mobile-hub'

    (the same five lines as the desktop aside);

  4. Do
    control-openhands browser screenshot --feature F17.mobile-hub --name hub
  5. Do
    control-openhands browser click 'testid=extensions-mobile-hub >> testid=sidebar-extensions-/mcp' --expect-url '/mcp(\?|$)'
  6. Check
    control-openhands browser attr 'testid=sidebar-mobile-back-button' aria-label

    is Customize, and

  7. Check
    control-openhands browser count 'testid=extensions-navbar-desktop >> visible'

    is 0.

  8. Check
    control-openhands browser bbox 'testid=mcp-page'

    has insideViewport true and pageHorizontalOverflow false;

  9. Do
    control-openhands browser screenshot --feature F17.phone --name mcp-page

    shows the header, toolbar and one column of cards.

  10. Check
    control-openhands browser click 'testid=sidebar-mobile-back-button' --expect-url '/customize(\?|$)'
  11. Check
    control-openhands browser count 'testid=extensions-mobile-hub'

    is 1.

  12. Note
    Drawer entry:
  13. Do
    control-openhands browser goto /conversations
  14. Do
    control-openhands browser click 'testid=sidebar-mobile-menu-toggle'
  15. Do
    control-openhands browser click 'testid=sidebar-mobile-drawer >> testid=sidebar-skills-link' --expect-url '/customize(\?|$)'
  16. Note
    the hub is shown.
  17. Note
    Tablets get the hub too:
  18. Do
    control-openhands browser viewport tablet

    (820 px) keeps it on /customize; run

  19. Do
    control-openhands browser click 'testid=extensions-mobile-hub >> testid=sidebar-extensions-/mcp' --expect-url '/mcp(\?|$)'
  20. Check
    control-openhands browser visible 'testid=extensions-navbar-desktop'
  21. Check
    control-openhands browser visible 'testid=sidebar-mobile-back-button'

    (both false),

  22. Check
    control-openhands browser bbox 'role=main'

    (520 px wide, pageHorizontalOverflow false) and

  23. Do
    control-openhands browser screenshot --feature F17.mobile-hub --name tablet-mcp
  24. Note
    the rail's
  25. Check
    control-openhands browser click 'testid=sidebar-skills-link' --expect-url '/customize(\?|$)'
  26. Note
    brings the hub back.
  27. Do
    control-openhands browser viewport desktop
  28. Wait
    control-openhands browser wait-url '/mcp(\?|$)'
  29. Note
    : widening on /customize redirects to /mcp.

Widths around the breakpoint #

  1. Note
    For each width W in 767, 768, 820, 1023, 1024, 1440 run
  2. Do
    control-openhands browser viewport Wx1024
  3. Do
    control-openhands browser goto /customize
  4. Note
    Below 1024 px
  5. Check
    control-openhands browser visible 'testid=extensions-mobile-hub'

    is true and

  6. Do
    control-openhands browser click 'testid=extensions-mobile-hub >> testid=sidebar-extensions-/mcp' --expect-url '/mcp(\?|$)'
  7. Note
    opens the MCP page; from 1024 px up
  8. Wait
    control-openhands browser wait-url '/mcp(\?|$)'
  9. Note
    passes at once and
  10. Check
    control-openhands browser count 'testid=extensions-mobile-hub'

    is 0.

  11. Note
    On /mcp,
  12. Check
    control-openhands browser visible 'testid=extensions-navbar-desktop'

    is false below 1024 px and true from 1024 px (the switch is exactly 1023 → 1024),

  13. Check
    control-openhands browser visible 'testid=mcp-add-custom-server'

    is true,

  14. Check
    control-openhands browser bbox 'testid=mcp-search-input'

    is wider than 150 px (622, 323, 375, 578, 257 and 673 today) and

  15. Check
    control-openhands browser bbox 'role=main'

    has pageHorizontalOverflow false with equal scrollWidth and clientWidth (main is full width at 767 px, W − 300 beside the rail from 768 to 1023 px, W − 640 beside the rail and the aside from 1024 px).

  16. Do
    control-openhands browser fill 'testid=mcp-search-input' GitHub
  17. Note
    keeps
  18. Check
    control-openhands browser count 'testid=mcp-marketplace-card-github'
  19. Note
    at 1;
  20. Do
    control-openhands browser click 'testid=mcp-search-clear'
  21. Note
    At the boundary take
  22. Do
    control-openhands browser screenshot --feature F17.breakpoint-sweep --name mcp-1023
  23. Note
    and --name mcp-1024.
  24. Note
    Finish with
  25. Do
    control-openhands browser viewport desktop

Page and empty state #

  1. Note
    On a fresh run run
  2. Do
    control-openhands browser goto /mcp
  3. Check
    control-openhands browser text 'testid=mcp-installed-empty'

    (No MCP servers installed yet. and Pick one from the marketplace below to get started.) and

  4. Check
    control-openhands browser count '[data-testid^="mcp-marketplace-card-"]'

    (55 today; the catalog ships with @openhands/extensions).

  5. Do
    control-openhands browser eval "[...document.querySelectorAll('main h2')].map(h=>h.textContent)"

    is ["Model Context Protocol (MCP)","Installed","Library"].

  6. Note
    Take
  7. Do
    control-openhands browser screenshot --feature F17.page-states --name empty-desktop
  8. Expect
    After the family,
  9. Check
    control-openhands browser errors --app-only

    shows pageErrors 0.

MCP page with an empty Installed section and integration library.
The MCP library offers integrations while the Installed section is empty. CLI capture · 1440 × 1000 · 9 October 2026 · Canvas 8793c111

MCP page shows its header, Installed empty state, and library cards.

Catalog view only; no server installed or tested.

How this screenshot was taken

agent server: 1.53.0 · automation: 1.19.0 (launcher default) · canvas: 1.26.0

control-openhands browser goto /mcp
control-openhands browser text testid=mcp-installed-empty
control-openhands browser screenshot --feature F17.page-states --name catalog

Search #

  1. Do
    control-openhands browser fill 'testid=mcp-search-input' time
  2. Check
    control-openhands browser count '[data-testid^="mcp-marketplace-card-"]'

    (1, mcp-marketplace-card-time).

  3. Note
    Fill qa-zzz-nothing;
  4. Check
    control-openhands browser text 'testid=mcp-marketplace-empty'

    is No matches for your search. (the installed list's search needs installed cards: it is driven at the end of F17.install-stdio).

  5. Do
    control-openhands browser click 'testid=mcp-search-clear'
  6. Check
    control-openhands browser value 'testid=mcp-search-input'

    is "" and the card count is back to 55.

Section filter #

  1. Do
    control-openhands browser click 'testid=mcp-section-filter >> testid=dropdown-trigger'
  2. Check
    control-openhands browser snapshot 'testid=mcp-section-filter'

    (button "Filter MCP servers" [expanded]: All and menu "Filter MCP servers" with All [checked], Installed, Library) and

  3. Do
    control-openhands browser click 'testid=mcp-section-filter-library'
  4. Note
    the main h2 list above loses Installed.
  5. Note
    Open the dropdown again and click testid=mcp-section-filter-installed;
  6. Check
    control-openhands browser count 'testid=mcp-marketplace-section'

    is 0 and

  7. Check
    control-openhands browser text 'testid=mcp-section-filter >> testid=dropdown-trigger'

    is Installed.

  8. Note
    Restore with testid=mcp-section-filter-all.

Open and close the install modal #

  1. Do
    control-openhands browser click 'testid=mcp-marketplace-card-time'
  2. Check
    control-openhands browser text 'testid=mcp-install-modal'

    (Time, its description, View documentation →, Command, Cancel, Install) and

  3. Check
    control-openhands browser value 'testid=mcp-install-field-command-readonly'

    (uvx mcp-server-time; browser enabled is false).

  4. Note
    Close with
  5. Do
    control-openhands browser click 'testid=mcp-install-cancel'
  6. Check
    control-openhands browser count 'testid=mcp-install-modal'

    is 0.

  7. Note
    Reopen with
  8. Do
    control-openhands browser click 'testid=mcp-marketplace-toggle-time'
  9. Note
    and close with
  10. Do
    control-openhands browser click 'testid=mcp-install-modal-close'
  11. Note
    Keyboard:
  12. Do
    control-openhands browser focus 'testid=mcp-marketplace-card-time'
  13. Do
    control-openhands browser press Enter

    (count 1),

  14. Do
    control-openhands browser press Escape

    (count 0).

Remote entry, masked key #

  1. Do
    control-openhands browser click 'testid=mcp-marketplace-card-stripe'
  2. Check
    control-openhands browser value 'testid=mcp-install-modal >> testid=mcp-install-field-url'

    is https://mcp.stripe.com/ and browser enabled on it is false.

  3. Note
    Click testid=mcp-install-modal >> testid=mcp-install-submit with the key empty:
  4. Do
    control-openhands browser eval "document.querySelector('[data-testid=mcp-install-field-api_key]').validationMessage"

    is Please fill out this field. and no request is sent.

  5. Do
    control-openhands browser fill 'testid=mcp-install-modal >> testid=mcp-install-field-api_key' rk_test_qa_dummy_000
  6. Check
    control-openhands browser attr 'testid=mcp-install-modal >> testid=mcp-install-field-api_key' type

    is password and

  7. Do
    control-openhands browser screenshot 'testid=mcp-install-modal' --feature F17.install-remote --name stripe-masked

    shows dots.

  8. Note
    Cancel; never click Install with a dummy key on a hosted server.

OAuth entry #

  1. Do
    control-openhands browser click 'testid=mcp-marketplace-card-granola'
  2. Check
    control-openhands browser text 'testid=mcp-install-modal >> testid=mcp-install-oauth-info'
  3. Note
    : This server uses OAuth for authentication. Click Install to connect — you will be redirected to authorize access. and No API key needed. The server handles the OAuth flow automatically. The URL field reads https://mcp.granola.ai/mcp and there is no mcp-install-field-api_key.
  4. Note
    Cancel; Install would start a real OAuth flow.

Entry that cannot be installed #

  1. Do
    control-openhands browser click 'testid=mcp-marketplace-card-sentry'
  2. Check
    control-openhands browser network --clear
  3. Do
    control-openhands browser click 'testid=mcp-install-modal >> testid=mcp-install-submit'
  4. Check
    control-openhands browser network --last 5
  5. Check
    control-openhands browser count 'testid=mcp-install-modal'
  6. Check
    control-openhands browser toasts
  7. Note
    Expected: such entries are hidden from the library, or the modal explains that the local backend cannot install them.
  8. Note
    Today the modal shows only the title, description and docs link, Install sends no request, shows nothing and the modal stays open (fail; cloudflare-bindings behaves the same).
  9. Note
    Cancel.

Install fails, modal stays #

  1. Note
    Make the server unable to start: behind the TLS proxy, move the seeded trust settings aside (shell arrange: mv "$OH_VERIFY_RUN/private/home/.config/uv/uv.toml"{,.off} and mv "$OH_VERIFY_RUN/private/home/.npmrc"{,.off}) and use an entry uv has never downloaded in this run, such as Fetch (Time succeeds from the uv cache once installed).
  2. Do
    control-openhands browser click 'testid=mcp-marketplace-card-fetch'

    (browser value 'testid=mcp-install-field-command-readonly' is uvx mcp-server-fetch) and

  3. Do
    control-openhands browser click 'testid=mcp-install-submit' --observe 'testid=mcp-install-submit' --observe-ms 15000
  4. Note
    the observed labels are Install, Verifying… [disabled], Install (about 6 s).
  5. Check
    control-openhands browser text 'testid=mcp-install-modal-error'

    is Connection failed: McpError: Connection closed and the modal stays open (<run>/private/stack.log shows UnknownIssuer).

  6. Expect
    An npx entry such as Memory fails differently: after about 17 s the error reads Connection timed out. Check the URL and try again. Cancel,
  7. Do
    control-openhands browser reload
  8. Check
    control-openhands browser count 'testid=mcp-installed-empty'

    is still 1 (no fetch card).

  9. Note
    Restore the trust files (mv ….off back) before the next bullet.

Install a STDIO entry #

  1. Note
    With the TLS precondition, run
  2. Do
    control-openhands browser click 'testid=mcp-marketplace-card-time'
  3. Do
    control-openhands browser click 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe-ms 20000
  4. Note
    : Install, Verifying…, Saving..., then <absent> (closed).
  5. Note
    After
  6. Do
    control-openhands browser reload
  7. Check
    control-openhands browser text 'testid=mcp-server-item >> has-text=Timezone-aware'

    is Time, STDIO, Timezone-aware current time, conversions, and timestamp formatting., uvx mcp-server-time, Not checked yet, Test connection; its data-server-id is time.

  8. Note
    Installing it again (same two commands, then browser reload) adds a second card (time_1): the library is add-only and its + toggle never shows installed (aria-checked stays false).
  9. Expect
    The copy keeps the Time catalog identity (#18034):
  10. Check
    control-openhands browser text '[data-server-id=time_1]'

    is time_1, STDIO, Timezone-aware current time, conversions, and timestamp formatting., uvx mcp-server-time, Not checked yet, Test connection (the title is the settings key because it differs from the catalog id), and

  11. Do
    control-openhands browser eval "[...document.querySelectorAll('[data-testid=mcp-server-item]')].map(c=>c.dataset.serverId+':'+c.querySelector('span[aria-hidden=true][title]')?.title)"

    is ["time:Time","time_1:Time"] (both badges come from the Time entry).

  12. Note
    Time has no logo, so both installed cards show the generic puzzle glyph where its library card shows a robot glyph.
  13. Note
    Search the installed list:
  14. Do
    control-openhands browser fill 'testid=mcp-search-input' Timezone
  15. Note
    keeps both cards (browser eval "[...document.querySelectorAll('[data-testid=mcp-server-item]')].map(c=>c.dataset.serverId)" is ["time","time_1"], matched by the catalog description) and one library card;
  16. Do
    control-openhands browser fill 'testid=mcp-search-input' qa-zzz-nothing
  17. Note
    makes
  18. Check
    control-openhands browser text 'testid=mcp-installed-empty-search'
  19. Expect
    No matches for your search.; clear with
  20. Do
    control-openhands browser click 'testid=mcp-search-clear'
  21. Do
    control-openhands browser screenshot --feature F17.install-stdio --name time-and-time_1

    shows both cards with the description.

Argument fields #

  1. Do
    control-openhands browser click 'testid=mcp-marketplace-card-filesystem'
  2. Check
    control-openhands browser snapshot 'testid=mcp-install-modal'

    shows the read-only Command npx -y @modelcontextprotocol/server-filesystem, a Paths (space separated) field and Each whitespace-separated token is appended as its own argument.;

  3. Check
    control-openhands browser attr 'testid=mcp-install-modal >> role=link' target

    is _blank (View documentation →, rel noreferrer).

  4. Note
    Click testid=mcp-install-submit with the field empty:
  5. Do
    control-openhands browser eval "document.querySelector('[data-testid=mcp-install-field-paths]').validationMessage"

    is Please fill out this field. and browser network records no request.

  6. Note
    Shell arrange mkdir -p "$OH_VERIFY_RUN/workspace/qa-fs-a" "$OH_VERIFY_RUN/workspace/qa-fs-b", then
  7. Do
    control-openhands browser fill 'testid=mcp-install-modal >> testid=mcp-install-field-paths' "$OH_VERIFY_RUN/workspace/qa-fs-a $OH_VERIFY_RUN/workspace/qa-fs-b"
  8. Do
    control-openhands browser click 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe-ms 90000

    (Install, Verifying…, Saving..., <absent>).

  9. Expect
    After browser reload,
  10. Check
    control-openhands browser text '[data-server-id=filesystem]'
  11. Note
    ends the command line with both paths, and
  12. Check
    control-openhands api GET /api/settings --pick agent_settings.mcp_config.filesystem

    has args ["-y","@modelcontextprotocol/server-filesystem","<…>/qa-fs-a","<…>/qa-fs-b"].

Save as secret #

  1. Note
    With the TLS precondition and no TAVILY_API_KEY secret (control-openhands api GET /api/settings/secrets), run
  2. Do
    control-openhands browser click 'testid=mcp-marketplace-card-tavily'
  3. Check
    control-openhands browser attr 'testid=mcp-install-modal >> testid=mcp-install-field-TAVILY_API_KEY' type

    is password,

  4. Do
    control-openhands browser eval "document.querySelector('[data-testid=mcp-install-save-secret-TAVILY_API_KEY] input').checked"

    is true, and

  5. Do
    control-openhands browser tooltip 'testid=mcp-install-modal >> role=button[name=/secret/i]'

    is MCP credentials aren't shared with automations. Save as a secret to make this value available to automations. Run

  6. Do
    control-openhands browser fill 'testid=mcp-install-modal >> testid=mcp-install-field-TAVILY_API_KEY' tvly-qa-dummy-000
  7. Do
    control-openhands browser click 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe 'role=status' --observe-ms 60000
  8. Note
    the observed toasts are MCP server saved. and Saved to secrets: TAVILY_API_KEY.
  9. Do
    control-openhands browser goto /settings/secrets
  10. Check
    control-openhands browser count 'testid=secret-item >> has-text=TAVILY_API_KEY'

    is 1.

  11. Expect
    The GitHub card's hosted token has the same toggle (mcp-install-save-secret-GITHUB_PERSONAL_ACCESS_TOKEN, checked).
  12. Note
    Clean up by deleting the tavily server (Delete below) and the secret through F14's Delete recipe.

Editor validation #

  1. Do
    control-openhands browser goto /mcp

    (Save as secret ends on /settings/secrets),

  2. Do
    control-openhands browser click 'testid=mcp-add-custom-server'
  3. Note
    With the default SSE type, click testid=add-mcp-server-form >> testid=submit-button after each change and read
  4. Check
    control-openhands browser text 'testid=add-mcp-server-form >> css=p.text-red-500 >> nth=0'
  5. Note
    : empty URL → URL is required;
  6. Do
    control-openhands browser fill 'testid=add-mcp-server-form >> testid=url-input' 'not a url'
  7. Note
    → Invalid URL format; ftp://127.0.0.1/mcp → URL must use http:// or https://; URL http://127.0.0.1:9/sse plus testid=server-name-input 'bad name' → Name can only contain letters, numbers, hyphens, and underscores; name qa_sse, then
  8. Do
    control-openhands browser click 'testid=add-mcp-server-form >> testid=auth-mode-dropdown'
  9. Do
    control-openhands browser click 'role=option[name="Header"]'
  10. Note
    → Header authentication requires a header; auth OAuth with testid=oauth-client-secret-input filled dummy-secret → OAuth client secret requires a client ID.
  11. Note
    Switch type with
  12. Do
    control-openhands browser click 'testid=add-mcp-server-form >> testid=server-type-dropdown'
  13. Do
    control-openhands browser click 'role=option[name="SHTTP"]'
  14. Note
    set auth back to None; testid=timeout-input 0 → Timeout must be positive, 5000 → Timeout cannot exceed 3600 seconds (1 hour).
  15. Note
    Switch to STDIO: empty → Name is required; testid=name-input 'bad name!' → the invalid-name message; name qa_time → Command is required; testid=command-input 'uvx mcp-server-time' → Command cannot contain spaces; command uvx plus testid=env-input NOEQUALS → Environment variables must follow KEY=value format.
  16. Note
    Once a STDIO qa_time exists (or any installed STDIO id such as time), the same name gives A STDIO server with this name already exists.
  17. Do
    control-openhands browser press Escape
  18. Note
    closes the editor.
  19. Note
    Header format: reopen the editor, pick SHTTP, fill url-input http://127.0.0.1:9/mcp, auth Header,
  20. Do
    control-openhands browser fill 'testid=add-mcp-server-form >> testid=headers-input' NOEQUALS
  21. Note
    and submit.
  22. Note
    Expected a header-specific message; today it reads Environment variables must follow KEY=value format (fail: the header check reuses the env validator's copy).
Add MCP server form with URL required validation message.
The custom-server form explains the required URL before attempting a connection. CLI capture · 1440 × 1000 · 9 October 2026 · Canvas 8793c111

Submitting the empty SSE form shows URL is required.

Only empty-URL validation illustrated; other transports and validation cases are not asserted.

How this screenshot was taken

agent server: 1.53.0 · automation: 1.19.0 (launcher default) · canvas: 1.26.0

control-openhands browser goto /mcp
control-openhands browser click testid=mcp-add-custom-server
control-openhands browser click 'testid=add-mcp-server-form >> testid=submit-button'
control-openhands browser screenshot --feature F17.custom-validation --name custom-validation

Test connection #

  1. Note
    Failure: in an SHTTP form with URL http://127.0.0.1:9/mcp and timeout 10, click testid=add-mcp-server-form >> testid=mcp-test-connection,
  2. Wait
    control-openhands browser wait 'testid=add-mcp-server-form >> testid=mcp-test-message' --timeout 60000
  3. Note
    the text is Could not reach the server (check the URL and server type): Client failed to connect: All connection attempts failed (the remote wording followed by the Agent Server's detail) and its class contains text-red-500.
  4. Note
    Clicking Add Server leaves the editor open (browser count 'testid=mcp-custom-editor' is 1).
  5. Note
    Success: in a STDIO form fill testid=name-input qa_time, testid=command-input uvx,
  6. Do
    control-openhands browser fill 'testid=add-mcp-server-form >> testid=args-input' mcp-server-time
  7. Do
    control-openhands browser fill 'testid=add-mcp-server-form >> testid=env-input' UV_NATIVE_TLS=true
  8. Do
    control-openhands browser click 'testid=add-mcp-server-form >> testid=mcp-test-connection' --observe 'testid=add-mcp-server-form >> testid=mcp-test-connection' --observe-ms 20000

    (Test connection, Verifying…, Test connection), the same wait and

  9. Check
    control-openhands browser text 'testid=add-mcp-server-form >> testid=mcp-test-message'
  10. Note
    : Connected — 2 tool(s) available, class text-green-500.
  11. Do
    control-openhands browser screenshot 'testid=mcp-custom-editor' --feature F17.test-connection --name stdio-success

Add the server #

  1. Note
    In the successful STDIO form run
  2. Do
    control-openhands browser click 'testid=add-mcp-server-form >> testid=submit-button'
  3. Wait
    control-openhands browser wait 'testid=mcp-custom-editor' --state detached --timeout 60000
  4. Do
    control-openhands browser reload
  5. Check
    control-openhands browser text 'testid=mcp-server-item >> has-text=qa_time'
  6. Note
    : qa_time, STDIO, Timezone-aware current time, conversions, and timestamp formatting., uvx mcp-server-time, Not checked yet, Test connection (it runs the Time entry's command and args, so it shows that entry's description and badge under its own name, #18034).
  7. Check
    control-openhands browser attr 'testid=mcp-server-item >> has-text=qa_time' data-server-id

    is qa_time (the id in the per-card test ids below) and its aria-label is Edit qa_time.

Custom remote server #

  1. Note
    With the Streamable HTTP fixture from Preconditions running, run
  2. Do
    control-openhands browser click 'testid=mcp-add-custom-server'
  3. Note
    pick SHTTP (server-type-dropdown, then role=option[name="SHTTP"]), fill testid=add-mcp-server-form >> testid=server-name-input qa_shttp and url-input http://127.0.0.1:38417/mcp, pick auth Bearer token (auth-mode-dropdown, then role=option[name="Bearer token"]) and fill testid=add-mcp-server-form >> testid=api-key-input qa-dummy-token (browser attr ... type is password).
  4. Note
    Click testid=add-mcp-server-form >> testid=mcp-test-connection;
  5. Check
    control-openhands browser text 'testid=add-mcp-server-form >> testid=mcp-test-message' --timeout 60000

    is Connected — 13 tool(s) available.

  6. Note
    Click testid=add-mcp-server-form >> testid=submit-button, wait for testid=mcp-custom-editor to detach and browser reload:
  7. Check
    control-openhands browser text '[data-server-id=qa_shttp]'

    is qa_shttp, HTTP, http://127.0.0.1:38417/mcp, Not checked yet, Test connection;

  8. Check
    control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_shttp

    has transport http and auth {"strategy":"bearer","value":"**********"}.

  9. Expect
    Its health probe reads Reachable — credentials not verified.
  10. Note
    Reopen it with
  11. Do
    control-openhands browser click 'testid=mcp-server-detail-qa_shttp'
  12. Note
    : browser snapshot 'testid=mcp-custom-editor' shows Edit MCP server, combobox Authentication Bearer token, and
  13. Check
    control-openhands browser value 'testid=edit-mcp-server-form >> testid=api-key-input'

    is **********; Escape.

  14. Note
    Agent side:
  15. Wait
    control-openhands conversation start --prompt "Call the qa_shttp MCP server's echo tool with message qa-ping. Do not use the terminal. Reply with only the tool's output." --wait --timeout 240
  16. Check
    control-openhands conversation events <id> --kinds ActionEvent,ObservationEvent,MessageEvent
  17. Note
    : tool qa_shttp_echo, observation Echo: qa-ping.
  18. Do
    control-openhands browser goto /mcp

Card health #

  1. Do
    control-openhands browser click 'testid=mcp-health-probe-qa_time' --observe 'testid=mcp-health-label-qa_time' --observe-ms 20000
  2. Note
    : Not checked yet, Checking connection…, Reachable — credentials not verified.
  3. Check
    control-openhands browser text 'testid=mcp-server-health-qa_time'
  4. Note
    adds the hint This check only proves the server is reachable and lists its tools; it does not verify your credentials.,
  5. Check
    control-openhands browser attr 'testid=mcp-server-health-qa_time >> testid=mcp-health-dot' data-status

    is healthy-connectivity, and the click did not open the editor (browser count 'testid=mcp-custom-editor' is 0).

  6. Note
    Failure: the editor refuses to save a broken server, so arrange one with
  7. Arrange
    control-openhands api POST /api/settings/mcp/qa_broken --data '{"transport":"stdio","command":"qa-no-such-command","enabled":false}' --write
  8. Do
    control-openhands browser reload
  9. Note
    then click testid=mcp-health-probe-qa_broken: the label is Could not start the server command: Client failed to connect: [Errno 2] No such file or directory: 'qa-no-such-command' in red (STDIO wording, no URL hint; the card clamps it to two lines, browser text 'testid=mcp-server-health-qa_broken' reads it whole), the dot's data-status is failed and the button reads Retry;
  10. Do
    control-openhands browser screenshot 'testid=mcp-server-item >> has-text=qa_broken' --feature F17.server-health --name failed

Delete #

  1. Do
    control-openhands browser click 'testid=mcp-server-detail-qa_broken'

    (a click on the card's centre lands on its health row and opens nothing: see Gotchas),

  2. Do
    control-openhands browser click 'testid=mcp-custom-editor-delete'
  3. Check
    control-openhands browser text 'testid=confirmation-modal'

    (Are you sure you want to delete this server?, Cancel, Confirm).

  4. Do
    control-openhands browser click 'testid=confirmation-modal >> testid=cancel-button'
  5. Note
    the editor is still open.
  6. Note
    Click Delete again, then
  7. Do
    control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button'
  8. Check
    control-openhands browser toasts

    (MCP server removed.).

  9. Note
    After
  10. Do
    control-openhands browser reload
  11. Check
    control-openhands browser count 'testid=mcp-server-item >> has-text=qa_broken'

    is 0, and

  12. Check
    control-openhands api GET /api/settings

    has no qa_broken.

Edit #

  1. Do
    control-openhands browser click 'testid=mcp-server-detail-qa_time'

    (the detail line opens the editor on every card; a plain click on the card's centre happens to open it on qa_time, whose Time description fills the centre, but not on a card without a description; keyboard: browser focus '[data-server-id=qa_time]' then browser press Enter) and

  2. Check
    control-openhands browser snapshot 'testid=mcp-custom-editor'
  3. Note
    : heading Edit MCP server, no Server Type field, a Delete button;
  4. Check
    control-openhands browser value 'testid=edit-mcp-server-form >> testid=env-input'

    is UV_NATIVE_TLS=**********.

  5. Do
    control-openhands browser fill 'testid=edit-mcp-server-form >> testid=args-input' $'mcp-server-time\n--local-timezone\nUTC'

    (bash ANSI-C quoting for the newlines), click testid=edit-mcp-server-form >> testid=mcp-test-connection (still Connected — 2 tool(s) available: the stored env value is reused), then

  6. Do
    control-openhands browser click 'testid=edit-mcp-server-form >> testid=submit-button'
  7. Note
    and wait for testid=mcp-custom-editor to detach.
  8. Expect
    The card shows a verdict at once (seeded from the save's test).
  9. Note
    After
  10. Do
    control-openhands browser reload
  11. Check
    control-openhands browser text 'testid=mcp-server-detail-qa_time'

    is uvx mcp-server-time --local-timezone UTC.

Stored secrets are reused, not their placeholder #

  1. Note
    Make a STDIO server that only starts with the right secret:
  2. Arrange
    control-openhands fixture mcp-server --name qa-vault
  3. Note
    prints <node> and <server.mjs>.
  4. Do
    control-openhands browser click 'testid=mcp-add-custom-server'
  5. Note
    pick STDIO, fill testid=add-mcp-server-form >> testid=name-input qa_vault and testid=add-mcp-server-form >> testid=command-input /bin/sh, then
  6. Do
    control-openhands browser fill 'testid=add-mcp-server-form >> testid=args-input' $'-c\ntest "$QA_VAULT_TOKEN" = qa-vault-secret-000 && exec "$QA_NODE" "$QA_SERVER"'
  7. Do
    control-openhands browser fill 'testid=add-mcp-server-form >> testid=env-input' $'QA_VAULT_TOKEN=qa-vault-secret-000\nQA_NODE=<node>\nQA_SERVER=<server.mjs>'
  8. Check
    control-openhands browser network --clear
  9. Note
    click testid=add-mcp-server-form >> testid=mcp-test-connection;
  10. Check
    control-openhands browser text 'testid=add-mcp-server-form >> testid=mcp-test-message' --timeout 60000

    is Connected — 1 tool(s) available.

  11. Note
    Direct proof of what the page sent:
  12. Check
    control-openhands browser network --bodies --filter 'api/mcp/test' --last 1

    lists one POST /api/mcp/test (200) whose body is {"server":{"type":"stdio","command":"/bin/sh","args":["-c","test \"$QA_VAULT_TOKEN\" = qa-vault-secret-000 && exec \"$QA_NODE\" \"$QA_SERVER\""],"env":{"QA_VAULT_TOKEN":"<redacted 19 chars>","QA_NODE":"<redacted 40 chars>","QA_SERVER":"<redacted 142 chars>"}},"name":"qa_vault"}: the command and args in clear, each env value replaced by the CLI with the length of the real value (19 for qa-vault-secret-000; 40 and 142 are <node> and <server.mjs> on this machine, ${#var} in a shell).

  13. Note
    Click testid=add-mcp-server-form >> testid=submit-button,
  14. Wait
    control-openhands browser wait 'testid=mcp-custom-editor' --state detached --timeout 60000
  15. Do
    control-openhands browser reload
  16. Check
    control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_vault

    has env {"QA_VAULT_TOKEN":"**********","QA_NODE":"**********","QA_SERVER":"**********"} (every env value is redacted; the page reads this same endpoint, there is no GET /api/settings/mcp).

  17. Do
    control-openhands browser click 'testid=mcp-server-detail-qa_vault'
  18. Check
    control-openhands browser value 'testid=edit-mcp-server-form >> testid=env-input'

    is the three lines QA_VAULT_TOKEN=**********, QA_NODE=**********, QA_SERVER=**********.

  19. Check
    control-openhands browser network --clear
  20. Note
    and click testid=edit-mcp-server-form >> testid=mcp-test-connection:
  21. Check
    control-openhands browser text 'testid=edit-mcp-server-form >> testid=mcp-test-message' --timeout 60000

    is Connected — 1 tool(s) available, which the shell gate only allows when the stored token, binary and path were sent (control-openhands browser screenshot 'testid=mcp-custom-editor' --feature F17.stored-secret-reuse --name masked-test-ok).

  22. Expect
    The same
  23. Check
    control-openhands browser network --bodies --filter 'api/mcp/test' --last 1
  24. Note
    row now carries "env":{"QA_VAULT_TOKEN":"<redacted 120 chars>","QA_NODE":"<redacted 140 chars>","QA_SERVER":"<redacted 268 chars>"}: neither the ********** placeholder (which --bodies would keep as is) nor the typed lengths.
  25. Check
    control-openhands browser network --last 5

    shows why: a GET /api/settings (and /server_info) right before the POST.

  26. Expect
    The page fetches the settings in encrypted mode and swaps each placeholder for the stored encrypted value (src/api/mcp-service/mcp-redacted-credentials.ts), which the Agent Server decrypts, so the browser never holds the plaintext and the lengths are those of Fernet tokens of the real values (19 → 120, 40 → 140, 142 → 268).
  27. Expect
    Counter-check that the gate bites:
  28. Do
    control-openhands browser fill 'testid=edit-mcp-server-form >> testid=env-input' $'QA_VAULT_TOKEN=qa-wrong\nQA_NODE=**********\nQA_SERVER=**********'
  29. Check
    control-openhands browser network --clear
  30. Note
    click Test connection again and
  31. Wait
    control-openhands browser wait 'testid=edit-mcp-server-form >> testid=mcp-test-message >> text=Connection' --timeout 90000
  32. Note
    the text is red and reads Connection failed: McpError: Connection closed or, when the client only notices at its deadline, Connection timed out. Check the URL and try again. (the gate exits before the MCP handshake; see Gotchas; --name wrong-token), and the --bodies row shows "QA_VAULT_TOKEN":"<redacted 8 chars>" next to the two kept placeholders at 140 and 268 chars: only the retyped value replaced its stored one.
  33. Do
    control-openhands browser click 'testid=mcp-custom-editor-close'
  34. Note
    discards it (count 'testid=mcp-custom-editor' 0); reopen with testid=mcp-server-detail-qa_vault: the env is the three placeholders again and Test connection is Connected — 1 tool(s) available.
  35. Do
    control-openhands browser press Escape
  36. Note
    Save with the placeholders in place: reopen with
  37. Do
    control-openhands browser click 'testid=mcp-server-detail-qa_vault'
  38. Note
    append one harmless argument (it only becomes the shell script's $0) with
  39. Do
    control-openhands browser fill 'testid=edit-mcp-server-form >> testid=args-input' $'-c\ntest "$QA_VAULT_TOKEN" = qa-vault-secret-000 && exec "$QA_NODE" "$QA_SERVER"\nqa-v2'
  40. Note
    leave env-input as the three placeholders, run
  41. Check
    control-openhands browser network --clear
  42. Do
    control-openhands browser click 'testid=edit-mcp-server-form >> testid=submit-button' --observe 'testid=edit-mcp-server-form >> testid=submit-button' --observe-ms 6000

    (Save Server, Save Server [disabled], <absent>) and

  43. Wait
    control-openhands browser wait 'testid=mcp-custom-editor' --state detached --timeout 60000
  44. Check
    control-openhands browser network --bodies --filter 'api/settings/mcp' --last 3

    lists one PATCH /api/settings/mcp/qa_vault (200) whose body is {"transport":"stdio","command":"/bin/sh","args":["-c","test \"$QA_VAULT_TOKEN\" = qa-vault-secret-000 && exec \"$QA_NODE\" \"$QA_SERVER\"","qa-v2"]}: a sparse patch with no env key at all (untouched secrets are left out, so the server keeps its stored values; neither the placeholder nor an encrypted value travels).

  45. Expect
    The same
  46. Check
    control-openhands browser network --filter 'api/settings/mcp' --last 3
  47. Note
    without --bodies lists that row without a body field.
  48. Expect
    The card's health label reads Reachable — credentials not verified.
  49. Note
    After
  50. Do
    control-openhands browser reload
  51. Check
    control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_vault.env

    is still {"QA_VAULT_TOKEN":"**********","QA_NODE":"**********","QA_SERVER":"**********"},

  52. Check
    control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_vault.args
  53. Note
    ends with qa-v2, and
  54. Check
    control-openhands browser text 'testid=mcp-server-detail-qa_vault'
  55. Note
    ends with "$QA_SERVER" qa-v2.
  56. Note
    Reopen with
  57. Do
    control-openhands browser click 'testid=mcp-server-detail-qa_vault'

    (the env is the three placeholders), run

  58. Check
    control-openhands browser network --clear
  59. Note
    and click testid=edit-mcp-server-form >> testid=mcp-test-connection:
  60. Check
    control-openhands browser text 'testid=edit-mcp-server-form >> testid=mcp-test-message' --timeout 60000

    is Connected — 1 tool(s) available again, which the gate only allows if the Save kept the real token, binary and path rather than storing the placeholder text (--name saved-with-placeholders), and the --bodies row carries qa-v2 in args with the three encrypted lengths (120, 140, 268) in env.

  61. Do
    control-openhands browser press Escape

One request per mutation #

  1. Note
    With qa_vault as the sibling, run
  2. Check
    control-openhands browser network --clear
  3. Do
    control-openhands browser click 'testid=mcp-add-custom-server'
  4. Note
    pick STDIO, fill testid=add-mcp-server-form >> testid=name-input qa_docs, testid=add-mcp-server-form >> testid=command-input <node> and testid=add-mcp-server-form >> testid=args-input <server.mjs>, click testid=add-mcp-server-form >> testid=submit-button and
  5. Wait
    control-openhands browser wait 'testid=mcp-custom-editor' --state detached --timeout 60000
  6. Check
    control-openhands browser network --filter '/api/settings/mcp'

    has total 1, a POST /api/settings/mcp/qa_docs with status 201, and

  7. Check
    control-openhands browser network --filter qa_vault

    has total 0.

  8. Note
    Edit:
  9. Check
    control-openhands browser network --clear
  10. Do
    control-openhands browser click 'testid=mcp-server-detail-qa_docs'
  11. Do
    control-openhands browser fill 'testid=edit-mcp-server-form >> testid=args-input' $'<server.mjs>\n--qa-v2'
  12. Note
    click testid=edit-mcp-server-form >> testid=submit-button and the same wait; the filter now lists one PATCH /api/settings/mcp/qa_docs (200) and
  13. Check
    control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_docs.args
  14. Note
    ends with --qa-v2.
  15. Note
    Delete:
  16. Check
    control-openhands browser network --clear
  17. Do
    control-openhands browser click 'testid=mcp-server-detail-qa_docs'
  18. Do
    control-openhands browser click 'testid=mcp-custom-editor-delete'
  19. Do
    control-openhands browser click 'testid=confirmation-modal >> testid=confirm-button'
  20. Wait
    control-openhands browser wait '[data-server-id=qa_docs]' --state detached --timeout 10000
  21. Note
    the filter lists one DELETE /api/settings/mcp/qa_docs (200).
  22. Note
    Sibling:
  23. Check
    control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_vault

    is unchanged from the end of the previous bullet (command /bin/sh, args still ending with qa-v2, the three ********** env values, enabled true), and after

  24. Do
    control-openhands browser click 'testid=mcp-server-detail-qa_vault'
  25. Note
    its Test connection still reads Connected — 1 tool(s) available: the stored secrets survived the sibling's writes.
  26. Do
    control-openhands browser press Escape

Credential probe #

  1. Note
    GitHub (hosted): run
  2. Do
    control-openhands browser click 'testid=mcp-marketplace-card-github'
  3. Do
    control-openhands browser fill 'testid=mcp-install-modal >> testid=mcp-install-field-api_key' ghp_qaInvalid000
  4. Check
    control-openhands browser network --clear
  5. Do
    control-openhands browser click 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe 'testid=mcp-install-modal >> testid=mcp-install-submit' --observe-ms 20000

    (Install, Verifying…, Install) and

  6. Check
    control-openhands browser text 'testid=mcp-install-modal-error'
  7. Expect
    The server refuses the token before any tool is listed, so the probe never runs and the text is Connection failed: HTTPStatusError: Client error '401 Unauthorized' for url 'https://api.githubcopilot.com/mcp/' …; the modal stays open (control-openhands browser count 'testid=mcp-install-modal' is 1),
  8. Check
    control-openhands browser network --filter 'api/(mcp|settings)'

    shows one POST /api/mcp/test and no /api/settings/mcp write,

  9. Check
    control-openhands api GET /api/settings --pick agent_settings.mcp_config.github

    has no value and

  10. Check
    control-openhands api GET /api/settings/secrets

    lists no GITHUB_PERSONAL_ACCESS_TOKEN (control-openhands browser screenshot 'testid=mcp-install-modal' --feature F17.credential-probe --name github-invalid);

  11. Do
    control-openhands browser click 'testid=mcp-install-cancel'
  12. Note
    Slack (stdio; it lists 8 tools with any token, so only the probe, a channel listing, can tell a bad token):
  13. Do
    control-openhands browser click 'testid=mcp-marketplace-card-slack'
  14. Do
    control-openhands browser fill 'testid=mcp-install-modal >> testid=mcp-install-field-SLACK_TEAM_ID' T_QA_INVALID
  15. Do
    control-openhands browser fill 'testid=mcp-install-modal >> testid=mcp-install-field-SLACK_BOT_TOKEN' xoxb-qa-invalid-000

    (type password),

  16. Do
    control-openhands browser click 'testid=mcp-install-modal >> testid=mcp-install-submit'
  17. Wait
    control-openhands browser wait 'testid=mcp-install-modal-error' --timeout 150000

    (the first run downloads the package through npx, about a minute: a plain click, then one long wait, rather than --observe-ms, which records a few transient states).

  18. Check
    control-openhands browser text 'testid=mcp-install-modal-error'

    is Credential check failed: invalid_auth with direct internet; behind the TLS-intercepting proxy the probe's own HTTPS call fails first and it reads Credential check failed: fetch failed.

  19. Note
    Either way the modal stays open and
  20. Check
    control-openhands api GET /api/settings --pick agent_settings.mcp_config.slack

    has no value (--name slack-invalid);

  21. Do
    control-openhands browser click 'testid=mcp-install-cancel'
  22. Note
    For Slack's own verdict behind the proxy, run the same command as a custom server, which keeps the entry's probe (#18034):
  23. Do
    control-openhands browser click 'testid=mcp-add-custom-server'
  24. Note
    pick STDIO, fill testid=add-mcp-server-form >> testid=name-input qa_slack, testid=add-mcp-server-form >> testid=command-input npx,
  25. Do
    control-openhands browser fill 'testid=add-mcp-server-form >> testid=args-input' $'-y\n@zencoderai/slack-mcp-server'
  26. Do
    control-openhands browser fill 'testid=add-mcp-server-form >> testid=env-input' $'SLACK_TEAM_ID=T_QA_INVALID\nSLACK_BOT_TOKEN=xoxb-qa-invalid-000\nNODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt'

    (your proxy's CA bundle), then

  27. Do
    control-openhands browser click 'testid=add-mcp-server-form >> testid=mcp-test-connection'
  28. Check
    control-openhands browser text 'testid=add-mcp-server-form >> testid=mcp-test-message' --timeout 90000
  29. Note
    : Credential check failed: invalid_auth, class text-red-500 (control-openhands browser screenshot 'testid=mcp-custom-editor' --feature F17.credential-probe --name slack-custom-invalid-auth).
  30. Do
    control-openhands browser click 'testid=add-mcp-server-form >> testid=submit-button' --observe 'testid=add-mcp-server-form >> testid=submit-button' --observe-ms 20000

    (Add Server, Add Server [disabled], Add Server: Save tests first) keeps the editor open (control-openhands browser count 'testid=mcp-custom-editor' is 1) with the same red message, and

  31. Check
    control-openhands api GET /api/settings --pick agent_settings.mcp_config.qa_slack

    has no value; close it with

  32. Do
    control-openhands browser click 'testid=mcp-custom-editor-close'

    (an Escape pressed while the Save's test still runs is ignored).

  33. Expect
    A server the catalog does not know gets no probe:
  34. Do
    control-openhands browser click 'testid=mcp-health-probe-qa_vault' --observe 'testid=mcp-health-label-qa_vault' --observe-ms 20000
  35. Note
    ends on Reachable — credentials not verified (the Card health wording).

The agent uses it #

  1. Note
    With qa_time enabled run
  2. Wait
    control-openhands conversation start --prompt "Use the qa_time MCP server's get_current_time tool with timezone UTC. Do not use the terminal. Reply with only the tool's datetime value." --wait --timeout 240
  3. Check
    control-openhands conversation events <id> --kinds ActionEvent,ObservationEvent,MessageEvent
  4. Note
    with the id it printed.
  5. Expect
    There is an ActionEvent with tool qa_time_get_current_time (MCP tools reach the agent as <server id>_<tool>), its observation reads [Tool 'qa_time_get_current_time' executed.] { "timezone": "UTC", "datetime": … }, and the agent's reply is that datetime.
  6. Check
    control-openhands conversation events <id> --grep qa_time_get_current_time --from-start
  7. Note
    also matches the SystemPromptEvent (the tool was offered).
  8. Do
    control-openhands browser screenshot --feature F17.agent-uses-server --name conversation

    shows the tool step (its title is model-written, e.g. Get current time in UTC via qa_time MCP) and the datetime reply. conversation start leaves the browser on the conversation:

  9. Do
    control-openhands browser goto /mcp

Enable and disable #

  1. Note
    On /mcp run
  2. Check
    control-openhands browser attr 'testid=mcp-installed-toggle-qa_time' aria-checked

    (true; aria-label Disable server),

  3. Do
    control-openhands browser click 'testid=mcp-installed-toggle-qa_time'
  4. Do
    control-openhands browser reload
  5. Note
    and the same attr (false; label Enable server).
  6. Check
    control-openhands api GET /api/settings

    shows agent_settings.mcp_config.qa_time.enabled false with command, args and env intact;

  7. Do
    control-openhands browser screenshot 'testid=mcp-server-item >> has-text=qa_time' --feature F17.enable-disable --name disabled

    shows the + icon.

  8. Note
    With no other server exposing that tool, run
  9. Wait
    control-openhands conversation start --prompt "Without running any command: is a tool named get_current_time available to you right now? Reply with only YES or NO." --wait --timeout 240
  10. Note
    the reply is NO,
  11. Check
    control-openhands api GET /api/conversations/<id> --pick agent.mcp_config.qa_time.enabled

    is false, and

  12. Check
    control-openhands conversation events <id> --grep qa_time_get_current_time --from-start

    has count 0 (no SystemPromptEvent lists it).

  13. Do
    control-openhands browser goto /mcp
  14. Note
    Click the toggle again and reload; it is true.

Phone modals #

  1. Note
    At browser viewport phone on /mcp run
  2. Do
    control-openhands browser click 'testid=mcp-add-custom-server'
  3. Check
    control-openhands browser bbox 'testid=mcp-custom-editor'

    (insideViewport true, pageHorizontalOverflow false) and

  4. Do
    control-openhands browser screenshot --feature F17.phone --name custom-editor
  5. Note
    close with testid=mcp-custom-editor-close.
  6. Note
    Do the same for testid=mcp-marketplace-card-stripe with testid=mcp-install-modal (--name install-modal), then
  7. Do
    control-openhands browser viewport desktop

Cloud backend #

  1. Note
    Blocked without an OpenHands Cloud backend.
  2. Note
    On the local backend
  3. Do
    control-openhands browser click 'testid=mcp-marketplace-card-github'

    shows only the MCP form (browser count 'testid=mcp-install-tab-native' is 0).

  4. Note
    With Cloud active, browser text 'testid=extensions-navbar-desktop' should list only MCP Servers and the cloud Skills link (browser attr 'testid=sidebar-extensions-/skills' href ends in /settings/skills), and the GitHub modal should show mcp-install-tab-native (Recommended) and mcp-install-tab-mcp.

Cleanup #

  1. Note
    On /mcp (not the conversation page conversation start left open), delete qa_time, qa_shttp, qa_vault and any time, time_1, tavily, filesystem cards through the editor's Delete: for each id, browser focus '[data-server-id=<id>]', browser press Enter, browser click 'testid=mcp-custom-editor-delete', browser click 'testid=confirmation-modal >> testid=confirm-button', browser wait '[data-server-id=<id>]' --state detached --timeout 10000.
  2. Expect
    After browser reload, browser count 'testid=mcp-installed-empty' is 1 and api GET /api/settings --pick agent_settings.mcp_config is {}.
  3. Note
    Delete the TAVILY_API_KEY secret on /settings/secrets (F14: testid=secret-item >> has-text=TAVILY_API_KEY >> testid=delete-secret-button, then testid=confirmation-modal >> testid=confirm-button), stop the Streamable HTTP fixture by its PID, and restore deepseek-flash as the active profile if you changed it.

Gotchas and known limits

  • Server errors on MCP mutations (a 404 on a server that is already removed, a 502 while the Agent Server is down) toast the server's message (MCP server '<id>' was not found), or An error occurred when the body has none (#18051). Expected: one toast per failed action. Known failure (reproduced 2026-10-08): a failed delete from the editor or a failed card toggle shows two identical toasts, one from the caller and one from the global mutation handler (#18181). Count them with click ... --observe 'role=status' or browser toasts --history.
  • Desktop /customize is a client-side redirect: browser url may still read /customize for a moment; use wait-url '/mcp(\?|$)'.
  • From 768 to 1023 px the sidebar rail stays and the top bar is gone, so a Customize detail page shows neither the aside nor a Back chevron; the rail's Customize link (sidebar-skills-link, unscoped at this width) returns to the hub.
  • On a phone the drawer and the hidden desktop rail both render sidebar-skills-link; scope it with testid=sidebar-mobile-drawer >> ... or the click fails with a strict-mode error, even though browser testids lists it once.
  • Editor dropdowns are comboboxes: click server-type-dropdown or auth-mode-dropdown, then role=option[name="..."]. Server types read SSE, STDIO and SHTTP (not "Streamable HTTP"); auth modes read None, Bearer token, Header and OAuth. The type dropdown exists only when adding.
  • The timeout field is type="number": browser fill refuses letters, so Timeout must be a valid number cannot be reached from the UI.
  • A red test message stays on screen when a later validation error appears, so css=p.text-red-500 can match two paragraphs; take >> nth=0 (the validation error comes first).
  • Install-modal required fields use native browser validation (a tooltip, no paragraph): assert validationMessage.
  • The installed and library toggles are role=switch buttons: read aria-checked, not aria-pressed.
  • MCP stdio servers start with a minimal environment. Behind a TLS-intercepting proxy, without trust settings in the run's private HOME, uvx catalog installs fail with Connection closed (<run>/private/stack.log shows UnknownIssuer) and npx ones with Connection timed out. That is the sandbox, not the product. launch now seeds the trust files from SSL_CERT_FILE/NODE_EXTRA_CA_CERTS, so a fresh run installs fine and F17.install-error must move them aside; once a package is in the run's uv cache it installs even without them.
  • An installed card's health row swallows clicks (so the probe button does not open the editor). On a card without a catalog description (qa_broken) that row is the card's centre, so a plain card click opens nothing; a catalog description moves it down. Open the editor by the card's name or mcp-server-detail-<id> line, or focus the card and press Enter.
  • conversation start leaves the browser on the conversation page; every MCP-page command after it needs browser goto /mcp first, or the per-card selectors silently wait on a page that has none.
  • @modelcontextprotocol/server-everything sse printed Server is running here but never answered on its port; use the streamableHttp mode for the remote-server fixture.
  • A server without a catalog credential probe reads Reachable — credentials not verified, never Connected — credentials verified (N tool(s)), even without credentials. Health after a save is seeded from the pre-save test and is lost on reload (Not checked yet).
  • Adding a custom server shows no toast; only catalog installs and deletes toast. Assert the card after reload.
  • Catalog installs are add-only: a second Time install becomes time_1. Since #18034 (closed #17930) STDIO servers match the catalog by command plus leading args instead of by name, so time_1 and a custom server that runs a catalog command under its own name (qa_time) keep that entry's description, badge and search keywords, while a server that only borrows a catalog name does not (a STDIO fetch running qa-no-such-command shows only its command) (F17.install-stdio, F17.custom-add). Twelve library entries (provider-OAuth-only, such as Sentry, HubSpot or the Cloudflare OAuth servers) open an empty modal whose Install does nothing (F17.install-unsupported) (#17922).
  • The section filter's trigger and menu are named Filter MCP servers since #18035 (closed #17939); a checkout without it reuses the conversation filter's Filter conversations. The test ids work on both.
  • A malformed header line in the custom editor shows Environment variables must follow KEY=value format (mcp-server-form.tsx runs headers through validateEnvFormat) (#17958); see F17.custom-validation.
  • A catalog stdio server runs with the Agent Server's minimal environment: behind a TLS-intercepting proxy its own HTTPS calls fail (fetch failed) even when npx could download it, and the install modal has no field for NODE_EXTRA_CA_CERTS; use the custom editor with the same command to see the provider's real answer (F17.credential-probe). The Credential check failed: fetch failed wording itself is #18161: the Slack interpreter turns any is_error tool result into a credential failure, though the server connected and listed its tools.
  • GitHub's hosted MCP endpoint answers an invalid token with HTTP 401 at connect, before tools are listed, so its get_me probe never runs and the modal shows the connection wording (Connection failed: HTTPStatusError … 401 Unauthorized), not Credential check failed; filed as OpenHands/software-agent-sdk#5607, since the test endpoint reports the 401 as error_kind unknown.
  • The shell-gated qa_vault fixture exits before the MCP handshake, which the Agent Server reports either at once as Connection failed: McpError: Connection closed or only at its deadline as Connection timed out. Check the URL and try again. (the timed-out wording was seen once on 2026-10-07; on 2026-10-08 all 21 tries answered Connection closed within 0.4 s); neither is the Could not start the server command wording, which needs a command that cannot be spawned at all. Wait on text=Connection, which matches both and not Connected — ….
  • browser network --bodies shows what the page sent, never a secret: a value under a key that names a credential (key, token, secret, auth, pass, session, sig, credential) and every value of an env or headers map becomes <redacted N chars> (N the real length), and a URL in any other string (a server's url) keeps its host and path but loses its userinfo and the values of its secret-named query parameters (https://<redacted 2 chars>:<redacted 8 chars>@host/mcp?api_key=<redacted 17 chars>), while the settings API's ********** placeholder is kept as is, so a row tells "placeholder sent" from "a value sent". The MCP editor sends neither for an untouched secret: Test connection swaps the placeholder for the stored encrypted value (a GET /api/settings in encrypted mode first; Fernet tokens, so 120 chars for a 19-char token, 140 for 40, 268 for 142) and Save leaves env out of its sparse PATCH altogether (F17.stored-secret-reuse). Without --bodies no row carries a body field, and only app-origin writes keep one.
  • A browser verb's client call waits 120 s, or 10 s longer than the verb's own --timeout or --observe-ms when that is longer, so browser wait 'testid=mcp-install-modal-error' --timeout 150000 runs its full deadline and a miss ends with Playwright's timeout and a failure screenshot (before this, every call was aborted at 120 s with TimeoutError: The operation was aborted due to timeout while the page kept going). For a first Slack install use a plain click, then that wait; --observe-ms is for a few transient states, not a minute-long outcome.
  • Known issue #17941: turning off an enabled card toggle can drop the click when the pointer moves in and presses at once (F17.enable-disable).

Source paths: src/routes/extensions-hub.tsx, src/routes/mcp.tsx, src/components/features/skills/extensions-navigation.tsx, src/components/features/skills/extensions-mobile-hub.tsx, src/components/features/mcp-page/, src/components/features/settings/mcp-settings/mcp-server-form.tsx, src/utils/mcp-marketplace-utils.ts, src/api/mcp-health/, src/utils/mobile-section-nav.ts.