MCP
Polo uses the Model Context Protocol in both directions, and the two are separate modules:
- Polo as a client (
api/src/modules/mcp/): a workspace registers an external MCP server, and agents may use the tools an administrator granted. - Polo as a server (
api/src/modules/mcp-server/): Claude Code, Codex, Cursor or any MCP client can work with a workspace through an access token.
The rule for the first direction is the one to remember: connecting a server enables none of its tools. Discovery and permission to execute are separate steps.
Polo as a client
Section titled “Polo as a client”1. Register a server
Section titled “1. Register a server”An administrator registers a server on the Tools page, which calls POST /api/mcp/servers with registerMcpSchema (application/contracts.ts):
| Field | Meaning |
|---|---|
name |
A display name |
endpoint |
A fixed URL. Credentials, query parameters and fragments in the URL are refused. |
token |
Optional bearer token. Sealed before it is stored. |
oauth |
Optional, instead of a token: issuer, client registration, approved scopes |
McpService.register discovers the catalog first and stores the server only if discovery succeeds. A server registered with oauth is stored first and waits for consent; its authorization state is one of awaiting_consent, connected, refreshing or reconnect_required (McpOAuthStatus).
2. Schema discovery
Section titled “2. Schema discovery”discoverCatalog (application/catalog.ts) asks the server for its tools and turns the answer into a stored catalog:
- Each tool gets a stable ID:
tool-plus the first 16 hex characters of the SHA-256 of its name. - Each tool gets a
fingerprintcomputed from its name, title, description and schemas (mcpFingerprint). - A catalog with more than 128 tools, or with two tools that collide on an ID, is refused.
- A catalog that echoes the server’s own authorization token back is refused.
The catalog is stored in the server’s ServerScope with a revision. POST /api/mcp/servers/:id/refresh discovers again; if anything differs, the catalog is replaced and the revision goes up by one. Every write to a server sends expected_revision, so two administrators cannot overwrite each other.
3. Grant tools
Section titled “3. Grant tools”Nothing is usable yet. An administrator now chooses tools with PUT /api/mcp/servers/:id/grants (grantsMcpSchema). Each grant is:
{ tool_id, mode: 'read' | 'write', fingerprint, verifiers: tool_id[] }McpService.grants enforces three things:
- The fingerprint must be current. The grant names the fingerprint the administrator reviewed. If the tool’s schema changed since, the grant is refused: “The approved MCP tool schema changed. Review the current fingerprint”.
- A write tool needs a verifier. Every
writegrant must list at least one other tool that is granted asread. That read tool is how Polo checks the write afterwards. - A read tool has no verifiers.
A grant keeps working only while its fingerprint matches. activeGrant in domain/mcp.ts compares them every time, so if a later refresh brings a changed schema, the old grant silently stops applying until someone reviews the new one. A server cannot widen a tool after it was approved.
4. Grant to an agent
Section titled “4. Grant to an agent”Server grants decide which tools exist as far as Polo is concerned. Agents are a second layer. McpProvider (infrastructure/mcp.provider.ts) exposes each actively granted tool as a target of the server’s connection, with two capabilities:
| Capability | Mode | Arguments |
|---|---|---|
mcp.read |
read | target, schema_sha256, arguments |
mcp.call |
write | the same, plus verification |
An agent’s profile must be granted mcp.read or mcp.call on the specific tool. A write tool whose verifiers are no longer active is not offered at all.
5. A call at run time
Section titled “5. A call at run time”The model does not get every tool’s schema in its prompt. It sees the granted tools by name and asks for a schema with mcp.describe; the runtime keeps at most two schemas in context (MAX_MCP_SCHEMAS in runtime/operation_tools.py).
- A read runs immediately and returns a receipt.
- A call is a proposed operation and follows the operation contract. Its arguments must include a
verificationblock: which read tool to call afterwards, with which arguments, whichresult_pathto look at, and theexpectedvalue. The reviewer sees and approves all of it. schema_sha256pins the call to the schema the model read. At dispatch the client checks the live tool’s fingerprint again and refuses if it changed.McpProvidersetsrequiresDispatchReceipt. A call whose outcome cannot be confirmed becomesunknown: “The MCP mutation outcome is uncertain. Verify the recorded operation before another write.”
Because an MCP call is fully described by its arguments and carries its own verifier, it is the one capability an administrator can pre-authorize today (preauthorizableFor).
Removing a server
Section titled “Removing a server”DELETE /api/mcp/servers/:id disables the server. It is refused while the server has an approved mutation that is still unresolved, so an uncertain write cannot be orphaned.
Polo as a server
Section titled “Polo as a server”Polo serves MCP over Streamable HTTP at <public URL>/mcp (McpEndpointController, routes POST /mcp and GET /mcp).
Access tokens
Section titled “Access tokens”A member creates a token under Settings → MCP access (/api/access-tokens). The model is in domain/access.ts:
- A token has an
accesslevel,readoract, and a separateapprovalsswitch. - Each tool has a level:
read,actorapprove.allows(identity, level)decides. A member with the viewer role can only read, whatever the token says. - Polo stores only a SHA-256 hash of the token and shows the token once.
- The member’s role is read each time the token is presented, so demoting a member takes effect at once.
Each tool calls Polo’s own HTTP route as the token’s owner (infrastructure/api-caller.ts). There is no second permission system: the member’s role, the approval rules and every validation apply exactly as in the app.
| Level | Tools |
|---|---|
| read | whoami, list_agents, list_tasks, get_task, wait_for_task, list_approvals, get_approval_rules, list_connections, list_services, list_cloud_accounts, list_cloud_resources, get_cloud_resource, generate_terraform, list_incidents, list_monitors, get_monitor, list_schedules, search_knowledge, list_knowledge, list_reports, get_report, get_usage, list_models |
| act | ask_agent, answer_question, follow_up, cancel_task, set_agent_rules, scan_cloud_account, create_monitor, set_monitor_enabled, create_schedule, set_schedule_enabled, add_knowledge |
| approve | approve_change, reject_change |
No tool reaches sign-in and passwords, team members and roles, the vault, model keys or the access tokens themselves.
The plungo command line (ui/cli/plungo.js) is a client of this server, so it can do exactly what the token allows.
Tests and maturity
Section titled “Tests and maturity”| What | Where |
|---|---|
| Client, OAuth and operations | api/test/mcp/ |
| Runtime path with a fixture server | tests/test_runtime_mcp.py, tests/test_runtime_mcp_catalog.py |
| Polo’s server, with a real MCP client over HTTP | api/test/mcp-server/mcp-server.test.ts |
| The command line as a real process | api/test/mcp-server/cli.test.ts |
docs/CAPABILITIES.md rates the MCP client family as verified locally: it runs end to end against a local fixture server. It has not been run against a third-party MCP server in a recorded test.
Where to go next
Section titled “Where to go next”- The operation contract for what happens to an
mcp.callafter it is proposed. - Connections for the user’s view of connected systems.
- Agents for granting tools to an agent.