MCP Integration

Connect any AI client that supports the Model Context Protocol (MCP) to this registry. Everything you need to integrate — endpoint, auth, transport, tool arguments, record schema — is on this page.

API base URL

This page is documentation, not the API. It is served from the docs host. Every API path lives on the MCP host:

https://openrouter-registry-mcp.aroughidea.com/api/mcp                                  ← MCP Streamable HTTP endpoint
https://openrouter-registry-mcp.aroughidea.com/api/oauth/token                          ← OAuth token endpoint
https://openrouter-registry-mcp.aroughidea.com/api/oauth/authorize                      ← OAuth authorization endpoint
https://openrouter-registry-mcp.aroughidea.com/api/oauth/register                       ← Dynamic client registration (RFC 7591)
https://openrouter-registry-mcp.aroughidea.com/api/oauth/register/{client_id}           ← Client self-management (RFC 7592)
https://openrouter-registry-mcp.aroughidea.com/.well-known/oauth-authorization-server   ← AS metadata (RFC 8414)
https://openrouter-registry-mcp.aroughidea.com/.well-known/oauth-protected-resource     ← PR metadata (RFC 9728)

If you point a client at the docs host by mistake, it will not 404: the docs host 308-redirects /api/mcp, /api/mcp/*, /api/oauth/* and all of /.well-known/* to the MCP host. A 308 preserves the request method and body, so a JSON-RPC POST /api/mcp and a form-encoded POST /api/oauth/token both survive the hop — provided your HTTP client follows redirects. Prefer configuring the MCP host directly and skip the extra round trip.

Operator note: those redirects are emitted from next.config.ts only when the docs deployment has NEXT_PUBLIC_MCP_URL set, and they are baked in at build time — setting the variable on an already-built deployment needs a redeploy. The MCP host likewise redirects /mcp-info back to the docs host when NEXT_PUBLIC_WEB_URL is set.

Authentication

Interactive clients

Claude Code, Cursor, VS Code and Claude Desktop authenticate automatically — you don't need to create or paste a token. On first use the server replies with a 401 that points to its OAuth metadata; the client registers itself via dynamic client registration, opens a browser to authorize (authorization code + PKCE), and stores the resulting token. Because this registry serves public model data, the authorization step is auto-approved, so the browser tab simply flashes and returns.

Server-to-server (client credentials)

Non-interactive services use the OAuth client-credentials grant. Two calls, copy-pasteable:

1. Exchange the client credentials for an access token. The token endpoint accepts application/x-www-form-urlencoded or JSON, and credentials either in the body (client_secret_post) or as HTTP Basic (client_secret_basic).

curl -sS -X POST https://openrouter-registry-mcp.aroughidea.com/api/oauth/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=client_credentials' \
  -d 'client_id=YOUR_CLIENT_ID' \
  -d 'client_secret=YOUR_CLIENT_SECRET' \
  -d 'scope=mcp:read'

# → {"access_token":"eyJhbGciOiJIUzI1NiJ9...","token_type":"Bearer",
#    "expires_in":3600,"scope":"mcp:read"}

2. Call a tool. Both headers are mandatory: the bearer token, and an Accept that names both media types.

curl -sS -X POST https://openrouter-registry-mcp.aroughidea.com/api/mcp \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"list_models","arguments":{"limit":5}}}'

# → HTTP/1.1 200  Content-Type: text/event-stream
#   event: message
#   data: {"result":{"content":[{"type":"text","text":"{\"models\":[…],\"count\":5,\"total\":452}"}]},"jsonrpc":"2.0","id":1}

Cache the token. expires_in is 3600 (one hour) and the token endpoint is rate-limited to 20 requests per minute per IP. Mint a token once, reuse it until it is close to expiry, then mint another — do not request one per tool call. The client-credentials grant issues no refresh token; just request a new access token.

Registering your own client

Dynamic client registration is intentionally open on this deployment: the catalogue is public, read-only data, and interactive MCP clients depend on self-registration to bootstrap. Registering with grant_types: ["client_credentials"] and no redirect_uris yields a confidential client with a secret — that is the shape a server-to-server integration wants:

curl -sS -X POST https://openrouter-registry-mcp.aroughidea.com/api/oauth/register \
  -H 'Content-Type: application/json' \
  -d '{"client_name":"my-service","grant_types":["client_credentials"],"scope":"mcp:read"}'

# → 201 Created
# {
#   "client_id": "…",
#   "client_secret": "…",                 ← shown once, store it now
#   "client_secret_expires_at": 0,        ← 0 = never expires
#   "client_id_issued_at": 1753488000,
#   "grant_types": ["client_credentials"],
#   "token_endpoint_auth_method": "client_secret_post",
#   "scope": "mcp:read",
#   "registration_access_token": "…",     ← shown once, store it too
#   "registration_client_uri": "https://openrouter-registry-mcp.aroughidea.com/api/oauth/register/…",
#   "authorization_endpoint": "https://openrouter-registry-mcp.aroughidea.com/api/oauth/authorize",
#   "token_endpoint": "https://openrouter-registry-mcp.aroughidea.com/api/oauth/token"
# }

grant_types is honoured and echoed back exactly as resolved. The supported values are authorization_code, refresh_token and client_credentials. Omit the field and it defaults to ["authorization_code","refresh_token"] when you supply redirect_uris, and ["client_credentials"] when you do not.

  • A client registered with redirect_uris is public: no secret, token_endpoint_auth_method: "none", PKCE required. Asking for client_credentials alongside redirect_uris is rejected with 400 invalid_client_metadata — a public client holds no secret, so honouring that grant would hand tokens to anyone who learns the client_id.
  • A client registered without redirect_uris is confidential: a secret is issued and token_endpoint_auth_method is client_secret_post.
  • Other rejected combinations, all 400 invalid_client_metadata: an empty grant_types array; any value outside the supported set; refresh_token without authorization_code; and authorization_code/refresh_token with no redirect_uris. Nothing is written to the database when one of these fires.
  • At the token endpoint, requesting a grant your client is not registered for returns 400 unauthorized_client. A grant this server does not implement at all (e.g. password) still returns 400 unsupported_grant_type.

Registration is rate-limited to 5 per 15 minutes per IP. Operators can require an initial access token by setting OAUTH_REGISTRATION_ACCESS_TOKEN (registration then needs Authorization: Bearer <that value> and returns 401 invalid_token without it), or refuse registration entirely with OAUTH_DISABLE_REGISTRATION=true, which returns 400 registration_not_supported.

Reading or deleting your registration

The registration response includes a registration_client_uri and a registration_access_token. Together they let you inspect or delete your own client without involving an operator (RFC 7592):

# Read the current registration (never returns the client_secret)
curl -sS https://openrouter-registry-mcp.aroughidea.com/api/oauth/register/YOUR_CLIENT_ID \
  -H "Authorization: Bearer $REGISTRATION_ACCESS_TOKEN"

# Delete it
curl -sS -X DELETE https://openrouter-registry-mcp.aroughidea.com/api/oauth/register/YOUR_CLIENT_ID \
  -H "Authorization: Bearer $REGISTRATION_ACCESS_TOKEN"
# → 204 No Content
  • DELETE revokes the client: it can no longer authorize or obtain tokens, and the token endpoint treats it as unknown. Access tokens already issued remain valid until they expire (within the hour).
  • Every failure mode — missing header, wrong scheme, wrong token, unknown client_id, already-deleted client — returns the same flat 401 {"error":"invalid_token"}. There is deliberately no 404, so the endpoint cannot be used to enumerate client IDs. A second DELETE therefore looks like an auth failure.
  • The registration_access_token is stored only as a hash. It is shown once, cannot be read back, and cannot be rotated. If you lose it, an operator must clean the client up from the admin panel. Clients registered before this endpoint existed have no management token and always receive 401 here.
  • Rate-limited to 30 requests per 15 minutes per IP.

Transport

POST https://openrouter-registry-mcp.aroughidea.com/api/mcp speaks MCP over Streamable HTTP, statelessly. A fresh transport and a fresh server instance are built for every POST, so no state survives between requests. The practical consequences are worth reading before you write a client by hand.

Handshake — not required

  • initialize is NOT required before tools/call or tools/list. A bare tool call as the very first request returns a normal result.
  • notifications/initialized is NOT required. If you do send it — or any POST containing only notifications and no JSON-RPC request — the server answers 202 with an empty body.
  • You may send initialize; it succeeds and reports serverInfo: { name: "openrouter-registry-mcp", version: "1.0.0" } and capabilities: { tools: { listChanged: true } }. It establishes nothing that persists, so the next call still has to stand on its own. You cannot batch initialize together with another message — that is rejected with 400 / -32600 ("Only one initialization request is allowed").
  • No Mcp-Session-Id is ever returned, so there is nothing to echo. If you send one anyway it is accepted and silently ignored, even if fabricated.

Required request headers

HeaderRequirementOn violation
AcceptMandatory. Must literally contain both application/json and text/event-stream. The check is a plain substring test — a bare */*, only one of the two, or omitting the header entirely all fail.406 "Not Acceptable: Client must accept both application/json and text/event-stream"
Content-TypeMandatory, must contain application/json.415 "Unsupported Media Type"
AuthorizationBearer token with the mcp:read scope, in production.401 invalid_token, or 403 insufficient_scope for a valid token without the scope. Both carry a WWW-Authenticate header pointing at /.well-known/oauth-protected-resource.
Mcp-Protocol-VersionOptional. If present it must be one of 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07.400 / -32000 unsupported protocol version

Response encoding — always SSE

There is no JSON response mode. Any POST carrying a JSON-RPC request answers 200 with Content-Type: text/event-stream, regardless of your Accept header — the Accept requirement above is a gate, not a negotiation. The body is exactly one frame, after which the stream closes:

event: message
data: {"result":{…},"jsonrpc":"2.0","id":1}

There is no id: field and no keep-alive traffic, so a hand-rolled client can simply read to end-of-stream and parse the single data: line. Only transport-level errors come back as application/json.

Error model

SituationHTTPBody
Tool threw / returned an error200SSE, a successful JSON-RPC result with result.isError: true and the message in result.content[0].text. Never an HTTP error.
Unknown tool name, or arguments failing the schema200Same shape — result.isError: true, text prefixed MCP error -32602:. Not a JSON-RPC error object.
Unknown JSON-RPC method200SSE carrying a JSON-RPC error object, code: -32601 "Method not found", with your request id.
Bad Accept/Content-Type, unparseable JSON or JSON-RPC, bad protocol version, illegal batch400/406/415application/json, never SSE: {"jsonrpc":"2.0","error":{"code":…,"message":…},"id":null}. Note id is always null, even when your request had one.
GET or DELETE on /api/mcp405{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed."},"id":null}. There is no standalone GET listening stream and no session-termination endpoint.

Because tool failures arrive as 200, a client that only checks the HTTP status will treat every error as success. Always inspect result.isError.

Tool reference

Naming: every filter argument and every field of a returned record is camelCase (maxInputPricePer1k, minContextLength, availableOnly, contextLength…). The single exception is sortBy, which accepts both spellings and treats them identically.

sortBy values: id, provider, display_name/displayName, context_length/contextLength, max_completion_tokens/maxCompletionTokens, input_price_per_1k/inputPricePer1k, output_price_per_1k/outputPricePer1k, image_price_per_1k/imagePricePer1k, created_at/createdAt. Default id. sortDir is asc (default) or desc — use sortBy: "createdAt", sortDir: "desc" for newest-first. Nullable sort columns place NULLs last.

verbose and fields control payload size on the four list-style tools — see Pagination & payload size below. Model[] in the return shapes means an array of the record documented in Model record schema.

list_models

List models in the registry with optional filtering and sorting. Retired models are included unless you pass availableOnly: true.

Arguments

{ limit?: number (omit = all records), offset?: number = 0, provider?: string, query?: string, sortBy?: string = "id", sortDir?: "asc" | "desc" = "asc", availableOnly?: boolean = false, verbose?: boolean = false, fields?: string[] }

Returns (JSON text content)

{ models: Model[], count: number, total: number }

count is the number of records in this page; total is every record matching provider/query/availableOnly, ignoring limit and offset.

search_models

Substring search across model ID, display name, and provider. Case-insensitive.

Arguments

{ query: string, limit?: number = 20 (1–100), offset?: number = 0, sortBy?: string = "id", sortDir?: "asc" | "desc" = "asc", verbose?: boolean = false, fields?: string[] }

Returns (JSON text content)

{ models: Model[], count: number, total: number }

No availability filter — retired models are included in search results.

find_models_by_criteria

Filter by budget, context window, and modality. Every parameter is optional; omit the ones you do not care about.

Arguments

{ maxInputPricePer1k?: number, maxOutputPricePer1k?: number, minContextLength?: number, modality?: string, limit?: number = 50 (1–200), offset?: number = 0, sortBy?: string = "id", sortDir?: "asc" | "desc" = "asc", verbose?: boolean = false, fields?: string[] }

Returns (JSON text content)

{ models: Model[], count: number, total: number }

Models with a NULL price pass the price filters — they are treated as free/unknown, not excluded. Prices are USD per 1,000 tokens.

semantic_search

Find models by semantic similarity to a natural-language description. Powered by openai/text-embedding-3-small via OpenRouter.

Arguments

{ query: string, limit?: number = 10 (1–50), offset?: number = 0, verbose?: boolean = false, fields?: string[] }

Returns (JSON text content)

{ models: Model[], count: number }

The only list-style tool with no total — results are ranked by vector distance, so "matching rows" is not a well-defined set. Only models that already have a description embedding are searchable.

resolve_model

Resolve a possibly-aliased or non-canonical model ID to its canonical form and fetch its details.

Arguments

{ input: string }

Returns (JSON text content)

{ input: string, resolved: string, source: string, found: boolean, model: Model | null }

Returns the FULL record — never projected, so description and metadata are present.

get_model

Get full details for a single model by canonical ID.

Arguments

{ id: string }

Returns (JSON text content)

{ found: boolean, model: Model | null }

ID matching is case-insensitive. Returns the FULL record — verbose/fields do not apply.

compare_models

Compare 2–5 models side-by-side on pricing, context length, and lifecycle.

Arguments

{ ids: string[] (2–5 canonical IDs) }

Returns (JSON text content)

{ comparison: Array<{ id, found, displayName, provider, description, modality, contextLength, maxCompletionTokens, inputPricePer1k, outputPricePer1k, imagePricePer1k, createdAt, providerExpirationAt, lastSeenAt, retiredAt, isAvailable, metadata }> }

A condensed comparison row, not a raw Model: it includes description and metadata but omits supportedParameters and fetchedAt. Missing IDs come back with found: false and null fields rather than an error.

get_registry_status

Current sync state plus live row counts, so list results can be reconciled against the last sync.

Arguments

{}

Returns (JSON text content)

{ status: { lastSuccessfulSync, lastAttemptedSync, lastError, recordCount, totalCount, availableCount, retiredCount } | null }

status is null when no sync has ever been recorded — the counts are absent in that case. See "Counts" below for what each number means.

get_sync_history

History of sync attempts, most recent first, with success/failure, record count, and error text.

Arguments

{ limit?: number = 50 (1–200) }

Returns (JSON text content)

{ history: Array<{ id, syncedAt, status, success, recordCount, error, finishedAt, partial }>, count: number }

One row per sync attempt. The row is opened as status "running" (success: null) before OpenRouter is contacted and updated in place when the attempt ends, so success: false always means a real failure and always carries an error. syncedAt is the start, finishedAt the end (null while running). A "running" row older than the newest finished row is an attempt whose process died mid-sync.

Available Resources

Read registry data directly via MCP resource URIs (read-only, accessible via resources/read). Resources are never projected — they always return full records including description and metadata.

registry://models

Full list of models in the registry (every record, unfiltered, sorted by id — includes retired models)

registry://status

Current sync status (lastSuccessfulSync, lastAttemptedSync, lastError, recordCount). Unlike get_registry_status it does NOT include the live totalCount/availableCount/retiredCount.

registry://models/{id}

Details for a specific model — URL-encode the canonical ID (e.g. registry://models/anthropic%2Fclaude-sonnet-4-5)

Available Prompts

Reusable prompt templates that guide model-selection and comparison workflows (accessible via prompts/get).

select_model

Generate a structured prompt to select the best model for a task

{ task_description: string, budget_usd_per_1k_tokens?: string, min_context_length?: string }
compare_models_prompt

Generate a structured prompt to compare a set of models side-by-side

{ model_ids: string }

Model record schema

Every tool that returns a model returns this record. All prices are USD per 1,000 tokens — the registry rescales OpenRouter's per-token figures on ingest, so no conversion is needed on your side. Timestamps are serialized as ISO-8601 UTC strings in JSON.

FieldTypeNullableMeaning / units
idstringnoCanonical model ID, in provider/model-name form. Always returned, even when fields omits it.
providerstringnoProvider slug — the segment before the first slash in id.
displayNamestringnoHuman-readable name from OpenRouter.
descriptionstring | nullyesFree-form model description. Omitted by the list-style tools unless verbose: true or listed in fields.
modalitystring | nullyesInput/output modalities in inputs->outputs form. See the note below the table.
contextLengthnumber | nullyesContext window size, in tokens.
maxCompletionTokensnumber | nullyesMaximum output tokens the top provider will generate.
inputPricePer1knumber | nullyesUSD per 1,000 prompt tokens. null means free or not published.
outputPricePer1knumber | nullyesUSD per 1,000 completion tokens. null means free or not published.
imagePricePer1knumber | nullyesUSD per 1,000 image inputs (OpenRouter publishes a per-image price; the registry scales it by 1,000 the same way it scales token prices). null means not priced per image.
createdAtstring (ISO-8601) | nullyesWhen the model was published on OpenRouter.
providerExpirationAtstring (ISO-8601) | nullyesProvider-declared scheduled expiry, when OpenRouter supplies one. This is a provider announcement and is unrelated to retiredAt.
supportedParametersstring[]noParameters the model accepts, e.g. tools, reasoning, temperature. Empty array when OpenRouter publishes none.
metadataobjectnoEverything in the OpenRouter model object that is not mapped to a field above. Omitted by the list-style tools unless verbose: true or listed in fields.
fetchedAtstring (ISO-8601)noThe sync that last wrote this row.
lastSeenAtstring (ISO-8601) | nullyesLast sync in which OpenRouter still listed this model.
retiredAtstring (ISO-8601) | nullyesWhen the registry marked this model unavailable. null while the model is available.
isAvailablebooleannoAuthoritative availability flag. See Retirement semantics.

Reading modality

OpenRouter writes modality as "inputs->outputs", with +-separated modalities on each side — e.g. text->text, text+image->text, text+image+file->text, text->image.

find_models_by_criteria's modality filter is a case-insensitive substring match over the whole string, arrow included. That makes it easy to get vision detection backwards:

  • To find models that accept images, match the left (input) side: modality: "image->", or the more specific modality: "text+image->text".
  • text->image is an image generator, not a vision model. A bare modality: "image" matches both, because it is a substring of each.

Retirement semantics

The registry keeps every model it has ever seen. Models that vanish from OpenRouter's catalogue are flagged, not deleted, so historical IDs stay resolvable.

  • isAvailable this is the authoritative field. It is the only column the query layer ever filters on: availableOnly: true compiles to is_available = TRUE. If you need one boolean, use this one.
  • retiredAt — a timestamp annotation only; it is never used as a filter. It marks when the current retirement episode began. If a model disappears and later returns, the upsert clears retiredAt back to null, so it is not a "was ever retired" history — it is reset on every comeback.
  • lastSeenAt — the last sync in which OpenRouter still listed the model. For every row a sync touches it is written from the same timestamp as fetchedAt, so the two are identical. For a retired model both freeze at the last sync where the model was still present, because the retirement sweep updates only isAvailable and retiredAt.

Can they disagree? No. isAvailable and retiredAt are written together by the same statements inside a single transaction — the upsert sets retired_at = NULL, is_available = TRUE together, and the retirement sweep sets is_available = FALSE and retired_at together. A partial or failed sync rolls back both, so it cannot leave a mixed state. A read of the whole production table found zero rows in either inconsistent combination.

Whole-provider disappearances are covered. The sweep is a single global UPDATE over every row the current sync did not touch, so a provider vanishing from OpenRouter's catalogue entirely is retired like any other absence. It used to run per provider, over only the providers present in the response — which by construction could never see a provider that had gone.

Guarded by volume, not by partitioning. A global sweep makes a truncated upstream response dangerous, so if a sync fetches fewer than 80% of the models currently marked available, the sweep is skipped and the run is recorded with partial: true in get_sync_history. The catalogue still updates; only retirement waits for a sync that looks whole. Deferring retirement by a day is recoverable — retiring most of the catalogue on one bad response is not. A run of consecutive partial entries means retirement data is going stale and upstream should be checked.

Historical footnote: a small number of rows were retired before the retiredAt column existed, and had it backfilled to equal fetchedAt. For those rows retiredAt is the last sync the model was present rather than the first sync it was missing. They are identifiable by retiredAt === lastSeenAt; every row retired since then has retiredAt > lastSeenAt. Separately, note that providerExpirationAt is a provider-announced expiry date and is completely independent of these three fields.

Pagination & payload size

limit / offset

All list-style tools take limit and offset, applied after sorting. Defaults and caps differ per tool:

ToolDefault limitMax limit
list_models50500
search_models20100
find_models_by_criteria50200
semantic_search1050
get_sync_history50200

list_models' default limit is 50 (it was 500) — raise it explicitly for bulk pulls. With the default sortBy: "id" paging is exact: id is unique, so successive pages are stable and disjoint. When you sort by any other column there is no secondary tiebreak, so rows that tie can shift between pages; for an exhaustive pull, either keep the default sort or pull everything in one page and sort client-side.

verbose and fields

list_models, search_models, find_models_by_criteria and semantic_search accept two projection arguments. get_model, resolve_model, compare_models and the registry:// resources ignore them and always return full records.

  • verbose (boolean, default false) — when false, description and metadata are omitted from every record. Those two are by far the largest fields (free-form prose, and the entire unmapped remainder of OpenRouter's model object), which is why they are off by default. Pass verbose: true if you need them.
  • fields (string array, no default) — explicit projection using camelCase Model field names. It wins over verbose. id is always included and comes first; the rest appear in the order you list them. Only the names in the Model field table above are accepted — an unrecognised one is a validation error, not a silently missing field, so a typo cannot be mistaken for absent data. Note that fields takes camelCase only, unlike sortBy, which accepts both spellings. An empty array is treated as not supplied.
// Cheapest possible catalogue pull: 4 fields per record, no limit
await mcp.callTool('list_models', {
  fields: ['displayName', 'contextLength', 'inputPricePer1k', 'outputPricePer1k'],
});
// → { models: [{ id, displayName, contextLength, inputPricePer1k, outputPricePer1k }, …],
//      count: 452, total: 452 }

Omitting limit returns everything — there is no cap, so a full-catalogue pull needs no pagination and no size probe. Confirm you got it all by checking count === total. Supply limit only when you deliberately want a page, and pair it with offset.

Counts: count vs total vs recordCount vs totalCount

These four numbers legitimately differ. They answer different questions:

FieldWhereMeans
countlist-style tool responsesRecords in this page. Affected by limit and offset.
totallist_models, search_models, find_models_by_criteriaRecords matching your filter/search/criteria, ignoring limit and offset. Use it to drive pagination. semantic_search has no total.
recordCountget_registry_statusHow many models OpenRouter returned during the last successful sync — a point-in-time count of that fetch, not a count of table rows. A failed sync never overwrites it.
totalCountget_registry_statusLive row count of the registry, which accumulates every model ever seen. totalCount = availableCount + retiredCount.
availableCount / retiredCountget_registry_statusLive rows with isAvailable true / false respectively.

So list_models reporting a larger total than recordCount is expected, not a bug: the default availableOnly: false includes retired rows. Note also that availableCount is not exactly the last sync's catalogue — it is that catalogue plus any rows stranded by the whole-provider gap described under Retirement semantics. Do not assume availableOnly: true returns exactly recordCount records; it normally returns slightly more.

Claude Code

Add the server with one command — Claude Code runs the OAuth browser login itself:

claude mcp add --transport http registry https://openrouter-registry-mcp.aroughidea.com/api/mcp

The first time an agent uses a registry tool, a browser opens to authorize; after that it stays connected.

Cursor

Add to ~/.cursor/mcp.json (or the project's .cursor/mcp.json). Cursor completes the OAuth flow in the browser on first use:

{
  "mcpServers": {
    "openrouter-registry": {
      "url": "https://openrouter-registry-mcp.aroughidea.com/api/mcp"
    }
  }
}

Claude Desktop Configuration

Add this to your Claude Desktop MCP configuration. It will prompt you to authorize in the browser on first use:

{
  "mcpServers": {
    "openrouter-registry": {
      "url": "https://openrouter-registry-mcp.aroughidea.com/api/mcp",
      "transport": "streamable-http"
    }
  }
}

GitHub Copilot (VS Code)

Add to your workspace's .vscode/mcp.json (or under "mcp" in settings.json):

{
  "servers": {
    "openrouter-registry": {
      "type": "http",
      "url": "https://openrouter-registry-mcp.aroughidea.com/api/mcp"
    }
  }
}

VS Code completes the OAuth browser login automatically. Only if your client can't do OAuth discovery, fall back to a bearer token in a headers object as "Authorization": "Bearer YOUR_ACCESS_TOKEN".

OpenAI Codex CLI

Add to ~/.codex/config.toml:

[mcp_servers.openrouter-registry]
url = "https://openrouter-registry-mcp.aroughidea.com/api/mcp"

Codex performs OAuth discovery automatically. Only if it can't, add bearer_token = "YOUR_ACCESS_TOKEN" as a fallback.

Usage Examples

Resolve a model ID in your agent:

// In your agent/assistant:
const result = await mcp.callTool('resolve_model', { input: 'anthropic/claude-sonnet-4-5' });
// → { resolved: 'anthropic/claude-sonnet-4-5', source: 'canonical', found: true, model: {...} }

Page through the catalogue using total:

const page = await mcp.callTool('list_models', { limit: 50, offset: 0 });
// → { models: [...50], count: 50, total: 452 }   ← keep paging while offset + count < total

Search models by name or provider, newest first:

const results = await mcp.callTool('search_models', {
  query: 'claude',
  limit: 10,
  sortBy: 'createdAt',   // camelCase and created_at are equivalent
  sortDir: 'desc',
});

Find models by natural language description:

const results = await mcp.callTool('semantic_search', {
  query: 'fast cheap summarization model with large context',
  limit: 10,
});

Filter for vision models — match the INPUT side of the modality arrow:

const visionModels = await mcp.callTool('find_models_by_criteria', {
  modality: 'image->',   // NOT 'text->image', which is an image GENERATOR
  limit: 20,
});

Find models within a budget and context window (prices are USD per 1,000 tokens):

const models = await mcp.callTool('find_models_by_criteria', {
  maxInputPricePer1k: 0.005,
  maxOutputPricePer1k: 0.015,
  minContextLength: 32000,
  limit: 20,
});

Ask for a description without pulling every field:

const models = await mcp.callTool('list_models', {
  limit: 20,
  fields: ['displayName', 'description'],   // id is always included
});

Compare models side-by-side:

const comparison = await mcp.callTool('compare_models', {
  ids: ['anthropic/claude-sonnet-4-5', 'openai/gpt-4o', 'google/gemini-pro-1.5'],
});

Reconcile counts before a bulk pull:

const status = await mcp.callTool('get_registry_status', {});
// → { status: { lastSuccessfulSync, lastAttemptedSync, lastError,
//               recordCount, totalCount, availableCount, retiredCount } }

Read the model list as a resource:

const result = await mcp.readResource('registry://models');
// → { contents: [{ mimeType: 'application/json', text: '{"models":[...]}' }] }

Use the select_model prompt to guide model selection:

const prompt = await mcp.getPrompt('select_model', {
  task_description: 'Summarize long legal documents',
  budget_usd_per_1k_tokens: '0.005',
  min_context_length: '32000',
});
// → prompt messages that instruct the model how to pick the best option

Use the compare_models_prompt for a structured comparison:

const prompt = await mcp.getPrompt('compare_models_prompt', {
  model_ids: 'anthropic/claude-sonnet-4-5,openai/gpt-4o',
});

Sync cadence & on-demand trigger

The registry refreshes daily at 00:00 UTC via Vercel Cron, configured in apps/mcp/vercel.json. Each run fetches OpenRouter's entire catalogue in one unpaginated request, upserts it, and sweeps models that have disappeared.

// apps/mcp/vercel.json
{
  "crons": [
    {
      "path": "/api/cron/sync",
      "schedule": "0 0 * * *"
    }
  ]
}

The same endpoint can be triggered on demand (it is also wired to the admin panel's Sync action):

curl -sS https://openrouter-registry-mcp.aroughidea.com/api/cron/sync -H "Authorization: Bearer $CRON_SECRET"
  • GET, not POST. When CRON_SECRET is set the bearer token must match exactly, otherwise the route returns 401.
  • In production with CRON_SECRET unset, the route fails closed with 503 "Cron auth not configured" on every invocation — set the secret and redeploy.
  • A sync writes one get_sync_history row. It is opened as status: "running" (success: null) before OpenRouter is contacted and updated in place when the attempt ends, so a success: false row is always a genuine failure and always carries an error. The old writer emitted two rows per sync; the migration removed each start marker that was paired with a completed row, so history reads one row per attempt throughout. An unpaired marker survives as running, which for that row is accurate.
  • After a successful sync, embeddings are generated for any models that gained a description, so semantic_search coverage catches up on the following run.