Airlock implements the Model Context Protocol (MCP) for AI agent communication.
Overview
MCP is a JSON-RPC based protocol that allows AI agents to discover and call tools. Airlock exposes your API operations as MCP tools.
Endpoints
Airlock exposes a single MCP HTTP endpoint per organization. Both URLs below are served by the same handler; agents can use either:
POST https://mcp.air-lock.ai/org/{slug}
POST https://mcp.air-lock.ai/
The endpoint provides access to all integrations in your organization through meta-tools:
| Meta-Tool | Description |
|---|---|
list_services | List all available integrations in the organization |
search_tools | Search for tools across all integrations by keyword |
describe_tools | Get detailed descriptions of specific tools |
execute_tool | Execute a tool on a specific integration |
activate_skill | Activate a skill to get its instructions and attachments |
read_skill_attachment | Load the full content of a skill attachment on demand |
list_agents | List portable agents published by the organization |
describe_agent | Get the full spec of a portable agent |
export_agent | Render a portable agent in a host's native format |
Used by Claude Desktop, Claude Code, Augment, and other MCP clients.
Protocol versions
Airlock negotiates the protocol per request via the MCP-Protocol-Version header. Supported revisions:
2026-07-28 2025-11-25 2025-06-18 2025-03-26 2024-11-05 2024-10-07
Omitting the header is treated as 2025-03-26. Any other value returns HTTP 400 with JSON-RPC error -32022 and a data.supported list of the accepted values.
The two generations use different request models, and airlock serves both from the same endpoint:
| Revision | Model |
|---|---|
2026-07-28 | Stateless. No initialize handshake and no Mcp-Session-Id. Clients call server/discover for capabilities and instructions, and carry their identity and capabilities on every request under params._meta. |
2025-* / 2024-* | Sessionful. The initialize handshake shown below, plus an Mcp-Session-Id on subsequent requests. |
Authentication
Bearer credentials
The endpoint accepts three kinds of credential in Authorization: Bearer <token>:
| Credential | Prefix | Where it comes from |
|---|---|---|
| OAuth access token | eyJ… (JWT) | The MCP OAuth flow below — what interactive clients use |
| Personal API token | alk_pat_… | Settings → Account in the Control Room. Acts as you: same role, access grants, and tool filtering as an OAuth session, and stops working when your org membership does |
| Service token | alk_svc_… | Issued to a service account for headless agents and CI |
Personal and service tokens skip the OAuth round trip entirely, which makes them the practical choice for scripts, cron jobs, and CI. Note that personal API tokens are off by default — an admin enables them for the organization under Settings → Security. See Personal API tokens.
MCP OAuth 2.0
Airlock supports the MCP OAuth 2.0 specification with PKCE and RFC 9728 protected-resource discovery:
- An unauthenticated request returns
401withWWW-Authenticate: Bearer resource_metadata="…". - Fetch the protected-resource metadata it names:
GET https://mcp.air-lock.ai/.well-known/oauth-protected-resource/org/{slug}(or/.well-known/oauth-protected-resourcefor the root endpoint). It names the authorization server. - Fetch the authorization-server metadata:
GET https://mcp.air-lock.ai/.well-known/oauth-authorization-server. - Register the client:
POST https://mcp.air-lock.ai/register. - Authorize: redirect the user to the
authorization_endpointfrom the metadata —https://control-room.air-lock.ai/mcp/authorizein production — with your PKCE parameters. Note this endpoint is on the Control Room domain, not the MCP domain. - Token exchange:
POST https://mcp.air-lock.ai/tokenwithgrant_type=authorization_code. - Token refresh:
POST https://mcp.air-lock.ai/tokenwithgrant_type=refresh_token.
Token Exchange
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code={code}&code_verifier={verifier}&redirect_uri={uri}
Response includes an opaque refresh token for long-lived sessions:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 28800,
"refresh_token": "abc123...",
"id_token": "eyJ...",
"scope": "openid profile email"
}Token Refresh
When the access token expires, request new tokens:
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token={refresh_token}&client_id={client_id}
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 28800,
"refresh_token": "def456...",
"id_token": "eyJ...",
"scope": "openid profile email"
}Refresh tokens are single-use and rotated: every successful refresh returns a new refresh_token that you must store in place of the one you sent. The token you presented stays valid only for a ~10-second grace window (so a retried request converges on the same result) and then stops working. A client that keeps replaying the original token starts failing with invalid_grant seconds later and is forced back through a full sign-in.
| Parameter | Lifetime | Notes |
|---|---|---|
| Access token | 8 hours | JWT, used in Authorization: Bearer header |
| Refresh token | 30 days | Opaque token, rotated on every use. Rotation does not extend the session: each successor wraps the same underlying credential from your original sign-in, so the chain stops working 30 days after that sign-in |
| ID token | 8 hours | JWT with user claims |
JSON-RPC Methods
initialize (legacy handshake)
Initialize the MCP session. This is the pre-2026-07-28 model — clients on 2026-07-28 call server/discover instead and never open a session:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "my-client",
"version": "1.0.0"
}
}
}Response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "<Your Organization Name> (Organization)",
"version": "1.0.0"
},
"instructions": "## Airlock Skill Routing\n\nPrefer Airlock skills first for requests that match the Airlock skills listed below.\n\n## Available Skills\n\n- **Code Review**: Review pull requests",
"_airlock": {
"bootstrap_version": 1,
"model_catalog_version": 1,
"skill_source": "preferred",
"disable_local_skills": false,
"routing_mode": "airlock_first",
"airlock_authority_scope": "matching_skills",
"activation_tool": "activate_skill",
"attachment_tool": "read_skill_attachment",
"skills": [
{
"id": "skill-1",
"name": "Code Review",
"description": "Review pull requests",
"ownership": "organization",
"tags": ["engineering"]
}
]
}
}
}The serverInfo.name is your organization's display name followed by (Organization) — for example, Acme (Organization).
For organization-wide airlock MCP connections the bootstrap is delivered as a vendor extension. On the legacy (2025-* / 2024-*) path it rides the initialize response at result._airlock. On the 2026-07-28 path there is no handshake, so the same object is returned under result._meta["airlock/bootstrap"] on server/discover and tools/list. Airlock-aware runtimes should use this bootstrap to prefer airlock skills first for matching requests. Local skills may still remain available when no airlock skill matches.
model_catalog_version is the version of the model catalog this deployment serves — a runtime that caches the catalog can compare it on each handshake and re-fetch when it changes.
2026-07-28 responses also carry result._meta.correlation_id, and result._meta.trace_id when the request included a valid W3C traceparent header.
Skill catalog visibility depends on the client. The skills catalog is delivered in
result.instructionsandresult._airlock.skills, but only clients that forwardinitialize.result.instructionsto the model as system context surface it to the agent. Claude Code and claude.ai do this; the Anthropic Managed Agents runtime does not (verified 2026-04-21). For runtimes that dropresult.instructions, instruct the agent in its system prompt to callairlock-management/list_skills(viaexecute_tool) beforeactivate_skill. See the Skills guide for the workaround and a copy-paste prompt fragment.
tools/list
List available tools:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}The organization endpoint exposes a small set of meta-tools, not your individual API operations. tools/list returns the meta-tools, and you discover and run individual operations through them.
Response:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{ "name": "list_services", "description": "List all connected services (APIs) available in this organization." },
{ "name": "search_tools", "description": "Search for available tools across all connected services by keyword." },
{ "name": "describe_tools", "description": "Get full input schemas for specific tools by their exact namespaced names." },
{ "name": "execute_tool", "description": "Execute a tool on any connected service." },
{ "name": "activate_skill", "description": "Activate a skill to get its instructions and attachments." },
{ "name": "read_skill_attachment", "description": "Read the full content of a skill attachment." },
{ "name": "list_agents", "description": "List portable agents published by this organization." },
{ "name": "describe_agent", "description": "Get the full spec of a portable agent." },
{ "name": "export_agent", "description": "Render a portable agent in a host's native format." }
]
}
}Airlock's virtual airlock/check_status tool (used to poll the outcome of an approval-gated call) is also available through these meta-tools.
Clients that negotiate the MCP Apps UI extension (io.modelcontextprotocol/ui) additionally see three tools — airlock_approval_card, airlock_ai_policy_sign_card, and airlock_widget.
The first two are render-only: they display a card inside the conversation but never perform the action they show, because approvals and policy signatures always happen in your authenticated Control Room session. airlock_widget is different — it actually executes the upstream tool it renders, through the same pipeline as execute_tool (grants, security blocks, policy, budget, and audit all apply), then displays the result in the airlock broker frame.
tools/call
Individual operations are not listed by tools/list and are not invoked directly by operationId. Instead, discover them with search_tools / describe_tools, then run them through execute_tool. Tools are referenced by their namespaced name in the form service-slug/tool-name (e.g. github/create_issue).
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "execute_tool",
"arguments": {
"tool": "github/list_issues",
"arguments": {
"limit": 10
}
}
}
}Response (success):
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "[{\"id\": 1, \"title\": \"Fix login bug\"}, ...]"
}
]
}
}Response (approval required — legacy format):
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "{\"status\": \"PENDING_APPROVAL\", \"requestId\": \"abc123\", \"taskId\": \"task-abc123\", \"tool\": \"github/create_issue\", \"approvalUrl\": \"https://control-room.air-lock.ai/requests/abc123\", \"message\": \"This action requires human approval. The user has been notified and can approve at: https://control-room.air-lock.ai/requests/abc123. To check the result, use execute_tool with tool \\\"airlock/check_status\\\" and request_id \\\"abc123\\\".\"}"
}
]
}
}Tip: The approval response shown above is a structured JSON object with the
requestId, ataskId, the gatedtool, theapprovalUrl, and a human-readablemessage. To observe the decision, the agent callsexecute_toolwith toolairlock/check_statusand therequest_id(a poll interval of ~15s is suggested).The exact payload depends on what your client negotiated. Clients that implement SEP-1036 URL elicitation receive
"status": "AWAITING_USER_ACTION"with anelicitationobject (mode: "url",elicitationId,url) instead of"status": "PENDING_APPROVAL"withapprovalUrl. Clients that support the MCP Apps UI extension receivePENDING_APPROVALplus auiobject pointing at theairlock_approval_cardtool. Treat bothPENDING_APPROVALandAWAITING_USER_ACTIONas "awaiting a human", and read the link fromapprovalUrlorelicitation.url— whichever is present.
MCP Tasks
On the 2026-07-28 path, airlock implements the MCP Tasks extension (SEP-2663). A client that opts in receives approval-gated calls as tasks and resolves them with the standard protocol methods instead of airlock's airlock/check_status tool.
Opting in requires two things, both per request: the 2026-07-28 protocol version, and the tasks extension declared in params._meta. The examples above use the legacy handshake, where the declaration is ignored and you always get the text payload.
// POST with header: MCP-Protocol-Version: 2026-07-28
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "execute_tool",
"arguments": {
"tool": "github/create_issue",
"arguments": { "title": "Fix login bug" }
},
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
}An approval-gated call then returns a CreateTaskResult instead of the payloads above:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"resultType": "task",
"taskId": "task-abc123",
"status": "working",
"statusMessage": "Waiting for approval — review and approve at: https://control-room.air-lock.ai/requests/abc123",
"createdAt": "2026-07-26T10:00:00.000Z",
"lastUpdatedAt": "2026-07-26T10:00:00.000Z",
"ttlMs": 86400000,
"pollIntervalMs": 15000
}
}The task fields are flat — there is no task wrapper object. Because a task result has no content blocks, the approval URL rides statusMessage while the task is working.
The declaration is per request. A tasks/get that omits it is answered with -32021 Missing Required Client Capability, so send the same _meta block on every poll. Clients that do not declare the extension keep the legacy format above, byte for byte.
| Method | Behavior |
|---|---|
tasks/get | Returns the task fields flat, under resultType: "complete". A completed task inlines the original tool result under result; a failed one carries error. |
tasks/cancel | Cooperative — always acknowledged with an empty result for a known task. Airlock honors it by withdrawing a still-pending approval, unless execution has already been claimed. Observe the real outcome with tasks/get. |
tasks/update | Acknowledged with an empty result. |
tasks/result and tasks/list were removed when Tasks left the core spec and are not served.
Approval statuses map onto task statuses as: pending → working, approved → completed (with the tool's output), rejected → cancelled, expired → failed.
Tool Mapping
OpenAPI operations map to MCP tools:
| OpenAPI | MCP Tool |
|---|---|
operationId: list_users | Tool name: list_users |
No operationId | Name derived from method + path — GET /users/{id} becomes get_users_id |
description (falls back to summary) | Tool description |
| Request body schema | Tool inputSchema |
| Path parameters | Added to inputSchema.properties |
| Query parameters | Added to inputSchema.properties |
Tool names are truncated to 64 characters. Use the truncated name in policy rules — it is the name both the runtime and the policy engine match on.
Error Handling
Airlock returns failures in two different shapes, and a client needs to check both.
Tool errors
A tool that is denied by policy, blocked, unavailable under your access grants, or that fails upstream returns a normal JSON-RPC result with isError: true and the reason in the text content block — not a JSON-RPC error:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{ "type": "text", "text": "Denied by policy: create_issue requires approval" }
],
"isError": true
}
}Always check result.isError, not just the presence of error. A client that only inspects error will silently treat a policy-denied call as a success.
The AI use policy gate is a notable case — the call was understood and deliberately not performed:
{
"content": [{ "type": "text", "text": "AI Use Policy acknowledgement required. … No tool call was made." }],
"isError": true,
"structuredContent": {
"status": "POLICY_SIGNATURE_REQUIRED",
"signUrl": "https://control-room.air-lock.ai/policy"
}
}structuredContent is only present for clients that negotiated the MCP Apps UI extension or SEP-1036 URL elicitation. A plain client receives just the text block with isError: true, with the sign-in URL inside the message text.
No upstream request was made. Surface the sign URL to the user and retry after they sign.
Protocol errors
Malformed JSON, unknown methods, invalid params, and unsupported protocol versions return a JSON-RPC error object:
{
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": {
"supported": ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", "2024-10-07"],
"requested": "2027-01-01"
}
}
}Error Codes
| Code | Meaning |
|---|---|
| -32700 | Parse error |
| -32600 | Invalid request |
| -32601 | Method not found |
| -32602 | Invalid params |
| -32603 | Internal error |
| -32020 | Header mismatch — the Mcp-Method or Mcp-Name request header disagrees with the JSON-RPC body (2026-07-28 only); data.mismatch says which |
| -32021 | Missing required client capability — e.g. a tasks/* call from a request that did not declare io.modelcontextprotocol/tasks; data.requiredCapabilities lists what to declare |
| -32022 | Unsupported MCP-Protocol-Version; data.supported lists the accepted revisions |
Rate Limiting
Airlock does not enforce a request-count cap on the MCP endpoint — request counts are tracked for analytics only (surfaced on the Usage and Analytics pages). Runtime spend is instead bounded per integration by token budgets: when an integration's hard token limit is crossed, its tool calls return an error until the budget resets or is raised.
An abuse-protection rate limit still applies: requests that trip it receive an HTTP 429 Too Many Requests response with a Retry-After header indicating how long to wait before retrying.