> ## 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

Plugins and MCP are ways to allow your agents to access various tools in a LLM-friendly way via function calling rather than some API endpoints or complex CLI commands.

## Adding a new Plugin

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. Open **Plugins** and select **Add MCP**. Paste the server's **MCP URL**, its README configuration or its command line: the form asks only for what it could not find there. Check the authentication 2501 detected (**Bearer token**, **API key** or **OAuth**); for a server that answers without authentication, select **Change** to pick another or leave **Anonymous / Agent managed** selected. Enter the name and description, choose the scope and enabled state in the top bar, and save. API-key authentication also needs a header name such as `X-Api-Key`. For **Bearer token** and **API key**, choose the Secret or Vault path credential the plugin sends: every agent assigned the plugin uses it. **Add MCP** waits while the field is being read, with the detection status inside the URL input, and stays unavailable while its text cannot be read, so a paste that fails never saves the previous server. The plugin starts enabled, and reaches no agent until it is assigned. Saving it does not deploy or connect to a service: assign it to an agent and check the connection from that agent. A hosted MCP server starts when a task or a check first uses it.

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. The engine runs one container per hosted MCP server and organization next to itself, shared by that organization's tasks, and stops it after thirty minutes without use or when the engine stops; a hosted MCP server in use has no time limit. Within a minute of a plugin being deleted or turned off, its hosted MCP server stops in every organization, even during a call, which then fails. The same goes for a container its plugin no longer describes: a plugin switched to a remote MCP server, moved to another organization, or saved with another image, other arguments, other variable names or other credentials. A hosted MCP server whose image, arguments or variables change, a rotated secret included, starts in a new container. After a rotated secret or a changed plain value, the old one stops once the new one runs and no call still uses it; after any other change, it stops within a minute, even during a call. A container gets at most half a CPU and 512 MiB of memory; on Kubernetes its pod requests a quarter of a CPU, and 64 MiB of ephemeral storage with a limit of 256 MiB, past which Kubernetes evicts it. On Docker its `/tmp` holds at most 64 MiB, taken from its memory; the rest of its file system stays writable and is not bounded. A hosted MCP server that fails to start is tried again after 5 seconds, then after longer waits up to 5 minutes. A connection check in that time shows when the next try is and why the last one failed, including the hosted MCP 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`). The engine replaces the values of the hosted MCP server's credential variables with `[REDACTED]` in that output and in its logs. A hosted MCP server must answer the MCP handshake within 60 seconds of starting; one that does not 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 to start. On Kubernetes a hosted MCP server's 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 a hosted MCP server runs on a network of its own, `2501-mcp-hosted`, and cannot reach other hosted MCP servers or containers that publish no port, but it reaches every container on the machine that publishes a port, 2501's database included when its port is published, even on `127.0.0.1` only, and whatever the machine reaches: your internal network, a cloud metadata address such as `169.254.169.254`, and the machine's own services; the firewall rules in [Isolate hosted MCP servers on Docker](#isolate-hosted-mcp-servers-on-docker), required on Docker, close those. On Kubernetes a hosted MCP server's 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. Only tenant-level administrators can add, change or delete a hosted MCP server; an organization administrator can switch their organization's running hosted MCP server off, in a save that changes nothing else. In the plugin's details, that administrator gets a **Turn off** button, and everything else stays read-only; 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. 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. A hosted MCP server 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.

The **MCP URL** field also takes what a README hands out: an `mcpServers` entry (Claude, Cursor, Windsurf), a `servers` entry (VS Code), comments included, or a command line such as `claude mcp add` or `docker run`. There is nothing to press: half a second after the text stops changing, the engine connects to the URL the way a task would and lists its tools without calling any. It tries Streamable HTTP first, with the 2026-07-28 protocol and then the 2025 versions, and falls back to HTTP+SSE for an older server. The form then fills in the name, URL, transport and authentication, and shows a verdict followed by what to do. The name is the one a pasted configuration gives, else the name the server announces for itself, else one taken from the URL's host name (none for an IP address or `localhost`), without words such as `mcp` and `server`; change it before saving if it does not suit. The verdict says how many tools it verified, or why it could not connect, such as `Not an MCP endpoint (HTTP 404)` or, when no HTTP answer came, what the network said: a refused connection, a host name that does not resolve, a TLS certificate the engine does not trust or a failed TLS handshake, or a timeout. A pasted configuration leaves only its URL in the field, and **Detect again** repeats the check. **Add MCP** stays unavailable while the engine refuses the address, for example cleartext HTTP to a public address; a server it could not reach can still be added. An authentication you change while the check runs is kept. A configuration that sends an `Authorization: Bearer` header is set up for a Bearer token, and any other header for an API key in that header. A server that refuses anonymous access and names its OAuth provider is set up for OAuth: 2501 registers itself when the provider allows it, and otherwise the form asks only for the client ID of a client registered there. A server that refuses anonymous access without naming a provider is set up for a Bearer token. Only the URL is sent: headers, tokens and other values in a pasted configuration are neither sent nor saved. A pasted `docker run` command, in a configuration or on its own, fills in the hosted MCP server instead: its image, its arguments, and its `-e` variables. 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. A pasted `npx` or `uvx` configuration is not run: build an image for it, as described below. An `mcp-remote` bridge is replaced by the URL it points to.

Without detection, connections created here use Streamable HTTP. Through the API, a connection can select either transport, and editing it here keeps that transport.

### Recommended Kubernetes MCP

For Kubernetes, we recommend [containers/kubernetes-mcp-server](https://github.com/containers/kubernetes-mcp-server), deployed in your cluster and added by its URL, such as `http://kubernetes-mcp.<namespace>.svc:8080/mcp`. Pin its image to a version you have tested, for example `quay.io/containers/kubernetes_mcp_server:v0.0.67`, rather than a moving tag. Run it read-only (`read_only = true` in its configuration file) and deny Secrets (`denied_resources`), and give its service account a Role that grants only `get`, `list` and `watch`, on the resources and in the namespaces your agents need to read. An installation that lets agents change the cluster through it turns `read_only` off, and must still deny Secrets: keep `denied_resources = [{ group = "", version = "v1", kind = "Secret" }]` in its configuration file (the setting as of v0.0.67), and leave `secrets` out of its service account's Role. Whatever a tool returns, a Secret's values included, goes into the task's trace, is stored with the task and is sent to the model provider. This server has no sign-in of its own in that setup, so keep its Service reachable only inside the cluster: the engine reaches an internal address over plain HTTP without any setting.

### 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. On Kubernetes, a hosted MCP server's pod names the pull secrets that the engine setting `MCP_HOSTED_IMAGE_PULL_SECRETS` lists, comma-separated. Unset, it names the executors' `EXECUTOR_MCP_IMAGE_PULL_SECRET`, which on a CLI installation is the short-lived login to 2501's own registry, where your images are not. Set to an empty value, it names none, so the pod takes the pull secrets of the namespace's `default` service account, such as OpenShift's login to its internal registry. On Docker, the engine pulls an image the machine lacks with the login `docker login` stored for that registry in the engine's Docker configuration: `config.json` in the directory `DOCKER_CONFIG` names, or `~/.docker`, inside the engine's container, so mount the file into it and point `DOCKER_CONFIG` at its directory. Credential helpers are not read. Otherwise 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. On the Swarm stack the CLI renders, name that file in the install configuration (`swarm.docker_config`) instead of mounting it yourself: see [Hosted MCP servers on Docker Swarm](/0.17/deployment/docker-swarm#hosted-mcp-servers).

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.

### What bounds a hosted MCP server

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 runs without root, gets no Linux capabilities and cannot gain privileges. It has at most half a CPU and 512 MiB of memory, on Kubernetes at most 256 MiB of writable storage, and on Docker at most 256 processes and a 64 MiB `/tmp`. On Kubernetes, the network policy the CLI applies lets it reach only DNS and public addresses; on Docker, the rules above do the same, and without them it reaches other containers' published ports and the machine's network.

## Managing Plugins

Only **administrators** can create, edit (enabling and disabling included) and delete plugins: an organization administrator manages the organization's plugins, except hosted MCP servers, which it can only switch off, and a tenant-level administrator also manages tenant-wide ones. Other users see the plugin list and each plugin's details, read-only, and so does an organization administrator for a tenant-wide plugin.

Go to **Command Center** → **Plugins**. The table shows each plugin's description, 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.

### 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 Status

Allow to quickly enable or disable a plugin. Useful if you realize some MCP functions do not behave in the way you expected. If a plugin is disabled, the agent can't call any of its functions, even if the plugins are associated to an agent.

### Assign to agents

A saved plugin's page lists the agents that have it. **Add an agent** appears before compact assignment chips and searches the agents of the plugin's organization, or of the selected organization for a tenant-wide plugin. Select the X on a chip to remove a direct assignment. Each change saves on its own and is the same assignment as choosing the plugin on the agent's page. Anyone who can edit agents can do this, even on a plugin they cannot edit. Agents that inherit the plugin from their specialty are listed too, read-only, with the specialty's name: change them from the specialty. Archived agents are not listed.

### Assign to a specialty

A saved plugin can also be given to a specialty. **Add a specialty** appears before compact assignment chips and searches for another; select a chip's X to remove the assignment. Each change saves on its own. The same choice is on the specialty's page: **MCP servers** lists the plugins it hands out and searches for another one it can take. Every agent of that specialty then has the plugin, including agents created later, and an agent that changes specialty gains or loses it. An agent's own assignment takes precedence. An inherited plugin cannot be taken away from one agent alone; detach it from the specialty instead. `DELETE /api/v1/agents/{agentId}/plugins/{pluginId}` on an inherited plugin returns 409 and names the specialty in `details.specialty_id`.

An organization's specialty takes that organization's plugins and tenant-wide ones, and a tenant-wide specialty only takes tenant-wide plugins, so an organization plugin's editor never offers a tenant-wide specialty. Attaching a plugin to a tenant-wide specialty or detaching it requires tenant-level access; without it, the editor offers none and shows their switches disabled. Assigning an enabled OAuth plugin is refused while an organization the specialty reaches has no connected account. For the same reason, creating an agent with a specialty that hands out an enabled OAuth plugin, or switching an agent to it, is refused until the agent's organization has connected its account. A plugin a specialty holds cannot be deleted, or moved to an organization the specialty does not belong to, until it is detached, and the specialty cannot move to a scope that no longer takes the plugin. The refused move answers `409 CONFLICT` with `details.reason` `plugin_move_blocked` and the specialties in `details.specialty_ids`, the same answer as a move refused for its agents, which lists them in `details.agent_ids`.

## Plugins per Agent

You can have granular control over which agent has access to a certain range of plugins.

Navigate to **Agents** and select the agent you want to manage.

From here, you can add or remove plugins for this agent. Anyone who can edit the agent can do this; it does not require an administrator. 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, except the ones its specialty already hands out, which show as inherited. This default belongs to the form: an agent created through the public API, a CSV or JSON import or the AI Assistant gets only the plugins the request names, plus the ones its specialty hands out.

### Considerations

Plugins and MCP functions are loaded in agent's context, which can impact an agent performance overtime (especially smaller models), so don't forget to clear your agent memory every once in a while to avoid hallucination, especially with plugins exceeding a few hundreds commands.

## Public API

Use `/api/v1/plugins` with a session or a scoped API key to list and create plugins. Read, update or delete one through `/api/v1/plugins/{id}`: updates use `POST` and preserve fields you omit. Search uses `GET /api/v1/plugins/search`, and list/search responses use cursor pagination. `tenant_wide=true` keeps only the tenant-wide plugins, `tenant_wide=false` only the ones an organization owns.

`GET /api/v1/plugins/{id}/agents` lists the agents that have a plugin, each with `assignment_source` (`agent` or `specialty`) and, for an inherited one, the `specialty`. `POST /api/v1/plugins/{id}/agents/{agentId}` assigns it to one agent and `DELETE` removes that assignment: the same writes, checks and errors as `POST /api/v1/agents/{agentId}/plugins` and `DELETE /api/v1/agents/{agentId}/plugins/{pluginId}`. `GET`, `POST` and `DELETE /api/v1/plugins/{id}/specialties[/{specialtyId}]` do the same for specialties.

Organization-scoped keys can manage their own plugins and read shared tenant plugins. They cannot change tenant-wide plugins or reach another organization's records. Creating a plugin without `org_id` makes it tenant-wide, which needs tenant-level access: an organization-scoped key or an organization administrator gets `403` and sends its organization's `org_id` instead.

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.

Remove all agent and specialty assignments before deleting a plugin. Deleting an assigned plugin returns `409 CONFLICT` and preserves both the plugin and its assignments; the message names the assigned agents and specialties, up to five of each, `details.agent_ids` lists the agents' IDs and `details.specialty_ids` the specialties'. Archiving an agent removes its plugin assignments. Moving a plugin to an organization returns `409 CONFLICT` while agents outside it are still assigned. Assigning an organization plugin to another organization returns a client error. The plugin `409 CONFLICT` answers carry `details.reason`: `plugin_in_use`, `plugin_move_blocked`, `plugin_name_conflict` (with `field: "name"`) or `plugin_inherited`.

Creation and renaming preserve the duplicate-name rule: organization plugins cannot repeat a name in the same organization; a tenant-wide plugin rejects a name already visible anywhere in the tenant. Moving a plugin to another organization, or making it tenant-wide, follows the same rule and returns `409 CONFLICT` with `plugin_name_conflict` when the name is taken there. Two requests saving the same name at the same time cannot both pass: the second waits for the first and is checked against it. Names are compared ignoring case, so `QA-ECHO` is taken once `qa-echo` exists, since both would lead the same tool names; plugins that already share a name this way keep working and keep their names. A name cannot hold control characters such as a line break.

## Connect an MCP endpoint

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. A Bearer or API-key plugin is enabled only with its credential: creating one without `credential_id` takes `enabled: false`, and returns 400 otherwise. An OAuth plugin can be created enabled, and then takes an assignment only in organizations that connected their account. 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. The API also accepts `authentication: { "method": "bearer", "credential_id": "<credential ID>" }` or `authentication: { "method": "api-key", "header_name": "X-API-Key", "credential_id": "<credential ID>" }`. 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. A Bearer or API-key plugin saved without `credential_id` is a draft that cannot be enabled until it has one. An assignment can be checked before enablement. OAuth uses the separate authorization flow below.

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.

URLs cannot contain a username, password, fragment or literal metadata/link-local address. Use HTTPS for external endpoints. Internal HTTP works without configuration: the engine permits cleartext to addresses inside its own network, whatever their port or host name, so a new internal MCP needs no deployment change. No setting turns this off: the engine always allows cleartext HTTP to private addresses. The decision is made on the address a name resolves to, so a public address is refused even when the URL looks internal. Storing an HTTP URL grants no permission by itself. When the engine refuses an endpoint by these rules, a connection check and the task's connection history name the rule rather than a connection failure: `cleartext_not_allowed` for cleartext HTTP to an address outside its network, `destination_not_allowed` for a metadata or link-local address, and `redirect_not_followed` for a server that answers with a redirect, which the engine never follows. 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. Behind the proxy, the engine still resolves each destination and applies these rules before the request, so it must be able to resolve those names itself; the proxy then resolves the name again on its own, and its own rules decide if the answer changed in between. A hosted MCP server reaches no proxy unless its variables name one, such as `HTTPS_PROXY`.

If endpoint restrictions change, previously stored settings remain readable and deletable. You can still edit the description or keep the plugin disabled without resubmitting its connection. Creating, replacing or enabling a connection must satisfy the current restrictions. Assigning a URL plugin to an agent does not activate it.

Tasks open their own client sessions to your existing MCP service. Completing or cancelling a task closes those clients and leaves the service and other tasks running. A task takes at most 200 plugin tools, counting plugins in the order they were assigned to the agent. The first plugin whose tools would pass that total is left out of the task, and so is every plugin assigned after it, even one small enough to fit; each is listed in the task's history. A plugin with more than 200 tools assigned first therefore leaves the task no plugin tools, so assign large plugins last. The limit holds for the whole task: a plugin left out is not connected later, a plugin assigned while the task runs is left out if its tools do not fit, and a plugin whose tool list grows past the limit is withdrawn. An unavailable endpoint does not prevent other plugins from providing tools. Already admitted calls may finish before cleanup. Before each model turn after the first, the task reads the agent's assignments and resolves each plugin once, secret included; the first turn uses what the task resolved when it started. A plugin that was removed, disabled, lost its endpoint or can no longer be authorized is withdrawn at that turn, and its client closes after its already admitted calls finish. A changed plugin or account is replaced by a client on the current assignment, and a renewed OAuth token or a rotated secret value reaches the open HTTP client without replacing it. If the assignments cannot be read at that moment, the plugins keep their clients for that turn. If the database or Vault is out of service while a plugin is resolved, the plugin keeps its client for at most three turns in a row or five minutes, and only while its assignment, connection and OAuth account are unchanged. A credential that is gone, a Vault path that Vault denies or does not hold, or a configuration the engine refuses withdraws the plugin at once. Between turns, each call rechecks the assignment, the plugin's connection and the OAuth account, without reading any secret: a removed or reassigned plugin, a changed account, a disabled plugin or a reconnected or renewed OAuth account stops the call at once, while a rotated secret or a withdrawn credential grant takes effect at the next turn. There is no fallback to anonymous access or another account. A resumed task opens fresh sessions with the current selection. A failed connection is tried again at the next turn, once per turn, and the turn waits up to three seconds for a reconnection in progress: a server back in time gives its tools to that turn, and one still down holds a turn no longer than that. A failed call does not close its client. It may have reached the server, so its outcome is unknown and the task never replays it automatically. A Streamable HTTP server that answers 404 on a session, as the MCP specification has a server do for a session it ended, gets a new session at once: a listing goes to the new session, and a call is sent again on it, once, since the server processed nothing on the old one. The call is answered as not called instead when the new session lists that tool differently or cannot be opened. A call the engine had started when it stopped may have run too: when the task resumes, it answers that call as outcome unknown instead of calling it again, so the agent checks the call's effect first, and the task details show the call in the MCP connection history. After checking, the agent may still call it again. A tool the agent selected after its plugin went away is answered with an error, and the task continues with the tools it still has; five such turns in a row with nothing else done fail the task. Tool lists are discovered on connection and reconnection, and again before each model turn. Each discovery reads the complete paginated list. A server that announces tool changes is listed again only after it announces one, or when its last listing is five minutes old; a server that does not announce changes is listed before every turn. A tool-list change notification immediately withdraws the affected tools until rediscovery completes, and a call refreshes the list first when the server announced a change. A tool keeps the same name for the whole task, so the tools offered to the model stay stable from turn to turn; a call planned against a replaced session or a changed input schema is refused, and the agent chooses the tool again from the current list. A failed discovery blocks only that MCP, while already admitted calls may finish and other clients remain usable. Tool schemas and the existing relevance filter are preserved, within limits that keep what every turn sends to the model bounded: a tool description longer than 2,000 characters is cut and ends with `[truncated]`, a tool whose input schema is larger than 16 KiB is left out, and so is, in listing order, the first tool that takes the plugin's tool definitions past 256 KiB, with every tool after it. Engine warnings name each tool cut or left out. An assigned plugin without an endpoint is recorded as migration-required and does not prevent other task work.

Task details show MCP connection observations from initialization and each resume, and only when there is something to report. After a successful credential change, the history also shows the previous and current credentials, by name when it can be read and by ID otherwise. Rotating the value behind an unchanged credential ID does not add an account-change entry. An OAuth replacement after a new consent records the previous and current authorization IDs and appears as a change of the organization's connected account. An unavailable connection includes its plugin name, ID and failure category. A plugin that becomes unavailable while the task runs, or that is assigned during the task and cannot connect, is recorded once with its reason, including when it was disabled or unassigned. Earlier failures remain visible after a resume succeeds. Anyone allowed to read the task can read this history; it does not enter the agent's dialogue. The agent is told which plugins it cannot use and why, including disabled ones and ones unassigned during the task, so it can report that instead of searching the host for them. That list is updated before every turn. Older tasks without observations have no history panel. These observations are not live connection checks. Engine warnings also include the task, agent, organization and plugin IDs, the failure category and the error behind it.

### Choose the credential a plugin sends

One plugin has one connection method and, for Bearer or API key, one credential. Choose it in the plugin's form, when adding the plugin or editing it, or set `credential_id` in its connection through the API. 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.

Every agent assigned the plugin, directly or through its specialty, sends that credential. To reach one service two ways, or with two accounts, create two plugins and assign each to the agents that need it. 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.

Use `POST /api/v1/agents/{agentId}/plugins` with `{ "plugin_id": "<plugin ID>" }` to add one assignment, and `POST /api/v1/agents/{agentId}/replace-plugins` with `{ "plugin_ids": [...] }` to save the agent's whole set in one write. `GET /api/v1/agents/{agentId}/plugins/{pluginId}` returns the plugin and `assignment_source`, which says whether the agent was assigned it directly (`agent`) or inherits it from its specialty (`specialty`). It includes disabled assignments. Both read routes return only the agent's own assignments by default; add `include_inherited=true` to include the plugins it inherits from its specialty. You need read access to both the agent and plugin. Auditors may read the assignment but cannot change it. Sessions and scoped API keys use the same routes. Agent creation takes `plugin_ids`.

A plugin the agent's organization cannot use - unknown, not visible to you, or scoped to another organization - is refused with `400 BAD_REQUEST` before anything is saved. `field` names the request field (`plugin_id` or `plugin_ids`) and `details.plugin_ids` lists the refused plugins. Detaching goes the other way: `DELETE /api/v1/agents/{agentId}/plugins/{pluginId}` answers `200` with the agent for a plugin the agent does not hold, unknown or not visible to you included, and changes nothing; only an inherited plugin is refused, with `409`. A plugin whose stored connection can no longer be read is returned with `connection: null` by every read, the agent's plugin list included, rather than failing it; it cannot be enabled until you save its connection again.

### Give each agent its own token

For an MCP server where each agent signs in with its own token, configure the plugin as anonymous, give the agent the token as a credential in its **accessible credentials**, and the agent passes it in the tool arguments as `{{secret:<name>}}`. 2501 puts in the value just before the call leaves, under the same rules as a placeholder in a command, so the MCP server must accept the token as a tool argument. A hosted MCP server takes placeholders the same way. A placeholder the agent has no access to fails the call and nothing is sent, and a Bearer, API-key or OAuth plugin refuses placeholders, since it already authenticates with its own credential. 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.

### 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**. 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 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.

The equivalent API call is `POST /api/v1/agents/{agentId}/plugins/{pluginId}/check` with no body. The caller needs permission to write the agent and read the plugin.

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.

A successful response contains `success: true`, `agent_id`, `plugin_id`, `checked_at` and `tool_count`, with the negotiated `transport` (`streamable-http` or `http-sse`) and `protocol_version` when the engine reports them. A failed check returns HTTP 200 with `success: false` and a `failures` list instead of `tool_count`, and, when the engine can say more than the category, a `detail` of at most 500 characters. 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. Categories are `invalid_configuration`, `connection_or_discovery_failed`, `cleartext_not_allowed`, `destination_not_allowed`, `redirect_not_followed`, `access_check_failed`, `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`, `assignment_changed`, `cleanup_failed`, `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) and `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). An unknown or inaccessible assignment returns 404; a failure to reach the engine returns an API error.

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.

### Authorize an OAuth account

On the Plugins page, select **OAuth** and choose a pre-registered client, automatic registration or dynamic registration. For a pre-registered client, enter its client ID. Leave **Use the provider's requested scopes** selected, or enter explicit scopes. A confidential client names an existing Secret or Vault path credential as its client secret, chosen in the plugin form; agents need no grant for it, and its value is never pasted into the plugin settings. The saved plugin page keeps these OAuth settings visible beside the connected account.

Save the plugin, then select **Connect account** in the plugin editor. Only someone who can edit the plugin connects it: an organization administrator for the organization's plugin, and a tenant-level administrator for a plugin shared with every organization, from each organization that uses it. Save any other edit first: connecting waits for the plugin to be saved. Agents assigned in the organization share the account. Depending on the provider, you connect in one of three ways, and Command Center picks the one that applies:

* **Automatic return.** Select **Open the sign-in page**, which opens the provider in a new tab, or **Copy the link** to open it elsewhere. After you consent, the provider sends your browser back to Command Center, which confirms the connection; **Open Plugins** returns you to the plugin list. When it cannot, the page says why. If your Command Center session ended before you came back, you are asked to sign in again, and the page then tells you to start the connection again.
* **A code shown in Command Center.** When the provider supports device sign-in, the editor shows a code and the provider's address instead. Open that address on any device that can reach the provider, enter the code and sign in; the editor notices on its own and says the account is connected. A refused or expired code offers **Start again**. A provider that does not let the client use device sign-in gets the sign-in page instead, and a check the provider fails to answer, or answers with an outage, is asked again later; anything else ends the code and says why.
* **Paste the return address.** If the page the provider sends your browser back to does not load, copy the whole address from the address bar, paste it into **Paste the address your browser ended on**, and select **Complete**. Command Center checks it exactly as it checks an automatic return. Only you can complete a sign-in you started, and only once.

Run **Check connection** from an assigned agent, then enable the plugin once every organization with a live assignment has connected its account. **Reconnect account** replaces the previous authorization and can select another account.

For an agent that works without anyone signing in, pick a pre-registered client, choose its client secret, and select **Sign in as a service account**. **Connect service account** then gets a token with the OAuth client credentials grant, without opening the provider, and 2501 asks for a new one before it expires. The provider must allow that grant for the client. In the API, a service account is `"grant": "client_credentials"` on an OAuth authentication whose client is pre-registered with a `client_secret_credential_id`. The editor shows whether the account is connected or its last renewal failed. A service account is asked a new token for again a minute after a failed renewal, so once its client secret or private key, or the provider, is fixed it recovers by itself, at the next check or task; **Check connection** establishes whether the provider accepts it.

The equivalent API configuration for a pre-registered public client is:

```json theme={null}
{
  "connection": {
    "transport": "streamable-http",
    "url": "https://mcp.example.com/mcp",
    "authentication": {
      "method": "oauth",
      "scopes": ["read"],
      "client": {
        "registration": "pre_registered",
        "client_id": "your-client-id"
      }
    }
  }
}
```

The connected account belongs to the plugin in one organization: every agent assigned there shares it, and a tenant-wide plugin connects once per organization. With an authenticated Command Center session that can edit the plugin (an administrator of its organization, or a tenant-level administrator for a tenant-wide plugin), call `POST /api/internal/plugins/{pluginId}/oauth/start`; other sessions get `403`, and the same holds for the device poll, the service account and completing a sign-in; an org-scoped plugin needs no body, a tenant-wide plugin takes `{ "org_id": "org_…" }`. The answer has `flow: "redirect"` with an `authorization_url` and the `client_registration` it signs in with, or `flow: "device"` with a `user_code`, a `verification_uri`, sometimes a `verification_uri_complete`, and an `interval` in seconds. For a redirect, open `authorization_url` in the same browser session and complete the provider consent screen. The address asks the provider to let you choose the account (`prompt=select_account`, or `prompt=login` when the provider lists the prompts it supports without that one), so a session already open at the provider does not connect its account without you choosing it; the provider returns to the deployment's fixed callback URL, which sends you to a Command Center page confirming the connection. If that page does not load, send the address the browser ended on to `POST /api/internal/mcp-oauth/complete` as `{ "url": "…" }`. For a device sign-in, call `POST /api/internal/plugins/{pluginId}/oauth/device/poll` with the same body every `interval` seconds: it answers `pending` with the interval to wait, `connected`, `denied` or `expired`. No token is returned. API keys cannot start or complete this user consent flow. `GET /api/v1/plugins/{pluginId}/oauth` answers with the organization's account, and its read-only `account_name` says who it signs in as: the `preferred_username`, email or subject from the ID token the provider returns when the plugin's scopes include `openid`, otherwise from the provider's OpenID Connect user info endpoint, and the client ID for a service account; null when the provider says none of these. The plugin's account panel shows it ("Connected as ..."), and so does a successful connection check (`account_name`).

Register the redirect URL the plugin form shows under **Redirect URL to register with your provider**, with its copy button. Each authorization server has its own, `https://your-command-center-host/api/internal/mcp-oauth/callback/<key>`, where the key comes from the server's issuer, so the form shows it once detection has found the provider, or after a first connect. Plugins on the same provider share it. A pre-registered client saved before this address existed, or created through the API without `"callback": "per_provider"` on its `client`, keeps the address every provider shares, `https://your-command-center-host/api/internal/mcp-oauth/callback`, and the form shows that one for it. The deployment's BASE\_URL determines the host of both: the engine builds the address from its own `BASE_URL`, so the engine and Command Center must have the same one. The provider sends the browser back to that address after consent, so BASE\_URL is the address users open Command Center with, HTTPS where you can. A plain-HTTP address, as on an installation reachable only on its own network, is accepted: some providers refuse to send the browser back to one, and the code sign-in or pasting the return address then still complete the sign-in. The plugin editor says when Command Center is served over plain HTTP, and the engine logs a warning once. Only a missing BASE\_URL stops the sign-in. The Client ID Metadata Document still needs an HTTPS address that resolves publicly. A sign-in expires after ten minutes, or when the provider's device code does, and can be completed only once by the user who started it. Starting again replaces your previous attempt, not a colleague's. The account already connected keeps working throughout, and is replaced only once a new one is authorized, so closing or refusing the consent screen disconnects nothing. Check an assignment, 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.

A pre-registered client becomes confidential when it names an existing Secret or Vault path credential in `client.client_secret_credential_id`, and `client_secret_basic` or `client_secret_post` is then chosen from what the provider advertises. The credential must be readable by every organization the plugin serves, so a tenant-wide plugin needs a tenant-wide credential, and agents need no grant for it. This credential holds the client secret, not an access token. Giving it a new value keeps the connected accounts: the provider still honours what it granted, and the new value is used from the next token request on, so it has to match what the provider expects. Making the credential tenant-wide keeps them too. Moving it into one organization is refused while a plugin outside that organization uses it: choose another credential in that plugin first. Naming a different credential does require connecting the account again. With `client: { "registration": "automatic" }`, 2501 needs no client created on the provider. When the provider advertises Client ID Metadata Documents and BASE\_URL is an HTTPS address that resolves to public addresses, the client ID is the address of the document Command Center publishes at `/api/mcp-oauth/client`, which the provider fetches. Otherwise, including when the provider cannot reach this installation, 2501 registers a client dynamically 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. The registration names no scopes, so the provider gives the client its default ones; the plugin's scopes are asked for at sign-in. On Keycloak, a scope asked for must be one of the realm's optional client scopes (a client scope of type Optional), and a provider that limits a client to the scopes it registered needs a pre-registered client. A registered client is reused until the provider's expiry for it, a sign-in still in flight reuses the client registered for it, and a client the provider no longer accepts is registered again: at once for a device code, at the next connect otherwise. With `client: { "registration": "dynamic" }`, 2501 always registers dynamically, even with a provider that reads metadata documents. Choose it when the provider cannot fetch the document from Command Center, for example behind an inbound firewall: a sign-in with the metadata document that expires without returning suggests it. A provider that offers neither, or refuses the registration, needs a pre-registered client, and connecting says so. For Microsoft Entra ID, which refuses the `resource` parameter, 2501 leaves it out.

A plugin trusts one authorization server, and sends client secrets, codes and tokens to that server only. When the plugin form detected it, saving the plugin trusts it (`trusted_issuer` on create or update, ignored when the plugin already trusts one). Otherwise the first connect trusts the server the MCP server names. The editor shows the trusted server, for example "Trusts login.example.com". If the MCP server later names another one, connecting is refused with a message saying so. When the move is expected, someone who can edit the plugin chooses **Forget trusted provider** (`DELETE /api/v1/plugins/{id}/oauth/trusted-issuer`): the connected accounts stay, their renewals fail until a server is trusted again, and that person's next connect trusts the server named then. Changing the plugin's connection or scope forgets the trusted server along with the connected accounts, and ends any sign-in in progress: a device sign-in approved at the provider afterwards polls `expired`, and a browser return cannot finish. Start the sign-in 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.

The provider must advertise PKCE S256 for the sign-in page (Microsoft Entra ID, which takes it without saying so, excepted), a client authentication method 2501 supports, HTTPS endpoints and resource metadata identifying its issuer and resource. An MCP server that publishes no resource metadata signs in at its own origin: detection offers OAuth there, and saving the plugin trusts that origin like any other provider. Unless the installation allows private providers, such a server on a private address is offered a token instead. The return to Command Center must name the provider (the `iss` parameter) when the provider says it does. 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. At the shared address the return must also name the provider for a metadata document and for a pre-registered client without a client secret, so such a client needs a provider that returns `iss` (RFC 9207), as Keycloak does, a client secret, or device sign-in. Tokens must use Bearer authentication and cover the requested scopes. An access token may use any visible ASCII character except quotes, angle brackets and backslash, and holds at most 8192 characters; a longer one fails sign-in or renewal. A provider that omits `expires_in` is accepted; the token then has no expiry (`expires_at` is null) and serves until an MCP server refuses it. Access and refresh tokens remain private to the plugin authorization and are excluded from API serialization and agent CSV/JSON or CLI imports.

When less than a minute or a tenth of its lifetime is left, whichever is longer, the next connection check renews an access token if the provider supplied a refresh token. A running task checks its connections before each turn, when the token must also outlast the turn, seven minutes at most, and again before each call. An MCP server that answers `401` processed nothing: the token is renewed however long it has left, and a call is sent again once with it; a connection it refused reconnects with the new token at the next turn, and a connection check tries again at once. Concurrent checks share one exchange and keep the same authorized account, across engine processes too, so a refresh token the provider rotates is used once. An open task session takes the new token without reconnecting, and the renewal does not appear as an account change. Automatic registration asks for refresh support: the metadata document declares it, and dynamic registration requests it when the provider advertises it. For a pre-registered client, enable the provider's refresh-token grant if you want automatic renewal. Nothing renews an account in the background: an access token that expired while nobody used the plugin reads **Connected, renews on next use** when the account holds a refresh token or is a service account, and **Reconnect needed** when it holds neither. `GET /api/v1/plugins/{id}/oauth` says which in `authorization.renewable`.

If no refresh token is available, renewal fails or its outcome is uncertain, the check returns `authorization_required` once the current token has expired. An account without a refresh token, or whose refresh token the provider refuses, needs consent again to restore access. A service account whose client secret was fixed, and an account whose refresh token the provider still accepts, after an outage for example, recover by themselves at the next renewal. The connected account is kept meanwhile: a provider that refuses and a provider that cannot be reached are indistinguishable here, so nothing is discarded on a guess. A failed renewal is left alone for a minute before the next check or task tries again, and the account panels say the last renewal failed and why: refused by the provider, a provider that could not be reached, a token 2501 does not accept, another authorization server named, no authorization server trusted yet, no refresh token, a client secret credential that could not be read, a client the provider no longer accepts, a provider on a private address, a saved connection that is not usable, or another error the engine logged. 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. The platform never replays an MCP call, except once after a `401`, which the server answers before processing anything. A token without an expiry, or one the provider revoked early, stops only when the MCP server refuses it.

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.

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.

A pre-registered client can authenticate with a private key instead of a client secret (`private_key_jwt`): in the plugin form choose **Client authentication: Private key (JWT)**, pick the Secret or Vault path credential that holds the PKCS #8 PEM private key, and enter the key ID (`kid`) the provider knows the public key by, if it asks for one, and the algorithm (RS256 by default, or ES256). Through the API, set `client.private_key: { "credential_id": "cred_…", "key_id": "…", "algorithm": "RS256" }` in place of `client_secret_credential_id`. Okta's org authorization server requires it for a service account. Every token request then carries a JWT signed with the key for the provider's token endpoint, valid for a minute. A provider that lists its client authentication methods without `private_key_jwt` is refused as unsupported. A key that cannot sign, because it is not PEM or not for the algorithm, is refused before the provider is opened.

### 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`.

## Give a legacy plugin an endpoint

Before upgrading, have the deployment owner stop the old engine and verify that its owned plugin containers or pods are gone. Remove any remaining owned resources explicitly, preserving independent executors. This step performs no cleanup of its own.

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, `ghcr.io/2501-ai/vmware-mcp`, is such a server: its variables are `GOVC_URL`, `GOVC_USERNAME`, `GOVC_PASSWORD` (a credential) and `GOVC_INSECURE=1` for a self-signed certificate. 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.

To serve it as one shared HTTP endpoint instead, run it behind an HTTP wrapper such as [mcp-proxy](https://github.com/punkpeye/mcp-proxy), for example `mcp-proxy --port 8080 -- vmware-mcp` in a container built from that image. Give that service the vCenter settings in its own environment: `GOVC_URL`, `GOVC_USERNAME`, `GOVC_PASSWORD`, and `GOVC_INSECURE=1` for a self-signed certificate. Set `MCP_PROXY_API_KEY` so the wrapper refuses a request without that key in its `X-API-Key` header. The endpoint is then `http://<service>:8080/mcp`, with the API-key method on the `X-API-Key` header and the key as the plugin's credential.

Install mcp-proxy when the image is built, so that the container needs no internet access when it starts. The VMware image ships Bun; in an image with Node.js, run `npm install mcp-proxy@6.7.19` instead:

```dockerfile theme={null}
FROM ghcr.io/2501-ai/vmware-mcp:<version>
WORKDIR /opt/mcp-proxy
RUN bun add mcp-proxy@6.7.19
ENTRYPOINT ["/opt/mcp-proxy/node_modules/.bin/mcp-proxy", "--port", "8080", "--", "vmware-mcp"]
```

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.

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.

### 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. `ghcr.io/2501-ai/vmware-mcp` reads the variables of govc, the vSphere command line it runs, instead. Each setting therefore moves to a variable of the new plugin when it is a hosted MCP server, or of the wrapper's container:

| Removed runtime | Now |
| - | - |
| `VMWARE_MCP_IMAGE` on the engine | The new plugin's image, or the base of the wrapper's image: `ghcr.io/2501-ai/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. |

| 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.

## Upgrading from the legacy runtime

Before upgrading, stop the old engine, let its calls and cleanup finish, and verify that its owned plugin containers or pods are gone. A forced stop may require targeted cleanup. Keep shared MCP services and independent executors running. The new runtime never starts the removed built-in MCPs, such as the GitHub and VMware ones: a plugin that used one reports Migration required and gives agents no tools until it is migrated, either by giving it an MCP endpoint (Add an endpoint in its editor, with the plugin turned off) or by adding its server again as a hosted MCP server. Of the containers and pods it runs, it deletes at start only the hosted MCP server ones its own engine left behind.

Back up the database before this upgrade: going back to the previous version means restoring that backup, never redeploying the previous image alone. The upgrade moves agents' plugins into a table of their own, which the previous engine cannot read, so on the upgraded database every one of its tasks fails. It also drops any assignment that tied a plugin to an agent or a specialty it could never reach, in another tenant or, for an organization plugin, another organization: no task used it, and it kept the plugin from being deleted. On Docker Swarm a failed update puts the previous engine back on its own, so restore the backup then too. See [Rolling back](/0.17/deployment/overview#rolling-back).


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