MCP & agents / MCP gateway API

# MCP gateway API reference

Every MCP gateway route: directory, connections, accounts and connect links, toolsets and their MCP endpoint, approvals, the trace and OAuth for MCP clients.

## Routes

Management routes under `/api/v1/organizations/{org}` take a console session token and the permission shown; unknown ids answer 404, never 403. Routes under `/v1` take an API key with the scope shown. The toolset endpoint also accepts Yelno MCP OAuth tokens. Errors use the gateway's [error envelope](https://yelno.ai/docs/errors), except the OAuth protocol endpoints (RFC 6749 and RFC 7591 shapes) and the MCP endpoint (JSON-RPC). Platform staff have their own routes under `/api/v1/admin/mcp` for the directory, host blocks, kill switches and pricing (setting a per-call price, or ending an organization's override with `DELETE /api/v1/admin/mcp/pricing/{organization_id}?reason=`); they are not listed here.

| Method | Path | Auth | What it does |
| --- | --- | --- | --- |
| GET | /api/v1/mcp/directory | public | Active directory entries. ?category=, ?q= |
| GET | /api/v1/mcp/directory/{id_or_slug} | public | One entry; 404 when disabled or unknown. |
| GET | /api/v1/organizations/{org}/mcp/connections | policy:read | List connections. ?status=, ?q= |
| POST | /api/v1/organizations/{org}/mcp/connections | policy:update | Create (201); 400 approval_required under an approval policy. |
| GET | /api/v1/organizations/{org}/mcp/connections/{id} | policy:read | One connection. |
| PATCH | /api/v1/organizations/{org}/mcp/connections/{id} | policy:update | Update fields, credential or OAuth client. |
| DELETE | /api/v1/organizations/{org}/mcp/connections/{id} | policy:update | Delete; revokes tokens, accounts, links, toolset memberships. |
| POST | /api/v1/organizations/{org}/mcp/connections/{id}/check | policy:update | Run a health check now. |
| POST | /api/v1/organizations/{org}/mcp/connections/{id}/refresh-tools | policy:update | Re-list tools now. |
| GET | /api/v1/organizations/{org}/mcp/connections/{id}/tools | policy:read | Cached tools. ?external_user_id= |
| GET | /api/v1/organizations/{org}/mcp/connections/{id}/health | policy:read | Health history, newest first. ?limit= (up to 200) |
| POST | /api/v1/organizations/{org}/mcp/connections/{id}/oauth/start | policy:update | Start managed OAuth; returns authorize_url. |
| GET | /api/v1/organizations/{org}/mcp/connections/{id}/accounts | policy:read | Connected accounts. ?external_user_id= |
| PUT | /api/v1/organizations/{org}/mcp/connections/{id}/accounts/{external_user_id} | policy:update | Set an end user's bearer or header credential. |
| DELETE | /api/v1/organizations/{org}/mcp/connections/{id}/accounts/{account_id} | policy:update | Revoke one account. |
| POST | /api/v1/organizations/{org}/mcp/connections/{id}/connect-links | policy:update | Create a connect link. |
| GET | /api/v1/organizations/{org}/mcp/connections/{id}/connect-links | policy:read | Links still pending. |
| GET | /api/v1/organizations/{org}/mcp/toolsets | policy:read | List toolsets. |
| POST | /api/v1/organizations/{org}/mcp/toolsets | policy:update | Create a toolset. |
| GET | /api/v1/organizations/{org}/mcp/toolsets/{id} | policy:read | One toolset. |
| PATCH | /api/v1/organizations/{org}/mcp/toolsets/{id} | policy:update | Update; members replaces the list. |
| DELETE | /api/v1/organizations/{org}/mcp/toolsets/{id} | policy:update | Delete; revokes its OAuth grants and tokens. |
| GET | /api/v1/organizations/{org}/mcp/toolsets/{id}/tools | policy:read | The toolset's tools as clients see them. ?external_user_id= |
| GET | /api/v1/organizations/{org}/mcp/approvals | approval:read | Approvals. ?status=, ?toolset_id=, ?limit= |
| GET | /api/v1/organizations/{org}/mcp/approvals/{id} | approval:read | One approval. |
| POST | /api/v1/organizations/{org}/mcp/approvals/{id}/approve | approval:decide | Approve, optionally always allow. |
| POST | /api/v1/organizations/{org}/mcp/approvals/{id}/deny | approval:decide | Deny. |
| GET | /api/v1/organizations/{org}/mcp/stats | usage:read | Calls, errors and latency, grouped. |
| GET | /api/v1/organizations/{org}/mcp/pricing | policy:read | The per-call price that applies to you. |
| GET | /api/v1/organizations/{org}/mcp/oauth-grants | policy:read | OAuth grants MCP clients hold. |
| POST | /api/v1/organizations/{org}/mcp/oauth-grants/{id}/revoke | policy:update | Revoke a grant and its tokens. |
| GET | /api/v1/organizations/{org}/mcp-calls | usage:read | The MCP trace, with gateway filters; cursor paging. |
| POST | /v1/mcp/toolsets/{toolset_id} | API key or MCP OAuth token | The toolset's MCP endpoint (GET and DELETE: 405). |
| POST | /v1/responses | inference:invoke | connection and toolset references in mcp tools. |
| POST | /v1/mcp/connect-links | mcp:connect | Create a connect link from your backend. |
| GET | /v1/mcp/accounts | mcp:connect | Connected accounts. ?connection=, ?external_user_id= |
| DELETE | /v1/mcp/accounts/{account_id} | mcp:connect | Revoke an account. |
| GET | /v1/mcp/connections | mcp:connect | Your connections, summarized. |
| GET | /api/v1/mcp/connect/{token} | link token | What the hosted connect page shows. |
| POST | /api/v1/mcp/connect/{token}/start | link token | OAuth servers: returns authorize_url. |
| POST | /api/v1/mcp/connect/{token}/credential | link token | Bearer or header servers: save the value. |
| GET | /api/v1/mcp/oauth/client-metadata.json | public | Yelno's client metadata document (Yelno as MCP client). |
| GET | /api/v1/mcp/oauth/callback | flow state | Managed OAuth callback; redirects the browser. |
| GET | /.well-known/oauth-protected-resource/v1/mcp/toolsets/{id} | public | Protected resource metadata for a toolset. |
| GET | /.well-known/oauth-authorization-server | public | Authorization server metadata. |
| POST | /api/v1/mcp/oauth/register | public | Dynamic client registration. |
| GET | /api/v1/mcp/oauth/authorize | session | Consent payload for the consent screen. |
| POST | /api/v1/mcp/oauth/authorize | session | Allow or deny; returns redirect_to. |
| POST | /api/v1/mcp/oauth/token | public client | Authorization code and refresh token grants. |
| POST | /api/v1/mcp/oauth/revoke | public client | Revoke a token (always 200). |
| GET | /api/v1/mcp/oauth/grants | session | Your own grants. |
| POST | /api/v1/mcp/oauth/grants/{id}/revoke | session | Revoke one of your own grants. |

Two browser pages complete the picture: the hosted connect page at `/mcp/connect/{token}` and the OAuth consent screen at `/mcp/oauth/authorize`. Ids and amounts in the examples below are illustrative. The walk-through is in the [MCP gateway guide](https://yelno.ai/docs/mcp-gateway).

## Directory

`GET /api/v1/mcp/directory` is public and returns `{data: DirectoryEntryOut[]}`, active entries only, filtered by `?category=` and `?q=`. `GET /api/v1/mcp/directory/{id_or_slug}` returns one, or 404 when it is disabled or unknown. Each entry was verified against the vendor's own documentation (`source_url`, `verified_at`). To connect one, create a connection with its `directory_entry_id`.

DirectoryEntryOut

```
{ "id": "mcpd_…", "slug": "linear", "name": "Linear", "publisher": "Linear",
  "description": "Issues, projects and cycles in your Linear workspace.",
  "category": "productivity",         // developer | productivity | payments | data | search | knowledge
                                      // | design | crm | support | cloud | automation | ai
  "icon_slug": "linear",
  "server_url": "https://mcp.linear.app/mcp",
  "transport": "streamable_http",     // the only transport supported
  "auth_type": "oauth2",              // none | bearer | header | oauth2
  "auth_header_name": null,           // for auth_type header, e.g. "x-api-key"
  "alt_auth": ["bearer"],             // other credential kinds the server accepts
  "client_registration": "auto",      // oauth2: auto | preregistered (needs a client id and secret)
  "has_platform_client": false,       // staff stored an OAuth client for this entry
  "docs_url": "https://linear.app/docs/mcp",
  "notes": "…",
  "requires_vendor_approval": false,  // the vendor only admits approved clients
  "state": "active",
  "verified_at": "…", "source_url": "https://linear.app/docs/mcp" }
```

## Connections

ConnectionOut

```
{ "id": "mcpcn_…", "organization_id": "org_…", "slug": "github", "name": "GitHub",
  "description": null,
  "server_url": "https://api.githubcopilot.com/mcp/", "host": "api.githubcopilot.com",
  "transport": "streamable_http",
  "auth_type": "oauth2",              // none | bearer | header | oauth2
  "auth_header_name": null,
  "ownership": "organization",        // organization | end_user
  "directory_entry_id": "mcpd_…", "directory_slug": "github",
  "enabled": true,                    // your kill switch
  "staff_disabled": false,            // platform kill switch; calls refused while true
  "status": "healthy",                // pending | healthy | degraded | unhealthy | needs_auth | needs_reauth | disabled
  "status_detail": null,              // {code, message} when not healthy
  "protocol": { "era": "legacy", "version": "2025-06-18" },
  "oauth": { "issuer": "…", "client_registration": "preregistered", "scopes": ["repo"], "has_client_secret": true },
  "account": { … },                   // organization ownership: its one account, or null
  "accounts_count": 0,                // end_user ownership: connected accounts
  "tools_count": 41, "tools_refreshed_at": "…", "last_checked_at": "…", "next_check_at": "…",
  "last_used_at": "…",
  "rate_limit_per_minute": null,      // null: no per-connection limit
  "call_timeout_seconds": 30,         // 1 to 120
  "created_by": "user_…", "created_at": "…", "updated_at": "…", "version": 3 }
```

ConnectedAccountOut

```
{ "id": "mcpa_…", "connection_id": "mcpcn_…",
  "external_user_id": null,           // null: the organization's account
  "status": "active",                 // active | needs_reauth | revoked
  "credential_hint": "Bearer ••••3f9a",   // OAuth: "OAuth (repo, read:org)"; never the secret
  "oauth": { "scopes": ["repo"], "expires_at": "…", "refreshable": true },   // or null
  "created_at": "…", "updated_at": "…", "last_used_at": "…" }
```

`POST /api/v1/organizations/{org}/mcp/connections` creates one and answers `201` with the connection:

Request body

```
{ "name": "GitHub", "slug": "github",            // slug optional; [a-z0-9][a-z0-9-]{0,47}
  "directory_entry_id": "mcpd_…",                     // OR server_url (+ transport, auth_type)
  "server_url": "https://mcp.example.com/mcp",        // https; no user info; no fragment
  "transport": "streamable_http",                     // "sse" is 400 mcp_transport_unsupported
  "auth_type": "bearer",
  "ownership": "organization",
  "credential": { "token": "…" },                     // bearer: {token}; header: {header_name, value}
  "oauth": { "client_id": "…", "client_secret": "…", "scopes": ["repo"] },   // optional pre-registered client
  "rate_limit_per_minute": 120, "call_timeout_seconds": 30,
  "reason": "…" }
```

400 · approval required

```
// 400 when an approval policy covers data_policy.relax
{ "error": { "type": "invalid_request", "code": "approval_required",
             "param": "approval_request_id", "message": "…",
             "approval_request_id": "apr_…" } }

// once approved, send the same request again with
X-Yelno-Approval: <approval_request_id>
```

- Give `directory_entry_id` or `server_url`. With an entry, URL, transport, auth type and header name come from it.
- Only the Streamable HTTP transport is supported: `transport: "sse"` is `400 mcp_transport_unsupported`.
- Registering a connection lets the gateway send data to a new host, so it is a data-policy relaxation. Under an enabled `data_policy.relax` approval policy, create (and a PATCH that changes `server_url`) answers `400 approval_required` with `error.approval_request_id`, and applies once resubmitted with `X-Yelno-Approval`. A blocked host is `403 mcp_host_blocked`.
- `credential` is valid only for an organization connection with `bearer` (`{token}`) or `header` (`{header_name, value}`) auth; values are single-line and up to 8 KiB; the header name must be an HTTP token and not a transport header (`Host`, `Content-*`, `Mcp-*`, `X-Yelno-Hop`, …).
- For `none`, `bearer` and `header` organization connections the create runs a health check within `call_timeout_seconds`; a failure returns the connection `unhealthy`, not an error. `oauth2` connections start `needs_auth`.
- Up to 100 connections per organization (`400 mcp_limit_reached`). Other errors: `mcp_server_url_invalid`, `mcp_slug_taken`, `mcp_directory_entry_unavailable`, `version_conflict`. Changes are audited as `mcp_connection.create`, `update` and `delete`, with `credential_set: true|false` instead of any secret.

`PATCH …/connections/{id}` accepts `name`, `description`, `slug`, `enabled`, `rate_limit_per_minute`, `call_timeout_seconds`, `server_url` (a data-policy relaxation, like create), `credential`, `oauth`, `version` and `reason`. `DELETE` answers `{id, deleted: true}`: it revokes upstream OAuth tokens (best effort), deletes accounts, flows and links, and removes the connection from every toolset. `POST …/check` runs a health check and returns a `HealthCheckOut`; `POST …/refresh-tools` returns `{tools_count, tools_refreshed_at}`.

ToolOut (GET …/connections/{id}/tools)

```
{ "name": "create_issue", "title": "…", "description": "…",
  "input_schema": { … }, "output_schema": { … },
  "annotations": { "readOnlyHint": false, "destructiveHint": true },
  "read_only": false,                 // annotations.readOnlyHint === true
  "destructive": true,                // !read_only && annotations.destructiveHint !== false
  "approval_default": true }
```

HealthCheckOut (GET …/connections/{id}/health)

```
{ "id": "mcph_…", "connection_id": "mcpcn_…", "checked_at": "…",
  "status": "ok",                     // ok | error | auth_required
  "latency_ms": 212, "tools_count": 41, "error_code": null,
  "protocol_version": "2025-06-18", "era": "legacy" }
```

Managed OAuth

```
POST …/connections/{id}/oauth/start
{ "external_user_id": "user-42",   // optional: an end user's account
  "for_me": false,                 // true: connect your own account (stored as yelno_user:<id>)
  "return_to": "…" }               // optional; a console page (else 400); default /console/mcp/connections/{id}
→ { "flow_id": "mcpf_…", "authorize_url": "https://…", "expires_at": "…" }
```

`return_to` must be a page of the console's origin, else 400. The browser returns from the vendor to `GET /api/v1/mcp/oauth/callback`, which redirects to `return_to` with `?mcp_oauth=connected|error&connection={id}&error_code=…`. For a connect link it redirects to the link's `return_url`, or without one to the hosted page `/mcp/connect/{token}`, never to the console. The vendor's error text is never echoed. `GET /api/v1/mcp/oauth/client-metadata.json` is the client metadata document Yelno presents to vendors that support it (`client_name: "Yelno MCP Gateway"`, public client, `authorization_code` and `refresh_token` grants).

## Accounts and connect links

`GET …/connections/{id}/accounts` lists `ConnectedAccountOut` rows (`?external_user_id=`). `PUT …/connections/{id}/accounts/{external_user_id}` with `{"credential": {…}}` sets an end user's bearer or header credential. `DELETE …/connections/{id}/accounts/{account_id}` revokes one account, with upstream revocation where possible. External user ids are 1 to 256 characters of `[A-Za-z0-9._:@|-]` (`400 mcp_external_user_invalid` otherwise).

Create a connect link

```
POST /api/v1/organizations/{org}/mcp/connections/{id}/connect-links
POST /v1/mcp/connect-links                      // body adds "connection": "<id|slug>"
{ "external_user_id": "user-42",
  "return_url": "https://app.example.com/settings/integrations",   // optional; https
  "expires_in_seconds": 3600 }                  // 60 to 604800, default 3600
→ 201 { "id": "mcpl_…", "url": "https://yelno.ai/mcp/connect/ymcl_…", "token": "ymcl_…",
        "connection_id": "mcpcn_…", "external_user_id": "user-42", "expires_at": "…" }
```

The token is shown once; the link is single use and consumed when the account connects. A link for a user who already has an account replaces that account. Links are valid only for end-user connections with `bearer`, `header` or `oauth2` auth; an organization connection's single account is authorized from the console (`400 mcp_connect_link_invalid` otherwise). `GET …/connections/{id}/connect-links` lists links still pending.

Hosted connect routes (no sign-in; the token is the authority)

```
GET /api/v1/mcp/connect/{token}
{ "connection": { "name": "Linear", "auth_type": "oauth2", "auth_header_name": null,
                  "directory": { "name": "Linear", "icon_slug": "linear", "docs_url": "…" } },
  "organization": { "name": "Acme" },
  "external_user_id": "user-42",
  "state": "pending",                 // pending | connected | expired
  "expires_at": "…" }

POST /api/v1/mcp/connect/{token}/start        → { "authorize_url": "https://…" }
POST /api/v1/mcp/connect/{token}/credential   { "value": "…" }
                                              → { "state": "connected", "return_url": "…" }
```

Unknown tokens answer 404. The credential route checks the value with `tools/list` first (`400 mcp_credential_rejected`). Attempts are limited to 10 per link and 30 per minute per source IP (`429 rate_limited`). After an OAuth sign-in the user lands on the link's `return_url` with `?mcp_oauth=connected|error` (and `error_code`), or on the hosted page itself when there is none. The hosted page reads `state: "connected"` even after the link is spent. Connections are audited as `mcp_account.connect` with `via: "connect_link"`.

## Toolsets

ToolsetOut

```
{ "id": "mcpts_…", "organization_id": "org_…", "slug": "eng-agents", "name": "Engineering agents",
  "description": null, "enabled": true, "staff_disabled": false,
  "read_only": false,
  "approval_mode": "destructive",     // destructive (default) | always | never
  "elicitation_approvals": true,      // in-place approval for OAuth callers
  "elicitation_for_api_keys": false,  // ...and for API-key callers (off: automation could approve itself)
  "redact_secrets": true,
  "flag_prompt_injection": true,
  "include_yelno_tools": false,       // API-key callers only
  "rate_limit_per_minute": 600,       // per calling principal; null = platform default (600)
  "project_ids": [], "api_key_ids": [],   // empty = any key of the organization
  "oauth_enabled": true,
  "members": [
    { "connection_id": "mcpcn_…", "connection_slug": "github", "connection_name": "GitHub",
      "namespace": "github",          // [a-z0-9][a-z0-9_-]{0,31}
      "allowed_tools": null,          // null = every tool
      "denied_tools": ["delete_repository"],
      "approval_required": ["create_pull_request"],
      "approval_exempt": ["search_issues"],
      "rate_limit_per_minute": null } ],
  "endpoint_url": "https://yelno.ai/v1/mcp/toolsets/mcpts_…",
  "resource_metadata_url": "https://yelno.ai/.well-known/oauth-protected-resource/v1/mcp/toolsets/mcpts_…",
  "tools_count": 37, "version": 4, "created_by": "user_…", "created_at": "…", "updated_at": "…" }
```

`POST …/mcp/toolsets` takes the writable fields above plus `reason`. `PATCH …/mcp/toolsets/{id}` takes any writable field; `members` replaces the whole list, and `version` is checked (`400 version_conflict`). `DELETE` revokes the toolset's OAuth grants and tokens. Up to 50 toolsets per organization (`400 mcp_limit_reached`) and 20 members per toolset (`400 invalid_request`, `param: "members"`). Other errors: `mcp_namespace_invalid`, `mcp_namespace_taken`, `mcp_connection_not_found`, `mcp_slug_taken`, `mcp_slug_invalid`, `version_conflict`. Audited as `mcp_toolset.create`, `update` and `delete`.

Approval rule per tool, first match wins: `approval_required`, then `approval_exempt`, then `approval_mode` (`always`, `never`, or `destructive`: approval unless `readOnlyHint: true` or `destructiveHint: false`). Tools are exposed as `<namespace>__<tool>`; a name outside `[A-Za-z0-9_.-]` or over 128 characters is made safe (other characters become `_`, truncated, de-duplicated with `_2`, `_3`) and mapped back.

With `read_only` on, tools that are not read-only are unlisted, and calling one is JSON-RPC `-32602` like any filtered tool. `elicitation_approvals` lets people signed in with OAuth approve a call in their client; `elicitation_for_api_keys` extends that to API-key callers and is off by default, because a headless automation could approve its own calls.

ToolsetToolOut (GET …/toolsets/{id}/tools)

```
{ "name": "github__create_issue", "namespace": "github", "connection_id": "mcpcn_…",
  "upstream_name": "create_issue", "title": "…", "description": "…",
  "annotations": { … }, "read_only": false, "approval_required": true, "input_schema": { … } }
```

## Toolset MCP endpoint

`POST` `/v1/mcp/toolsets/{toolset_id}` `mcp:toolset · inference:invoke · OAuth`

Streamable HTTP, stateless. No session id is issued and a received `Mcp-Session-Id` is ignored. Each request gets one `application/json` JSON-RPC message; a body of only notifications or responses gets 202 with no body; legacy batches get an array. `GET` and `DELETE` answer 405 with `Allow: POST`. A browser `Origin` other than the console's gets 403. Because the endpoint is stateless, `initialize` is optional: `tools/list` and `tools/call` work without it.

Authentication. `Authorization: Bearer yk_live_…` (or `yk_test_…`, or `x-api-key`) with `mcp:toolset` or `inference:invoke`, from the toolset's organization (another organization's key gets 404, `-32600`) and inside its project and key scoping (403, `-32001` with `data.error.code` `mcp_toolset_key_not_allowed`). Or `Authorization: Bearer ymcp_at_…`, a live MCP OAuth token whose resource is this toolset's URL, from an active grant whose member still holds `key:create`. Management tokens are refused with 401. A disabled toolset is 403 with `data.error.code` `mcp_toolset_disabled`; an organization the platform switched off is 403 with `mcp_gateway_disabled`. The full status table is under Errors.

401

```
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="yelno", resource_metadata="https://yelno.ai/.well-known/oauth-protected-resource/v1/mcp/toolsets/mcpts_…", scope="mcp:toolset"

{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32001, "message": "…" } }
```

Without `oauth_enabled`, the challenge omits `resource_metadata`.

Protocol versions. Modern `2026-07-28` and legacy `2025-11-25`, `2025-06-18` and `2025-03-26`. Legacy: `initialize` echoes a supported requested version, else `2025-11-25`; then `notifications/initialized` (202), `ping`, `tools/list`, `tools/call`; an `MCP-Protocol-Version` header naming an unsupported version gets 400. Modern: requests carry `params._meta["io.modelcontextprotocol/protocolVersion"]`; the `MCP-Protocol-Version`, `Mcp-Method` and (for `tools/call`) `Mcp-Name` headers must match the body (400, `-32020`); an unsupported version is 400 `-32022` with `data: {supported, requested}`. Every modern result carries `resultType`. Capabilities are `{"tools": {"listChanged": false}}`; `resources/*` and `prompts/*` are not offered.

server/discover (modern)

```
{ "resultType": "complete",
  "supportedVersions": ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26"],
  "capabilities": { "tools": { "listChanged": false } },
  "instructions": "…",               // lists the namespaces
  "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "yelno-toolset", "title": "Engineering agents", "version": "1.0" } },
  "ttlMs": …, "cacheScope": "private" }
```

tools/list aggregates every enabled member's cached tools, filtered by `allowed_tools`, `denied_tools`, read-only mode and the caller's accounts, in member order then upstream order, 100 per page with an opaque `nextCursor`. Yelno's own tools come first when `include_yelno_tools` is on (API-key callers). Each tool keeps its upstream `inputSchema`, `outputSchema`, `annotations`, `title`, `icons` and `description` (up to 4,000 characters) under its namespaced name, and `_meta` gains `"io.yelno/approval": "required" | "none"`. Modern results carry `ttlMs: 60000` and `cacheScope: "private"`.

tools/call checks, in order (the first failure decides):

| # | Check |
| --- | --- |
| 1 | Unknown or filtered tool: JSON-RPC -32602. |
| 2 | Connection disabled, staff-disabled, host-blocked or not authorized yet: tool error mcp_connection_unavailable. (A disabled toolset is refused with 403 before any method.) |
| 3 | Read-only toolset and the tool is not read-only: the tool is unlisted and unknown, JSON-RPC -32602. |
| 4 | Account: the organization's, or the end user's from X-Yelno-External-User (API key) or yelno_user:<id> (OAuth). None: tool error mcp_account_missing with connect_hint. |
| 5 | Rate limits: tool error mcp_rate_limited with retry_after_seconds. |
| 6 | Approval: tool error mcp_approval_required or mcp_approval_denied, or an elicitation input request for modern clients. |
| 7 | Price hold when the per-call price is above 0: tool error insufficient_funds. |
| 8 | The upstream tools/call with the account's credential, within call_timeout_seconds, response up to 2 MB. An expiring OAuth token is refreshed first; an upstream 401 marks the account needs_reauth (tool error mcp_connection_needs_reauth). |
| 9 | Output: secret redaction, injection flagging, size caps. |
| 10 | Trace row, content capture when allowed, charge captured or released. |

Result forwarding. `text`, `image` and `audio` content (base64 up to 1 MB each), `resource_link` and `resource` (embedded text or blob up to 1 MB) pass through. A larger media item becomes a text note such as `[image omitted by the gateway: 3.2 MB exceeds 1 MB]`. `structuredContent` passes through up to 1 MB, else it is dropped with a note. An upstream `isError: true` result passes through as-is. Results carry `_meta["io.yelno/call_id"]` and, when output was flagged, `_meta["io.yelno/flags"]`.

Gateway tool errors are results with `isError: true`, a readable `content` text, and `structuredContent.error` = `{code, message, …}` (codes under Errors). An upstream `isError` result passes through unchanged and is traced as `mcp_tool_error`:

Tool error

```
{ "jsonrpc": "2.0", "id": 3, "result": {
  "isError": true,
  "content": [{ "type": "text", "text": "…" }],
  "structuredContent": { "error": { "code": "mcp_rate_limited", "message": "…", "retry_after_seconds": 12 } },
  "_meta": { "io.yelno/call_id": "mcpc_…" } } }
```

## Approvals

`GET …/mcp/approvals` (`approval:read`) lists `{data: ApprovalOut[]}`, filtered by `?status=pending|approved|denied|expired|used`, `?toolset_id=` and `?limit=`; `GET …/approvals/{id}` returns one. `POST …/approvals/{id}/approve` (`approval:decide`) takes `{always_allow?: false, reason?}`; `always_allow` also adds the tool to the member's `approval_exempt` (an audited toolset update). `POST …/approvals/{id}/deny` takes `{reason?}`. Deciding an approval that is not pending is `409 mcp_approval_decided`. Decisions are audited as `mcp_approval.approve` and `deny`.

ApprovalOut

```
{ "id": "mcpap_…", "organization_id": "org_…", "toolset_id": "mcpts_…", "toolset_name": "Engineering agents",
  "connection_id": "mcpcn_…", "connection_name": "GitHub",
  "tool": "github__create_issue", "upstream_tool": "create_issue",
  "arguments_sha256": "…",
  "arguments": null,                  // only while content capture for the mcp surface is on
  "external_user_id": "user-42",
  "requested_by": { "kind": "api_key", "api_key_id": "key_…", "user_id": null, "client_name": null },
  "status": "pending",                // pending | approved | denied | expired | used
  "decided_by": null, "decided_at": null, "decided_via": null,   // console | elicitation
  "reason": null, "always_allow": false,
  "expires_at": "…", "approved_until": null, "used_at": null, "call_id": null,
  "approval_url": "https://yelno.ai/console/mcp/approvals/mcpap_…", "created_at": "…" }
```

An approved call must be retried within 15 minutes (`approved_until`); a pending approval expires after 24 hours; each approval runs at most one call, and the call's trace row carries `approval_id`. In-place approval by elicitation goes to OAuth callers on modern clients when `elicitation_approvals` is on, and to API-key callers only with `elicitation_for_api_keys`; everyone else gets the `mcp_approval_required` tool error with the `approval_url`. Approvals for Responses references use the Responses round trip instead.

## Trace and statistics

`GET /api/v1/organizations/{org}/mcp-calls` (`usage:read`) takes the filters `connection_id`, `toolset_id`, `tool_name`, `status`, `surface`, `since` and `until` (ISO 8601), and `cursor` (the last row id of the previous page), alongside the existing `response_id`, `request_id` and `limit`. The answer carries `next_cursor`, null on the last page. Rows add `surface` (`responses` or `gateway`), `connection_id`, `toolset_id`, `external_user_id`, `oauth_client_id`, `approval_id`, `bytes_in` (received from the upstream server), `bytes_out` (sent to it), `charge_nanos` (a string) and `flags` (for example `["prompt_injection", "secret_redacted"]`). For gateway rows, `response_id` is the gateway request id (`mcpg_…`), `server_label` the toolset slug, `server_host` the upstream host and `kind` `list_tools` or `call_tool`. Rate-limit and kill-switch refusals are `refused` with an `error_code`. Rows never hold arguments, outputs or credentials.

GET …/mcp/stats (usage:read)

```
GET /api/v1/organizations/{org}/mcp/stats?since=…&until=…&group_by=tool
{ "since": "…", "until": "…", "group_by": "tool",     // connection | toolset | tool | day
  "totals": { "calls": 120, "errors": 4, "error_rate": 0.0333, "p50_ms": 210, "p95_ms": 1300,
              "bytes_in": 912345, "charged_nanos": "0" },
  "rows": [ { "key": { "connection_id": "mcpcn_…", "connection_name": "GitHub", "tool": "github__search_issues" },
              "calls": 80, "errors": 1, "error_rate": 0.0125, "p50_ms": 180, "p95_ms": 900 } ] }
```

The window defaults to the last 7 days and can span up to 93. Only `call_tool` rows count; `errors` is failed plus refused. Row keys depend on `group_by`: `{day: "YYYY-MM-DD"}`, `{toolset_id, toolset_name}`, `{connection_id, connection_name}`, and for `tool` the connection plus `tool`.

## OAuth for MCP clients

For clients that sign in with OAuth (Claude and ChatGPT connectors, Cursor, VS Code), Yelno is the authorization server. The issuer is `https://yelno.ai`. Tokens are opaque and bound to one toolset URL: they are valid only at that toolset's endpoint and never accepted by the management API. Both metadata documents are public; the protected-resource document is 404 for unknown toolsets and toolsets with `oauth_enabled` off. `/.well-known/openid-configuration` is not served.

Edge routing. In a deployment, the web edge must send `GET /.well-known/oauth-authorization-server` (exact path) and every path under `/.well-known/oauth-protected-resource/` to the API, in addition to `/api/*` and `/v1/*`. Everything else (registration, token, revocation, the consent API, the callback, the client metadata document, the toolset endpoints) is already under `/api/v1` or `/v1`. `/mcp/oauth/authorize` and `/mcp/connect/{token}` are pages of the web app.

Protected resource metadata

```
GET /.well-known/oauth-protected-resource/v1/mcp/toolsets/{id}
{ "resource": "https://yelno.ai/v1/mcp/toolsets/mcpts_…", "authorization_servers": ["https://yelno.ai"],
  "scopes_supported": ["mcp:toolset"], "bearer_methods_supported": ["header"],
  "resource_name": "Yelno MCP toolset", "resource_documentation": "https://yelno.ai/docs/mcp-gateway" }
```

Authorization server metadata

```
GET /.well-known/oauth-authorization-server
{ "issuer": "https://yelno.ai",
  "authorization_endpoint": "https://yelno.ai/mcp/oauth/authorize",
  "token_endpoint": "https://yelno.ai/api/v1/mcp/oauth/token",
  "registration_endpoint": "https://yelno.ai/api/v1/mcp/oauth/register",
  "revocation_endpoint": "https://yelno.ai/api/v1/mcp/oauth/revoke",
  "response_types_supported": ["code"], "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "revocation_endpoint_auth_methods_supported": ["none"],
  "scopes_supported": ["mcp:toolset"],
  "client_id_metadata_document_supported": true,
  "authorization_response_iss_parameter_supported": true,
  "service_documentation": "https://yelno.ai/docs/mcp-gateway" }
```

Client registration. A client can use an `https://` `client_id` with a path that points to its client metadata document: it is fetched through the network guard (up to 16 KiB, cached 24 hours), its `client_id` must equal the URL exactly, it needs `client_name` and `redirect_uris`, and the consent screen shows the client's host as verified. Or it can register dynamically (deprecated in the MCP specification, still supported). Only public clients are issued: a requested `client_secret_*` method is answered with `none`. Redirect URIs (1 to 10) must be https, loopback http (`127.0.0.1`, `[::1]` or `localhost`, any port) or, for `application_type: "native"`, a private-use scheme; no fragments. Registration is limited to 5 per minute per source IP and 100 per minute platform-wide; over that, 429 `{"error": "slow_down"}` with `Retry-After`. Clients with no grant are deleted after 30 days.

Dynamic client registration

```
POST /api/v1/mcp/oauth/register
{ "client_name": "Claude", "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"],
  "token_endpoint_auth_method": "none", "application_type": "web", "client_uri": "…", "logo_uri": "…" }
→ 201 { "client_id": "ymc_…", "client_id_issued_at": 1760000000, "client_name": "Claude",
        "redirect_uris": [ … ], "grant_types": [ … ], "response_types": ["code"],
        "token_endpoint_auth_method": "none", "application_type": "web" }
```

Authorize. The client opens `https://yelno.ai/mcp/oauth/authorize` with `response_type=code`, `client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_method=S256`, `scope=mcp:toolset` and `resource` (the toolset URL). The page signs the person in if needed and calls the consent API. Validation failures are `400 invalid_request` with a `code`; nothing is ever redirected to an unvalidated URI. `invalid_target` covers a resource that is not a toolset URL, a toolset with OAuth off, or a toolset the person cannot use (they must be a member of its organization holding `key:create`). Older clients that send no `resource` get `toolsets` to choose from.

Consent

```
GET /api/v1/mcp/oauth/authorize?response_type=code&client_id=…&redirect_uri=…&state=…
    &code_challenge=…&code_challenge_method=S256&scope=mcp:toolset&resource=…
{ "client": { "client_id": "ymc_…", "name": "Claude", "kind": "dcr",      // dcr | cimd
              "verified_host": null, "client_uri": null, "logo_uri": null },
  "redirect_uri": "…", "state": "…",
  "scopes": [{ "name": "mcp:toolset", "description": "Use the tools of this toolset on your behalf" }],
  "toolset": { "id": "mcpts_…", "name": "Engineering agents", "organization": { "id": "org_…", "name": "Acme" },
               "tools_count": 37, "connections": [{ "name": "GitHub", "ownership": "end_user", "connected": false }] },
  "toolsets": [ … ],                  // choices when resource was absent
  "existing_grant": null }

POST /api/v1/mcp/oauth/authorize
{ "decision": "allow", "client_id": "…", "redirect_uri": "…", "scope": "mcp:toolset", "state": "…",
  "code_challenge": "…", "code_challenge_method": "S256", "resource": "…", "toolset_id": "…" }
→ { "redirect_to": "<redirect_uri>?code=…&state=…&iss=https://yelno.ai" }
```

Allow creates or updates the grant (client, person, toolset) and issues a single-use code valid for 10 minutes; deny redirects with `error=access_denied`. Grants are audited as `mcp_oauth_grant.create`.

Token

```
POST /api/v1/mcp/oauth/token     (form or JSON; client_id in the body)
grant_type=authorization_code&code=…&redirect_uri=…&code_verifier=…&client_id=…&resource=…
grant_type=refresh_token&refresh_token=…&client_id=…

→ { "access_token": "ymcp_at_…", "token_type": "Bearer", "expires_in": 3600,
    "refresh_token": "ymcp_rt_…", "scope": "mcp:toolset" }

→ 400 { "error": "invalid_grant", "error_description": "…" }
```

When `resource` is sent with the code, it must equal the grant's toolset URL. Access tokens live 1 hour and refresh tokens 30 days, and refresh tokens rotate: reusing a rotated refresh token revokes every token of the grant. Only hashes are stored. Errors use the flat RFC 6749 shape with status 400 (`invalid_client` 401). `POST /api/v1/mcp/oauth/revoke` takes `token` and `client_id` and always answers 200.

Grants

```
GET /api/v1/organizations/{org}/mcp/oauth-grants
{ "data": [ { "id": "mcpog_…", "client_id": "ymc_…", "client_name": "Claude", "client_kind": "dcr",
              "user_id": "user_…", "email": "…", "toolset_id": "mcpts_…", "toolset_name": "Engineering agents",
              "state": "active", "created_at": "…", "last_used_at": "…", "live_tokens": 2 } ] }
```

`POST …/mcp/oauth-grants/{id}/revoke` (`policy:update`) revokes a grant and its tokens, audited. Each person can list their own grants (same row shape) at `GET /api/v1/mcp/oauth/grants` and revoke one with `POST /api/v1/mcp/oauth/grants/{id}/revoke`, which answers `{id, state: "revoked"}`.

## Backend routes

For your own backend, with an API key that has the `mcp:connect` scope: create connect links for your users, see which accounts they connected and revoke them. `X-Yelno-External-User` on the toolset endpoint and on `/v1/responses` picks an account within the key's organization only; it never selects a tenant, and it is not accepted from OAuth callers. A key without `mcp:connect` gets `403 insufficient_scope`.

`POST` `/v1/mcp/connect-links` `mcp:connect`

`GET` `/v1/mcp/accounts` `mcp:connect`

`DELETE` `/v1/mcp/accounts/{account_id}` `mcp:connect`

`GET` `/v1/mcp/connections` `mcp:connect`

Accounts and connections

```
GET /v1/mcp/connections
{ "data": [ { "id": "mcpcn_…", "slug": "linear", "name": "Linear", "auth_type": "oauth2",
              "ownership": "end_user", "status": "healthy", "tools_count": 23 } ] }

GET /v1/mcp/accounts?connection=linear&external_user_id=user-42
{ "data": [ ConnectedAccountOut ] }

DELETE /v1/mcp/accounts/mcpa_…
```

The connect-link body is the one in Accounts and connect links, plus `connection` (id or slug). Revoking an account asks the vendor to revoke its tokens, best effort.

## Webhook events

Four events in the `mcp` category of the webhook catalog. Subscribe a [webhook endpoint](https://yelno.ai/docs/webhooks) to them, or to `mcp.*`.

| Event | Sent when |
| --- | --- |
| mcp.connection.unhealthy | A connection's status turned unhealthy. |
| mcp.connection.needs_reauth | A connection or one account turned needs_reauth (refresh failed or the server answered 401). |
| mcp.connection.recovered | An unhealthy connection turned healthy. |
| mcp.approval.requested | A gateway call created a pending approval. |

Payloads

```
// mcp.connection.unhealthy · mcp.connection.needs_reauth · mcp.connection.recovered
{ "connection_id": "mcpcn_…", "connection_slug": "github", "name": "GitHub",
  "status": "needs_reauth", "previous_status": "healthy", "error_code": "…",
  "account_id": "mcpa_…", "external_user_id": "user-42",   // when one account is affected
  "checked_at": "…" }

// mcp.approval.requested (never carries the arguments)
{ "approval_id": "mcpap_…", "toolset_id": "mcpts_…", "connection_id": "mcpcn_…",
  "tool": "github__create_issue", "external_user_id": "user-42",
  "approval_url": "https://yelno.ai/console/mcp/approvals/mcpap_…", "expires_at": "…" }
```

## Errors

Management and `/v1` key routes answer the standard [error envelope](https://yelno.ai/docs/errors), `{"error": {type, message, code, param, …}}`. The OAuth protocol endpoints use the RFC 6749 and RFC 7591 shapes described above.

| Code | HTTP | Where |
| --- | --- | --- |
| mcp_server_url_invalid · mcp_credential_invalid · mcp_transport_unsupported · mcp_slug_taken · mcp_slug_invalid · mcp_namespace_invalid · mcp_namespace_taken · mcp_limit_reached · mcp_directory_entry_unavailable · mcp_external_user_invalid · mcp_connect_link_invalid · mcp_credential_rejected · mcp_oauth_not_applicable · mcp_oauth_state_invalid · mcp_host_invalid · mcp_host_block_exists · mcp_reference_conflict · mcp_connector_unsupported · mcp_streaming_unsupported · mcp_toolset_key_not_allowed (Responses) · version_conflict · invalid_request | 400 | Create, update and connect routes; /v1/responses. |
| approval_required | 400 | Connection create or server_url change under an enabled data_policy.relax approval policy. error.approval_request_id names the request; resubmit with X-Yelno-Approval. |
| mcp_connection_not_found · mcp_toolset_not_found | 404 · 400 | 404 on management routes; 400 when named in a /v1/responses tool. |
| not_found | 404 | Unknown ids (never 403 across organizations), an unknown or disabled directory entry, an unknown connect link. |
| mcp_host_blocked | 403 | Connection create or update, and ad-hoc server_url tools on /v1/responses. |
| insufficient_scope | 403 | /v1/mcp/* key routes called without mcp:connect. |
| mcp_approval_decided | 409 | Approving or denying an approval that is no longer pending. |
| mcp_oauth_discovery_failed · mcp_oauth_client_required · mcp_oauth_unreachable · mcp_server_blocked · mcp_oauth_failed | 424 | oauth/start, a connect link's start, and refresh-tools. |
| mcp_list_tools_failed | 424 | A /v1/responses reference to a disabled, unauthorized or unreachable connection or toolset. |
| rate_limited | 429 | Connect-link pages. Dynamic client registration answers 429 {"error": "slow_down"}. |

The toolset endpoint answers JSON-RPC:

| Situation | HTTP | JSON-RPC |
| --- | --- | --- |
| No or invalid credential, or a management token | 401 + WWW-Authenticate | -32001 |
| Key of another organization, or unknown toolset | 404 | -32600 |
| Key without mcp:toolset or inference:invoke; key or project scoping | 403 | -32001 (data.error.code mcp_toolset_key_not_allowed for scoping) |
| Toolset disabled, or gateway switched off by staff | 403 | -32001, data.error.code mcp_toolset_disabled or mcp_gateway_disabled |
| Browser Origin other than the console's | 403 | -32600 |
| Unsupported MCP-Protocol-Version header or _meta version | 400 | -32022 with data.supported |
| Modern header mismatch (MCP-Protocol-Version, Mcp-Method, Mcp-Name) | 400 | -32020 |
| Unknown method | 404 (modern) · 200 (legacy) | -32601 |
| Unknown or filtered tool, bad params, bad cursor, tampered, expired or used requestState | 200 | -32602 |
| Parse error · invalid request | 400 | -32700 · -32600 |

Gateway refusals and upstream failures answer HTTP 200 with a result whose `isError` is true and whose `structuredContent.error.code` is one of these. An upstream `isError` result passes through unchanged.

| Code | Meaning |
| --- | --- |
| mcp_connection_unavailable | Connection disabled, staff-disabled, host-blocked or not authorized yet. |
| mcp_connection_needs_reauth | The upstream answered 401; the account is now needs_reauth. |
| mcp_account_missing | No account for this caller on an end-user connection; carries connect_hint. |
| mcp_rate_limited | A rate limit was reached; carries retry_after_seconds. |
| mcp_approval_required · mcp_approval_denied | The call needs approval (carries approval_id, approval_url, expires_at), or it was denied or declined. |
| insufficient_funds · mcp_price_currency_mismatch | The per-call hold could not be placed; the call was not made. |
| mcp_gateway_disabled | The gateway was switched off for your organization. |
| mcp_timeout · mcp_server_unreachable · mcp_server_error | The upstream server timed out, could not be reached, or failed. |
| mcp_protocol_error · mcp_protocol_unsupported | The upstream answered something that is not valid MCP, or only a protocol version the gateway does not speak. |
| mcp_input_required | The upstream server asked the client for input during the call, which the gateway does not relay. |
| mcp_server_blocked · mcp_host_blocked · mcp_redirect_refused | The network guard refused the address, the platform blocks the host, or the server tried to redirect. |
| mcp_response_too_large | The upstream response was over 2 MB. |

What the gateway does and does not do. It calls only the remote MCP servers your organization connects and allows, hosts no code and runs no local servers. Redaction is the only change it makes to tool output. It cannot judge what a tool does: connect only servers you trust.

Source: https://yelno.ai/docs/mcp-gateway-api
