diff --git a/.gitignore b/.gitignore index 7f5ed9b..6bcd5d7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ *.log .DS_Store .env + +.claude/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b724b42..cd285c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,10 @@ Guidance for Claude Code (claude.ai/code) when working in this repository. ### Distribution -Meant to be used **without cloning**. Two first-class paths: +Meant to be used **without cloning**. -- **npm package `@agentailor/mcp`** — `npx @agentailor/mcp` runs the stdio server (Claude Desktop). The `bin` maps to `dist/index.stdio.js`, which carries a `#!/usr/bin/env node` shebang. -- **Hosted HTTP** — the HTTP transport runs on a native Node host: build `pnpm install --frozen-lockfile && pnpm build`, start `pnpm start:http`. `GITHUB_TOKEN` is set as a secret; `PORT` is injected by the host; health check is `/health`. Node 20 pinned via `engines` + `.node-version`. +- **npm package `@agentailor/mcp`** (live) — `npx @agentailor/mcp` runs the stdio server (Claude Desktop). The `bin` maps to `dist/index.stdio.js`, which carries a `#!/usr/bin/env node` shebang. +- **Hosted HTTP** (planned, not live yet — presented as "coming soon" in the README) — the HTTP transport runs on a native Node host: build `pnpm install --frozen-lockfile && pnpm build`, start `pnpm start:http`. `GITHUB_TOKEN` is set as a secret; `PORT` is injected by the host; health check is `/health`. Node 20 pinned via `engines` + `.node-version`. Self-hosters can run this today; the _Agentailor-hosted_ endpoint is not yet published. Clone-and-run is a secondary/contributor path. The `Dockerfile` is for local stdio and self-hosting anywhere. @@ -60,12 +60,21 @@ Registered by workflow, not by endpoint. Names are prefixed `agentailor_` to avo - `src/tools/blog.ts` — `agentailor_get_blog_index`, `agentailor_search_articles`, `agentailor_read_article` - `src/tools/repos.ts` — `agentailor_list_repos`, `agentailor_read_repo_doc` +**Response contract (JSON vs text), decided per-tool by what the consumer needs:** + +- **List/search tools return JSON** (stringified typed objects): `agentailor_search_articles` → `{ results, total, shown, hint? }`; `agentailor_list_repos` → `{ repos }`. The agent parses fields — the `.md` URL and repo `name` feed the next tool call — so structure beats a string it would have to scrape. `response_format` selects _which fields_ appear (concise = fewer keys). +- **Content tools return raw Markdown/text**: `agentailor_read_article`, `agentailor_read_repo_doc`, `agentailor_get_blog_index`. The payload _is_ the thing the agent reads; wrapping it in JSON would only add tokens. + +This is the reasoning behind the guide's "structured, semantic responses" principle — applied deliberately, not by wrapping everything. + +**ASCII-only in tool descriptions and outputs.** No decorative unicode (`->`, em dashes, stars) in tool descriptions or tool-facing strings — keep them plain ASCII for broad client/terminal compatibility. + ### Shared core - `src/config.ts` — fetch targets (`BLOG_BASE_URL`, `GITHUB_ORG`), safety regexes, and defaults. Env overrides: `BLOG_BASE_URL`, `GITHUB_ORG`, `PORT`, `GITHUB_TOKEN`. - `src/http.ts` — `fetchText` (plain text fetch, no cache — content is always fresh; rely on `GITHUB_TOKEN` for the API limit), `githubHeaders` (adds `Authorization` only when `GITHUB_TOKEN` is set), and error types: `RateLimitError` (GitHub 403-with-`x-ratelimit-remaining: 0` or 429), `NotFoundError` (404). - `src/content.ts` — `Post` model, `getPosts()` (fetches/parses `llms.json`), and the pure `searchPosts(posts, params)` filter (query + tags + guidesOnly). -- `src/format.ts` — `formatSearchResults` with `concise`/`detailed` shapes and an overflow hint. +- `src/format.ts` — `buildSearchResponse` returns the typed search envelope `{ results, total, shown, hint? }` (pure), with `concise`/`detailed` field selection and an overflow hint. - `src/validate.ts` — pure input validation (`isBlogArticleUrl`, `isSafeRepo`, `normalizeDocPath`). Tools throw `UserError` on invalid input so the message reaches the agent. ### Data source diff --git a/README.md b/README.md index b3653f6..5748c81 100644 --- a/README.md +++ b/README.md @@ -2,39 +2,27 @@ An MCP server that connects your AI assistant to [Agentailor](https://agentailor.com) — search the blog, read articles in full, and pull docs from the open-source repos, all over the Model Context Protocol. -Two ways to use it, no clone required: +Use it with no clone required: -- **Hosted HTTP** — point your client at Agentailor's endpoint. Zero install. - **npx (local stdio)** — run `npx @agentailor/mcp` as a command in Claude Desktop and other local clients. +- **Hosted HTTP** _(coming soon)_ — a zero-install Agentailor-hosted endpoint you point your client at. Scaffolded with [`@agentailor/create-mcp-server`](https://www.npmjs.com/package/@agentailor/create-mcp-server) on [FastMCP](https://github.com/punkpeye/fastmcp) — the tool building its own ecosystem. ## Tools -| Tool | What it does | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `agentailor_get_blog_index` | Returns the blog's content map — every published article with title, date, tags, summary. | -| `agentailor_search_articles` | Ranked article search in one tool: free-text `query`, `tags` filter, `guidesOnly`, `limit`, `response_format`. | -| `agentailor_read_article` | Fetches a single article's full Markdown by its `blog.agentailor.com` `.md` URL. | -| `agentailor_list_repos` | Lists the public repos in the `agentailor` GitHub org. | -| `agentailor_read_repo_doc` | Reads a `README.md` or `docs/*.md` from a given org repo. | +| Tool | What it does | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `agentailor_get_blog_index` | Returns the blog's content map (Markdown) — every published article with title, date, tags, summary. | +| `agentailor_search_articles` | Ranked article search in one tool: free-text `query`, `tags` filter, `guidesOnly`, `limit`, `response_format`. Returns JSON. | +| `agentailor_read_article` | Fetches a single article's full Markdown by its `blog.agentailor.com` `.md` URL. | +| `agentailor_list_repos` | Lists the public repos in the `agentailor` GitHub org. Returns JSON. | +| `agentailor_read_repo_doc` | Reads a `README.md` or `docs/*.md` from a given org repo (Markdown). | -Scope is Agentailor's public content: the blog and the open-source org repos. +Scope is Agentailor's public content: the blog and the open-source org repos. The list/search tools (`search_articles`, `list_repos`) return structured JSON; the content tools return raw Markdown. ## Use it -### Hosted (no install) - -Point any HTTP MCP client at Agentailor's hosted endpoint: - -``` -https:///mcp -``` - -For a client that speaks streamable HTTP, that URL is all you need. Nothing to install or run. - -> The final hosted URL is published on [agentailor.com](https://agentailor.com). - ### npx (local stdio) Run the server as a local command — ideal for Claude Desktop. In `claude_desktop_config.json`: @@ -64,6 +52,10 @@ The repo tools read **public** GitHub data, so no token is required. If you hit } ``` +### Hosted HTTP (coming soon) + +A zero-install, Agentailor-hosted HTTP endpoint is planned — point any streamable-HTTP MCP client at a single URL, nothing to install or run. It isn't live yet; for now use the npx setup above. The endpoint URL will be published on [agentailor.com](https://agentailor.com) when it's ready. + ## Designed to the "Writing Effective Tools for AI Agents" playbook These tools follow the five principles from Agentailor's [Writing Effective Tools for AI Agents](https://blog.agentailor.com/posts/writing-tools-for-ai-agents) — here's exactly how: diff --git a/package.json b/package.json index 0e79f4f..08fca7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentailor/mcp", - "version": "0.1.0", + "version": "0.2.0", "description": "MCP server for Agentailor — search the blog and read articles and open-source repo docs over the Model Context Protocol.", "type": "module", "license": "Apache-2.0", @@ -19,13 +19,13 @@ "ai", "llm" ], - "homepage": "https://github.com/agentailor/agentailor-mcp#readme", + "homepage": "https://github.com/agentailor/mcp#readme", "repository": { "type": "git", - "url": "git+https://github.com/agentailor/agentailor-mcp.git" + "url": "git+https://github.com/agentailor/mcp.git" }, "bugs": { - "url": "https://github.com/agentailor/agentailor-mcp/issues" + "url": "https://github.com/agentailor/mcp/issues" }, "publishConfig": { "access": "public" diff --git a/src/config.ts b/src/config.ts index ba4b7d0..0846b91 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,7 +8,7 @@ export const BLOG_BASE_URL = ( /** Structured, machine-readable post index. */ export const LLMS_JSON_URL = `${BLOG_BASE_URL}/llms.json`; -/** Corpus map (llms-full.txt) — the "what exists" index. */ +/** Corpus map (llms-full.txt): the "what exists" index. */ export const BLOG_INDEX_URL = `${BLOG_BASE_URL}/llms-full.txt`; /** Public GitHub org whose repos are exposed. */ diff --git a/src/content.ts b/src/content.ts index 25b2f66..38842bd 100644 --- a/src/content.ts +++ b/src/content.ts @@ -36,31 +36,53 @@ function norm(s: string): string { return s.toLowerCase().replace(/[\s_-]+/g, ""); } -function matchesQuery(post: Post, query: string): boolean { - const q = query.toLowerCase(); +/** Splits a query into lowercased terms, dropping empties. */ +function queryTerms(query: string): string[] { + return query.toLowerCase().split(/\s+/).filter(Boolean); +} + +/** True when a single term appears in the post's title, summary, or any tag. */ +function termMatches(post: Post, term: string): boolean { return ( - post.title.toLowerCase().includes(q) || - post.summary.toLowerCase().includes(q) || - post.tags.some((t) => t.toLowerCase().includes(q)) + post.title.toLowerCase().includes(term) || + post.summary.toLowerCase().includes(term) || + post.tags.some((t) => t.toLowerCase().includes(term)) ); } +/** How many of the given terms match the post (OR semantics, ranking signal). */ +function countMatchedTerms(post: Post, terms: string[]): number { + return terms.reduce((n, term) => n + (termMatches(post, term) ? 1 : 0), 0); +} + function matchesTags(post: Post, tags: string[]): boolean { const postTags = post.tags.map(norm); return tags.every((t) => postTags.includes(norm(t))); } /** - * Filters posts by free-text query, tag membership, and/or guides-only. - * Empty params return the full (date-desc) index. Results are already sorted - * newest-first as emitted by the blog. + * Filters posts by tag membership (AND), guides-only, and a free-text query. + * The query is split into terms; a post matches if it contains ANY term, and + * results are ranked by how many terms match (then newest-first). Without a + * query, order is unchanged (date-desc, as emitted by the blog). */ export function searchPosts(posts: Post[], params: SearchParams): Post[] { const { query, tags, guidesOnly } = params; - return posts.filter((post) => { + + const filtered = posts.filter((post) => { if (guidesOnly && !post.guide) return false; if (tags && tags.length > 0 && !matchesTags(post, tags)) return false; - if (query && query.trim() && !matchesQuery(post, query)) return false; return true; }); + + const terms = query ? queryTerms(query) : []; + if (terms.length === 0) return filtered; + + // Keep posts matching at least one term, then stable-sort by match count + // desc. Input is already newest-first, so ties preserve date-desc order. + return filtered + .map((post) => ({ post, score: countMatchedTerms(post, terms) })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .map(({ post }) => post); } diff --git a/src/format.ts b/src/format.ts index 1e659f8..70cb930 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,46 +1,72 @@ -/** Response formatting: concise vs detailed shapes for search results. */ +/** Search-response shaping: concise vs detailed article records + envelope. */ import type { ResponseFormat } from "./config.js"; import type { Post } from "./content.js"; -/** One search hit, rendered per the requested verbosity. */ -function formatPost(post: Post, format: ResponseFormat): string { - const badge = post.guide ? " [Guide]" : ""; - if (format === "concise") { - return `- ${post.title}${badge} — ${post.url}`; - } - const tags = post.tags.length ? ` (${post.tags.join(", ")})` : ""; - return [ - `### ${post.title}${badge}`, - `${post.url}`, - `${post.date}${tags}`, - post.summary, - ].join("\n"); +/** A single article result. Detailed adds date, tags, and summary. */ +export interface ArticleResult { + title: string; + url: string; + guide: boolean; + date?: string; + tags?: string[]; + summary?: string; +} + +/** The JSON envelope returned by agentailor_search_articles. */ +export interface SearchResponse { + results: ArticleResult[]; + total: number; + shown: number; + hint?: string; +} + +/** Projects a post to a result record per the requested verbosity. */ +function toResult(post: Post, format: ResponseFormat): ArticleResult { + const base: ArticleResult = { + title: post.title, + url: post.url, + guide: post.guide, + }; + if (format === "concise") return base; + return { + ...base, + date: post.date, + tags: post.tags, + summary: post.summary, + }; } /** - * Renders a search result set. `total` is the pre-truncation count so an - * overflow hint can steer the agent toward a narrower query. + * Builds the search-response envelope. `total` is the pre-truncation count so + * an overflow hint can steer the agent toward a narrower query. */ -export function formatSearchResults( +export function buildSearchResponse( results: Post[], total: number, limit: number, format: ResponseFormat -): string { +): SearchResponse { if (results.length === 0) { - return "No matching articles. Try a broader query, fewer tags, or drop guidesOnly."; + return { + results: [], + total: 0, + shown: 0, + hint: "No matching articles. Try a broader query, fewer tags, or drop guidesOnly.", + }; } - const sep = format === "concise" ? "\n" : "\n\n"; - const body = results.map((p) => formatPost(p, format)).join(sep); + const response: SearchResponse = { + results: results.map((p) => toResult(p, format)), + total, + shown: results.length, + }; if (total > results.length) { - const hint = - `\n\nShowing ${results.length} of ${total} matches. ` + + response.hint = + `Showing ${results.length} of ${total} matches. ` + `Narrow with a more specific query, tags, or guidesOnly to see the most relevant ones ` + `(or raise limit up to ${total}).`; - return body + hint; } - return body; + return response; } diff --git a/src/server.ts b/src/server.ts index 054643c..41b3fc3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,8 +4,8 @@ import { registerRepoTools } from "./tools/repos.js"; const server = new FastMCP({ name: "agentailor-mcp", - version: "0.1.0", - // GET /health → "ok" on the HTTP transport (ignored by stdio). + version: "0.2.0", + // GET /health returns "ok" on the HTTP transport (ignored by stdio). health: { enabled: true }, }); diff --git a/src/tools/blog.ts b/src/tools/blog.ts index d64f4e5..62c2dc0 100644 --- a/src/tools/blog.ts +++ b/src/tools/blog.ts @@ -6,7 +6,7 @@ import { SEARCH_LIMIT_DEFAULT, } from "../config.js"; import { getPosts, searchPosts } from "../content.js"; -import { formatSearchResults } from "../format.js"; +import { buildSearchResponse } from "../format.js"; import { fetchText, NotFoundError } from "../http.js"; import { isBlogArticleUrl } from "../validate.js"; @@ -14,12 +14,12 @@ import { isBlogArticleUrl } from "../validate.js"; export function registerBlogTools(server: FastMCP): void { server.addTool({ name: "agentailor_get_blog_index", - description: `Returns the Agentailor blog's content map — the list of every published article with its title, date, tags, and summary. + description: `Returns the Agentailor blog's content map: the list of every published article with its title, date, tags, and summary. **When to use:** - To see what topics the blog covers before answering ("what do you have on X?"). - As a first step when you're not sure which article is relevant. -- Prefer agentailor_search_articles when the user names a topic — it returns a filtered, ranked shortlist instead of the whole map.`, +- Prefer agentailor_search_articles when the user names a topic; it returns a filtered, ranked shortlist instead of the whole map.`, parameters: z.object({}), execute: async () => { return await fetchText(BLOG_INDEX_URL); @@ -28,21 +28,21 @@ export function registerBlogTools(server: FastMCP): void { server.addTool({ name: "agentailor_search_articles", - description: `Searches the Agentailor blog and returns a ranked shortlist of matching articles (title, url, date, tags, summary). One tool for all discovery: free-text, by tag, and guides-only. + description: `Searches the Agentailor blog and returns a ranked shortlist of matching articles as JSON. One tool for all discovery: free-text, by tag, and guides-only. **When to use:** -- "What articles cover MCP?" → query: "mcp" -- "Show me everything tagged langgraph and tools" → tags: ["langgraph", "tools"] -- "Which complete guides do you have?" → guidesOnly: true +- "What articles cover MCP?" with query: "mcp" +- "Show me everything tagged langgraph and tools" with tags: ["langgraph", "tools"] +- "Which complete guides do you have?" with guidesOnly: true - Combine them: query + tags + guidesOnly all narrow together. -Returns links to the .md version of each article; call agentailor_read_article to read one in full.`, +Returns JSON: { results, total, shown, hint? }. Each result carries the article's .md URL in results[].url; pass that to agentailor_read_article to read one in full.`, parameters: z.object({ query: z .string() .optional() .describe( - 'Free-text terms matched against title, summary, and tags. Example: "memory" or "langgraph vs llamaindex". Omit to list everything.' + 'Free-text terms matched against title, summary, and tags. Multiple words are matched individually and ranked by how many match, so partial matches still return results. Example: "memory" or "langgraph vs llamaindex". Omit to list everything.' ), tags: z .array(z.string()) @@ -67,7 +67,7 @@ Returns links to the .md version of each article; call agentailor_read_article t .enum(["concise", "detailed"]) .optional() .describe( - 'Output verbosity. "concise" (default) is a titled link list; "detailed" adds date, tags, and summary per result.' + 'Output verbosity. "concise" (default) returns title, url, and guide per result; "detailed" adds date, tags, and summary.' ), }), execute: async ({ query, tags, guidesOnly, limit, response_format }) => { @@ -75,12 +75,13 @@ Returns links to the .md version of each article; call agentailor_read_article t const matches = searchPosts(posts, { query, tags, guidesOnly }); const cap = limit ?? SEARCH_LIMIT_DEFAULT; const shown = matches.slice(0, cap); - return formatSearchResults( + const response = buildSearchResponse( shown, matches.length, cap, response_format ?? "concise" ); + return JSON.stringify(response, null, 2); }, }); diff --git a/src/tools/repos.ts b/src/tools/repos.ts index 497c7ea..02b98e2 100644 --- a/src/tools/repos.ts +++ b/src/tools/repos.ts @@ -21,18 +21,20 @@ interface Repo { export function registerRepoTools(server: FastMCP): void { server.addTool({ name: "agentailor_list_repos", - description: `Lists the public open-source repositories in the ${GITHUB_ORG} GitHub org, with names, descriptions, topics, stars, and URLs. + description: `Lists the public open-source repositories in the ${GITHUB_ORG} GitHub org as JSON, with names, descriptions, topics, stars, and URLs. **When to use:** - "What open-source tools does Agentailor have?" - "Do you have a starter/template for MCP?" -- To find a repo before reading its docs with agentailor_read_repo_doc.`, +- To find a repo before reading its docs with agentailor_read_repo_doc. + +Returns JSON: { repos }. Use a repo's "name" to read its docs with agentailor_read_repo_doc.`, parameters: z.object({ response_format: z .enum(["concise", "detailed"]) .optional() .describe( - 'Output verbosity. "concise" (default) is name + description; "detailed" adds topics and star counts.' + 'Output verbosity. "concise" (default) returns name, description, and url; "detailed" adds topics and stars.' ), }), execute: async ({ response_format }) => { @@ -44,14 +46,16 @@ export function registerRepoTools(server: FastMCP): void { ); const repos = JSON.parse(raw) as Repo[]; const detailed = response_format === "detailed"; - return repos - .map((r) => { - const desc = r.description ?? "No description"; - if (!detailed) return `- ${r.name}: ${desc} — ${r.html_url}`; - const topics = r.topics?.length ? ` [${r.topics.join(", ")}]` : ""; - return `- ${r.name}${topics} (★${r.stargazers_count}): ${desc} — ${r.html_url}`; - }) - .join("\n"); + const result = repos.map((r) => { + const base = { + name: r.name, + description: r.description ?? null, + url: r.html_url, + }; + if (!detailed) return base; + return { ...base, topics: r.topics ?? [], stars: r.stargazers_count }; + }); + return JSON.stringify({ repos: result }, null, 2); }, }); @@ -60,8 +64,8 @@ export function registerRepoTools(server: FastMCP): void { description: `Fetches a Markdown doc (the README, or a file under docs/) from a public ${GITHUB_ORG} repo. **When to use:** -- The user asks how to use a specific repo, or about its architecture → read its README first. -- The user asks about a specific documented topic → read the matching docs/*.md file. +- The user asks how to use a specific repo, or about its architecture: read its README first. +- The user asks about a specific documented topic: read the matching docs/*.md file. Only README.md and docs/*.md paths are allowed.`, parameters: z.object({ diff --git a/test/content.test.ts b/test/content.test.ts index 2096eb8..733ac51 100644 --- a/test/content.test.ts +++ b/test/content.test.ts @@ -26,6 +26,26 @@ describe("searchPosts", () => { expect(byTitle.map((p) => p.slug)).toContain("agent-development-roadmap"); }); + it("matches multiple query terms with OR and ranks by match count", () => { + // No article contains the contiguous phrase "agents tools", but several + // contain one or both terms. Old phrase-matching returned nothing here. + const res = searchPosts(posts, { query: "agents tools" }); + expect(res.length).toBeGreaterThan(0); + // "writing-tools-for-ai-agents" matches both terms -> ranked first. + expect(res[0].slug).toBe("writing-tools-for-ai-agents"); + }); + + it("degrades gracefully when only some terms match", () => { + // "roadmap" hits one post; "nonexistentterm" hits none. Still returns the + // roadmap post instead of a silent empty. + const res = searchPosts(posts, { query: "roadmap nonexistentterm" }); + expect(res.map((p) => p.slug)).toContain("agent-development-roadmap"); + }); + + it("returns empty only when no term matches anything", () => { + expect(searchPosts(posts, { query: "zzz qqq" })).toHaveLength(0); + }); + it("is case- and format-insensitive for tags", () => { const a = searchPosts(posts, { tags: ["mcp server"] }); const b = searchPosts(posts, { tags: ["mcp-server"] }); diff --git a/test/format.test.ts b/test/format.test.ts index 1cc30af..80d2d0a 100644 --- a/test/format.test.ts +++ b/test/format.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Post } from "../src/content.js"; -import { formatSearchResults } from "../src/format.js"; +import { buildSearchResponse } from "../src/format.js"; const post: Post = { title: "MCP TypeScript SDK Complete Guide", @@ -12,34 +12,40 @@ const post: Post = { summary: "Everything in the MCP TypeScript SDK, end to end.", }; -describe("formatSearchResults", () => { - it("concise format is a link list and flags guides", () => { - const out = formatSearchResults([post], 1, 20, "concise"); - expect(out).toContain(post.url); - expect(out).toContain("[Guide]"); - expect(out).not.toContain(post.summary); +describe("buildSearchResponse", () => { + it("concise result carries title, url, and guide only", () => { + const res = buildSearchResponse([post], 1, 20, "concise"); + expect(res.results).toHaveLength(1); + const [r] = res.results; + expect(r).toEqual({ title: post.title, url: post.url, guide: true }); + expect(r.summary).toBeUndefined(); + expect(res.total).toBe(1); + expect(res.shown).toBe(1); }); - it("detailed format includes date, tags, and summary", () => { - const out = formatSearchResults([post], 1, 20, "detailed"); - expect(out).toContain(post.summary); - expect(out).toContain("2026-03-18"); - expect(out).toContain("MCP, Tools"); + it("detailed result adds date, tags, and summary", () => { + const [r] = buildSearchResponse([post], 1, 20, "detailed").results; + expect(r.date).toBe("2026-03-18"); + expect(r.tags).toEqual(["MCP", "Tools"]); + expect(r.summary).toBe(post.summary); }); it("adds an overflow hint when results are truncated", () => { - const out = formatSearchResults([post], 42, 1, "concise"); - expect(out).toMatch(/Showing 1 of 42/); - expect(out).toContain("Narrow"); + const res = buildSearchResponse([post], 42, 1, "concise"); + expect(res.total).toBe(42); + expect(res.shown).toBe(1); + expect(res.hint).toMatch(/Showing 1 of 42/); }); it("omits the hint when nothing is truncated", () => { - const out = formatSearchResults([post], 1, 20, "concise"); - expect(out).not.toMatch(/Showing/); + const res = buildSearchResponse([post], 1, 20, "concise"); + expect(res.hint).toBeUndefined(); }); - it("returns a helpful message for no results", () => { - const out = formatSearchResults([], 0, 20, "concise"); - expect(out).toMatch(/No matching articles/); + it("returns an empty result set with a helpful hint for no matches", () => { + const res = buildSearchResponse([], 0, 20, "concise"); + expect(res.results).toEqual([]); + expect(res.total).toBe(0); + expect(res.hint).toMatch(/No matching articles/); }); }); diff --git a/test/http.test.ts b/test/http.test.ts index 1f991d5..ab369a3 100644 --- a/test/http.test.ts +++ b/test/http.test.ts @@ -14,15 +14,9 @@ function mockResponse( describe("fetchText", () => { afterEach(() => vi.restoreAllMocks()); - it("returns body text on success and does not cache", async () => { - // Fresh Response per call: a body can only be read once. - const spy = vi - .spyOn(globalThis, "fetch") - .mockImplementation(async () => mockResponse("hello")); - - expect(await fetchText("https://x.test/a")).toBe("hello"); + it("returns body text on success", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(mockResponse("hello")); expect(await fetchText("https://x.test/a")).toBe("hello"); - expect(spy).toHaveBeenCalledTimes(2); // no cache — each call hits the network }); it("throws RateLimitError on 403 with x-ratelimit-remaining: 0", async () => {