diff --git a/README.md b/README.md index a818c9f..e4d40f0 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ claude mcp add --transport http modelparams https://modelparams.dev/mcp codex mcp add modelparams --url https://modelparams.dev/mcp ``` -Four tools: `validate_model_params` to check a params object before you send it, `get_model_params` for one model's parameter surface and lifecycle metadata, `list_models` and `find_models_supporting` to search the catalog. +Four tools: `validate_model_params` to check a params object before you send it, `get_model_params` for one model's parameter surface and lifecycle metadata, `list_provider_models` for everything one provider serves, and `list_models` to search the catalog. **Without MCP** — `npx skills add mnfst/modelparams.dev` installs the companion agent skill, and [llms.txt](https://modelparams.dev/llms.txt) points an agent at a URL. diff --git a/api/mcp.ts b/api/mcp.ts index d55928b..b9939cc 100644 --- a/api/mcp.ts +++ b/api/mcp.ts @@ -34,7 +34,7 @@ const USAGE = { "parameters a model accepts, and validates a params object before you send it.", transport: "streamable-http", stateless: true, - tools: ["validate_model_params", "get_model_params", "list_models", "find_models_supporting"], + tools: ["validate_model_params", "get_model_params", "list_models", "list_provider_models"], install: { "claude-code": "claude mcp add --transport http modelparams https://modelparams.dev/mcp", codex: "codex mcp add modelparams --url https://modelparams.dev/mcp", diff --git a/packages/modelparams-mcp/README.md b/packages/modelparams-mcp/README.md index ab5fe80..2f6e09f 100644 --- a/packages/modelparams-mcp/README.md +++ b/packages/modelparams-mcp/README.md @@ -83,9 +83,45 @@ Every parameter for one model — type, range, enum values, default, and the con Model ids, filtered by provider or substring. Use it to resolve the exact catalog id. -### `find_models_supporting` +### `list_provider_models` -Which models expose a given parameter — `reasoning_effort`, `thinking.budget_tokens`, `top_k`. Answers "which models let me set X". Returns near-miss suggestions when nothing matches, since callers usually have the provider's spelling rather than the catalog's. +Everything one provider serves, in one call: its models, the `wireId` each one needs, their lifecycle status, and the parameter surfaces they expose. For picking a model *and* configuring it in the same step — the question you have when a provider is fixed and the model isn't, which is the normal state of affairs on Bedrock and Vertex. + +Models are grouped by parameter surface rather than listed one by one, because a provider's models are far less varied than their count suggests — Bedrock's 56 models are four distinct surfaces, since Converse normalises most of them: + +```json +{ "provider": "bedrock" } +``` + +```json +{ + "provider": "bedrock", + "baseUrls": ["https://bedrock-runtime.*.amazonaws.com"], + "total": 56, + "returned": 56, + "truncated": false, + "paramProfiles": [ + { + "id": "p1", + "modelCount": 41, + "parameterCount": 4, + "params": [{ "path": "inferenceConfig.maxTokens", "type": "integer", "range": { "min": 1 } }], + "defaults": {} + } + ], + "models": [ + { + "model": "bedrock/claude-opus-4-6", + "authType": "api_key", + "wireId": "{scope}.anthropic.claude-opus-4-6-v1", + "profile": "p2" + } + ], + "wireIdNote": "A wireId containing {scope} needs one substitution before you send it…" +} +``` + +That grouping is what makes the whole provider fit in a tool result: flat, Bedrock's parameters are roughly an order of magnitude larger than the profile form. ## Model ids diff --git a/packages/modelparams-mcp/src/server.ts b/packages/modelparams-mcp/src/server.ts index 9041a50..5cf82d7 100644 --- a/packages/modelparams-mcp/src/server.ts +++ b/packages/modelparams-mcp/src/server.ts @@ -2,9 +2,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { CATALOG } from "modelparams"; import { z } from "zod"; import { - findModelsSupporting, getModelParams, listCatalogModels, + listProviderModels, validateModelParams, type ToolPayload, } from "./tools.js"; @@ -116,21 +116,28 @@ export function createServer(version = "0.0.0"): McpServer { ); server.registerTool( - "find_models_supporting", + "list_provider_models", { - title: "Find models supporting a parameter", + title: "List a provider's models and their parameters", description: - 'Find which models accept a given parameter, e.g. "reasoning_effort", ' + - '"thinking.budget_tokens", or "top_k". Use to answer "which models support X" or to ' + - "pick a model that exposes a knob you need.", + "Everything one provider serves: its models, the wire id each one needs, their " + + "lifecycle status, and the parameter surfaces they expose — types, ranges, enum values, " + + "defaults, and conditional rules — in a single call. Models that share a parameter " + + "surface share a profile, so a provider with dozens of models stays small enough to " + + "read. Use this to pick a model and configure it in one step, especially on Bedrock and " + + "Vertex where the parameter paths differ from the underlying model's native API.", inputSchema: { - parameter: z.string().describe('Exact parameter path, e.g. "top_k" or "thinking.type".'), - provider: z.string().optional().describe("Restrict to one provider slug."), - limit: z.number().int().positive().max(1000).optional().describe("Default 100."), + provider: z + .string() + .describe( + 'Provider slug, e.g. "bedrock", "vertex", "anthropic". Call list_models with no arguments to see them all.', + ), + query: z.string().optional().describe("Case-insensitive substring match on the model id."), + limit: z.number().int().positive().max(1000).optional().describe("Default 200."), }, annotations: READ_ONLY, }, - async (args) => reply(findModelsSupporting(args)), + async (args) => reply(listProviderModels(args)), ); return server; diff --git a/packages/modelparams-mcp/src/tools.ts b/packages/modelparams-mcp/src/tools.ts index 8b57ab8..5201111 100644 --- a/packages/modelparams-mcp/src/tools.ts +++ b/packages/modelparams-mcp/src/tools.ts @@ -4,6 +4,7 @@ import { getDefaults, getModel, listModels, + PROVIDER_ENDPOINTS, PROVIDERS, resolveByBaseUrl, resolveModelId, @@ -181,54 +182,104 @@ export function listCatalogModels(input: { }; } -/** Which models expose a given knob — the "can I use X anywhere?" question. */ -export function findModelsSupporting(input: { - parameter: string; - provider?: string; +/** + * Every parameter surface a provider serves, and which models share each one. + * + * A provider's models are far less varied than their count suggests: Bedrock's + * 56 models are four distinct parameter surfaces, because Converse normalises + * most of them. Returning one profile per surface, with the models pointing at + * it, is what makes "give me everything you have on this provider" fit in a + * tool result at all — flat, Bedrock is an order of magnitude larger. + */ +export function listProviderModels(input: { + provider: string; + query?: string; limit?: number; }): ToolPayload { - const { parameter, provider, limit = 100 } = input; - const needle = parameter.toLowerCase(); - - const matches: { model: ModelId; param: Param }[] = []; - for (const entry of CATALOG) { - if (provider && entry.provider !== provider) continue; - const param = (entry.params as readonly Param[]).find((p) => p.path.toLowerCase() === needle); - if (param) matches.push({ model: modelIdOf(entry), param }); - } + const { provider, query, limit = 200 } = input; - if (matches.length === 0) { - // A near-miss list beats an empty result — the caller usually has the - // provider's spelling of the parameter, not the catalog's. - const known = new Set(); - for (const entry of CATALOG) { - for (const p of entry.params as readonly Param[]) { - if (p.path.toLowerCase().includes(needle) || needle.includes(p.path.toLowerCase())) { - known.add(p.path); - } - } - } + if (!PROVIDERS.includes(provider as Provider)) { return { - parameter, - total: 0, - message: `No model in the catalog accepts "${parameter}".`, - similarParameters: [...known].sort().slice(0, 10), + error: "unknown_provider", + message: `"${provider}" is not a provider in the catalog.`, + providers: PROVIDERS, }; } + let entries = CATALOG.filter((e) => e.provider === provider); + if (query) { + const needle = query.toLowerCase(); + entries = entries.filter((e) => modelIdOf(e).toLowerCase().includes(needle)); + } + + const total = entries.length; + const shown = entries.slice(0, limit); + + // Group by parameter surface. Codegen emits params in a stable order, so the + // serialised array is a sound identity for "same surface". + const byShape = new Map(); + for (const entry of shown) { + const params = entry.params as readonly Param[]; + const shape = JSON.stringify(params); + const bucket = byShape.get(shape); + if (bucket) bucket.entries.push(entry); + else byShape.set(shape, { params, entries: [entry] }); + } + + // Commonest surface first, then alphabetically — so `p1` is the provider's + // house style and the ids stay stable across identical calls. + const profiles = [...byShape.values()].sort( + (a, b) => + b.entries.length - a.entries.length || + modelIdOf(a.entries[0]!).localeCompare(modelIdOf(b.entries[0]!)), + ); + const profileOf = new Map(); + profiles.forEach((profile, i) => { + for (const entry of profile.entries) profileOf.set(modelIdOf(entry), `p${i + 1}`); + }); + + const models = shown.map((entry) => { + const id = modelIdOf(entry); + return { + model: id, + authType: entry.authType, + ...("wireId" in entry ? { wireId: entry.wireId } : {}), + // Lifecycle travels with the row: a tool whose whole job is helping an + // agent choose a model must not hand back a retired one unmarked. + ...(entry.status !== undefined ? { status: entry.status } : {}), + ...(entry.replacement !== undefined ? { replacement: entry.replacement } : {}), + ...(entry.shutdownOn !== undefined ? { shutdownOn: entry.shutdownOn } : {}), + profile: profileOf.get(id), + }; + }); + + // Bedrock reaches most models through a cross-region inference profile, so the + // wire id carries a placeholder the caller has to fill in. Say so, in the + // response that hands over those ids, rather than leaving it to the docs. + const templated = shown.some((e) => "wireId" in e && e.wireId.includes("{scope}")); + return { - parameter, - total: matches.length, - returned: Math.min(matches.length, limit), - truncated: matches.length > limit, - models: matches.slice(0, limit).map(({ model, param }) => ({ - model, - type: param.type, - ...(param.default !== undefined ? { default: param.default } : {}), - ...(param.range ? { range: param.range } : {}), - ...(param.values ? { values: param.values } : {}), - ...(param.applicability ? { appliesOnlyWhen: param.applicability } : {}), + provider, + baseUrls: PROVIDER_ENDPOINTS[provider as keyof typeof PROVIDER_ENDPOINTS], + total, + returned: shown.length, + truncated: total > shown.length, + paramProfiles: profiles.map((profile, i) => ({ + id: `p${i + 1}`, + modelCount: profile.entries.length, + parameterCount: profile.params.length, + params: profile.params.map(describeParam), + defaults: getDefaults(modelIdOf(profile.entries[0]!) as ModelId), })), - docs: `https://modelparams.dev/parameters/${parameter.replace(/\./g, "-")}`, + models, + ...(templated + ? { + wireIdNote: + "A wireId containing {scope} needs one substitution before you send it: the " + + "routing geography of the inference profile — us, eu, apac, jp, au, ca, sa, or " + + "global. A wireId without the placeholder is already complete.", + } + : {}), + docs: `https://modelparams.dev/providers/${provider}`, }; } diff --git a/packages/modelparams-mcp/tests/server.test.ts b/packages/modelparams-mcp/tests/server.test.ts index beca57f..2c5c8e7 100644 --- a/packages/modelparams-mcp/tests/server.test.ts +++ b/packages/modelparams-mcp/tests/server.test.ts @@ -41,11 +41,31 @@ interface ListResult { error?: string; } -interface FindResult { - parameter: string; +interface ProviderModelsResult { + provider: string; + baseUrls: string[]; total: number; - models: { model: string; type: string }[]; - similarParameters?: string[]; + returned: number; + truncated: boolean; + paramProfiles: { + id: string; + modelCount: number; + parameterCount: number; + params: ParamInfo[]; + defaults: Record; + }[]; + models: { + model: string; + authType: string; + wireId?: string; + profile: string; + status?: string; + replacement?: string; + shutdownOn?: string; + }[]; + wireIdNote?: string; + error?: string; + providers?: string[]; } /** Parse the JSON payload a tool replies with. */ @@ -70,9 +90,9 @@ describe("tool discovery", () => { it("advertises the four catalog tools", async () => { const { tools } = await client.listTools(); expect(tools.map((t) => t.name).sort()).toEqual([ - "find_models_supporting", "get_model_params", "list_models", + "list_provider_models", "validate_model_params", ]); }); @@ -237,24 +257,111 @@ describe("list_models", () => { }); }); -describe("find_models_supporting", () => { - it("finds models exposing a parameter", async () => { - const out = await call("find_models_supporting", { parameter: "top_k" }); - expect(out.total).toBeGreaterThan(0); - expect(out.models[0]!.model).toBeTruthy(); +describe("list_provider_models", () => { + it("returns a provider's whole surface in one call", async () => { + const out = await call("list_provider_models", { provider: "bedrock" }); + expect(out.provider).toBe("bedrock"); + expect(out.total).toBeGreaterThan(10); + expect(out.truncated).toBe(false); + expect(out.models).toHaveLength(out.total); + expect(out.baseUrls.length).toBeGreaterThan(0); }); - it("scopes to a provider", async () => { - const out = await call("find_models_supporting", { - parameter: "top_k", - provider: "anthropic", + it("collapses shared parameter surfaces into far fewer profiles than models", async () => { + const out = await call("list_provider_models", { provider: "bedrock" }); + expect(out.paramProfiles.length).toBeLessThan(out.models.length / 4); + // Commonest surface first, so p1 is the provider's house style. + expect(out.paramProfiles[0]!.id).toBe("p1"); + expect(out.paramProfiles[0]!.modelCount).toBeGreaterThanOrEqual( + out.paramProfiles[out.paramProfiles.length - 1]!.modelCount, + ); + }); + + it("points every model at a profile that exists", async () => { + const out = await call("list_provider_models", { provider: "vertex" }); + const ids = new Set(out.paramProfiles.map((p) => p.id)); + expect(out.models.every((m) => ids.has(m.profile))).toBe(true); + }); + + it("groups models by surface rather than by name", async () => { + const out = await call("list_provider_models", { provider: "bedrock" }); + const byProfile = new Map(); + for (const profile of out.paramProfiles) byProfile.set(profile.id, profile.params); + // Two models on the same profile must agree parameter-for-parameter. + const grouped = out.models.filter((m) => m.profile === "p1"); + expect(grouped.length).toBeGreaterThan(1); + const paths = byProfile.get("p1")!.map((p) => p.path); + expect(paths).toContain("inferenceConfig.maxTokens"); + }); + + it("hands over the wire id, and flags the ones needing substitution", async () => { + const out = await call("list_provider_models", { provider: "bedrock" }); + const templated = out.models.find((m) => m.wireId?.includes("{scope}")); + expect(templated).toBeDefined(); + expect(out.wireIdNote).toContain("{scope}"); + }); + + it("omits the wire id note for a provider that has no placeholders", async () => { + const out = await call("list_provider_models", { provider: "openai" }); + expect(out.wireIdNote).toBeUndefined(); + }); + + it("filters by substring", async () => { + const out = await call("list_provider_models", { + provider: "bedrock", + query: "claude", + }); + expect(out.models.length).toBeGreaterThan(0); + expect(out.models.every((m) => m.model.includes("claude"))).toBe(true); + }); + + it("flags truncation rather than silently cutting off", async () => { + const out = await call("list_provider_models", { + provider: "bedrock", + limit: 3, }); - expect(out.models.every((m) => m.model.startsWith("anthropic/"))).toBe(true); + expect(out.models).toHaveLength(3); + expect(out.truncated).toBe(true); + expect(out.total).toBeGreaterThan(3); }); - it("suggests near misses when nothing matches", async () => { - const out = await call("find_models_supporting", { parameter: "temperatur" }); - expect(out.total).toBe(0); - expect(out.similarParameters).toContain("temperature"); + it("marks a model whose lifecycle is tracked", async () => { + const entry = getModel("openai/gpt-5.5") as unknown as Record; + const previous = { + status: entry.status, + replacement: entry.replacement, + shutdownOn: entry.shutdownOn, + }; + + Object.assign(entry, { + status: "deprecated", + replacement: "openai/gpt-5.6-sol", + shutdownOn: "2026-10-23", + }); + + try { + const out = await call("list_provider_models", { + provider: "openai", + query: "gpt-5.5", + }); + // An agent choosing from this list must see the retirement on the row it + // is choosing from, not only if it looks the model up separately. + expect(out.models.find((m) => m.model === "openai/gpt-5.5")).toMatchObject({ + status: "deprecated", + replacement: "openai/gpt-5.6-sol", + shutdownOn: "2026-10-23", + }); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete entry[key]; + else entry[key] = value; + } + } + }); + + it("reports an unknown provider with the valid set", async () => { + const out = await call("list_provider_models", { provider: "aws" }); + expect(out.error).toBe("unknown_provider"); + expect(out.providers).toContain("bedrock"); }); }); diff --git a/packages/modelparams/src/index.ts b/packages/modelparams/src/index.ts index ce16bc3..cc2502b 100644 --- a/packages/modelparams/src/index.ts +++ b/packages/modelparams/src/index.ts @@ -23,6 +23,7 @@ export type { } from "./standard-schema.js"; export { MODEL_IDS, PROVIDERS } from "./generated/model-ids.js"; +export { PROVIDER_ENDPOINTS } from "./generated/provider-endpoints.js"; export { DEFAULTS } from "./generated/defaults.js"; export { CATALOG, BY_ID } from "./generated/data.js"; diff --git a/skills/llm-model-parameters/SKILL.md b/skills/llm-model-parameters/SKILL.md index 6a77087..1e92ec3 100644 --- a/skills/llm-model-parameters/SKILL.md +++ b/skills/llm-model-parameters/SKILL.md @@ -35,7 +35,8 @@ If the `modelparams` MCP server is connected, prefer these — they need no netw - `get_model_params` — every parameter for one model, with types, ranges, defaults, and conditional rules. - `list_models` — find the exact catalog id, filtered by provider or substring. -- `find_models_supporting` — which models expose a given parameter. +- `list_provider_models` — every model one provider serves, with its wire id and + parameter surface. Reach for it when the provider is fixed and the model is not. Not connected? Install it: diff --git a/src/data/llms.ts b/src/data/llms.ts index 2baafde..c5b4edb 100644 --- a/src/data/llms.ts +++ b/src/data/llms.ts @@ -109,7 +109,7 @@ function guideApi(siteUrl: string): string[] { "```", "", "Tools: `validate_model_params`, `get_model_params`, `list_models`,", - "`find_models_supporting`.", + "`list_provider_models`.", "", "## JSON Schema", "", diff --git a/src/views/api.ejs b/src/views/api.ejs index 9d3c297..2a53b80 100644 --- a/src/views/api.ejs +++ b/src/views/api.ejs @@ -126,7 +126,7 @@ codex mcp add modelparams --url https://modelparams.dev/mcp validate_model_params, get_model_params, list_models, - find_models_supporting. + list_provider_models.

Using a coding agent that supports skills? Install the companion skill with diff --git a/tests/mcp-endpoint-protocol.test.ts b/tests/mcp-endpoint-protocol.test.ts index 9682a01..b6e0968 100644 --- a/tests/mcp-endpoint-protocol.test.ts +++ b/tests/mcp-endpoint-protocol.test.ts @@ -50,9 +50,9 @@ describe("the MCP endpoint over real HTTP", () => { it("advertises the four tools", async () => { const { tools } = await client.listTools(); expect(tools.map((tool) => tool.name).sort()).toEqual([ - "find_models_supporting", "get_model_params", "list_models", + "list_provider_models", "validate_model_params", ]); }); diff --git a/tests/mcp-endpoint.test.ts b/tests/mcp-endpoint.test.ts index a3cb4b5..a495096 100644 --- a/tests/mcp-endpoint.test.ts +++ b/tests/mcp-endpoint.test.ts @@ -60,9 +60,9 @@ describe("POST /mcp", () => { const body = (await res.json()) as RpcReply; const names = (body.result as { tools: { name: string }[] }).tools.map((t) => t.name).sort(); expect(names).toEqual([ - "find_models_supporting", "get_model_params", "list_models", + "list_provider_models", "validate_model_params", ]); });