> ## Documentation Index
> Fetch the complete documentation index at: https://docs.2501.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Plugins

> Connect AI Agents to MCPs and plugins

This page sets up both kinds of [plugin](/0.18/core-concepts/plugins), MCP servers and HTTP APIs; [Shell, Central and plugins](/0.18/best-practices/mcp-plugins) explains when to use one over another.

### Who manages plugins

Go to **Command Center** → **Plugins**. The page has an **MCP** tab, shown first, and an **HTTP API** tab. The MCP tab lists each plugin's name, description, type, scope, state and creation time; the HTTP API tab lists each API's name, location, scope, state and creation time. Open a row or select its arrow to reach the plugin's page; the trash action deletes an unassigned plugin. The plugin page keeps scope, enabled state and **Save Changes** in its top bar. Its name, description, connection and authentication fields appear in one form.

#### What each role can do

Only administrators change plugins; everyone else reads them:

| | Organization administrator | Tenant-level administrator | Other users |
| - | - | - | - |
| Create, edit, enable, disable or delete the organization's plugins | Yes | Yes | No |
| The same for tenant-wide plugins | No, read-only | Yes | No |
| Add, change or delete a hosted MCP server | No | Yes | No |
| Turn off a hosted MCP server | Yes, in its organization | Yes | No |
| See the plugin list and each plugin's details | Yes | Yes | Yes |

On a hosted MCP server, an organization administrator gets a **Turn off** button, in a save that changes nothing else; once it is off, the details say that only a tenant-level administrator can change it or turn it back on, and no **Delete** is offered.

### Scope

The **Scope** selector controls which organizations can use the plugin. **Scoped to Organization**, the default, associates it with the currently selected organization only, restricting it to that org's agents. **Scoped to Tenant** makes it available to all organizations in the tenant. Selecting the tenant scope requires tenant-level access; without it the selector stays on **Scoped to Organization**. Changing an OAuth plugin's scope disconnects its OAuth accounts and forgets its trusted provider. While an enabled OAuth plugin is assigned to agents, the scope change is refused unless the same save turns the plugin off.

### Enabled state

Switching a plugin off stops every agent from using it from the next turn, and keeps the assignments.

### Assign plugins to an agent

Open **Agents**, select the agent, and add or remove its plugins, MCP servers and HTTP APIs alike. Anyone who can edit the agent can do this, without being an administrator; an auditor can read the assignments but not change them. Each assignment shows the plugin kind and authentication method. When you create an agent in Command Center, the enabled plugins that need no account (anonymous plugins, hosted MCP servers, and Bearer or API-key plugins holding their credential) are added by default. This default belongs to the form: an agent created through the public API or the AI Assistant gets only the plugins the request names, and one created by a CSV or JSON import gets none.

Through the API, [save one assignment](/0.18/api-reference/agents/save-a-plugin-assignment), [replace the agent's whole set](/0.18/api-reference/agents/replace-an-agents-plugin-assignments), or pass `plugin_ids` when creating the agent.

## Remote MCP servers

### Remote MCP server endpoints

#### Endpoint rules

| Rule | When it is broken |
| - | - |
| No username, password, fragment or literal metadata or link-local address in the URL | The URL is refused |
| Cleartext HTTP goes only to addresses inside the engine's own network, whatever their port or host name; everything else takes HTTPS | A check and the task's connection history show `cleartext_not_allowed` |
| No metadata or link-local address | They show `destination_not_allowed` |
| No redirects: the engine never follows one | They show `redirect_not_followed` |

The rules apply to the address a name resolves to, so a public address is refused even when the URL looks internal. No setting turns off cleartext HTTP to private addresses, so a new internal MCP server needs no deployment change. Storing an HTTP URL grants no permission by itself, and assigning it to an agent does not activate it. If the rules change, saved settings stay readable and deletable, and you can still edit the description or keep the plugin disabled; creating, replacing or enabling a connection must meet the current rules.

#### Through a proxy

An engine that sends its outbound traffic through a proxy (`HTTP_PROXY`, `HTTPS_PROXY`) sends MCP endpoints and OAuth providers through it too, except the hosts `NO_PROXY` names, so list internal MCP services there. The engine still resolves each destination and applies the rules above first, so it must be able to resolve those names itself. A hosted MCP server reaches no proxy unless its variables name one, such as `HTTPS_PROXY`.

### MCP server detection

What the **MCP URL** field accepts when you [add a remote MCP server](#add-a-remote-mcp-server), and how 2501 sets up the plugin from it.

#### What the MCP URL field accepts

| You paste | 2501 fills in |
| - | - |
| An MCP URL | The URL |
| An `mcpServers` entry (Claude, Cursor, Windsurf) or a `servers` entry (VS Code), comments included | Its URL, name, transport and authentication; only the URL stays in the field |
| A command line such as `claude mcp add` | The same |
| An `mcp-remote` bridge | The URL it points to |
| A `docker run` command, in a configuration or on its own | A hosted MCP server: its image, arguments and `-e` variables |
| An `npx` or `uvx` configuration | Nothing is run: [build an image for it](#build-an-image-for-an-npm-or-pypi-server) |

Only the URL is sent: headers, tokens and other values in a pasted configuration are neither sent nor saved. In a `docker run` command, a variable whose name holds a secret word, such as `GITHUB_TOKEN`, `API_KEY`, `PGPASSWORD`, `SSH_PASSPHRASE` or `accessToken`, becomes a credential to choose, and its pasted value is not kept; a name where that word is only part of another, such as `BASE_PATH` or `MAX_TOKENS`, stays a plain value. Options 2501 does not grant a hosted MCP server, such as host folders (`-v`), published ports (`-p`) or network settings, are left out and named.

#### How detection picks the authentication

The engine connects to the URL the way a task would, over Streamable HTTP or, for an older server, HTTP+SSE, and lists its tools without calling any.

| What it finds | Set up as |
| - | - |
| The server answers without authentication | Anonymous |
| A pasted configuration sends an `Authorization: Bearer` header | Bearer token |
| A pasted configuration sends any other header | API key, in that header |
| The server refuses anonymous access and names its OAuth provider | OAuth: 2501 registers itself when the provider allows it; otherwise the form asks for the client ID of a client registered there |
| The server refuses anonymous access without naming a provider | Bearer token |

The form then shows how many tools it verified, or why it could not connect, such as `Not an MCP endpoint (HTTP 404)`, a refused connection, a host name that does not resolve, an untrusted TLS certificate, a failed TLS handshake or a timeout; **Detect again** repeats the check. **Add MCP** stays unavailable while the engine refuses the address, such as cleartext HTTP to a public address, but a server it could not reach can still be added. The name is the one a pasted configuration gives, else the one the server announces, else one taken from the URL's host name without words such as `mcp` and `server` (none for an IP address or `localhost`); change it if it does not suit.

### Add a remote MCP server

A plugin is either a **remote MCP server**, which 2501 connects to by its URL, or a **hosted MCP server**, which the engine runs itself from a container image.

1. Open **Plugins** and select **Add MCP**.
2. Paste the server's **MCP URL**, its README configuration or its command line. The form fills in what it finds and asks only for the rest. See [What the MCP URL field accepts](#what-the-mcp-url-field-accepts).
3. Check the authentication 2501 detected, as [How detection picks the authentication](#how-detection-picks-the-authentication) describes. For a server that answers without authentication, leave **Anonymous / Agent managed** selected, or select **Change** to pick another. An API key also needs a header name such as `X-Api-Key`.
4. For **Bearer token** or **API key**, choose the Secret or Vault path credential the plugin sends: every agent assigned the plugin uses it.
5. Enter the name and description, choose the scope and enabled state in the top bar, and save.
6. Assign the plugin to an agent and check the connection from there: saving deploys and connects to nothing.

The plugin starts enabled and reaches no agent until it is assigned. A hosted MCP server starts when a task or a check first uses it. Without detection, connections created here use Streamable HTTP; through the API a connection can select either transport, and editing it here keeps that transport.

### Connect an MCP endpoint through the API

Create a plugin through the public API with a custom name, an optional description, and an anonymous connection:

```json theme={null}
{
  "name": "Customer VMware MCP",
  "description": "Read-only inventory endpoint",
  "org_id": "<organization ID>",
  "connection": {
    "transport": "streamable-http",
    "url": "https://mcp.example.com/mcp",
    "authentication": { "method": "anonymous" }
  }
}
```

A plugin created without `enabled` starts enabled. Set `enabled: false` when creating or updating a plugin to keep its assigned agents from using it, and `enabled: true` to let them again, from the next turn of a task already running. The Plugins page saves a new plugin enabled, and asks for the credential of a Bearer or API-key plugin before saving it. Saving a URL never connects to it, resolves a credential or deploys a server. These settings name the credential, never its value. API-key header names must be valid HTTP names and cannot replace transport or session headers such as `Host`, `Content-Type`, `Mcp-Session-Id` or `Mcp-Protocol-Version`. Custom headers such as `X-API-Key` and raw `Authorization` schemes are accepted. An assignment can be checked before enablement.

Updates can change the name or description, or replace the entire connection object. Omitting a field preserves it. A plugin without an endpoint can be given one in an update that also leaves it disabled; see [Give a legacy plugin an endpoint](#give-a-legacy-plugin-an-endpoint). No other change of connection type is accepted. Each authentication method takes different fields, as [Authentication methods](#authentication-methods) lists.

## Hosted MCP servers

### Hosted MCP server lifecycle

The engine runs one container per hosted MCP server and organization next to itself, shared by that organization's tasks; a hosted MCP server in use has no time limit. On Kubernetes its variables reach its pod through a Secret the pod owns and that is deleted with it, so the engine's role needs to create secrets.

#### What stops or replaces the container

| Event | The container |
| - | - |
| Thirty minutes without use, or the engine stops | Stops |
| The plugin is deleted or turned off | Stops within a minute in every organization, even during a call, which then fails |
| The plugin is switched to a remote MCP server or moved to another organization | Stops within a minute, even during a call |
| The image, arguments, variable names or credentials change | A new one starts; the old one stops within a minute, even during a call |
| A secret is rotated or a plain value changes | A new one starts; the old one stops once the new one runs and no call still uses it |

#### When a start fails

| Failure | What happens |
| - | - |
| The container fails to start | It is tried again after 5 seconds, then after longer waits up to 5 minutes |
| No MCP handshake within 60 seconds | It is started once more in a fresh container, then fails. Set `MCP_HOSTED_HANDSHAKE_TIMEOUT_MS` on the engine for an image that needs longer |

Meanwhile a connection check shows when the next try is and why the last one failed, including the server's last error output when it exited during startup. A task is told only that the plugin could not be reached, and the engine logs the full error (`MCP plugin is unavailable`). Credential variable values are replaced with `[REDACTED]` in that output and in the logs.

#### Image pull logins

The login an image is pulled with depends on where it runs:

| Where | Pulled with |
| - | - |
| Kubernetes, with `MCP_HOSTED_IMAGE_PULL_SECRETS` set on the engine | The pull secrets it lists, comma-separated |
| Kubernetes, with `MCP_HOSTED_IMAGE_PULL_SECRETS` unset | The executors' `EXECUTOR_MCP_IMAGE_PULL_SECRET`: on a CLI installation, the short-lived login to 2501's own registry, where your images are not |
| Kubernetes, with `MCP_HOSTED_IMAGE_PULL_SECRETS` set to an empty value | The pull secrets of the namespace's `default` service account, such as OpenShift's login to its internal registry |
| Docker | The login `docker login` stored for that registry in `config.json`, in the directory `DOCKER_CONFIG` names or `~/.docker`, inside the engine's container. Credential helpers are not read. |
| The Docker Swarm stack the CLI renders | That file, named in the install configuration as `swarm.docker_config`; see [Hosted MCP servers on Docker Swarm](/0.18/deployment/docker-swarm#hosted-mcp-servers) |

### Hosted MCP server limits

Only a tenant-level administrator adds or changes a hosted MCP server, and 2501 runs its image exactly as named, so trusting a tenant administrator with hosted MCP servers means trusting any image they name. The container gets no Linux capabilities and cannot gain privileges. It never runs as root, whatever user its image names: it runs as user 65532, or on OpenShift as the user the cluster assigns. The image must therefore work as any non-root user: its files readable by every user (readable by group 0, as OpenShift expects), and nothing written outside `/tmp`, which is its `HOME` unless it sets one. Serve an image that cannot through [an HTTP wrapper](#give-a-legacy-plugin-an-endpoint) instead.

#### Resource limits

| | Kubernetes | Docker |
| - | - | - |
| CPU | At most half a CPU; the pod requests a quarter | At most half a CPU |
| Memory | At most 512 MiB | At most 512 MiB |
| Writable storage | 64 MiB of ephemeral storage requested, 256 MiB at most, past which Kubernetes evicts the pod | `/tmp` at most 64 MiB, taken from its memory; the rest of its file system stays writable and is not bounded |
| Processes | - | At most 256 |

#### Network

On Kubernetes its pods carry the label `app.kubernetes.io/managed-by: 2501-mcp-hosted`, and the `mcp-hosted-isolation` network policy the CLI generates with the other manifests holds those pods, and only those, to DNS and public addresses, with no traffic in. To let them reach an internal service, such as a vCenter, apply a policy of your own that selects the same label and allows its range; `allow-mcp-hosted-internal` in `kubernetes-overlays/networkpolicy.example.yml` is one to adapt. The pods get no Service environment variables. On Amazon EKS the policy applies a few seconds after a pod starts unless the VPC CNI runs with `NETWORK_POLICY_ENFORCING_MODE=strict`, which denies every new pod all traffic until its policies apply. On Docker, the [firewall rules below](#isolate-hosted-mcp-servers-on-docker) do the same, and without them it reaches other containers' published ports and the machine's network.

### Add a hosted MCP server

A **hosted MCP server** runs from a container image: paste its `docker run` command in the **MCP URL** field, and the hosted MCP server's fields appear below it, to check or complete: the image, such as `registry.example.com/team/mcp-server:1.2`, its arguments one per line, and its environment variables. A variable holds either a plain value, which everyone who can read plugins sees, or a Secret or Vault path credential, which 2501 reads when the hosted MCP server starts and never shows. An existing plugin switches between a remote MCP server and a hosted MCP server only in a save that also disables it. Its first start may pull the image, so check the connection from an agent once after saving. If a proxy in front of Command Center gives up on the check first, the agent's form says the server is still starting: check again a minute later. Only tenant-level administrators can add, change or delete a hosted MCP server; see [Who manages plugins](#who-manages-plugins).

### Run hosted MCP servers without internet access

Put the hosted MCP server's image in a registry your cluster, or the engine's machine on Docker, can pull from, and enter it by that reference. The engine pulls it with the login [Image pull logins](#image-pull-logins) lists.

On Docker, mount the file into the engine's container and point `DOCKER_CONFIG` at its directory, or pull or load the image on the engine's machine first (`docker pull`, or `docker load` from a saved image): the engine uses an image the machine already has.

#### Build an image for an npm or PyPI server

A server published only on npm or PyPI needs an image built for it. Build it in your own pipeline, through your package mirror, and pin the version so that an installation does not change on its own:

```dockerfile theme={null}
FROM node:24-slim
RUN npm install -g @upstash/context7-mcp@4.1.1
ENTRYPOINT ["context7-mcp"]
```

For a Python server, start from `python:3.12-slim`, `pip install` the pinned package and set its command as the `ENTRYPOINT`. A server that only reaches an online service, such as Context7, still needs that service: without internet access, choose servers that talk to your own systems.

### Isolate hosted MCP servers on Docker

On Docker, including Docker Swarm, these firewall rules are a required step on every machine where the engine runs hosted MCP servers. Without them a hosted MCP server's network keeps it away only from containers that publish no port. It still reaches every container that publishes a port, on that container's own address and published port, even a port published on `127.0.0.1` only, such as a database, and whatever the machine itself reaches: private ranges, the cloud metadata address and the machine's own services. To hold hosted MCP servers to public addresses, drop their traffic to private ranges, the metadata address and the machine itself, as root on the engine's machine, once the engine has started a first hosted MCP server and created the network:

```bash theme={null}
SUBNET=$(docker network inspect 2501-mcp-hosted -f '{{(index .IPAM.Config 0).Subnet}}')
for RANGE in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 169.254.0.0/16; do
  iptables -I DOCKER-USER -s "$SUBNET" -d "$RANGE" -j DROP
done
iptables -I INPUT -s "$SUBNET" -j DROP
```

`DOCKER-USER` covers traffic to other machines and to other containers, published ports included, since Docker gives containers addresses in private ranges, and `INPUT` covers the machine's own services. If the Docker daemon gives networks addresses outside these ranges (`default-address-pools`), add those ranges to the loop. Name resolution keeps working, since Docker resolves names for the container from the machine itself. To let hosted MCP servers reach an internal service, insert (`-I`) an `ACCEPT` rule for its address after running these, so it comes first. The rules last until the machine restarts, and apply to the network as it is: save them with your firewall tooling, and run them again if the network is removed and created again. On Kubernetes, the network policy does this instead.

## MCP credentials and OAuth

### Plugin credentials

One plugin has one connection method and, for Bearer or API key, one credential, which every agent assigned the plugin sends. It must be a Secret or Vault path in the plugin's organization or shared across the tenant; a tenant-wide plugin takes only a tenant-wide credential. No token value appears in the picker or in any response. Changing the credential or the URL of an enabled plugin needs no switch off: a running task's next call is refused as changed, and its next turn connects with the new settings. A credential a plugin sends cannot be deleted, even while the plugin is disabled.

#### Authentication methods

Each `authentication.method` takes different fields, and not all can start enabled:

| Method | Also send | Can start enabled |
| - | - | - |
| Anonymous | Nothing | Yes |
| Bearer token | `credential_id` | Only with `credential_id`. Without it, send `enabled: false` to save a draft; otherwise the request fails with `400` |
| API key | `header_name` and `credential_id` | As for a Bearer token |
| OAuth | `client`, see [Authorize an OAuth account](#authorize-an-oauth-account) | Yes, but it takes an assignment only in organizations that connected their account |

#### Which plugins take placeholders

| Plugin | Placeholders |
| - | - |
| Anonymous remote MCP server | Yes |
| Hosted MCP server | Yes |
| Bearer, API-key or OAuth MCP server | Refused: it already authenticates with its own credential |
| HTTP API | Refused: give it one credential, and create a plugin per credential |

A placeholder the agent has no access to fails the call, and nothing is sent. The value is hidden in the tool result the agent reads, the task history and the ticket, and the recorded call keeps the placeholder. The MCP server writes the tool descriptions the agent reads, so it can ask for any credential the agent has: give that agent only the credentials the server may see.

### OAuth accounts

How an OAuth plugin identifies itself, trusts its provider and keeps its accounts signed in. [Authorize an OAuth account](#authorize-an-oauth-account) connects one.

#### How 2501 identifies itself to the provider

| Choose | When | In the form | In the API (`client`) |
| - | - | - | - |
| Automatic | You don't create a client on the provider | **Automatic registration** | `registration: "automatic"` |
| Dynamic | As automatic, but the provider cannot reach Command Center, for example behind an inbound firewall | **Dynamic registration** | `registration: "dynamic"` |
| Public client | You created a client without a secret | **Pre-registered client**, its client ID | `registration: "pre_registered"`, `client_id` |
| Client secret | Your client has a secret | The above, and a **Client secret** credential | The above, and `client_secret_credential_id` |
| Private key | Your client signs with a key, as Okta's org authorization server requires for a service account | The above, and **Client authentication**: **Private key (JWT)** | The above, and `private_key: { "credential_id": "cred_…", "key_id": "…", "algorithm": "RS256" }` |
| Service account | Agents work without anyone signing in | A client secret or private key, and **Sign in as a service account** | The above, and `grant: "client_credentials"` |

* **Automatic:** when the provider reads Client ID Metadata Documents and BASE\_URL is an HTTPS address that resolves to public addresses, the client ID is the document Command Center publishes at `/api/mcp-oauth/client`, which the provider fetches. Otherwise 2501 registers dynamically.
* **Dynamic:** 2501 registers a client with the exact callback, once per provider: a public client, or a confidential one when the provider takes no public client, whose secret 2501 keeps. It is reused until the provider's expiry for it, and registered again once the provider no longer accepts it. A provider that offers neither automatic nor dynamic registration, or refuses it, needs a pre-registered client, and connecting says so.
* **Client secret:** sent as `client_secret_basic` or `client_secret_post`, as the provider advertises.
* **Private key:** a PKCS #8 PEM key, RS256 by default or ES256, with the key ID (`kid`) if the provider asks for one. Each token request carries a JWT signed with it, valid for a minute.
* **Service account:** **Connect service account** gets a token with the client credentials grant, without opening the provider, and the provider must allow that grant for the client.

The client secret or private key credential is chosen in the plugin form, never pasted, and agents need no grant for it. It must be readable by every organization the plugin serves, so a tenant-wide plugin needs a tenant-wide credential. A new value keeps the connected accounts and is used from the next token request; making it tenant-wide keeps them too; naming a different credential means connecting again. Moving it into one organization is refused while a plugin outside that organization uses it. A key that cannot sign is refused before the provider is opened, and a provider whose listed methods leave out `private_key_jwt` is refused as unsupported. A sign-in with the metadata document that expires without returning suggests choosing dynamic registration. Registration names no scopes, so the provider gives its defaults and the plugin's scopes are asked for at sign-in; on Keycloak each must be an Optional client scope, and a provider that limits a client to its registered scopes needs a pre-registered client. 2501 leaves out the `resource` parameter for Microsoft Entra ID, which refuses it.

#### Redirect URL

Register the redirect URL the plugin form shows under **Redirect URL to register with your provider**, with its copy button:

| Client | Redirect URL |
| - | - |
| Every client, by default | `https://your-command-center-host/api/internal/mcp-oauth/callback/<key>`, one per authorization server, keyed by its issuer. The form shows it once detection has found the provider, or after a first connect, and plugins on the same provider share it |
| A pre-registered client saved before per-provider addresses existed, or created through the API without `"callback": "per_provider"` on its `client` | `https://your-command-center-host/api/internal/mcp-oauth/callback`, shared by every provider |

The host is the deployment's `BASE_URL`, which the engine and Command Center must share: the address users open Command Center with, HTTPS where you can. A plain-HTTP address, on an installation reachable only on its own network, is accepted, but some providers refuse to send the browser back to one; the code sign-in or pasting the return address then still complete the sign-in, and the plugin editor says when Command Center is served over plain HTTP. Only a missing `BASE_URL` stops the sign-in. The Client ID Metadata Document still needs an HTTPS address that resolves publicly.

#### Trusted provider

A plugin trusts one authorization server, and sends client secrets, codes and tokens to that server only. The editor shows it, for example "Trusts login.example.com".

| Event | Trust |
| - | - |
| Saving a plugin whose form detected the provider | Trusts it (`trusted_issuer` on create or update, ignored when the plugin already trusts one) |
| The first connect, otherwise | Trusts the server the MCP server names |
| The MCP server later names another server | Connecting is refused, with a message saying so |
| Someone who can edit the plugin chooses **Forget trusted provider** (`DELETE /api/v1/plugins/{id}/oauth/trusted-issuer`) | Forgotten. The connected accounts stay, their renewals fail until a server is trusted again, and that person's next connect trusts the server named then |
| The plugin's connection or scope changes | Forgotten, with the connected accounts, and any sign-in in progress ends: start it again |

The provider's addresses must be public HTTPS addresses. An installation whose identity provider sits on its own private network sets `MCP_OAUTH_ALLOW_PRIVATE_PROVIDERS=true` on the engine; without it, detection and connecting both say the provider is on a private address and name that setting.

#### Provider requirements

The provider must meet these, or sign-in or renewal fails:

| The provider must | Note |
| - | - |
| Advertise PKCE S256 for the sign-in page | Microsoft Entra ID takes it without saying so |
| Offer a client authentication method 2501 supports | See the table of client choices above |
| Publish resource metadata naming its issuer and resource | An MCP server without it signs in at its own origin: detection offers OAuth there, and saving the plugin trusts that origin. On a private address it is offered a token instead, unless the installation allows private providers |
| Name itself (`iss`) on the return to Command Center, when it says it does | At the shared callback address, a metadata-document client and a pre-registered client without a client secret need a provider that returns `iss` (RFC 9207), as Keycloak does; otherwise use a client secret or device sign-in |
| Issue Bearer tokens that cover the requested scopes | |
| Keep an access token to at most 8192 visible ASCII characters, without quotes, angle brackets or backslash | |
| Send `expires_in`, optionally | Without it the token has no expiry (`expires_at` is null) and serves until an MCP server refuses it |

A return arriving at a provider's own redirect URL was sent to that server's address, so that is all; 2501 registers its clients there, and so do pre-registered clients set up in the plugin form. The address does not prove which client the return is for: at a provider with open client registration, anyone can register a client with that address and send a code there, and only the sign-in's state and PKCE verifier keep that code from completing it. Access and refresh tokens remain private to the plugin authorization and are excluded from API serialization and agent CSV/JSON imports.

#### Token renewal

```mermaid theme={null}
flowchart LR
  A(["Connected"]) -->|1| B["Renewing"]
  B -->|2| A
  B -->|3| C["Last renewal failed"]
  C -->|4| B
  A -->|5| D["Connected, renews on next use"]
  D -->|6| B
  A -->|7| E["Reconnect needed"]
  E -->|8| A
```

1. A connection check, a task turn or a call finds less than a minute or a tenth of the token's lifetime left, whichever is longer; before a turn the token must also outlast the turn, seven minutes at most. Or the MCP server answers `401`: it processed nothing, so the token is renewed however long it has left, the call is sent again once, and a refused connection reconnects at the next turn.
2. A new token. Concurrent checks share one exchange and keep the same account, across engine processes too, and an open task session takes the token without reconnecting.
3. The provider refuses or cannot be reached. The account is kept, and once its token has expired a check returns `authorization_required`.
4. The next check or task, a minute later.
5. The token expires while nobody uses the plugin, and the account holds a refresh token or is a service account: nothing renews in the background.
6. The next check, task or call.
7. The token expires and the account holds no refresh token.
8. **Reconnect account**.

Automatic registration asks for refresh support; for a pre-registered client, enable the provider's refresh-token grant if you want automatic renewal. `authorization.renewable` in `GET /api/v1/plugins/{id}/oauth` says whether an account renews. A token without an expiry, or one the provider revoked early, stops only when the MCP server refuses it.

#### Renewal failures

The account panel says the last renewal failed and why. The account is kept meanwhile, since a provider that refuses and one that cannot be reached look the same, and a service account whose client secret or private key was fixed recovers by itself at the next renewal.

| Reason shown | What to do |
| - | - |
| Refused by the provider | Reconnect the account. For a service account, check the client ID, its secret or private key and its scopes first |
| The provider could not be reached | Nothing: the next task tries again |
| A token 2501 does not accept | Reconnect, then check the provider if it happens again |
| Another authorization server named | If the move is expected, forget the trusted provider, then connect again |
| No authorization server trusted yet | Connect the plugin, or detect the server again and save the plugin |
| No refresh token | Reconnect; if it happens again, add the scope the provider uses for offline access, often `offline_access` |
| The client secret or private key credential could not be read | Check the credential chosen in the plugin connection |
| A client the provider no longer accepts | Start again: 2501 registers a new client |
| A provider on a private address | If it is meant to be on your network, set `MCP_OAUTH_ALLOW_PRIVATE_PROVIDERS=true` on the engine |
| A saved connection that is not usable | Check it, then reconnect |
| The provider does not support what the connection requires | Check the OAuth client settings on the plugin |
| A private key that cannot sign | Choose the right key or algorithm: RS256 takes an RSA key, ES256 a P-256 EC key |
| Another error | The engine log has the detail |

A missing scope the plugin's own scope list leaves out is shown with a prompt to add it there, since reconnecting never asks beyond that list.

#### Missing scopes

If a server refuses a call because the connected account lacks a scope, the call fails with a message naming it and the account shows **Reconnect needed with scopes** followed by the scopes the server asks for. Reconnecting the account, a service account included, asks the provider for them as well; nothing asks for them on the server's word alone. A plugin that lists its own scopes never asks beyond them, and when the provider or the server lists the scopes it knows, only those are asked for. A scope the provider refuses is no longer asked for: connect again.

#### Advanced settings

Two optional fields of the OAuth connection, under **Advanced** in the plugin form, cover providers that need more:

* `audience`: the API the tokens are for, sent as the `audience` parameter on the sign-in page and on every token and device request, service accounts included. Auth0 needs it to issue a token for an API rather than refuse the service account ("No audience parameter was provided").
* `metadata_url`: the HTTPS address of the authorization server's metadata document, for a provider whose metadata does not live at its issuer. Azure AD B2C publishes it under a user flow, for example `https://<tenant>.b2clogin.com/<tenant>.onmicrosoft.com/<user flow>/v2.0/.well-known/openid-configuration`, and names a tenant ID issuer. Sign-in and renewal then read that document instead of the server the MCP server names, and the plugin trusts the issuer the document names. The issuer and the endpoints the document lists must be on the document's own origin.

### Choose the credential a plugin sends

Choose it in the plugin's form, when adding the plugin or editing it, or set `credential_id` in its connection through the API; see [Plugin credentials](#plugin-credentials) for which credentials it takes. To reach one service two ways, or with two accounts, create two plugins and assign each to the agents that need it.

### Give each agent its own token

For an MCP server where each agent signs in with its own token:

1. Configure the plugin as anonymous.
2. Give the agent the token as a credential in its **accessible credentials**.
3. The agent passes it in the tool arguments as `{{secret:<name>}}`, and 2501 puts in the value just before the call leaves, under the same rules as a placeholder in a command.

The MCP server must therefore accept the token as a tool argument. [Which plugins take placeholders](#which-plugins-take-placeholders) lists where this works.

### Authorize an OAuth account

On the Plugins page, select **OAuth**, choose [how 2501 identifies itself to the provider](#how-2501-identifies-itself-to-the-provider), and leave **Use the provider's requested scopes** selected or enter explicit scopes. Save the plugin, then connect an account as described below. Run **Check connection** from an assigned agent, then enable the plugin once every organization with a live assignment has connected its account; an enabled plugin only accepts new assignments in organizations that did. **Reconnect account** replaces the previous authorization and can select another account. The saved plugin page keeps these OAuth settings visible beside the connected account.

#### Connect an account

Connect an account once per organization: every agent assigned there shares it, and a tenant-wide plugin connects in each organization that uses it.

1. In the plugin editor, select **Connect account**. Only someone who can edit the plugin can.
2. Sign in at the provider, in the way Command Center offers:
   * **Sign-in page.** Select **Open the sign-in page**, or **Copy the link** to open it elsewhere, then choose the account and consent. The provider sends your browser back to Command Center.
   * **A code.** When the provider supports device sign-in, the editor shows a code and the provider's address. Open the address on any device that can reach the provider, enter the code and sign in. A refused or expired code offers **Start again**.
   * **Paste the return address.** If the page the provider sends you back to does not load, copy the whole address from the address bar into **Paste the address your browser ended on**, and select **Complete**.
3. Command Center says the account is connected.

* A sign-in expires after ten minutes, or with the provider's device code. Only the user who started it can complete it, once, and starting again replaces only your own attempt.
* The connected account keeps working until a new one is authorized, so closing or refusing the consent screen disconnects nothing. No token is returned.
* The sign-in page asks the provider to let you choose the account (`prompt=select_account`, or `prompt=login` when the provider lists its prompts without that one), so a session already open there does not connect its account unchosen.
* A provider that does not let the client use device sign-in gets the sign-in page instead. A device check the provider fails to answer is asked again later; anything else ends the code and says why.
* If your Command Center session ended before you came back, sign in again and start the connection again.
* The account shows as "Connected as ...", also on a successful check: the `preferred_username`, email or subject from the ID token when the scopes include `openid`, else from the provider's user info endpoint, or the client ID for a service account.

#### Connect an account through the API

With a Command Center session that can edit the plugin; other sessions get `403`, and API keys cannot:

| Step | Call | Answer |
| - | - | - |
| Start | `POST /api/internal/plugins/{pluginId}/oauth/start`, no body, or `{ "org_id": "org_…" }` for a tenant-wide plugin | `flow: "redirect"` with `authorization_url` and `client_registration`, or `flow: "device"` with `user_code`, `verification_uri`, sometimes `verification_uri_complete`, and `interval` |
| Poll a code | `POST /api/internal/plugins/{pluginId}/oauth/device/poll`, same body, every `interval` seconds | `pending` with the interval to wait, `connected`, `denied` or `expired` |
| Paste the return address | `POST /api/internal/mcp-oauth/complete` with `{ "url": "…" }` | The connection, checked exactly as an automatic return |
| Read the account | `GET /api/v1/plugins/{pluginId}/oauth` | The organization's account, with `account_name` |

### Connect Microsoft Entra ID

Entra ID does not register clients on its own, so create the client there first:

1. In the Entra admin center, open **App registrations** and select **New registration**. Under **Redirect URI**, choose **Web** and enter the redirect URL the plugin form shows for your tenant's issuer, `https://your-command-center-host/api/internal/mcp-oauth/callback/<key>`: start the plugin with the MCP server's URL and copy it from there.
2. Under **Certificates & secrets**, create a client secret. Store its value in 2501 as a Secret credential and name it as the plugin's client secret.
3. Under **API permissions**, add the delegated permissions the MCP server expects, usually a scope of its own app registration such as `api://<application ID>/mcp.read`, and grant admin consent if your tenant requires it.
4. To let people sign in with a code instead, under **Authentication**, turn on **Allow public client flows**. Connecting then shows a code first.

In the plugin, choose a pre-registered client with the app's **Application (client) ID**, and enter the scopes: the MCP server's scope, `offline_access` so 2501 receives a refresh token, and `openid profile` to show who signed in. Entra issues a token for one API at a time, so do not mix the MCP server's scope with Microsoft Graph scopes. The trusted provider is your tenant's issuer, `https://login.microsoftonline.com/<tenant ID>/v2.0`. An MCP server that names a multi-tenant endpoint (`common` or `organizations`) is trusted as that endpoint, for example `https://login.microsoftonline.com/common/v2.0`: people from any tenant can then sign in, and a service account is not available, since it needs a tenant.

For a service account, add an **Application permission** of the MCP server's app registration instead, grant admin consent, select **Sign in as a service account**, and enter the single scope `api://<application ID>/.default`.

## MCP servers in a task

### MCP servers during a task

A failed call may have reached the server, so its outcome is unknown and the task never sends it again. [Handling Permissions](/0.18/risk/permissions#plugin-access-during-a-task) covers what this means for revoking access.

Task details show each plugin the task could not use, with its reason, and each credential or OAuth account change; anyone who can read the task can read this history, and it does not enter the agent's dialogue.

#### Tool limits

| Limit | Value | Past it |
| - | - | - |
| Plugin tools per task, counted in the order plugins were assigned | 200 | The plugin is left out for the whole task, with every plugin assigned after it, and the task's history lists each. Assign large plugins last. |
| A tool's description | 2,000 characters | Cut, ending with `[truncated]` |
| A tool's input schema | 16 KiB | The tool is left out |
| A plugin's tool definitions | 256 KiB | The first tool past it is left out, with every tool after it |

#### When a change reaches a running task

| Change to a plugin | Reaches a running task |
| - | - |
| Turned off, removed from the agent, or its OAuth account changed | At once: the next call is stopped |
| Secret rotated, credential grant withdrawn, endpoint lost | At the next turn |
| Assigned while the task runs | At the next turn, if its tools fit |

### Connection check results

What [checking an assigned connection](#check-an-assigned-connection) returns.

The engine initializes the saved endpoint, lists tools and closes its test client. For Bearer and API-key authentication, it resolves only the plugin's Secret or Vault path after checking that it is still in the agent's organization or tenant-wide; a draft without one fails the check. Invalid secrets and unresolved Vault values block the check without falling back to anonymous access or another credential. It calls no tool, deploys nothing and leaves the shared service running. You can check a disabled assignment without enabling it, except a hosted MCP server: a check starts the hosted MCP server, so only a tenant-level administrator can check one while it is turned off, and anyone else gets `403`. The public agent plugin list contains enabled plugins by default; `GET /api/v1/agents/{agentId}/plugins?include_disabled=true` also returns disabled assignments.

The result is an instant snapshot. Success requires that the assignment, enablement, connection and credential access still match when the client closes. Bearer and API-key checks resolve the plugin's credential again before returning success, so a changed Vault value also invalidates the result. The check does not prove access to individual operations or guarantee that a later task can connect. Checking is optional and does not change activation or runtime authorization.

The result displays the check time, tool count, negotiated transport and protocol version, or a failure reason. It is local to the open form, clears when assignments change, and is not continuous monitoring. To use the connection in a task, separately enable the plugin. Bearer and API-key connections need the plugin's credential, described above. A successful check is optional and does not grant runtime access.

#### Check results

| Result | Status | Body |
| - | - | - |
| Connected | `200` | `success: true`, `agent_id`, `plugin_id`, `checked_at`, `tool_count`, and the negotiated `transport` (`streamable-http` or `http-sse`) and `protocol_version` when the engine reports them |
| Not connected | `200` | `success: false` and a `failures` list of the categories below, with a `detail` of at most 500 characters when the engine can say more |
| Unknown or inaccessible assignment | `404` | |
| Engine unreachable | An API error | |

For a remote MCP server it names the HTTP status, a URL that does not look like an MCP endpoint (it answered HTTP 404 or 405, with a web page or other content that is not MCP messages, named by its media type, or with JSON that is not an MCP message, such as a REST API's root), a host name that does not resolve, a TLS certificate the engine does not trust, a failed TLS handshake, a refused connection (with the address and port tried) or a closed one, or a timeout, and never repeats what the server answered. For a hosted MCP server it is the start error, its credential values redacted, such as an image that cannot be pulled, a namespace quota used up, a role that cannot create pods or secrets, a handshake that timed out or all 32 hosted MCP servers in use. The agent's plugin list shows it after the failure.

#### Failure categories

| Category | Meaning |
| - | - |
| `invalid_configuration` | The saved connection, or the credential the plugin sends, is missing or invalid |
| `connection_or_discovery_failed` | The engine could not connect or list tools |
| `cleartext_not_allowed`, `destination_not_allowed`, `redirect_not_followed` | The engine refused the endpoint; see [Remote MCP server endpoints](#remote-mcp-server-endpoints) |
| `access_check_failed` | The engine could not verify that the agent may use the plugin |
| `credential_unavailable` | The bound Secret or Vault path could not be read |
| `authorization_required` | The organization has no connected OAuth account |
| `authentication_failed` | The MCP server answered HTTP 401 or 403 to the plugin's credentials |
| `image_unavailable` | The engine could not get a hosted MCP server's image |
| `migration_required` | The plugin has no endpoint; see [Give a legacy plugin an endpoint](#give-a-legacy-plugin-an-endpoint) |
| `assignment_changed` | The assignment or connection changed during the check; check again |
| `cleanup_failed` | The test client could not close cleanly |
| `hosted_server_starting` | The check waited 45 seconds for a hosted MCP server that is still starting; the start goes on, so check again in a minute |
| `hosted_server_start_failed` | The same, for a hosted MCP server whose last start failed; `detail` gives that failure, such as a handshake that timed out, so fix the image, arguments or variables before checking again |

### Check an assigned connection

Open an agent's detail page and select the plugin, including a disabled plugin. Save the agent, then select the flash icon beside the saved assignment; its tooltip reads **Check connection**. HTTP API assignments have no check here; check them on the plugin's form instead. The result appears below the assigned plugins. To remove the assignment, select its X and save the agent. The form blocks saving if it cannot load assignments; use **Retry** before continuing.

The equivalent API call is `POST /api/v1/agents/{agentId}/plugins/{pluginId}/check` with no body. [Connection check results](#connection-check-results) lists what it returns. The caller needs permission to write the agent and read the plugin.

## HTTP APIs

An HTTP API plugin connects agents to APIs no MCP server covers, such as your internal APIs or APIs that LLMs have no prior knowledge of.

### HTTP API requests

An agent assigned an HTTP API plugin gets three tools: one to send a request (method, relative path, query, body and content type), one to list the uploaded documentation files, and one to read a file in `cat -n`-numbered pages. The request tool answers with the status, headers and body as JSON. A task in [investigate mode](/0.18/risk/read-only-agents) is held to `GET`, whatever method the request names. Any other task can send `GET`, `HEAD`, `OPTIONS`, `POST`, `PUT`, `PATCH` or `DELETE`.

A request body holds at most 64 KiB. A request gets 10 seconds to answer, once its executor runs; one that must start an executor first waits up to 3 minutes for it. Redirects are not followed: the agent reads the `3xx` answer itself. An error status, such as `404` or `500`, reaches the agent like any other answer. A response body past 64 KiB is cut and marked `truncated`, `set-cookie` headers are removed, and the credential is replaced in the response wherever it appears.

A request refused before it left, by the destination rules, the outbound proxy or a failed connection, tells the agent that nothing reached the API. A request that was sent but got no complete answer tells the agent its outcome is unknown and, for any method but `GET`, `HEAD` or `OPTIONS`, to check its effect before sending it again. 2501 never sends a request again on its own.

Requests go through the same [Blacklist](/0.18/configure/blacklist#http-api-requests) check as a shell command.

### HTTP API documentation files

Documentation is optional but highly recommended. OpenAPI or Swagger files work, and so does plain Markdown or text documentation. Each file keeps its path inside the folder it was uploaded from, such as `api/schemas/order.yaml`.

#### File limits

| Limit | Value |
| - | - |
| File types | JSON, YAML, Markdown or plain text; other types in a folder are skipped |
| One file | 10 MiB, with no line longer than 10,000 characters |
| One API | 50 files and 100 MiB |

* Hidden files and folders, such as `.git`, and `node_modules` are skipped.
* Minified JSON puts a whole file on one line and is refused, so pretty-print it before uploading.
* A path that leaves the folder, such as one with `..`, or that holds an invisible character, such as a right-to-left override, is refused.
* Files uploaded together are stored together: if one is refused, none are, and the message names every refused file with its reason.
* Uploading a file at the same path replaces the previous one for every agent.

There is no search across the files, and they are not copied to the task host or malware-scanned. The agent reads files as written, so leave secrets out of them.

### Add an HTTP API

An HTTP API plugin calls a service directly, with structured requests instead of an MCP server in front of it. Open **Plugins**, select the **HTTP API** tab beside **MCP**, and choose **Add HTTP API**.

Enter a name and a description. The description tells the agent what the API is for and when to use it. An HTTP API plugin has no tool descriptions of its own, so a save without a description is refused; without uploaded documentation, the description is all the agent has.

Choose where requests run from, **Main executor** or a registered cluster, and enter the **Base URL**: every request goes to a relative path under it, so it cannot carry query parameters itself. The location is fixed once the plugin is saved; create a new plugin to run the same API from elsewhere. An HTTP API that runs on a cluster must belong to that cluster's organization. On **Main executor**, requests use the engine's [outbound proxy](/0.18/deployment/configuration#outbound-proxy); on a cluster, they connect directly from the cluster. Choose the authentication: **Anonymous**, **Bearer token**, or **API key header** with the header name, each with the Secret or Vault path credential to send; see [Choose the credential a plugin sends](#choose-the-credential-a-plugin-sends).

To confirm the API is reachable before saving, click **Check connection** below the authentication, when adding the plugin or on its page. An executor in the chosen location opens a connection to the base URL's host and port, completes the TLS handshake for HTTPS, and closes it: no HTTP request is sent and no credential is used. The result is **Connected**, or the step that failed - the executor start, the host name lookup, the outbound proxy, the connection, the TLS handshake, or a destination 2501 refuses - and never anything from the API. Behind the engine's outbound proxy, a **Main executor** check asks the proxy to open the connection, as a request does. A check on a cluster starts an executor with the cluster's **Executor placement** and shows each step as it runs. A check does not prove that the credential works or that a request succeeds. Checking needs permission to edit the plugin.

To give the agent documentation, pick files with **Add files**, or a whole folder with **Add folder**; see [HTTP API documentation files](#http-api-documentation-files) for what is accepted.

### Connect an HTTP API through the API

Create an HTTP API plugin through the public API with its base URL, where it runs from, and anonymous authentication:

```json theme={null}
{
  "name": "Billing API",
  "description": "Customer billing records. Use GET /customers/{id} to look one up.",
  "org_id": "<organization ID>",
  "connection": {
    "transport": "http-api",
    "base_url": "https://api.example.com/v1/",
    "location": { "kind": "main" },
    "authentication": { "method": "anonymous" }
  }
}
```

Set `location` to `{ "kind": "satellite", "cluster_id": "<cluster ID>" }` to send requests from a registered cluster instead of the Main executor. `authentication` also takes `{ "method": "bearer", "credential_id": "<credential ID>" }` or `{ "method": "api-key", "header_name": "X-Api-Key", "credential_id": "<credential ID>" }`.

Upload reference files with `POST /api/v1/plugins/{id}/documents`: a `multipart/form-data` body with one `file` part and one `relative_path` part per file, in the same order, sending its `Content-Length`. `GET /api/v1/plugins/{id}/documents` lists the stored files, and `DELETE /api/v1/plugins/{id}/documents/{documentId}` removes one. See [HTTP API documentation files](#http-api-documentation-files) for the accepted file types and size limits.

## Legacy plugins

### Plugins without an endpoint

Without a URL connection, creation accepts `GitHub MCP Plugin`, `Notion MCP Plugin` and `vmware`, and saves the plugin enabled unless the request sends `enabled: false`, like any new plugin. Records without an endpoint report **Migration required** and provide no tools. Creation compatibility does not start a built-in backend. Other existing legacy names remain readable and editable; creating a new custom name requires a URL connection.

Saving the endpoint preserves credential grants and task history. It does not copy engine secrets, deploy the new service or stop any service or executor. Invalid settings, lost access or concurrent re-enablement prevent the update. Once a plugin has an endpoint it cannot be taken back to having none, and returning to the removed runtime is unsupported.

### Legacy VMware plugin settings

Where the settings of the VMware MCP that the removed runtime started go. [Give a legacy plugin an endpoint](#give-a-legacy-plugin-an-endpoint) moves the plugin.

#### VMware settings

The removed runtime ran the image the engine setting `VMWARE_MCP_IMAGE` named (`vmware-mcp:latest` by default) and passed it every engine variable whose name starts with `VMWARE_`, unchanged. The VMware MCP reads the variables of govc, the vSphere command line it runs, instead. Each setting therefore moves to a variable of the new hosted MCP server:

#### VMware settings that move

| Removed runtime | Now |
| - | - |
| `VMWARE_MCP_IMAGE` on the engine | The new plugin's image: `605134458542.dkr.ecr.eu-west-3.amazonaws.com/2501.ai/release/vmware-mcp:<version>` |
| Each `VMWARE_*` variable on the engine | The `GOVC_*` variable below that holds the same setting. List the old ones with `kubectl set env deploy/engine --list \| grep VMWARE_` (`oc` on OpenShift) or in `env.engine` on Docker Swarm, then remove them from the engine. |

#### GOVC variables

| Variable | Required | Holds |
| - | - | - |
| `GOVC_URL` | Yes | The vCenter or ESXi address |
| `GOVC_USERNAME` | Yes | The account the agents act as |
| `GOVC_PASSWORD` | Yes | Its password; choose a Secret or Vault path credential |
| `GOVC_INSECURE` | No | `1` to accept a self-signed certificate |
| `GOVC_TIMEOUT_MS` | No | How long one govc command may run, in milliseconds; 120000 by default |

Other govc variables, such as `GOVC_DATACENTER`, set its defaults as usual.

### Give a legacy plugin an endpoint

Before upgrading, the deployment owner stops the old engine as [Plugins from the legacy runtime](/0.18/deployment/overview#plugins-from-the-legacy-runtime) describes.

Deploy the replacement MCP service, or run the server as a hosted MCP server: a server shipped as a container image can be a hosted MCP server, which 2501 runs itself. Create it as a new plugin with that image, under a new name: the legacy plugin, such as `vmware`, keeps its name. Assign the new plugin to the agents that used the legacy one, then turn the legacy plugin off or delete it. The VMware MCP that the removed runtime started is such a server, published as `605134458542.dkr.ecr.eu-west-3.amazonaws.com/2501.ai/release/vmware-mcp:<version>`: it takes no arguments, and [Legacy VMware plugin settings](#legacy-vmware-plugin-settings) lists its variables and where each old setting goes. On Kubernetes, allow the vCenter's address range for hosted MCP server pods with a policy of your own, since they reach only public addresses by default.

1. Open the unconfigured plugin's editor on the Plugins page and choose **Add an endpoint**.
2. Enter the deployed MCP URL and authentication method, turn the plugin off, then save.
3. Reload and verify the saved connection. The plugin keeps its ID and agent assignments and remains disabled.
4. For a Bearer or API-key endpoint, choose the credential the plugin sends; for OAuth, connect the account of each organization. Check each saved connection, then enable the plugin.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.