Skip to content

Connect external MCP servers over HTTP/SSE, with per-tool policy (#17) - #100

Merged
derekhearst merged 17 commits into
mainfrom
feat/17-external-mcp
Sep 24, 2026
Merged

derekhearst merged 17 commits into
mainfrom
feat/17-external-mcp

Conversation

@derekhearst

Copy link
Copy Markdown
Owner

Stacked on the #18 PR (feat/18-conversation-lifecycle); retargets to main once that merges. Adds migration 0080_mcp_servers on top of #18's 0079 (regenerated by drizzle-kit during stacking). HTTP/SSE connectors only; stdio is out of scope for this PR (see docs/mcp/spec.md).

Summary

Picked up the interrupted attempt and finished #17 with triage options A and B. Chat runs can now use remote MCP servers over HTTP or SSE. The new mcp_servers table (migration 0079) stores the bearer token and header values as one document encrypted with APP_ENCRYPTION_KEY. A new Settings → Connectors page (/settings/connectors) adds, edits, tests, enables, disables and removes servers, and sets each tool to Allow, Ask or Block. Enabled rows are loaded only for interactive chat runs whose agent has no fixed tool list. They are merged into mcpServers beside our own server, which is placed last so nothing can shadow it. strictMcpConfig is on for every run, blocked tools go into disallowedTools, and the CLI's generic MCP resource readers are disallowed. The tool gate trusts the configuration row, not a name the server chooses. A call is refused unless its server is one of the run's rows and the SDK's provenance agrees (source 'dynamic', same key). A call using our own server name from any source other than 'sdk' is refused, and Allow becomes Ask when there is no provenance. Block refuses in every mode; Allow runs in Ask and Accept-edits unless "Require approval for all tools" is on; Plan refuses every connector tool. The Test button uses its own MCP client and sends every request through a new guarded fetch (egress spelling check, per-socket address check, no redirects, capped body); run start also resolves each connector's host under the egress rule. Also added: tool cards labelled "Tool · connector", ledger rows with provider 'mcp:', and an mcp_unavailable notice when system/init reports a connector failed or needs-auth. Two bugs fixed along the way: a label-only edit re-sealed the secrets and wiped the test result, and a connector's Allow overrode "Require approval for all tools".

After review: Picked up the review on top of the 8 existing #17 commits. There were no must-fix items; all 4 should_fix items are fixed, plus 2 of the minor edge cases from the notes and one of the residual-risk notes.

  1. Late connector failures are now reported. The notice that names an unavailable connector used to read only system/init, where remote connectors are almost always still pending. A new pure module, src/lib/engine/mcp-status-watch.ts, asks the session again (Query.mcpServerStatus()) once the turn is under way. It asks on the model's messages, stream events and tool results, never on init or on the result, because the CLI's stdin is closed by the time the result arrives. It keeps asking only while a connector is unsettled: at least 2 s apart, at most 15 times, each ask abandoned after 1 s. It reports each connector once and only this run's own connectors. The hot file stream.server.ts gets just an import, an optional type member and one observe() call.
  2. Reserved names. computer-use and remote-devices are now refused as connector names. I checked both in the bundled CLI.
  3. The On/Off switch springs back when its save fails. Before, it stayed flipped while the text and the database said the opposite.
  4. Docs. The .mcp.json row in docs/runtime/spec.md now says the engine ignores the file since strictMcpConfig (it is still protected). docs/mcp/mcp.md step 4 and "Live status" now describe the re-check honestly, including that a failure after the turn's last step goes unreported.

Also fixed from the notes:

  • A tool name over 128 characters is left out of the tool list instead of being clipped. The clipped name broke every policy save for that connector.
  • Removing a stored header and adding it back in a different letter case now replaces it instead of deleting it.
  • docs/mcp/mcp.md now has a security note that the engine's own connection may follow redirects.

bun run check shows 0 errors, bun run build passes and bun run db:check is fine. The worktree is clean.

Closes #17

What was addressed

Audit findings (from the 2026-09-22 subsystem audit) were re-checked against the code before any change; "not a bug" means the claim did not hold up.

# Item Verdict Note
should_fix-1 src/lib/engine/sdk-notices.ts:152 (init-only connector notice) should_fix-1 src/lib/engine/sdk-notices.ts:152 (init-only connector notice) fixed New src/lib/engine/mcp-status-watch.ts calls session.mcpServerStatus() during the turn (while stdin is open, never at the result), throttled and capped; it emits one kept mcp_unavailable notice for this run's connectors that are newly failed or need a sign-in. sdk-notices.ts exports mcpServersDown/mcpUnavailableNotice, which both paths share. I checked in the bundled CLI that MCP startup does not block by default (MCP_CONNECTION_NONBLOCKING !== false), and in sdk.mjs that stdin closes on the first result. docs/mcp/mcp.md step 4 and the 'Live status' bullet were reworded to say exactly what is
should_fix-2 src/lib/engine/mcp-connectors.ts:56 (RESERVED_CONNECTOR_NAMES) should_fix-2 src/lib/engine/mcp-connectors.ts:56 (RESERVED_CONNECTOR_NAMES) fixed Added 'computer-use' and 'remote-devices', after checking the CLI binary (hne/VG set; Hu in the dar set beside 'ide'). Updated the 'Names' list and the SDK-verified list in docs/mcp/mcp.md. New cases in tests/mcp.config.spec.ts (reserved, and longer look-alike names still allowed) and tests/engine.connectors.spec.ts (such rows never reach composeMcpServers or buildRunMcpConnectors).
should_fix-3 src/routes/settings/connectors/+page.svelte:255 (one-way switch) should_fix-3 src/routes/settings/connectors/+page.svelte:255 (one-way switch) fixed toggle() takes event.currentTarget; if setMcpServerEnabledCommand throws, it sets input.checked = connector.enabled and then rethrows, so the error alert still shows. tests/crud/mcp.crud.spec.ts routes '/setMcpServerEnabledCommand' to a scripted 400, then asserts the alert, the unchecked switch, 'Off' and the unchanged DB row. I confirmed the built remote id ends with '/setMcpServerEnabledCommand'.
should_fix-4 docs/runtime/spec.md:168 (.mcp.json row) should_fix-4 docs/runtime/spec.md:168 (.mcp.json row) fixed The row now says the engine ignores .mcp.json since #17 (only AgentStudio's own server and Settings → Connectors servers load), that the file stays protected, and links docs/mcp/mcp.md. I also added a pointer from the unbuilt 'MCP server lifecycle' design section to the Connectors doc.
note: tool name >128 chars clipped with an ellipsis breaks policy saves note: tool name >128 chars clipped with an ellipsis breaks policy saves fixed toolSnapshot now returns null for a tool with no name or a name over 128 characters, and the probe skips it; such a tool asks, like any tool without a policy. Pinned in tests/mcp.probe.spec.ts; documented in mcp.md 'Other limits' and in spec.md.
note: edit form remove + re-add of a header in different case deletes it note: edit form remove + re-add of a header in different case deletes it fixed applySecretsPatch now applies removals first, then values, so the replacement holds whatever order the form sends them in. Pinned in tests/mcp.config.spec.ts in both orders.
note: engine's MCP client may follow redirects (undocumented) note: engine's MCP client may follow redirects (undocumented) fixed Added a Security notes bullet to docs/mcp/mcp.md. The wording is hedged ('may') because I did not trace the CLI's redirect handling.
note: bypass mode runs Ask connector tools without a card note: bypass mode runs Ask connector tools without a card not-a-bug Matches the existing bypass semantics and is already documented in the UI and in mcp.md's approval table; left for the owner to decide.

Verification

  • bun run check: 0 errors
  • bun run build: passes
  • Specs: Playwright was not run: the branch carries migration 0079_mcp_servers, so no Playwright run and no app boot against the shared dev DB. bun run db:check passes. Instead, the pure specs were run under bun test with a scratchpad preload that maps @playwright/test to bun:test (no DB, no server): - tests/engine.mcp-status-watch.spec.ts, tests/engine.sdk-notices.spec.ts, tests/mcp.config.spec.ts, tests/mcp.probe.spec.ts and tests/engine.connectors.spec.ts: 110 passed, 0 failed. - tests/engine.connectors-stream.spec.ts, with a stub for the registry module's bareToolName, copied from the real one: 9 passed, 0 failed. With the stream.server.ts change stashed, the new 'kept warning' test fails, as expected. - tests/crud/mcp.crud.spec.ts (the switch spring-back step) has not been run; CI will run it on both projects.
  • Adds a migration — not booted against the shared dev database; CI runs the specs on a fresh one.
  • Independent review: approve (0 must-fix, 4 should-fix), addressed in a follow-up round.

Docs updated

  • docs/mcp/mcp.md
  • docs/mcp/spec.md
  • docs/runtime/spec.md
  • docs/structure/parity-reaudit-2026-09-22.md

Follow-ups / residual risk

  • CI must run the Playwright specs, because nothing ran them locally (migration rule). Most important: tests/crud/mcp.crud.spec.ts (new switch spring-back step, both projects), tests/engine.connectors-stream.spec.ts, tests/engine.mcp-status-watch.spec.ts, tests/mcp.config.spec.ts, tests/mcp.probe.spec.ts and tests/engine.connectors.spec.ts.
  • The late connector check is best effort by design. It asks only while the CLI's stdin is open (before the first result), at most 15 times, at least 2 s apart. A connector that fails after the turn's last step is not reported, and the docs say so. If a hard guarantee is ever wanted, the option is alwaysLoad on connectors, which blocks the turn start until they connect; it was deliberately not chosen.
  • I did not verify that the engine's own MCP client follows HTTP redirects; the Security note says 'may'.
  • Owner awareness, unchanged: Bypass mode runs connector tools set to Ask without a card, as documented.
  • Not done: the CLI also treats a longer list of desktop-app server names (terminal, office, dispatch, skills, …) as first-party, but only in its artifact and telemetry paths. They are not reserved because they have no effect on an SDK run.
  • No push or PR; the branch is ready for one.

🤖 Generated with Claude Code

derekhearst and others added 15 commits September 23, 2026 19:57
…olicy (#17)

The engine can now be handed the operator's connectors (HTTP/SSE MCP
servers) and decides each of their calls against the connector's own
configuration row, never against a name the server chose.

- mcp-connectors.ts (pure): the connector name rule (lower-case, digits,
  single hyphens, max 32, agentstudio and the CLI's own server names
  reserved), the run's connector map with per-tool allow/ask/block keyed
  by the CLI's spelling of the tool name, connectorCallVerdict, and
  composeMcpServers (connectors first, ours last so no key can shadow
  it; none for an agent with a fixed tool list).
- A call is refused unless its <server> is one of the run's rows and the
  SDK's provenance agrees (source 'dynamic', same key); with no
  provenance reported, allow degrades to ask. Our own server name from
  any source but 'sdk' is refused.
- resolveToolGate takes the policy: block denies in every mode, allow
  runs unasked in default and acceptEdits, plan still refuses, and the
  per-tool settings list is not consulted for external tools.
- canUseTool and the PreToolUse hook pass the SDK's mcpServer /
  mcp_server provenance through decideToolCall.
- Options: strictMcpConfig so only servers AgentStudio passes load (no
  .mcp.json, user, plugin or claude.ai connectors); blocked tools go in
  disallowedTools; the CLI's generic MCP resource readers are refused.
- sdk-notices: system/init reporting a configured server failed or
  needs-auth becomes one mcp_unavailable notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ection test (#17)

Operators can add remote MCP servers over Streamable HTTP or SSE and
chat runs consume them.

- mcp_servers (migration 0079): one row per connector per user with an
  immutable validated name, label, transport, URL, header names, a
  has-bearer-token flag, the bearer token and header values as one
  document encrypted with APP_ENCRYPTION_KEY, per-tool policies, the
  last test's tool list and outcome, enabled, timeout. Audit actions
  mcp_server.created/updated/deleted record names, never values.
- mcp-config.ts (pure): URL rule (http/https, no credentials in the URL,
  public hosts only by the egress policy unless listed in
  MCP_ALLOWED_PRIVATE_HOSTS), header and token rules, the blank-keeps /
  null-removes secrets patch, and the SDK config.
- mcp.server.ts: CRUD scoped to the caller, a list that never selects
  the secrets, and loadRunMcpServers, which gives only interactive chat
  runs whose agent has no fixed tool list the enabled rows, re-checks
  each URL and resolves its host under the egress guard, and names any
  connector it leaves out in a notice.
- mcp-probe.server.ts: the Test button's own MCP client (the SDK's Query
  handle lives for one turn), connect and list tools with a 10 s
  deadline, every request through a new guarded fetch
  (egress-fetch.server.ts: spelling check, per-socket address check,
  no redirects, capped body, streamed for SSE).
- /settings/connectors: add, edit, test, enable/disable, remove, and
  Allow/Ask/Block per tool; linked from Settings.
- The chat stream route loads the connectors per run and passes them to
  the options and the gate.

Stdio servers are not a transport: they would run outside the Bash
sandbox with the CLI's environment (see docs/mcp/mcp.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onnector (#17)

getFriendlyToolLabel names an mcp__<server>__<tool> call by the tool's
own name plus " · <server>" in every status, and never borrows the copy
written for one of our tools. The per-call ledger entry carries
provider 'mcp:<server>' so usage can be grouped by connector; ours and
the built-ins carry none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
system/init naming a configured server as failed or needs-auth gives
one kept warning; pending, all-connected and our own sdk server say
nothing; a server name is reduced to safe characters and clipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/mcp-fixture.ts is a hand-written MCP server speaking Streamable
HTTP and the older SSE transport, plus a redirect, a server that never
answers and one that echoes credentials in a 500. mcp.probe.spec.ts
drives the Test button's client against it through the guarded fetch:
both transports list every page of tools and send the saved headers;
401s say whether credentials were missing or refused; loopback, a name
resolving to a private address and redirects are refused before the
server sees a request unless that exact host is allowed; a stuck server
costs the deadline; the body cap holds.

The probe now reads its own deadline and the MCP client's per-request
timeout the same way ("No answer within 1 second."), and redacts a
bearer token on its own as well as the whole header value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…last test (#17)

The edit form sends a blank token and blank header values for
everything it did not touch. updateMcpServer treated any bearerToken
key as touching the secrets, so relabelling a connector re-encrypted
them and wiped its test result. Now only a patch that asks for
something (null, or a non-blank value) opens the stored secrets, and
only a real difference re-seals them and clears the result.

Specs, against the live database and through the page:
- mcp.server.spec.ts: secrets encrypted and absent from the list and
  the audit trail; name, URL and uniqueness rules on the server; no
  APP_ENCRYPTION_KEY refuses a token but not an open server; blank
  keeps, null removes, a value replaces; enable/disable/policy/delete
  audited; another user's id reaches nothing; the Test button records
  its outcome and keeps the last tool list on failure; run start gives
  only interactive unscoped chat runs the enabled rows, decrypted, with
  blocked tools, and leaves out one whose host resolves privately.
- crud/mcp.crud.spec.ts (desktop and mobile): add with a token and a
  header, switch off, a failing test, edit keeping secrets, Allow and
  Block per tool, remove; Settings links to the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tor's allowed tool (#17)

The switch promises that every tool call pauses. A connector's Allow
used to override it because the gate ignored the settings for external
tools. The settings still never loosen a connector tool, but for an
external name the settings callback can only be true under the
wildcard (nobody can tick a connector's tool on the per-tool list), so
Allow now asks when it is on, with a reason naming the switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…shipped

- docs/mcp/mcp.md (new): what a connector is, adding/testing/policies,
  what happens in a chat turn, the approval table by mode, integrations,
  business rules, security notes, what is not supported yet (stdio,
  OAuth) and why, and what was checked in the Agent SDK 0.3.278 typings,
  sdk.mjs and the bundled CLI.
- docs/mcp/spec.md: rewritten from the unbuilt pre-SDK design to the
  table, modules, run-start and gate contracts, and the specs.
- README: Connectors feature, APP_ENCRYPTION_KEY's second use,
  MCP_ALLOWED_PRIVATE_HOSTS, the docs link and /settings/connectors.
- .env.example: MCP_ALLOWED_PRIVATE_HOSTS.
- tools.md and settings spec: connectors' tools and the new page.
- claude-desktop-parity.md: the MCP row is "behind" with v1 described;
  parity-reaudit-2026-09-22.md: #17 shipped (v1), with what is left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… turn (#17)

The "connector unavailable" notice read only system/init. The bundled
CLI (0.3.278) starts MCP servers without blocking the turn
(MCP_CONNECTION_NONBLOCKING !== false) and alwaysLoad is never set, so
a remote connector is almost always still `pending` there; one that
then failed (401, refused connection, wrong path) was never reported.

mcp-status-watch.ts (new, pure apart from the injected session call and
clock) asks Query.mcpServerStatus() again once the turn is under way:
on assistant, user, stream_event and tool_progress messages, never on
init or on the result (the SDK closes the CLI's stdin when the first
result arrives). It asks only while one of the run's connectors is
unsettled, at least 2 s apart, at most 15 times, each ask abandoned
after 1 s. A connector newly failed or needing a sign-in gets one kept
mcp_unavailable warning; each is reported once per turn, init's report
included, and only the run's own connector keys count.

sdk-notices.ts exports the shared pieces (mcpServersDown,
mcpUnavailableNotice); init's notice is unchanged. stream.server.ts
gains the optional mcpServerStatus on EngineQuerySource and one
observe() call in the loop; runs with no connectors, or a test double
without the method, never ask.

Specs: engine.mcp-status-watch.spec.ts pins when it asks, spacing, the
cap, report-once, only-our-connectors, and a failing or silent ask;
engine.connectors-stream.spec.ts drives it through runEngineStream
(pending in init, failed on the ask -> one kept notice; no connectors
-> no ask).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…17)

The bundled CLI special-cases two more server names by exact match:
computer-use (its first-party and telemetry checks) and remote-devices
(beside ide in its set of built-in servers, with a tool list of its
own). Both passed connectorNameProblem, so a connector could be
registered under a name the CLI treats as its own.

Specs: mcp.config.spec.ts refuses both and keeps longer names that only
start the same way; engine.connectors.spec.ts shows a row under either
name never reaches the SDK options or the run's connector map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… fails (#17)

The switch was one-way (checked={connector.enabled} with onchange). When
setMcpServerEnabledCommand failed, `connectors` did not change, so
Svelte never reset the checkbox: it stayed flipped while the On/Off
text beside it and the database said the opposite. toggle() now puts
the input back to connector.enabled before the error is shown.

Spec: mcp.crud.spec.ts routes the command to a scripted 400, clicks the
switch, and checks the alert, an unchecked switch, "Off", and an
unchanged row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
)

Two edge cases from review:

- toolSnapshot clipped a tool name over 128 characters to 128 plus an
  ellipsis. The clipped name matched no call, and it failed the policy
  command's max(128) key check, so "Every tool" (and any change to that
  tool) could not be saved for the connector. A tool with no name or a
  name over 128 characters is now left out of the list; it asks, like
  any tool without a policy.
- The edit form sends new headers before the stored rows' removals, so
  removing "X-Team" and adding "x-team" deleted the header just added.
  applySecretsPatch now applies removals first, then values.

Specs: mcp.probe.spec.ts (128 kept, 129 and blank dropped) and
mcp.config.spec.ts (the replacement holds in either order).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ew role (#17)

- docs/mcp/mcp.md: a chat turn's step 4 and "Live status" now describe
  what happens: connectors connect in the background, are usually still
  connecting at the start, and are re-checked while the turn runs; one
  that fails after the last step goes unreported. Reserved names gain
  computer-use and remote-devices. New security note: the engine's own
  connection may follow redirects. The long-tool-name rule. What was
  checked in the SDK: MCP_CONNECTION_NONBLOCKING, mcpServerStatus() and
  when stdin closes, the two exact-name servers.
- docs/mcp/spec.md: the two new modules, the watch's contract under
  Notices, the snapshot rule, and the specs table.
- docs/runtime/spec.md: the protected-files row for .mcp.json said it
  decides which tool servers connect; since strictMcpConfig the engine
  ignores it (still protected). The unbuilt MCP lifecycle design now
  points at the Connectors doc.
- parity-reaudit-2026-09-22.md: #17's notice line and what is left open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the wave-3 chain (#14, #9, #22, #24, #4, #35, #32, #18 and main)
under #17's connectors so the PRs merge in order.

Migrations:
- drizzle/: took #18's migrations, snapshots and _journal.json verbatim
  (0079_conversation_lifecycle), deleted this branch's 0079_mcp_servers.sql
  and its 0079 snapshot, kept the mcp_servers schema .ts, and let
  `bun run db:generate --name mcp_servers` emit 0080_mcp_servers.sql with
  0080_snapshot.json chained on #18's 0079 (SQL identical to the old 0079).
  `bun run db:check` passes.
- docs/mcp/spec.md, docs/structure/parity-reaudit-2026-09-22.md: the
  connectors table is migration 0080.

Engine:
- tool-decision.ts: both gates kept. Order is scope, then #17's own-server
  provenance check and connector verdict (unknown server / mismatched
  provenance refused), then #4's AskUserQuestion exemption, then
  containment and resolveToolGate with the connector's per-tool policy.
  The provenance checks sit before the exemption, which is keyed on a bare
  name a spoofed own-server call would share.
- stream.server.ts: imports from both sides (watchConnectorStatus beside
  background shells, AskUserQuestion, UsageCoverage, turn-input). The
  PreToolUse hook is #32's (delegation admission, updatedInput) with #17's
  `mcp_server` provenance passed to decide(); canUseTool destructures both
  #4's `agentID` and #17's `mcpServer`.
- options.server.ts: #9's imports (no `$env` import, gateway env) with
  #17's tools.server import (no ENGINE_MCP_SERVER); composeMcpServers,
  strictMcpConfig and the MCP resource-tool disallow list sit beside #4's
  toolConfig/settings and #32's disallowed Workflow/SendMessage.
- builtin-tools.ts, permission-mode.ts, sdk-notices.ts, chat.ts and the
  stream route merged cleanly; the route loads connectors once per turn and
  hands them to every #24 resume-fallback attempt.

Docs:
- tools.md: #4's MCP endpoint wording (no ask_user) plus #17's Connectors
  bullet.
- claude-desktop-parity.md: wave-4 line keeps #17 struck through (v1) and
  #18/#22's landed notes.
- parity-reaudit-2026-09-22.md: #17 shipped (v1) and #32 shipped rows; the
  order list keeps #17 done and #4/#18/#14 shipped.
- runtime/spec.md: the chat tool check lists connectors as its fourth check
  and says AskUserQuestion skips all but the tool-list check.
- mcp/spec.md: notes the provenance checks run before the AskUserQuestion
  exemption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derekhearst
derekhearst changed the base branch from feat/18-conversation-lifecycle to main September 24, 2026 07:03
derekhearst and others added 2 commits September 24, 2026 01:07
The spec clicked "Add connector" as soon as the server-rendered heading
showed, before the page hydrated, so the click had no handler and the form
never opened — every time on CI's slower runners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n specs

The stub records every OpenRouter request in the Playwright worker, and the
in-process job worker can run another spec's job meanwhile — CI caught an
evaluator call landing in "nothing reached the extractor or the embeddings".
The assertion now filters to calls carrying the turn under test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derekhearst
derekhearst merged commit 8b587e5 into main Sep 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Connect external MCP servers (consume, not just serve)

1 participant