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_urisis public: no secret,token_endpoint_auth_method: "none", PKCE required. Asking forclient_credentialsalongsideredirect_urisis rejected with400 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_urisis confidential: a secret is issued andtoken_endpoint_auth_methodisclient_secret_post. - Other rejected combinations, all
400 invalid_client_metadata: an emptygrant_typesarray; any value outside the supported set;refresh_tokenwithoutauthorization_code; andauthorization_code/refresh_tokenwith noredirect_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 returns400 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 ContentDELETErevokes 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 no404, so the endpoint cannot be used to enumerate client IDs. A secondDELETEtherefore looks like an auth failure. - The
registration_access_tokenis 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 receive401here. - 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
initializeis NOT required beforetools/callortools/list. A bare tool call as the very first request returns a normal result.notifications/initializedis NOT required. If you do send it — or any POST containing only notifications and no JSON-RPC request — the server answers202with an empty body.- You may send
initialize; it succeeds and reportsserverInfo: { name: "openrouter-registry-mcp", version: "1.0.0" }andcapabilities: { tools: { listChanged: true } }. It establishes nothing that persists, so the next call still has to stand on its own. You cannot batchinitializetogether with another message — that is rejected with400/-32600("Only one initialization request is allowed"). - No
Mcp-Session-Idis 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
| Header | Requirement | On violation |
|---|---|---|
Accept | Mandatory. 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-Type | Mandatory, must contain application/json. | 415 "Unsupported Media Type" |
Authorization | Bearer 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-Version | Optional. 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
| Situation | HTTP | Body |
|---|---|---|
| Tool threw / returned an error | 200 | SSE, 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 schema | 200 | Same shape — result.isError: true, text prefixed MCP error -32602:. Not a JSON-RPC error object. |
| Unknown JSON-RPC method | 200 | SSE 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 batch | 400/406/415 | application/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/mcp | 405 | {"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_modelsList 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_modelsSubstring 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_criteriaFilter 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_searchFind 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_modelResolve 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_modelGet 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_modelsCompare 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_statusCurrent 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_historyHistory 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://modelsFull list of models in the registry (every record, unfiltered, sorted by id — includes retired models)
registry://statusCurrent 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_modelGenerate 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_promptGenerate 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.
| Field | Type | Nullable | Meaning / units |
|---|---|---|---|
id | string | no | Canonical model ID, in provider/model-name form. Always returned, even when fields omits it. |
provider | string | no | Provider slug — the segment before the first slash in id. |
displayName | string | no | Human-readable name from OpenRouter. |
description | string | null | yes | Free-form model description. Omitted by the list-style tools unless verbose: true or listed in fields. |
modality | string | null | yes | Input/output modalities in inputs->outputs form. See the note below the table. |
contextLength | number | null | yes | Context window size, in tokens. |
maxCompletionTokens | number | null | yes | Maximum output tokens the top provider will generate. |
inputPricePer1k | number | null | yes | USD per 1,000 prompt tokens. null means free or not published. |
outputPricePer1k | number | null | yes | USD per 1,000 completion tokens. null means free or not published. |
imagePricePer1k | number | null | yes | USD 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. |
createdAt | string (ISO-8601) | null | yes | When the model was published on OpenRouter. |
providerExpirationAt | string (ISO-8601) | null | yes | Provider-declared scheduled expiry, when OpenRouter supplies one. This is a provider announcement and is unrelated to retiredAt. |
supportedParameters | string[] | no | Parameters the model accepts, e.g. tools, reasoning, temperature. Empty array when OpenRouter publishes none. |
metadata | object | no | Everything 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. |
fetchedAt | string (ISO-8601) | no | The sync that last wrote this row. |
lastSeenAt | string (ISO-8601) | null | yes | Last sync in which OpenRouter still listed this model. |
retiredAt | string (ISO-8601) | null | yes | When the registry marked this model unavailable. null while the model is available. |
isAvailable | boolean | no | Authoritative 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 specificmodality: "text+image->text". text->imageis an image generator, not a vision model. A baremodality: "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: truecompiles tois_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 clearsretiredAtback tonull, 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 asfetchedAt, 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 onlyisAvailableandretiredAt.
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:
| Tool | Default limit | Max limit |
|---|---|---|
list_models | 50 | 500 |
search_models | 20 | 100 |
find_models_by_criteria | 50 | 200 |
semantic_search | 10 | 50 |
get_sync_history | 50 | 200 |
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,descriptionandmetadataare 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. Passverbose: trueif you need them.fields(string array, no default) — explicit projection using camelCaseModelfield names. It wins over verbose.idis 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 thatfieldstakes camelCase only, unlikesortBy, 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:
| Field | Where | Means |
|---|---|---|
count | list-style tool responses | Records in this page. Affected by limit and offset. |
total | list_models, search_models, find_models_by_criteria | Records matching your filter/search/criteria, ignoring limit and offset. Use it to drive pagination. semantic_search has no total. |
recordCount | get_registry_status | How 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. |
totalCount | get_registry_status | Live row count of the registry, which accumulates every model ever seen. totalCount = availableCount + retiredCount. |
availableCount / retiredCount | get_registry_status | Live 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/mcpThe 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 < totalSearch 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 optionUse 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, notPOST. WhenCRON_SECRETis set the bearer token must match exactly, otherwise the route returns401.- In production with
CRON_SECRETunset, the route fails closed with503 "Cron auth not configured"on every invocation — set the secret and redeploy. - A sync writes one
get_sync_historyrow. It is opened asstatus: "running"(success: null) before OpenRouter is contacted and updated in place when the attempt ends, so asuccess: falserow is always a genuine failure and always carries anerror. 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 asrunning, which for that row is accurate. - After a successful sync, embeddings are generated for any models that gained a description, so
semantic_searchcoverage catches up on the following run.