Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/client/webmcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ interface ModelContext {
provideContext?: (context: { tools: ToolDefinition[] }) => void;
}

function modelId(model: CatalogModel): string {
export function modelId(model: CatalogModel): string {
const suffix = model.authType === "subscription" ? "-subscription" : "";
return `${model.provider}/${model.model}${suffix}`;
}
Expand Down Expand Up @@ -113,7 +113,10 @@ function setToggleGroup(selector: string, key: string, wanted: string[]): void {
});
}

function searchModels(catalog: Catalog, params: Record<string, unknown>) {
// Pure catalog filter shared by the agent tool and exercised directly in tests.
// Side-effect free so the search contract agents depend on can be unit-tested
// without a DOM; the visible-filter mirroring lives in searchModels below.
export function searchCatalog(catalog: Catalog, params: Record<string, unknown>) {
const query = asString(params.query)?.toLowerCase();
const auth = asString(params.auth);
const providers = asStringList(params.provider);
Expand All @@ -132,8 +135,6 @@ function searchModels(catalog: Catalog, params: Record<string, unknown>) {
return true;
});

reflectInUi({ query: asString(params.query) ?? "", auth, providers, capabilities });

return {
total: matches.length,
returned: Math.min(matches.length, limit),
Expand All @@ -149,6 +150,17 @@ function searchModels(catalog: Catalog, params: Record<string, unknown>) {
};
}

function searchModels(catalog: Catalog, params: Record<string, unknown>) {
const result = searchCatalog(catalog, params);
reflectInUi({
query: asString(params.query) ?? "",
auth: asString(params.auth),
providers: asStringList(params.provider),
capabilities: asStringList(params.capability),
});
return result;
}

async function getModelParameters(params: Record<string, unknown>): Promise<ToolResponse> {
const id = asString(params.id);
if (!id) return ok({ error: "Provide an `id` such as anthropic/claude-opus-4-7." });
Expand Down
133 changes: 133 additions & 0 deletions src/server/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import express from "express";
import { buildCapabilityFacets, buildCatalog, buildProviderFacets } from "../data/catalog.js";
import { buildLlmsFullTxt, buildLlmsTxt } from "../data/llms.js";
import { DIST_ASSETS_DIR } from "../data/paths.js";
import { buildModelJsonSchema } from "../schema/generate.js";
import { renderIndex } from "../build/render.js";
import { renderModelPage } from "../build/render-model.js";
import { renderProviderPage } from "../build/render-provider.js";
import { renderGlossaryPage } from "../build/render-glossary.js";
import { SITE_URL } from "../data/site.js";
import { modelId, type Model } from "../schema/model.js";

/**
* Supplies the catalog to each request. The dev server passes a caching loader;
* tests pass a fixed array. Keeping the data source injectable lets the routes be
* exercised over real HTTP without booting the file watcher or the bundler.
*/
export type LoadModels = () => Promise<Model[]>;

/** Build the HTTP app that serves the site, the JSON API, and the llms.txt feeds. */
export function makeApp(loadModels: LoadModels): express.Express {
const app = express();
app.disable("x-powered-by");

app.use("/assets", express.static(DIST_ASSETS_DIR, { maxAge: 0 }));

app.get("/", async (_req, res, next) => {
try {
const models = await loadModels();
const catalog = buildCatalog(models);
const capabilities = buildCapabilityFacets(models);
const providers = buildProviderFacets(models);
const html = await renderIndex({ catalog, capabilities, providers });
res.setHeader("Cache-Control", "no-store");
res.type("html").send(html);
} catch (err) {
next(err);
}
});

app.get("/glossary", async (_req, res, next) => {
try {
const models = await loadModels();
res.setHeader("Cache-Control", "no-store");
res.type("html").send(await renderGlossaryPage(models));
} catch (err) {
next(err);
}
});

app.get("/providers/:provider", async (req, res, next) => {
try {
const models = await loadModels();
const providerModels = models.filter((m) => m.provider === req.params.provider);
if (providerModels.length === 0) {
res.status(404).type("text/plain").send("Unknown provider");
return;
}
res.setHeader("Cache-Control", "no-store");
res.type("html").send(await renderProviderPage(req.params.provider, providerModels, models));
} catch (err) {
next(err);
}
});

app.get("/models/:provider/:slug", async (req, res, next) => {
try {
const models = await loadModels();
const wanted = `${req.params.provider}/${req.params.slug}`;
const model = models.find((m) => modelId(m) === wanted);
if (!model) {
res.status(404).type("text/plain").send("Unknown model");
return;
}
res.setHeader("Cache-Control", "no-store");
res.type("html").send(await renderModelPage(model, models));
} catch (err) {
next(err);
}
});

app.get("/api/v1/models.json", async (_req, res, next) => {
try {
const models = await loadModels();
res.json(buildCatalog(models));
} catch (err) {
next(err);
}
});

app.get("/api/v1/schema.json", (_req, res) => {
res.json(buildModelJsonSchema());
});

app.get("/llms.txt", async (_req, res, next) => {
try {
const models = await loadModels();
res.type("text/plain; charset=utf-8").send(buildLlmsTxt(SITE_URL, models));
} catch (err) {
next(err);
}
});

app.get("/llms-full.txt", async (_req, res, next) => {
try {
const models = await loadModels();
res.type("text/plain; charset=utf-8").send(buildLlmsFullTxt(SITE_URL, models));
} catch (err) {
next(err);
}
});

app.get("/api/v1/models/:provider/:slug.json", async (req, res, next) => {
try {
const models = await loadModels();
const wanted = `${req.params.provider}/${req.params.slug}`;
const model = models.find((m) => modelId(m) === wanted);
if (!model) {
res.status(404).json({ error: "not_found", id: wanted });
return;
}
res.json({ $schema: "https://modelparams.dev/api/v1/schema.json", ...model });
} catch (err) {
next(err);
}
});

app.get("/healthz", (_req, res) => {
res.json({ ok: true });
});

return app;
}
130 changes: 4 additions & 126 deletions src/server/dev.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import path from "node:path";
import chokidar from "chokidar";
import express from "express";
import { buildCapabilityFacets, buildCatalog, buildProviderFacets } from "../data/catalog.js";
import { buildLlmsFullTxt, buildLlmsTxt } from "../data/llms.js";
import { loadAllModels } from "../data/load.js";
import { CLIENT_DIR, DIST_ASSETS_DIR, MODELS_DIR, VIEWS_DIR } from "../data/paths.js";
import { buildModelJsonSchema } from "../schema/generate.js";
import { CLIENT_DIR, MODELS_DIR, VIEWS_DIR } from "../data/paths.js";
import { bundleClientScript, compileStyles, copyStaticAssets } from "../build/assets.js";
import { renderIndex } from "../build/render.js";
import { renderModelPage } from "../build/render-model.js";
import { renderProviderPage } from "../build/render-provider.js";
import { renderGlossaryPage } from "../build/render-glossary.js";
import { SITE_URL } from "../data/site.js";
import { modelId, type Model } from "../schema/model.js";
import { type Model } from "../schema/model.js";
import { makeApp } from "./app.js";

const PORT = Number(process.env.PORT ?? 3000);

Expand Down Expand Up @@ -74,126 +66,12 @@ async function rebuildClientAssets(): Promise<void> {
await Promise.all([bundleClientScript(false), compileStyles(), copyStaticAssets()]);
}

function makeApp(): express.Express {
const app = express();
app.disable("x-powered-by");

app.use("/assets", express.static(DIST_ASSETS_DIR, { maxAge: 0 }));

app.get("/", async (_req, res, next) => {
try {
const { models } = await getCache();
const catalog = buildCatalog(models);
const capabilities = buildCapabilityFacets(models);
const providers = buildProviderFacets(models);
const html = await renderIndex({ catalog, capabilities, providers });
res.setHeader("Cache-Control", "no-store");
res.type("html").send(html);
} catch (err) {
next(err);
}
});

app.get("/glossary", async (_req, res, next) => {
try {
const { models } = await getCache();
res.setHeader("Cache-Control", "no-store");
res.type("html").send(await renderGlossaryPage(models));
} catch (err) {
next(err);
}
});

app.get("/providers/:provider", async (req, res, next) => {
try {
const { models } = await getCache();
const providerModels = models.filter((m) => m.provider === req.params.provider);
if (providerModels.length === 0) {
res.status(404).type("text/plain").send("Unknown provider");
return;
}
res.setHeader("Cache-Control", "no-store");
res.type("html").send(await renderProviderPage(req.params.provider, providerModels, models));
} catch (err) {
next(err);
}
});

app.get("/models/:provider/:slug", async (req, res, next) => {
try {
const { models } = await getCache();
const wanted = `${req.params.provider}/${req.params.slug}`;
const model = models.find((m) => modelId(m) === wanted);
if (!model) {
res.status(404).type("text/plain").send("Unknown model");
return;
}
res.setHeader("Cache-Control", "no-store");
res.type("html").send(await renderModelPage(model, models));
} catch (err) {
next(err);
}
});

app.get("/api/v1/models.json", async (_req, res, next) => {
try {
const { models } = await getCache();
res.json(buildCatalog(models));
} catch (err) {
next(err);
}
});

app.get("/api/v1/schema.json", (_req, res) => {
res.json(buildModelJsonSchema());
});

app.get("/llms.txt", async (_req, res, next) => {
try {
const { models } = await getCache();
res.type("text/plain; charset=utf-8").send(buildLlmsTxt(SITE_URL, models));
} catch (err) {
next(err);
}
});

app.get("/llms-full.txt", async (_req, res, next) => {
try {
const { models } = await getCache();
res.type("text/plain; charset=utf-8").send(buildLlmsFullTxt(SITE_URL, models));
} catch (err) {
next(err);
}
});

app.get("/api/v1/models/:provider/:slug.json", async (req, res, next) => {
try {
const { models } = await getCache();
const wanted = `${req.params.provider}/${req.params.slug}`;
const model = models.find((m) => modelId(m) === wanted);
if (!model) {
res.status(404).json({ error: "not_found", id: wanted });
return;
}
res.json({ $schema: "https://modelparams.dev/api/v1/schema.json", ...model });
} catch (err) {
next(err);
}
});

app.get("/healthz", (_req, res) => {
res.json({ ok: true });
});

return app;
}

async function main(): Promise<void> {
console.log("[dev] bundling client assets...");
await rebuildClientAssets();
await refresh();
watch();
const app = makeApp();
const app = makeApp(async () => (await getCache()).models);
app.listen(PORT, () => {
console.log(`[dev] modelparams.dev → http://localhost:${PORT}`);
});
Expand Down
Loading
Loading