<!-- Source: https://docs.squirro.com/en/latest/technical/agents/v1-external-mcp.html -->
# V1 Catalog MCP Servers (preview)

The V1 agent runtime consumes MCP servers as **catalog-authored
resources** rather than per-agent tool configurations. This page is
the operator reference for authoring those resources.

> **Note**
>
> This is the V1 surface. The legacy V0 _MCP Server Tools_
> configuration (see [MCP Server Tools](mcp-server-tools.md#mcp-server-tools)) is unaffected and
> continues to work for agents that don’t run on V1.

## Authoring a server

Each MCP server is a `MCP` resource in the project’s genai_config
workspace. Three authoring shapes via the `auth_mode` field:

**anonymous**

Server is open or trusts the cluster network. No credentials.

```yaml
kind: MCP
schema: v1
metadata:
  id: open-search
title: Open Search
url: https://mcp.example.com/sse
transport: sse
requires_approval: true
```

**static_bearer**

Single shared bearer token from the tenant’s secret store
(`EnvSecretResolver` reads `SQ_<HANDLE_UPPER>` by convention;
other resolvers are pluggable).

```yaml
kind: MCP
schema: v1
metadata:
  id: company-kb
title: Company KB
url: https://mcp.example.com/kb
transport: streamable_http
auth_mode: static_bearer
bearer_handle: company_kb_token
requires_approval: false
```

**per_user_oauth**

Each end user authorises the server out-of-band through OAuth
2.1 with PKCE. The platform stores per-user access tokens
Fernet-encrypted at rest, keyed on
`(tenant, project_id, user, server_id)` — since
`server_id` is project-local, the credential scope is too
(two projects authoring `id="linear"` against different
upstreams are independent; each prompts the user once). The
platform’s OAuth client is registered dynamically (RFC 7591) on
each user’s first authorize.

```yaml
kind: MCP
schema: v1
metadata:
  id: linear
title: Linear
summary: Linear issue tracker
url: https://mcp.linear.app/sse
transport: sse
auth_mode: per_user_oauth
requires_approval: true
tool_approval_overrides:
  search_issues: false  # opt this one tool out of approval
```

## Activating the surface

The capability toggle `capability.mcp.enabled` gates the entire
MCP surface. The default is `auto` — which resolves to `True`
when at least one `MCP` resource is authored, `False` otherwise.

Explicit `false` (compliance lock) hides every server even when
resources are present. Explicit `true` requires at least one
authored resource (publish-time validator).

## Approval policy

Each server declares a default `requires_approval` (default
`true` — fail-safe). Individual tools can opt out via
`tool_approval_overrides` keyed on the upstream tool name (as the
server’s `tools/list` advertises it, **not** the namespaced
`mcp_<id>__<tool>` form). Precedence at dispatch time:

1. Catalog `tool_approval_overrides[upstream]`
2. Server-advertised `_meta.requires_approval` on the tool (where
   the server uses Squirro’s MCP convention)
3. Server-level `requires_approval`
4. Hard default `True`

Approval gating runs as one HITL prompt per turn carrying every
pending tool call; the user approves / edits / rejects each one in
one resume action.

## User OAuth flow

For `per_user_oauth` servers:

1. The catalog endpoint (`GET /v1/projects/{pid}/catalog`)
   advertises the server with `requires_oauth: true`,
   `authenticated: false`, and `authorize_url:
   /v1/external_mcp/authorize?server_id=<id>`.
2. The FE redirects the user’s browser to `authorize_url`.
3. The platform discovers the OAuth metadata, runs PKCE, redirects
   to the IdP.
4. The IdP redirects back to `/v1/external_mcp/callback`; the
   platform exchanges the code for tokens and stores them
   Fernet-encrypted under `(tenant, project_id, user, server_id)`.
5. The next catalog poll shows the server as `authenticated:
   true`. The agent can now dispatch tools through it.

### Disconnecting an integration

The FE drives disconnect through
`DELETE /v1/external_mcp/secrets/{project_id}/{server_id}`. The
endpoint targets the calling user’s own row keyed
`(tenant, project_id, user, server_id)`; there is no admin
override that disconnects another user’s integration.
`project-read` is the floor — a read-only project member can still
disconnect their own integration.

The endpoint is **local-delete only**: it does not call the IdP’s
token-revoke endpoint. The grant on the upstream lingers until the
user revokes it from their IdP account, and tokens expire naturally.
This matches the per-project scope (revoking at the IdP would
invalidate any sibling project issued via the same client_id).
Returns 204 on success, 404 when no row exists for the target.

The redirect URI the IdP echoes **must** be pinned in production via
`SQ_GENAI_V1_EXTERNAL_MCP_REDIRECT_URI`. The value must exactly
match the URL registered with the IdP (most IdPs reject token
exchanges with mismatched redirect URIs). Without the env var, the
platform falls back to deriving the URI from the incoming request’s
`X-Forwarded-Host` / `X-Forwarded-Proto` headers — convenient
for local dev behind an honest reverse proxy, but a security risk
in any environment where those headers can be spoofed. The
platform logs a warning whenever the fallback path is used.

Token refresh is not a runtime concern. An expired token surfaces
as a structured `auth_required` tool error mid-turn; the user
re-authorises through the same flow. The platform does not
silently refresh.

## Going further

- Plugin authors writing tools exposed through `/studio-mcp` (the
  internal aggregator) consult the V1 internal developer guide
  `studio-mcp-developers-guide.md` under the genai service docs.
- Resource schemas + the picker payload shape are documented in
  the genai_config reference under the V1 `MCP` and
  `StudioMcpConfig` kinds.
