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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ dist/
*.log
.DS_Store
.env

.claude/
17 changes: 13 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
36 changes: 14 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<agentailor-mcp-host>/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`:
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
42 changes: 32 additions & 10 deletions src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
76 changes: 51 additions & 25 deletions src/format.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 2 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});

Expand Down
23 changes: 12 additions & 11 deletions src/tools/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ 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";

/** Registers the blog content tools on the given server. */
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);
Expand All @@ -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())
Expand All @@ -67,20 +67,21 @@ 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 }) => {
const posts = await getPosts();
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);
},
});

Expand Down
Loading
Loading