Skip to content
Draft
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
24 changes: 24 additions & 0 deletions scripts/mcp-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ try {
const renderTool = tools.tools.find((tool) => tool.name === "render");
assert.equal(renderTool?.inputSchema.required?.includes("type"), true);

const cards = await client.callTool({
name: "list_cards",
arguments: {},
});
const cardsText = cards.content[0]?.type === "text" ? cards.content[0].text : "";
assert.match(cardsText, /stats: GitHub stats\s+\[required: username\]/);
assert.match(cardsText, /Catalog version: smoke, live/);

const themes = await client.callTool({
name: "list_themes",
arguments: {},
});
const themesText = themes.content[0]?.type === "text" ? themes.content[0].text : "";
assert.match(themesText, /dark, tokyo_night/);
assert.match(themesText, /theme=tokyo_night/);

const rendered = await client.callTool({
name: "render",
arguments: {
Expand All @@ -63,6 +79,14 @@ try {
}),
/requires: username/
);

await assert.rejects(
client.callTool({
name: "missing_tool",
arguments: {},
}),
/Unknown tool: missing_tool/
);
} finally {
await transport.close();
}
50 changes: 49 additions & 1 deletion scripts/verify-pack.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";

const root = new URL("..", import.meta.url);
Expand Down Expand Up @@ -76,4 +77,51 @@ for (const packedPath of packedPaths) {
assert.ok(allowed.test(packedPath), `unexpected file in npm pack output: ${packedPath}`);
}

const tempRoot = mkdtempSync(join(tmpdir(), "profilekit-mcp-pack-"));
try {
const actualPack = spawnSync("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", tempRoot], {
cwd: root,
encoding: "utf8",
});
if (actualPack.status !== 0) {
process.stderr.write(actualPack.stderr);
process.stderr.write(actualPack.stdout);
process.exit(actualPack.status ?? 1);
}

const [actualManifest] = JSON.parse(actualPack.stdout);
const tarball = join(tempRoot, actualManifest.filename);
assert.ok(existsSync(tarball), `npm pack did not create expected tarball: ${tarball}`);

const consumer = join(tempRoot, "consumer");
mkdirSync(consumer);
writeFileSync(join(consumer, "package.json"), '{"type":"module","private":true}\n');

const install = spawnSync("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], {
cwd: consumer,
encoding: "utf8",
});
if (install.status !== 0) {
process.stderr.write(install.stderr);
process.stderr.write(install.stdout);
process.exit(install.status ?? 1);
}

const importSmoke = spawnSync(process.execPath, [
"--input-type=module",
"-e",
"import { runServer } from 'profilekit-mcp'; if (typeof runServer !== 'function') throw new Error('runServer export missing');",
], {
cwd: consumer,
encoding: "utf8",
});
if (importSmoke.status !== 0) {
process.stderr.write(importSmoke.stderr);
process.stderr.write(importSmoke.stdout);
process.exit(importSmoke.status ?? 1);
}
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}

console.log(`package surface looks good (${manifest.entryCount} packed files).`);
43 changes: 43 additions & 0 deletions src/fetch-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,49 @@ test("non-JSON body → bundled fallback (no throw escapes)", async () => {
assert.equal(c.source, "fallback");
});

test("timeout covers a stalled JSON body, not only the initial fetch", async () => {
globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
const signal = init?.signal;
assert.ok(signal, "fetch should receive an AbortSignal");
return {
ok: true,
status: 200,
async json() {
return new Promise((_resolve, reject) => {
if (signal.aborted) {
reject(new Error("already aborted"));
return;
}
signal.addEventListener("abort", () => reject(new Error("aborted body")), { once: true });
});
},
};
}) as unknown as typeof fetch;

const c = await Promise.race([
getCatalog(URL),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("catalog timeout did not cover body read")), 4_000);
}),
]);
assert.equal(c.source, "fallback");
});

test("fallback result is reused during retry backoff", async () => {
let calls = 0;
globalThis.fetch = (async () => {
calls++;
return { ok: false, status: 500, async json() { return {}; } };
}) as unknown as typeof fetch;

const first = await getCatalog(URL);
const second = await getCatalog(URL);

assert.equal(first.source, "fallback");
assert.equal(second.source, "fallback");
assert.equal(calls, 1, "backoff window must not re-fetch immediately after fallback");
});

test("getCatalog memoizes within a process (one fetch, cached result)", async () => {
let calls = 0;
globalThis.fetch = (async () => {
Expand Down
123 changes: 78 additions & 45 deletions src/fetch-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,43 @@ export interface ResolvedCatalog {
}

let cachedPromise: Promise<ResolvedCatalog> | null = null;
let cachedFallback: ResolvedCatalog | null = null;
// After a fallback result, back off briefly before re-fetching so a
// persistently-down remote is not hammered on every single tool call.
let nextRetryAt = 0;
const FALLBACK_RETRY_MS = 30_000;
const CATALOG_TIMEOUT_MS = 3_000;

export function getCatalog(url: string = process.env.PROFILEKIT_CATALOG_URL ?? DEFAULT_CATALOG_URL): Promise<ResolvedCatalog> {
// Only a successful remote fetch is cached permanently. A fallback result
// (transient network blip, timeout, or remote outage) is NOT pinned for the
// life of the process: clear the cache once it resolves so a later call can
// re-fetch and the server self-heals, subject to a short backoff.
if (!cachedPromise && Date.now() >= nextRetryAt) {
if (cachedPromise) {
return cachedPromise;
}
if (cachedFallback && Date.now() < nextRetryAt) {
return Promise.resolve(cachedFallback);
}
if (!cachedPromise) {
cachedPromise = loadCatalog(url).then((result) => {
if (result.source === "fallback") {
cachedFallback = result;
cachedPromise = null;
nextRetryAt = Date.now() + FALLBACK_RETRY_MS;
} else {
cachedFallback = null;
nextRetryAt = 0;
}
return result;
});
}
// During the backoff window (or while the in-flight promise is pending) serve
// the current promise if present; otherwise fall back once without caching.
return cachedPromise ?? loadCatalog(url);
return cachedPromise;
}

export function resetCatalogCache(): void {
cachedPromise = null;
cachedFallback = null;
nextRetryAt = 0;
}

Expand All @@ -45,49 +56,71 @@ function toStringArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
}

async function loadCatalog(url: string): Promise<ResolvedCatalog> {
async function fetchJsonWithTimeout(url: string): Promise<unknown> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error(`catalog request timed out after ${CATALOG_TIMEOUT_MS}ms`));
}, CATALOG_TIMEOUT_MS);
});

try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// Treat the parsed body as unknown shape — a 200 response is not a promise
// of a well-typed catalog. Every field is validated/coerced below.
const data = (await res.json()) as {
version?: unknown;
cards?: unknown;
themes?: unknown;
};
if (!data.cards || typeof data.cards !== "object") {
throw new Error("malformed catalog: missing `cards`");
}
// Normalize each entry by TYPE, not just by presence. A remote catalog
// served with HTTP 200 can still carry wrong-typed fields (e.g. `required`
// as a string after an API drift). Coercing with `?? default` only guards
// `undefined`/`null` — a string would survive and later blow up
// `entry.required.filter(...)` / `.join(...)` in the tool handlers. We
// coerce to the expected shape and DROP only the individual entries that
// are not objects, so one bad entry can't discard the whole live catalog.
const cards: Record<string, CardEntry> = {};
for (const [name, rawValue] of Object.entries(data.cards as Record<string, unknown>)) {
if (!rawValue || typeof rawValue !== "object") continue; // skip null/garbage entries
const raw = rawValue as { description?: unknown; required?: unknown; common_params?: unknown };
cards[name] = {
description: typeof raw.description === "string" ? raw.description : "",
required: toStringArray(raw.required),
common_params: toStringArray(raw.common_params),
};
}
if (Object.keys(cards).length === 0) {
throw new Error("malformed catalog: no usable card entries");
}
return {
cards,
themes: Array.isArray(data.themes) ? toStringArray(data.themes) : FALLBACK_THEMES,
source: "remote",
version: typeof data.version === "string" ? data.version : undefined,
return await Promise.race([
(async () => {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})(),
timeout,
]);
} finally {
if (timer) clearTimeout(timer);
}
}

function normalizeCatalog(data: unknown): ResolvedCatalog {
const rawCatalog = data as {
version?: unknown;
cards?: unknown;
themes?: unknown;
};
if (!rawCatalog.cards || typeof rawCatalog.cards !== "object") {
throw new Error("malformed catalog: missing `cards`");
}
// Normalize each entry by TYPE, not just by presence. A remote catalog
// served with HTTP 200 can still carry wrong-typed fields (e.g. `required`
// as a string after an API drift). Coercing with `?? default` only guards
// `undefined`/`null` — a string would survive and later blow up
// `entry.required.filter(...)` / `.join(...)` in the tool handlers. We
// coerce to the expected shape and DROP only the individual entries that
// are not objects, so one bad entry can't discard the whole live catalog.
const cards: Record<string, CardEntry> = {};
for (const [name, rawValue] of Object.entries(rawCatalog.cards as Record<string, unknown>)) {
if (!rawValue || typeof rawValue !== "object") continue; // skip null/garbage entries
const raw = rawValue as { description?: unknown; required?: unknown; common_params?: unknown };
cards[name] = {
description: typeof raw.description === "string" ? raw.description : "",
required: toStringArray(raw.required),
common_params: toStringArray(raw.common_params),
};
}
if (Object.keys(cards).length === 0) {
throw new Error("malformed catalog: no usable card entries");
}
return {
cards,
themes: Array.isArray(rawCatalog.themes) ? toStringArray(rawCatalog.themes) : FALLBACK_THEMES,
source: "remote",
version: typeof rawCatalog.version === "string" ? rawCatalog.version : undefined,
};
}

async function loadCatalog(url: string): Promise<ResolvedCatalog> {
try {
const data = await fetchJsonWithTimeout(url);
return normalizeCatalog(data);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
process.stderr.write(`[profilekit-mcp] remote catalog fetch failed (${reason}); using bundled fallback\n`);
Expand Down
Loading
Loading