@chrissgon/agent-ready-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christopher Gonçalves
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # agent-ready-kit
2
+
3
+ Make a small site readable by AI agents from one JSON data file. `agent-ready` validates the file against a strict schema and turns it into:
4
+
5
+ - **`llms.txt`**, one per site language, in the format of [llmstxt.org](https://llmstxt.org/): an H1 with the name, a blockquote summary, the public profiles and detail paragraphs, then H2 sections of `[name](url): notes` links.
6
+ - **schema.org JSON-LD**: one `@graph` with the owner (`Person` or `Organization`) and one `SoftwareSourceCode` per product.
7
+ - **A read-only MCP server** with three tools, `get_profile`, `list_products` and `list_posts`, served over stdio or from any runtime that speaks Web `Request`/`Response` (Netlify Functions v2, Deno, Bun, Cloudflare Workers, Node).
8
+
9
+ All three read the same validated object, so a name or a URL can never differ between them (a test checks it). The design comes from the personal site chrissgon.dev, where one data module feeds the pages, `llms.txt`, the JSON-LD and the MCP endpoint.
10
+
11
+ ## Install
12
+
13
+ Requires Node.js 22 or later. The package is an ES module with TypeScript types. The package is `@chrissgon/agent-ready-kit`; its command is `agent-ready`.
14
+
15
+ ```sh
16
+ npm install @chrissgon/agent-ready-kit
17
+ ```
18
+
19
+ Or run the CLI without installing it (the package has one command, so `npx` runs `agent-ready`):
20
+
21
+ ```sh
22
+ npx @chrissgon/agent-ready-kit check --data site.json
23
+ npx @chrissgon/agent-ready-kit build --data site.json --out public
24
+ ```
25
+
26
+ ## The data file
27
+
28
+ One JSON file with four keys. Every object is strict: a field the schema does not declare fails validation. This is `fixtures/person.json` (fake example data), shortened:
29
+
30
+ ```json
31
+ {
32
+ "site": { "url": "https://sam.example.com", "langs": ["en", "pt"], "mcp": "https://sam.example.com/api/mcp" },
33
+ "owner": {
34
+ "type": "Person",
35
+ "name": "Sam Example",
36
+ "alternateName": "samexample",
37
+ "jobTitle": "Software Engineer",
38
+ "label": { "en": "Software engineer · Small open-source tools for the web", "pt": "Engenheiro de software · ..." },
39
+ "about": { "en": ["Sam builds small, well-tested tools for the web ..."], "pt": ["Sam cria ferramentas ..."] },
40
+ "profiles": [{ "network": "Code", "handle": "samexample", "url": "https://git.example.org/samexample" }]
41
+ },
42
+ "products": [
43
+ {
44
+ "id": "tidy-tables",
45
+ "name": "Tidy Tables",
46
+ "url": "https://tidytables.example.com",
47
+ "codeRepository": "https://git.example.org/samexample/tidy-tables",
48
+ "npm": "@samexample/tidy-tables",
49
+ "license": "MIT",
50
+ "programmingLanguage": ["CSS", "TypeScript"],
51
+ "summary": { "en": "Accessible data tables in one small stylesheet.", "pt": "Tabelas de dados ..." }
52
+ }
53
+ ],
54
+ "posts": [
55
+ {
56
+ "id": "tables-for-everyone",
57
+ "title": { "en": "Tables for everyone", "pt": "Tabelas para todos" },
58
+ "date": "2026-08-14",
59
+ "lang": ["en", "pt"],
60
+ "url": "https://blog.example.com/tables-for-everyone"
61
+ }
62
+ ]
63
+ }
64
+ ```
65
+
66
+ | Field | Rule |
67
+ |-------|------|
68
+ | `site.url` | https origin; a trailing slash is removed |
69
+ | `site.langs` | language codes (`en`, `pt-BR`), at least one, no repeats; the first is the default |
70
+ | `site.mcp` | optional https URL of your MCP endpoint, listed in `llms.txt` under "For agents" |
71
+ | `owner.type` | `Person` or `Organization`; `jobTitle` is allowed on a `Person` only |
72
+ | `owner.name`, `owner.alternateName` | the name, and an optional handle |
73
+ | `owner.label` | one line per site language: the `llms.txt` summary and the JSON-LD `description` |
74
+ | `owner.about` | optional paragraphs per site language |
75
+ | `owner.profiles` | `network`, optional `handle`, https `url`; become `sameAs` in the JSON-LD |
76
+ | `products[]` | optional slug `id` (unique), `name`, `url` and/or `codeRepository` (at least one), optional `npm` and `license` (SPDX id), `programmingLanguage`, `summary` per site language |
77
+ | `posts[]` | unique slug `id`, `title` for each language of the post, `date` as YYYY-MM-DD, `lang` (site languages), https `url`; order does not matter, outputs list newest first |
78
+
79
+ Every error names the field, one per line, and `check` exits with 1:
80
+
81
+ ```text
82
+ $ npx @chrissgon/agent-ready-kit check --data fixtures/forbidden-email.json
83
+ data: owner.email: forbidden field "email", the kit never publishes it
84
+ ```
85
+
86
+ Other examples: `data: owner.nickname: unknown field`, `data: posts.1.id: duplicate id "same-id" (first at posts.0)`, `data: owner.label.pt: missing text for site language "pt"`.
87
+
88
+ `fixtures/org.json` is a one-language `Organization` example.
89
+
90
+ ## What the kit never publishes
91
+
92
+ `worksFor`, `address`, `homeLocation`, `birthDate` and `email` are rejected anywhere in the data file, and `generateJsonLd` refuses them again if code adds them to the data at run time. The MCP tools return slices of the validated file and nothing else: no tool writes, sends, executes or searches free text, and an argument the tool does not declare is rejected.
93
+
94
+ ## Commands
95
+
96
+ ```text
97
+ agent-ready build --data <file> --out <dir> llms.txt per language (the first at <dir>/llms.txt, the others at
98
+ <dir>/<lang>/llms.txt) and <dir>/jsonld.json; prints the paths
99
+ agent-ready check --data <file> validate; prints a one-line summary
100
+ agent-ready mcp --data <file> serve the read-only MCP server on stdin/stdout
101
+ agent-ready --help
102
+ ```
103
+
104
+ Data goes to stdout and diagnostics to stderr. Exit codes: 0 ok, 1 invalid data, 2 wrong usage or unreadable file.
105
+
106
+ `check` on `fixtures/person.json` prints `ok: fixtures/person.json: Person "Sam Example", 2 products, 3 posts, languages en, pt`; `build` prints the files it wrote: `out/llms.txt`, `out/pt/llms.txt` and `out/jsonld.json`.
107
+
108
+ A local MCP client starts the server as a command:
109
+
110
+ ```json
111
+ { "command": "npx", "args": ["-y", "@chrissgon/agent-ready-kit", "mcp", "--data", "/path/to/site.json"] }
112
+ ```
113
+
114
+ ## Use it from code
115
+
116
+ ```ts
117
+ import { createMcpHandler, generateJsonLd, generateLlms, loadData, serializeJsonLd } from "@chrissgon/agent-ready-kit";
118
+
119
+ const data = await loadData("site.json"); // throws DataError with one line per problem
120
+ const llms = generateLlms(data); // default language; generateLlms(data, { lang: "pt" }) for another
121
+ const jsonld = serializeJsonLd(generateJsonLd(data)); // safe inside <script type="application/ld+json">
122
+ const handler = createMcpHandler(data); // (request: Request) => Promise<Response>
123
+ ```
124
+
125
+ ### Exports
126
+
127
+ | Import from | Exports | Loads the MCP SDK |
128
+ |-------------|---------|-------------------|
129
+ | `@chrissgon/agent-ready-kit` | everything below, plus `serveStdio` and `VERSION` | yes |
130
+ | `@chrissgon/agent-ready-kit/data` | `validate(value)`, `parseData(jsonText)`, `loadData(file)`, `DataError` (its `issues` holds one line per problem), `SiteDataSchema` (zod), `FORBIDDEN_FIELDS`; types `SiteData`, `Owner`, `Product`, `Post` | no |
131
+ | `@chrissgon/agent-ready-kit/llms` | `generateLlms(data, { lang, labels })`, `generateLlmsParts(data, { lang, labels })`, `LLMS_PARTS`, `DEFAULT_LLMS_LABELS`, `llmsPath(data, lang)`; types `LlmsOptions`, `LlmsLabels`, `LlmsPart` | no |
132
+ | `@chrissgon/agent-ready-kit/jsonld` | `generateJsonLd(data, { lang, licenseUrl })`, `serializeJsonLd(doc)`, `ownerIdOf(data)`, `spdxLicenseUrl(id)`; types `JsonLdDocument`, `JsonLdNode`, `JsonLdOptions` | no |
133
+ | `@chrissgon/agent-ready-kit/mcp` | `createMcpHandler(data, options)`, `buildServer(data, { name, version })`, `TOOL_NAMES`, `instructionsFor(name)`, `DEFAULT_MAX_BODY_BYTES`; types `McpHandler`, `McpHandlerOptions`, `McpServerOptions` | yes |
134
+
135
+ Pages that only need `llms.txt` or the JSON-LD import from `/data`, `/llms` and `/jsonld`, so they never load the MCP SDK.
136
+
137
+ ### llms.txt in parts, and in your language
138
+
139
+ `generateLlmsParts` returns the parts of `llms.txt` in order (`head`, `about`, `products`, `writing`, `agents`), each ending in one newline, or `""` when the data has nothing for it. Joined with a blank line they are `generateLlms`; a site can put its own sections between them.
140
+
141
+ `labels` sets the headings and the fixed words inside the lines for a language; a missing label keeps its English default (`DEFAULT_LLMS_LABELS`). `about` has no default: set it to put the about paragraphs under their own H2.
142
+
143
+ ```ts
144
+ import { generateLlms } from "@chrissgon/agent-ready-kit/llms";
145
+
146
+ generateLlms(data, {
147
+ lang: "pt",
148
+ labels: { about: "Sobre", products: "Produtos", writing: "Escrita", forAgents: "Para agentes", code: "Código", license: "Licença", languages: "idiomas" },
149
+ });
150
+ ```
151
+
152
+ ### JSON-LD
153
+
154
+ The owner's `@id` is `<site.url>/#person` (or `#organization`); every product's `author` points to it. A product's `license` becomes `https://spdx.org/licenses/<id>.html`; pass `licenseUrl: (id) => ...` for another page. `lang` picks the language of the descriptions.
155
+
156
+ ## The MCP server
157
+
158
+ | Tool | Input | Output (`structuredContent`) |
159
+ |------|-------|------------------------------|
160
+ | `get_profile` | optional `lang` | `lang`, `type`, `name`, `alternateName`, `jobTitle`, `label`, `about`, `url`, `profiles` |
161
+ | `list_products` | optional `lang` | `lang`, `products[]`: `id`, `name`, `url`, `codeRepository`, `npm`, `license`, `programmingLanguage`, `summary` |
162
+ | `list_posts` | `limit` 1 to 50 (default 10), optional `lang` | `lang`, `total`, `posts[]`: `id`, `title`, `date`, `languages`, `url`, newest first |
163
+
164
+ `lang` is one of the site's languages (default: the first) and picks the language of the texts; a post with no title in that language keeps its own. Optional fields are absent when the data has no value. Every tool carries `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: false` and an output schema. The server's instructions say: "Read-only. This server only holds <name>'s public profile, products and posts; any other personal data does not exist here."
165
+
166
+ Check it with the MCP Inspector (the server command goes before `--`, the Inspector options after it):
167
+
168
+ ```sh
169
+ npx -y @modelcontextprotocol/inspector@2.8.0 --cli npx -y @chrissgon/agent-ready-kit mcp --data site.json -- --method tools/list
170
+ npx -y @modelcontextprotocol/inspector@2.8.0 --cli npx -y @chrissgon/agent-ready-kit mcp --data site.json -- --method tools/call --tool-name list_posts --tool-arg limit=2
171
+ ```
172
+
173
+ ### In a Netlify Function (v2)
174
+
175
+ The handler is stateless: each POST gets a new server and transport, responses are JSON (no SSE stream), any method other than POST gets 405 with `Allow: POST`, a body over `maxBodyBytes` (default 64 KiB) gets 413 before it is parsed, and every response carries `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`. That is the pattern proven in the chrissgon.dev spike on Netlify Functions v2.
176
+
177
+ ```ts
178
+ // netlify/functions/mcp.mts
179
+ import type { Config } from "@netlify/functions";
180
+ import { validate } from "@chrissgon/agent-ready-kit/data";
181
+ import { createMcpHandler } from "@chrissgon/agent-ready-kit/mcp";
182
+ import site from "../../site.json" with { type: "json" };
183
+
184
+ export default createMcpHandler(validate(site), { name: "example.com", version: "1.0.0" });
185
+
186
+ export const config: Config = { path: "/api/mcp" };
187
+ ```
188
+
189
+ `name` and `version` are what the server reports to clients (default `agent-ready` and this package's version). A public endpoint costs something on every call; Netlify can rate-limit a function from its `config` (`rateLimit: { windowLimit, windowSize, aggregateBy: ["ip", "domain"] }`, see https://docs.netlify.com/manage/security/secure-access-to-sites/rate-limiting/).
190
+
191
+ ### In a static site
192
+
193
+ Run `build` before the site generator and publish its output at the site root, so `llms.txt` is served at `/llms.txt` and each other language at `/<lang>/llms.txt`:
194
+
195
+ ```sh
196
+ npx @chrissgon/agent-ready-kit build --data site.json --out public
197
+ ```
198
+
199
+ Put the JSON-LD in the page head, from `public/jsonld.json` or from code with `serializeJsonLd(generateJsonLd(data))`:
200
+
201
+ ```html
202
+ <script type="application/ld+json">{"@context":"https://schema.org","@graph":[ ... ]}</script>
203
+ ```
204
+
205
+ ## Development
206
+
207
+ | Task | Command |
208
+ |------|---------|
209
+ | Install | `npm ci` |
210
+ | Type-check | `npm run typecheck` |
211
+ | Test | `npm test` (vitest; includes the MCP Inspector CLI over stdio) |
212
+ | Build | `npm run build` (writes `dist/`) |
213
+ | Packed tarball smoke test | `npm run test:pack` (packs, runs the CLI with `npx`, installs the tarball in a scratch project, imports every entry point and type-checks against the shipped types; needs the registry) |
214
+
215
+ From a clone, `node dist/cli.js <command>` runs the CLI after `npm run build`.
216
+
217
+ ## Releasing
218
+
219
+ Releases are published by `.github/workflows/publish.yml` with npm trusted publishing (no token) and provenance. The owner bumps `version` in `package.json` and `src/version.ts` through a pull request, then pushes the tag `v<version>` on the merged commit. The workflow checks that the tag matches `package.json` and is on `main`, runs the secret scan, types, tests, build and the packed-tarball smoke test, and publishes with `--access public`; a prerelease version (`0.2.0-beta.1`) goes to the `next` dist-tag.
220
+
221
+ ## License
222
+
223
+ MIT, see [LICENSE](LICENSE).
package/dist/cli.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ export declare const HELP = "Usage: agent-ready <command> --data <file> [--out <dir>]\n\nMake a small site readable by AI agents from one JSON data file.\n\nCommands:\n build --data <file> --out <dir> Write llms.txt (one per site language; the first at <dir>/llms.txt,\n the others at <dir>/<lang>/llms.txt) and <dir>/jsonld.json.\n Prints the written paths.\n check --data <file> Validate the data file. Prints a one-line summary.\n mcp --data <file> Serve the read-only MCP server (get_profile, list_products,\n list_posts) on stdin and stdout.\n\nOptions:\n --data <file> The JSON data file.\n --out <dir> Output folder (build only).\n -h, --help Show this help.\n\nExit codes: 0 ok, 1 invalid data, 2 wrong usage or unreadable file.\n";
3
+ export interface Io {
4
+ stdout: (text: string) => void;
5
+ stderr: (text: string) => void;
6
+ }
7
+ /** Run the CLI with `argv` (without the node and script paths). Resolves to the exit code. */
8
+ export declare function run(argv: string[], io?: Io): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ // agent-ready: build llms.txt and JSON-LD, check a data file, or serve the read-only MCP server on stdio.
3
+ // Data goes to stdout, diagnostics to stderr. Exit 0 ok, 1 invalid data, 2 wrong usage.
4
+ import { realpathSync } from "node:fs";
5
+ import { mkdir, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { parseArgs } from "node:util";
9
+ import { generateJsonLd } from "./jsonld.js";
10
+ import { generateLlms, llmsPath } from "./llms.js";
11
+ import { DataError, loadData } from "./load.js";
12
+ import { serveStdio } from "./stdio.js";
13
+ export const HELP = `Usage: agent-ready <command> --data <file> [--out <dir>]
14
+
15
+ Make a small site readable by AI agents from one JSON data file.
16
+
17
+ Commands:
18
+ build --data <file> --out <dir> Write llms.txt (one per site language; the first at <dir>/llms.txt,
19
+ the others at <dir>/<lang>/llms.txt) and <dir>/jsonld.json.
20
+ Prints the written paths.
21
+ check --data <file> Validate the data file. Prints a one-line summary.
22
+ mcp --data <file> Serve the read-only MCP server (get_profile, list_products,
23
+ list_posts) on stdin and stdout.
24
+
25
+ Options:
26
+ --data <file> The JSON data file.
27
+ --out <dir> Output folder (build only).
28
+ -h, --help Show this help.
29
+
30
+ Exit codes: 0 ok, 1 invalid data, 2 wrong usage or unreadable file.
31
+ `;
32
+ const processIo = {
33
+ stdout: (text) => process.stdout.write(text),
34
+ stderr: (text) => process.stderr.write(text),
35
+ };
36
+ class UsageError extends Error {
37
+ }
38
+ async function readData(file) {
39
+ try {
40
+ return await loadData(file);
41
+ }
42
+ catch (err) {
43
+ if (err instanceof DataError)
44
+ throw err;
45
+ throw new UsageError(`cannot read ${file}: ${err.message}`);
46
+ }
47
+ }
48
+ async function build(data, out, io) {
49
+ const files = data.site.langs.map((lang) => [join(out, llmsPath(data, lang)), generateLlms(data, { lang })]);
50
+ files.push([join(out, "jsonld.json"), `${JSON.stringify(generateJsonLd(data), null, 2)}\n`]);
51
+ for (const [path, content] of files) {
52
+ await mkdir(dirname(path), { recursive: true });
53
+ await writeFile(path, content, "utf8");
54
+ io.stdout(`${path}\n`);
55
+ }
56
+ }
57
+ /** Run the CLI with `argv` (without the node and script paths). Resolves to the exit code. */
58
+ export async function run(argv, io = processIo) {
59
+ try {
60
+ const { values, positionals } = parseArgs({
61
+ args: argv,
62
+ allowPositionals: true,
63
+ strict: true,
64
+ options: {
65
+ data: { type: "string" },
66
+ out: { type: "string" },
67
+ help: { type: "boolean", short: "h" },
68
+ },
69
+ });
70
+ if (values.help) {
71
+ io.stdout(HELP);
72
+ return 0;
73
+ }
74
+ const [command, ...extra] = positionals;
75
+ if (!command)
76
+ throw new UsageError("missing command");
77
+ if (!["build", "check", "mcp"].includes(command))
78
+ throw new UsageError(`unknown command "${command}"`);
79
+ if (extra.length)
80
+ throw new UsageError(`unexpected argument "${extra[0]}"`);
81
+ if (!values.data)
82
+ throw new UsageError(`${command} needs --data <file>`);
83
+ if (command === "build" && !values.out)
84
+ throw new UsageError("build needs --out <dir>");
85
+ if (command !== "build" && values.out)
86
+ throw new UsageError(`--out only applies to build`);
87
+ const data = await readData(values.data);
88
+ if (command === "check") {
89
+ const { owner, products, posts, site } = data;
90
+ io.stdout(`ok: ${values.data}: ${owner.type} "${owner.name}", ${products.length} products, ${posts.length} posts, languages ${site.langs.join(", ")}\n`);
91
+ }
92
+ else if (command === "build") {
93
+ await build(data, values.out, io);
94
+ }
95
+ else {
96
+ await serveStdio(data);
97
+ io.stderr(`agent-ready: read-only MCP server for "${data.owner.name}" on stdio\n`);
98
+ }
99
+ return 0;
100
+ }
101
+ catch (err) {
102
+ if (err instanceof DataError) {
103
+ io.stderr(`${err.message}\n`);
104
+ return 1;
105
+ }
106
+ const message = err.message;
107
+ // parseArgs throws TypeError with a code for unknown or malformed options.
108
+ if (err instanceof UsageError || err.code?.startsWith("ERR_PARSE_ARGS")) {
109
+ io.stderr(`agent-ready: ${message}\nRun "agent-ready --help" for usage.\n`);
110
+ return 2;
111
+ }
112
+ io.stderr(`agent-ready: ${message}\n`);
113
+ return 1;
114
+ }
115
+ }
116
+ /** True when this file is the process entry point, also through the npm bin symlink. */
117
+ function isEntry() {
118
+ const entry = process.argv[1];
119
+ if (!entry)
120
+ return false;
121
+ try {
122
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ if (isEntry()) {
129
+ // Never call process.exit: the mcp command keeps the process alive on stdin.
130
+ process.exitCode = await run(process.argv.slice(2));
131
+ }
package/dist/data.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { FORBIDDEN_FIELDS, SiteDataSchema, type Owner, type Post, type Product, type SiteData } from "./schema.js";
2
+ export { DataError, loadData, parseData, validate } from "./load.js";
package/dist/data.js ADDED
@@ -0,0 +1,3 @@
1
+ // Entry point `@chrissgon/agent-ready-kit/data`: the data file contract and its validation, without the MCP SDK.
2
+ export { FORBIDDEN_FIELDS, SiteDataSchema } from "./schema.js";
3
+ export { DataError, loadData, parseData, validate } from "./load.js";
@@ -0,0 +1,11 @@
1
+ import { type McpServerOptions } from "./mcp.js";
2
+ import type { SiteData } from "./schema.js";
3
+ export type McpHandler = (request: Request) => Promise<Response>;
4
+ /** Default largest request body, in bytes. A JSON-RPC call to these tools is a few hundred bytes. */
5
+ export declare const DEFAULT_MAX_BODY_BYTES: number;
6
+ export interface McpHandlerOptions extends McpServerOptions {
7
+ /** Largest request body accepted, in bytes; a larger one gets 413 before it is parsed. Default 64 KiB. */
8
+ maxBodyBytes?: number;
9
+ }
10
+ /** A handler that serves the read-only MCP server over `data`. */
11
+ export declare function createMcpHandler(data: SiteData, options?: McpHandlerOptions): McpHandler;
@@ -0,0 +1,56 @@
1
+ // A Web Request handler for the MCP server, for any runtime with the Fetch API (Netlify Functions v2,
2
+ // Deno, Bun, Cloudflare Workers, Node 22+). Stateless Streamable HTTP, as proven in the chrissgon.dev
3
+ // spike: a new server and transport per request, no session id, JSON responses instead of SSE, so a
4
+ // buffered serverless response is enough; anything but POST gets 405, a body over the limit gets 413,
5
+ // and every response says it must not be cached.
6
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
7
+ import { buildServer } from "./mcp.js";
8
+ /** Default largest request body, in bytes. A JSON-RPC call to these tools is a few hundred bytes. */
9
+ export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
10
+ /** Set on every response: tool answers are per request, and the body is JSON, never sniffed as anything else. */
11
+ const NO_STORE = { "cache-control": "no-store", "x-content-type-options": "nosniff" };
12
+ function jsonRpcError(status, message, headers = {}) {
13
+ return new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }), {
14
+ status,
15
+ headers: { "content-type": "application/json", ...NO_STORE, ...headers },
16
+ });
17
+ }
18
+ /** A handler that serves the read-only MCP server over `data`. */
19
+ export function createMcpHandler(data, options = {}) {
20
+ const { maxBodyBytes = DEFAULT_MAX_BODY_BYTES, ...serverOptions } = options;
21
+ const tooLarge = () => jsonRpcError(413, `Request body too large (limit ${maxBodyBytes} bytes).`);
22
+ return async (request) => {
23
+ // Stateless: no server-initiated SSE stream (GET) and no session to end (DELETE).
24
+ if (request.method !== "POST") {
25
+ return jsonRpcError(405, "Method not allowed. This stateless MCP endpoint accepts POST only.", { allow: "POST" });
26
+ }
27
+ const declared = Number(request.headers.get("content-length") ?? "0");
28
+ if (Number.isFinite(declared) && declared > maxBodyBytes)
29
+ return tooLarge();
30
+ // The declared length can be missing or wrong, so the body that was read is measured too.
31
+ const body = await request.arrayBuffer();
32
+ if (body.byteLength > maxBodyBytes)
33
+ return tooLarge();
34
+ const server = buildServer(data, serverOptions);
35
+ const transport = new WebStandardStreamableHTTPServerTransport({
36
+ sessionIdGenerator: undefined,
37
+ enableJsonResponse: true,
38
+ });
39
+ try {
40
+ await server.connect(transport);
41
+ const response = await transport.handleRequest(new Request(request.url, { method: "POST", headers: request.headers, body }));
42
+ for (const [key, value] of Object.entries(NO_STORE))
43
+ response.headers.set(key, value);
44
+ return response;
45
+ }
46
+ catch (err) {
47
+ console.error("agent-ready: mcp handler error", err);
48
+ return jsonRpcError(500, "Internal server error");
49
+ }
50
+ finally {
51
+ // With JSON responses the body is complete before handleRequest resolves, so closing here is safe.
52
+ await transport.close();
53
+ await server.close();
54
+ }
55
+ };
56
+ }
@@ -0,0 +1,8 @@
1
+ export { FORBIDDEN_FIELDS, SiteDataSchema, type Owner, type Post, type Product, type SiteData } from "./schema.js";
2
+ export { DataError, loadData, parseData, validate } from "./load.js";
3
+ export { DEFAULT_LLMS_LABELS, generateLlms, generateLlmsParts, LLMS_PARTS, llmsPath, type LlmsLabels, type LlmsOptions, type LlmsPart, } from "./llms.js";
4
+ export { generateJsonLd, ownerIdOf, serializeJsonLd, spdxLicenseUrl, type JsonLdDocument, type JsonLdNode, type JsonLdOptions, } from "./jsonld.js";
5
+ export { buildServer, instructionsFor, TOOL_NAMES, type McpServerOptions } from "./mcp.js";
6
+ export { createMcpHandler, DEFAULT_MAX_BODY_BYTES, type McpHandler, type McpHandlerOptions } from "./handler.js";
7
+ export { serveStdio } from "./stdio.js";
8
+ export { VERSION } from "./version.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // Public API of @chrissgon/agent-ready-kit. The subpaths `/data`, `/llms`, `/jsonld` and `/mcp` export parts
2
+ // of it; all but `/mcp` load without the MCP SDK.
3
+ export { FORBIDDEN_FIELDS, SiteDataSchema } from "./schema.js";
4
+ export { DataError, loadData, parseData, validate } from "./load.js";
5
+ export { DEFAULT_LLMS_LABELS, generateLlms, generateLlmsParts, LLMS_PARTS, llmsPath, } from "./llms.js";
6
+ export { generateJsonLd, ownerIdOf, serializeJsonLd, spdxLicenseUrl, } from "./jsonld.js";
7
+ export { buildServer, instructionsFor, TOOL_NAMES } from "./mcp.js";
8
+ export { createMcpHandler, DEFAULT_MAX_BODY_BYTES } from "./handler.js";
9
+ export { serveStdio } from "./stdio.js";
10
+ export { VERSION } from "./version.js";
@@ -0,0 +1,23 @@
1
+ import { type SiteData } from "./schema.js";
2
+ export interface JsonLdOptions {
3
+ /** Language of the descriptions, one of `site.langs`; defaults to the first. */
4
+ lang?: string;
5
+ /** The URL of a product's license from its SPDX id. Default: the SPDX page, https://spdx.org/licenses/<id>.html. */
6
+ licenseUrl?: (spdxId: string) => string;
7
+ }
8
+ /** The SPDX page of a license, e.g. https://spdx.org/licenses/MIT.html. */
9
+ export declare const spdxLicenseUrl: (spdxId: string) => string;
10
+ export interface JsonLdNode {
11
+ "@type": string;
12
+ "@id"?: string;
13
+ [property: string]: unknown;
14
+ }
15
+ export interface JsonLdDocument {
16
+ "@context": "https://schema.org";
17
+ "@graph": JsonLdNode[];
18
+ }
19
+ /** The owner node's `@id`: the site URL with `#person` or `#organization`. Products point to it as `author`. */
20
+ export declare const ownerIdOf: (data: SiteData) => string;
21
+ export declare function generateJsonLd(data: SiteData, { lang, licenseUrl }?: JsonLdOptions): JsonLdDocument;
22
+ /** JSON for a `<script type="application/ld+json">`, with "<" escaped so no tag can close the script. */
23
+ export declare const serializeJsonLd: (doc: JsonLdDocument) => string;
package/dist/jsonld.js ADDED
@@ -0,0 +1,49 @@
1
+ // schema.org JSON-LD from the data file: one @graph with the owner (Person or Organization) and one
2
+ // SoftwareSourceCode per product, each pointing to the owner's @id (<site>/#person or #organization). Properties used are defined on
3
+ // https://schema.org/SoftwareSourceCode and its parents CreativeWork and Thing (read 2026-09-30).
4
+ import { forbiddenPaths } from "./schema.js";
5
+ import { assertLang, defaultLang } from "./select.js";
6
+ /** The SPDX page of a license, e.g. https://spdx.org/licenses/MIT.html. */
7
+ export const spdxLicenseUrl = (spdxId) => `https://spdx.org/licenses/${spdxId}.html`;
8
+ /** Throw `jsonld: forbidden field <path>` for every forbidden field found in `value`. */
9
+ function refuseForbidden(value) {
10
+ const found = forbiddenPaths(value).map((p) => `jsonld: forbidden field ${p.map(String).join(".")}`);
11
+ if (found.length)
12
+ throw new Error(found.join("\n"));
13
+ }
14
+ /** The owner node's `@id`: the site URL with `#person` or `#organization`. Products point to it as `author`. */
15
+ export const ownerIdOf = (data) => `${data.site.url}/#${data.owner.type.toLowerCase()}`;
16
+ export function generateJsonLd(data, { lang = defaultLang(data), licenseUrl = spdxLicenseUrl } = {}) {
17
+ // The data is typed, but code can still add a field at run time; refuse it before and after building.
18
+ refuseForbidden(data);
19
+ assertLang(data, lang, "jsonld");
20
+ const { owner, site } = data;
21
+ const ownerId = ownerIdOf(data);
22
+ const ownerNode = { "@type": owner.type, "@id": ownerId, name: owner.name };
23
+ if (owner.alternateName)
24
+ ownerNode.alternateName = owner.alternateName;
25
+ if (owner.type === "Person" && owner.jobTitle)
26
+ ownerNode.jobTitle = owner.jobTitle;
27
+ ownerNode.description = owner.label[lang];
28
+ ownerNode.url = `${site.url}/`;
29
+ if (owner.profiles.length)
30
+ ownerNode.sameAs = owner.profiles.map((p) => p.url);
31
+ const productNodes = data.products.map((p) => {
32
+ const node = { "@type": "SoftwareSourceCode", name: p.name, description: p.summary[lang] };
33
+ if (p.url)
34
+ node.url = p.url;
35
+ if (p.codeRepository)
36
+ node.codeRepository = p.codeRepository;
37
+ if (p.programmingLanguage.length)
38
+ node.programmingLanguage = p.programmingLanguage;
39
+ if (p.license)
40
+ node.license = licenseUrl(p.license);
41
+ node.author = { "@id": ownerId };
42
+ return node;
43
+ });
44
+ const doc = { "@context": "https://schema.org", "@graph": [ownerNode, ...productNodes] };
45
+ refuseForbidden(doc);
46
+ return doc;
47
+ }
48
+ /** JSON for a `<script type="application/ld+json">`, with "<" escaped so no tag can close the script. */
49
+ export const serializeJsonLd = (doc) => JSON.stringify(doc).replace(/</g, "\\u003c");
package/dist/llms.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { SiteData } from "./schema.js";
2
+ /** Headings and the fixed words inside the lines. Every one has an English default. */
3
+ export interface LlmsLabels {
4
+ /** H2 above the about paragraphs. Default: none, so the paragraphs are free text before the H2 sections. */
5
+ about?: string;
6
+ /** H2 of the products. Default "Products". */
7
+ products: string;
8
+ /** H2 of the posts. Default "Writing". */
9
+ writing: string;
10
+ /** H2 of the MCP endpoint and the other languages' files. Default "For agents". */
11
+ forAgents: string;
12
+ /** Before a product's code repository. Default "Code". */
13
+ code: string;
14
+ /** Before a product's license. Default "License". */
15
+ license: string;
16
+ /** Before a product's programming languages. Default "Programming languages". */
17
+ programmingLanguages: string;
18
+ /** Before a post's languages. Default "languages". */
19
+ languages: string;
20
+ /** The MCP endpoint's link text. Default "MCP server". */
21
+ mcpServer: string;
22
+ /** The MCP endpoint's notes, before the tool names. Default "read-only, Streamable HTTP (POST); tools". */
23
+ mcpNotes: string;
24
+ /** The notes of a link to another language's file, before the language code. Default "this file in". */
25
+ thisFileIn: string;
26
+ }
27
+ export declare const DEFAULT_LLMS_LABELS: Readonly<LlmsLabels>;
28
+ export interface LlmsOptions {
29
+ /** One of `site.langs`; defaults to the first. */
30
+ lang?: string;
31
+ /** Headings and words for this language; missing ones keep their English default. */
32
+ labels?: Partial<LlmsLabels>;
33
+ }
34
+ /** The parts of llms.txt, in order. Joined, they are `generateLlms`; a site can place its own parts between them. */
35
+ export declare const LLMS_PARTS: readonly ["head", "about", "products", "writing", "agents"];
36
+ export type LlmsPart = (typeof LLMS_PARTS)[number];
37
+ /** Where the llms.txt of `lang` lives, relative to the site root: the default language at the root. */
38
+ export declare function llmsPath(data: SiteData, lang: string): string;
39
+ /**
40
+ * Each part of llms.txt as text ending in one newline, or "" when the data has nothing for it (no about,
41
+ * no products, no posts, no MCP endpoint and one language). `generateLlms` joins the non-empty parts
42
+ * with a blank line between them.
43
+ */
44
+ export declare function generateLlmsParts(data: SiteData, { lang, labels }?: LlmsOptions): Record<LlmsPart, string>;
45
+ export declare function generateLlms(data: SiteData, options?: LlmsOptions): string;
package/dist/llms.js ADDED
@@ -0,0 +1,69 @@
1
+ import { TOOL_NAMES } from "./tools.js";
2
+ import { assertLang, defaultLang, postsNewestFirst, postTitle, productLink } from "./select.js";
3
+ export const DEFAULT_LLMS_LABELS = {
4
+ products: "Products",
5
+ writing: "Writing",
6
+ forAgents: "For agents",
7
+ code: "Code",
8
+ license: "License",
9
+ programmingLanguages: "Programming languages",
10
+ languages: "languages",
11
+ mcpServer: "MCP server",
12
+ mcpNotes: "read-only, Streamable HTTP (POST); tools",
13
+ thisFileIn: "this file in",
14
+ };
15
+ /** The parts of llms.txt, in order. Joined, they are `generateLlms`; a site can place its own parts between them. */
16
+ export const LLMS_PARTS = ["head", "about", "products", "writing", "agents"];
17
+ /** Where the llms.txt of `lang` lives, relative to the site root: the default language at the root. */
18
+ export function llmsPath(data, lang) {
19
+ return lang === defaultLang(data) ? "llms.txt" : `${lang}/llms.txt`;
20
+ }
21
+ /**
22
+ * Each part of llms.txt as text ending in one newline, or "" when the data has nothing for it (no about,
23
+ * no products, no posts, no MCP endpoint and one language). `generateLlms` joins the non-empty parts
24
+ * with a blank line between them.
25
+ */
26
+ export function generateLlmsParts(data, { lang = defaultLang(data), labels = {} } = {}) {
27
+ assertLang(data, lang, "llms");
28
+ const w = { ...DEFAULT_LLMS_LABELS, ...labels };
29
+ const { owner, site } = data;
30
+ const block = (lines) => (lines.length ? `${lines.join("\n")}\n` : "");
31
+ const section = (title, items) => items.length ? block([...(title ? [`## ${title}`, ""] : []), ...items]) : "";
32
+ const head = [`# ${owner.name}`, "", `> ${owner.label[lang]}`];
33
+ if (owner.profiles.length) {
34
+ head.push("", ...owner.profiles.map((p) => `- [${p.network}](${p.url})${p.handle ? `: ${p.handle}` : ""}`));
35
+ }
36
+ const about = (owner.about?.[lang] ?? []).flatMap((paragraph, i) => (i ? ["", paragraph] : [paragraph]));
37
+ const products = data.products.map((p) => {
38
+ const notes = [p.summary[lang]];
39
+ if (p.url && p.codeRepository)
40
+ notes.push(`${w.code}: ${p.codeRepository}.`);
41
+ if (p.npm)
42
+ notes.push(`npm: ${p.npm}.`);
43
+ if (p.license)
44
+ notes.push(`${w.license}: ${p.license}.`);
45
+ if (p.programmingLanguage.length)
46
+ notes.push(`${w.programmingLanguages}: ${p.programmingLanguage.join(", ")}.`);
47
+ return `- [${p.name}](${productLink(p)}): ${notes.join(" ")}`;
48
+ });
49
+ const writing = postsNewestFirst(data).map((p) => `- [${postTitle(p, lang)}](${p.url}): ${p.date}; ${w.languages}: ${p.lang.join(", ")}`);
50
+ const agents = [];
51
+ if (site.mcp)
52
+ agents.push(`- [${w.mcpServer}](${site.mcp}): ${w.mcpNotes} ${TOOL_NAMES.join(", ")}`);
53
+ for (const other of site.langs.filter((l) => l !== lang)) {
54
+ agents.push(`- [llms.txt (${other})](${site.url}/${llmsPath(data, other)}): ${w.thisFileIn} ${other}`);
55
+ }
56
+ return {
57
+ head: block(head),
58
+ about: section(w.about, about),
59
+ products: section(w.products, products),
60
+ writing: section(w.writing, writing),
61
+ agents: section(w.forAgents, agents),
62
+ };
63
+ }
64
+ export function generateLlms(data, options = {}) {
65
+ const parts = generateLlmsParts(data, options);
66
+ return LLMS_PARTS.map((name) => parts[name])
67
+ .filter(Boolean)
68
+ .join("\n");
69
+ }
package/dist/load.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { type SiteData } from "./schema.js";
2
+ /** The data file is not valid. `issues` holds one `data: <field path>: <message>` line per problem. */
3
+ export declare class DataError extends Error {
4
+ readonly issues: string[];
5
+ constructor(issues: string[]);
6
+ }
7
+ /** Validate a parsed data file. Throws DataError when it is not valid. */
8
+ export declare function validate(value: unknown): SiteData;
9
+ /** Parse and validate the text of a data file. */
10
+ export declare function parseData(json: string): SiteData;
11
+ /** Read, parse and validate a data file. A file that cannot be read throws the file system error. */
12
+ export declare function loadData(file: string): Promise<SiteData>;
package/dist/load.js ADDED
@@ -0,0 +1,49 @@
1
+ // Read and validate a data file. Every problem becomes one line `data: <field path>: <message>`.
2
+ import { readFile } from "node:fs/promises";
3
+ import { FORBIDDEN_FIELDS, forbiddenPaths, SiteDataSchema } from "./schema.js";
4
+ /** The data file is not valid. `issues` holds one `data: <field path>: <message>` line per problem. */
5
+ export class DataError extends Error {
6
+ issues;
7
+ constructor(issues) {
8
+ super(issues.join("\n"));
9
+ this.name = "DataError";
10
+ this.issues = issues;
11
+ }
12
+ }
13
+ const FORBIDDEN = new Set(FORBIDDEN_FIELDS);
14
+ const line = (path, message) => `data: ${path.length ? path.map(String).join(".") : "(root)"}: ${message}`;
15
+ function zodLines(issues) {
16
+ return issues.flatMap((issue) => {
17
+ if (issue.code === "unrecognized_keys") {
18
+ // Forbidden keys are reported on their own, with a clearer message.
19
+ return issue.keys.filter((k) => !FORBIDDEN.has(k)).map((k) => line([...issue.path, k], "unknown field"));
20
+ }
21
+ return [line(issue.path, issue.message)];
22
+ });
23
+ }
24
+ /** Validate a parsed data file. Throws DataError when it is not valid. */
25
+ export function validate(value) {
26
+ const forbidden = forbiddenPaths(value).map((p) => line(p, `forbidden field "${String(p.at(-1))}", the kit never publishes it`));
27
+ const result = SiteDataSchema.safeParse(value, {
28
+ error: (iss) => (iss.input === undefined && iss.code === "invalid_type" ? "required" : undefined),
29
+ });
30
+ const issues = [...forbidden, ...(result.success ? [] : zodLines(result.error.issues))];
31
+ if (issues.length || !result.success)
32
+ throw new DataError(issues.length ? issues : [line([], "invalid data")]);
33
+ return result.data;
34
+ }
35
+ /** Parse and validate the text of a data file. */
36
+ export function parseData(json) {
37
+ let value;
38
+ try {
39
+ value = JSON.parse(json);
40
+ }
41
+ catch (err) {
42
+ throw new DataError([line([], `invalid JSON: ${err.message}`)]);
43
+ }
44
+ return validate(value);
45
+ }
46
+ /** Read, parse and validate a data file. A file that cannot be read throws the file system error. */
47
+ export async function loadData(file) {
48
+ return parseData(await readFile(file, "utf8"));
49
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { SiteData } from "./schema.js";
3
+ export { TOOL_NAMES } from "./tools.js";
4
+ export interface McpServerOptions {
5
+ /** The server name reported to clients in `initialize`. Default "agent-ready". */
6
+ name?: string;
7
+ /** The server version reported to clients. Default: this package's version. */
8
+ version?: string;
9
+ }
10
+ /** The server's instructions for `owner`: read-only, and nothing but the public profile, products and posts. */
11
+ export declare const instructionsFor: (ownerName: string) => string;
12
+ /** A new server over `data`. Stateless: build one per connection or per HTTP request. */
13
+ export declare function buildServer(data: SiteData, { name, version }?: McpServerOptions): McpServer;
package/dist/mcp.js ADDED
@@ -0,0 +1,125 @@
1
+ // The read-only MCP server: three tools that return slices of the validated data file and nothing else.
2
+ // No tool writes, sends, executes or searches free text; inputs are strict, so an extra argument is
3
+ // rejected by validation (the tool contract of chrissgon.dev's ADR-0004).
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { z } from "zod";
6
+ import { defaultLang, postsNewestFirst, postTitle } from "./select.js";
7
+ import { VERSION } from "./version.js";
8
+ export { TOOL_NAMES } from "./tools.js";
9
+ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
10
+ const ProfileOutput = z.object({
11
+ lang: z.string(),
12
+ type: z.enum(["Person", "Organization"]),
13
+ name: z.string(),
14
+ alternateName: z.string().optional(),
15
+ jobTitle: z.string().optional(),
16
+ label: z.string(),
17
+ about: z.array(z.string()),
18
+ url: z.string(),
19
+ profiles: z.array(z.object({ network: z.string(), handle: z.string().optional(), url: z.string() })),
20
+ });
21
+ const ProductsOutput = z.object({
22
+ lang: z.string(),
23
+ products: z.array(z.object({
24
+ id: z.string().optional(),
25
+ name: z.string(),
26
+ url: z.string().optional(),
27
+ codeRepository: z.string().optional(),
28
+ npm: z.string().optional(),
29
+ license: z.string().optional(),
30
+ programmingLanguage: z.array(z.string()),
31
+ summary: z.string(),
32
+ })),
33
+ });
34
+ const PostsOutput = z.object({
35
+ lang: z.string(),
36
+ total: z.int(),
37
+ posts: z.array(z.object({ id: z.string(), title: z.string(), date: z.string(), languages: z.array(z.string()), url: z.string() })),
38
+ });
39
+ /** A tool result whose text is the JSON of its structured content. */
40
+ const result = (structuredContent) => ({
41
+ content: [{ type: "text", text: JSON.stringify(structuredContent) }],
42
+ structuredContent,
43
+ });
44
+ /** Drop keys whose value is undefined, so optional fields are absent rather than null. */
45
+ const compact = (value) => Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
46
+ /**
47
+ * MCP lets a client omit `arguments` in tools/call. SDK 1.31.0 validates the missing value against the
48
+ * tool's object schema and fails with "expected object, received undefined", so a call without
49
+ * arguments is passed on as `{}`. Must run before the first registerTool, which installs the handler.
50
+ */
51
+ function treatMissingArgumentsAsEmpty(server) {
52
+ const inner = server.server;
53
+ const install = inner.setRequestHandler.bind(inner);
54
+ // The SDK's generic handler types do not survive a wrapper; the request shape is checked at run time.
55
+ inner.setRequestHandler = ((schema, handler) => install(schema, (request, extra) => handler(request.method === "tools/call" && request.params?.arguments === undefined
56
+ ? { ...request, params: { ...request.params, arguments: {} } }
57
+ : request, extra)));
58
+ }
59
+ /** The server's instructions for `owner`: read-only, and nothing but the public profile, products and posts. */
60
+ export const instructionsFor = (ownerName) => `Read-only. This server only holds ${ownerName}'s public profile, products and posts; any other personal data does not exist here.`;
61
+ /** A new server over `data`. Stateless: build one per connection or per HTTP request. */
62
+ export function buildServer(data, { name = "agent-ready", version = VERSION } = {}) {
63
+ const { owner, site } = data;
64
+ const langs = site.langs;
65
+ const server = new McpServer({ name, version }, { instructions: instructionsFor(owner.name) });
66
+ treatMissingArgumentsAsEmpty(server);
67
+ /** Every tool takes `lang`, the language of the texts it returns; the site's first language by default. */
68
+ const lang = z
69
+ .enum(langs)
70
+ .default(defaultLang(data))
71
+ .describe(`Language of the texts: ${langs.map((l) => `"${l}"`).join(" or ")} (default "${defaultLang(data)}").`);
72
+ server.registerTool("get_profile", {
73
+ title: "Get profile",
74
+ description: `Return ${owner.name}'s public profile: name, label, about, site URL and public profiles. Read-only.`,
75
+ inputSchema: z.strictObject({ lang }),
76
+ outputSchema: ProfileOutput,
77
+ annotations: READ_ONLY,
78
+ }, async ({ lang: l }) => result(compact({
79
+ lang: l,
80
+ type: owner.type,
81
+ name: owner.name,
82
+ alternateName: owner.alternateName,
83
+ jobTitle: owner.type === "Person" ? owner.jobTitle : undefined,
84
+ label: owner.label[l],
85
+ about: owner.about?.[l] ?? [],
86
+ url: `${site.url}/`,
87
+ profiles: owner.profiles.map((p) => compact({ network: p.network, handle: p.handle, url: p.url })),
88
+ })));
89
+ server.registerTool("list_products", {
90
+ title: "List products",
91
+ description: `List ${owner.name}'s products with their URL, code repository, npm package, license, programming languages and summary. Read-only.`,
92
+ inputSchema: z.strictObject({ lang }),
93
+ outputSchema: ProductsOutput,
94
+ annotations: READ_ONLY,
95
+ }, async ({ lang: l }) => result({
96
+ lang: l,
97
+ products: data.products.map((p) => compact({
98
+ id: p.id,
99
+ name: p.name,
100
+ url: p.url,
101
+ codeRepository: p.codeRepository,
102
+ npm: p.npm,
103
+ license: p.license,
104
+ programmingLanguage: p.programmingLanguage,
105
+ summary: p.summary[l],
106
+ })),
107
+ }));
108
+ server.registerTool("list_posts", {
109
+ title: "List posts",
110
+ description: `List ${owner.name}'s posts, newest first: title, date, languages and link. Read-only.`,
111
+ inputSchema: z.strictObject({
112
+ limit: z.int().min(1).max(50).default(10).describe("How many posts, newest first: 1 to 50 (default 10)."),
113
+ lang,
114
+ }),
115
+ outputSchema: PostsOutput,
116
+ annotations: READ_ONLY,
117
+ }, async ({ limit, lang: l }) => result({
118
+ lang: l,
119
+ total: data.posts.length,
120
+ posts: postsNewestFirst(data)
121
+ .slice(0, limit)
122
+ .map((p) => ({ id: p.id, title: postTitle(p, l), date: p.date, languages: p.lang, url: p.url })),
123
+ }));
124
+ return server;
125
+ }
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+ /** Fields the kit never publishes, wherever they appear in the data file. */
3
+ export declare const FORBIDDEN_FIELDS: readonly ["worksFor", "address", "homeLocation", "birthDate", "email"];
4
+ /** Paths of every forbidden key anywhere in a value, e.g. [["owner", "email"]]. */
5
+ export declare function forbiddenPaths(value: unknown, path?: PropertyKey[]): PropertyKey[][];
6
+ export declare const SiteDataSchema: z.ZodObject<{
7
+ site: z.ZodObject<{
8
+ url: z.ZodPipe<z.ZodURL, z.ZodTransform<string, string>>;
9
+ langs: z.ZodArray<z.ZodString>;
10
+ mcp: z.ZodOptional<z.ZodURL>;
11
+ }, z.core.$strict>;
12
+ owner: z.ZodDiscriminatedUnion<[z.ZodObject<{
13
+ name: z.ZodString;
14
+ alternateName: z.ZodOptional<z.ZodString>;
15
+ label: z.ZodRecord<z.ZodString, z.ZodString>;
16
+ about: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
17
+ profiles: z.ZodArray<z.ZodObject<{
18
+ network: z.ZodString;
19
+ handle: z.ZodOptional<z.ZodString>;
20
+ url: z.ZodURL;
21
+ }, z.core.$strict>>;
22
+ type: z.ZodLiteral<"Person">;
23
+ jobTitle: z.ZodOptional<z.ZodString>;
24
+ }, z.core.$strict>, z.ZodObject<{
25
+ name: z.ZodString;
26
+ alternateName: z.ZodOptional<z.ZodString>;
27
+ label: z.ZodRecord<z.ZodString, z.ZodString>;
28
+ about: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
29
+ profiles: z.ZodArray<z.ZodObject<{
30
+ network: z.ZodString;
31
+ handle: z.ZodOptional<z.ZodString>;
32
+ url: z.ZodURL;
33
+ }, z.core.$strict>>;
34
+ type: z.ZodLiteral<"Organization">;
35
+ }, z.core.$strict>], "type">;
36
+ products: z.ZodArray<z.ZodObject<{
37
+ id: z.ZodOptional<z.ZodString>;
38
+ name: z.ZodString;
39
+ url: z.ZodOptional<z.ZodURL>;
40
+ codeRepository: z.ZodOptional<z.ZodURL>;
41
+ npm: z.ZodOptional<z.ZodString>;
42
+ license: z.ZodOptional<z.ZodString>;
43
+ programmingLanguage: z.ZodArray<z.ZodString>;
44
+ summary: z.ZodRecord<z.ZodString, z.ZodString>;
45
+ }, z.core.$strict>>;
46
+ posts: z.ZodArray<z.ZodObject<{
47
+ id: z.ZodString;
48
+ title: z.ZodRecord<z.ZodString, z.ZodString>;
49
+ date: z.ZodISODate;
50
+ lang: z.ZodArray<z.ZodString>;
51
+ url: z.ZodURL;
52
+ }, z.core.$strict>>;
53
+ }, z.core.$strict>;
54
+ /** A validated data file. */
55
+ export type SiteData = z.output<typeof SiteDataSchema>;
56
+ export type Owner = SiteData["owner"];
57
+ export type Product = SiteData["products"][number];
58
+ export type Post = SiteData["posts"][number];
package/dist/schema.js ADDED
@@ -0,0 +1,143 @@
1
+ // The data file contract. Every object is strict: a field that is not declared here fails validation,
2
+ // so nothing reaches llms.txt, the JSON-LD or the MCP server unless this schema names it.
3
+ import { z } from "zod";
4
+ /** Fields the kit never publishes, wherever they appear in the data file. */
5
+ export const FORBIDDEN_FIELDS = ["worksFor", "address", "homeLocation", "birthDate", "email"];
6
+ const FORBIDDEN = new Set(FORBIDDEN_FIELDS);
7
+ /** Paths of every forbidden key anywhere in a value, e.g. [["owner", "email"]]. */
8
+ export function forbiddenPaths(value, path = []) {
9
+ if (Array.isArray(value))
10
+ return value.flatMap((v, i) => forbiddenPaths(v, [...path, i]));
11
+ if (value === null || typeof value !== "object")
12
+ return [];
13
+ return Object.entries(value).flatMap(([key, v]) => FORBIDDEN.has(key) ? [[...path, key]] : forbiddenPaths(v, [...path, key]));
14
+ }
15
+ const LANG = /^[a-z]{2,3}(-[A-Z]{2})?$/;
16
+ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
17
+ const NPM_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
18
+ const SPDX_ID = /^[A-Za-z0-9.+-]+$/;
19
+ const text = z.string().trim().min(1, { error: "must not be empty" });
20
+ const https = z.url({ protocol: /^https$/, error: "must be an https URL" });
21
+ const lang = z.string().regex(LANG, { error: "must be a language code such as en or pt-BR" });
22
+ /** A text per language code; which languages are required is checked against `site.langs`. */
23
+ const localized = z.record(z.string(), text);
24
+ const Profile = z.strictObject({
25
+ /** The network or site name shown to readers, e.g. "GitHub". */
26
+ network: text,
27
+ handle: text.optional(),
28
+ url: https,
29
+ });
30
+ const ownerFields = {
31
+ name: text,
32
+ /** A handle or short name. */
33
+ alternateName: text.optional(),
34
+ /** One line that says who the owner is; the llms.txt summary. */
35
+ label: localized,
36
+ /** Paragraphs about the owner, per language. */
37
+ about: z.record(z.string(), z.array(text).min(1)).optional(),
38
+ profiles: z.array(Profile),
39
+ };
40
+ const Person = z.strictObject({ type: z.literal("Person"), ...ownerFields, jobTitle: text.optional() });
41
+ const Organization = z.strictObject({ type: z.literal("Organization"), ...ownerFields });
42
+ const Product = z
43
+ .strictObject({
44
+ /** Optional stable slug, unique among products; the MCP server returns it so an agent can refer to a product. */
45
+ id: z.string().regex(SLUG, { error: "must be a lowercase slug such as my-product" }).optional(),
46
+ name: text,
47
+ url: https.optional(),
48
+ codeRepository: https.optional(),
49
+ /** npm package name. */
50
+ npm: z.string().regex(NPM_NAME, { error: "must be an npm package name" }).optional(),
51
+ /** SPDX license identifier, e.g. MIT. */
52
+ license: z.string().regex(SPDX_ID, { error: "must be an SPDX license identifier such as MIT" }).optional(),
53
+ programmingLanguage: z.array(text),
54
+ summary: localized,
55
+ })
56
+ .refine((p) => p.url !== undefined || p.codeRepository !== undefined, {
57
+ error: "needs a url or a codeRepository",
58
+ });
59
+ const Post = z.strictObject({
60
+ id: z.string().regex(SLUG, { error: "must be a lowercase slug such as my-first-post" }),
61
+ /** The title per language of the post. */
62
+ title: localized,
63
+ date: z.iso.date({ error: "must be a date YYYY-MM-DD" }),
64
+ lang: z.array(lang).min(1),
65
+ url: https,
66
+ });
67
+ const Site = z.strictObject({
68
+ /** The site's origin, https, without a trailing slash (one is removed). */
69
+ url: https.transform((u) => u.replace(/\/+$/, "")),
70
+ /** Languages of the site; the first is the default. */
71
+ langs: z.array(lang).min(1),
72
+ /** Public URL of the site's MCP endpoint, when it serves one. */
73
+ mcp: https.optional(),
74
+ });
75
+ export const SiteDataSchema = z
76
+ .strictObject({
77
+ site: Site,
78
+ owner: z.discriminatedUnion("type", [Person, Organization]),
79
+ products: z.array(Product),
80
+ posts: z.array(Post),
81
+ })
82
+ .superRefine((data, ctx) => {
83
+ const langs = data.site.langs;
84
+ const known = new Set(langs);
85
+ const list = langs.join(", ");
86
+ const seenLang = new Set();
87
+ langs.forEach((l, i) => {
88
+ if (seenLang.has(l))
89
+ ctx.addIssue({ code: "custom", path: ["site", "langs", i], message: `duplicate language "${l}"` });
90
+ seenLang.add(l);
91
+ });
92
+ /** Every site language has a text, and no key is a language the site does not declare. */
93
+ const checkLocalized = (value, path) => {
94
+ if (!value)
95
+ return;
96
+ for (const l of langs) {
97
+ if (!(l in value))
98
+ ctx.addIssue({ code: "custom", path: [...path, l], message: `missing text for site language "${l}"` });
99
+ }
100
+ for (const key of Object.keys(value)) {
101
+ if (!known.has(key))
102
+ ctx.addIssue({ code: "custom", path: [...path, key], message: `"${key}" is not a site language (${list})` });
103
+ }
104
+ };
105
+ checkLocalized(data.owner.label, ["owner", "label"]);
106
+ checkLocalized(data.owner.about, ["owner", "about"]);
107
+ data.products.forEach((p, i) => checkLocalized(p.summary, ["products", i, "summary"]));
108
+ const productIndex = new Map();
109
+ data.products.forEach((p, i) => {
110
+ if (p.id === undefined)
111
+ return;
112
+ const first = productIndex.get(p.id);
113
+ if (first !== undefined) {
114
+ ctx.addIssue({ code: "custom", path: ["products", i, "id"], message: `duplicate id "${p.id}" (first at products.${first})` });
115
+ }
116
+ else {
117
+ productIndex.set(p.id, i);
118
+ }
119
+ });
120
+ const firstIndex = new Map();
121
+ data.posts.forEach((post, i) => {
122
+ const first = firstIndex.get(post.id);
123
+ if (first !== undefined) {
124
+ ctx.addIssue({ code: "custom", path: ["posts", i, "id"], message: `duplicate id "${post.id}" (first at posts.${first})` });
125
+ }
126
+ else {
127
+ firstIndex.set(post.id, i);
128
+ }
129
+ post.lang.forEach((l, j) => {
130
+ if (!known.has(l))
131
+ ctx.addIssue({ code: "custom", path: ["posts", i, "lang", j], message: `"${l}" is not a site language (${list})` });
132
+ });
133
+ for (const key of Object.keys(post.title)) {
134
+ if (!known.has(key))
135
+ ctx.addIssue({ code: "custom", path: ["posts", i, "title", key], message: `"${key}" is not a site language (${list})` });
136
+ }
137
+ for (const l of post.lang) {
138
+ if (known.has(l) && !(l in post.title)) {
139
+ ctx.addIssue({ code: "custom", path: ["posts", i, "title", l], message: `missing text for post language "${l}"` });
140
+ }
141
+ }
142
+ });
143
+ });
@@ -0,0 +1,11 @@
1
+ import type { Post, SiteData } from "./schema.js";
2
+ /** The site's default language: the first of `site.langs`. */
3
+ export declare const defaultLang: (data: SiteData) => string;
4
+ /** Throw `<prefix>: "<lang>" is not a site language (...)` unless the site declares `lang`. */
5
+ export declare function assertLang(data: SiteData, lang: string, prefix: string): void;
6
+ /** Posts sorted newest first; posts with the same date keep the file's order. */
7
+ export declare const postsNewestFirst: (data: SiteData) => Post[];
8
+ /** A post's title in `lang`, else in the post's first language. */
9
+ export declare const postTitle: (post: Post, lang: string) => string;
10
+ /** The public link of a product: its URL, else its code repository. */
11
+ export declare const productLink: (p: SiteData["products"][number]) => string;
package/dist/select.js ADDED
@@ -0,0 +1,14 @@
1
+ /** The site's default language: the first of `site.langs`. */
2
+ export const defaultLang = (data) => data.site.langs[0];
3
+ /** Throw `<prefix>: "<lang>" is not a site language (...)` unless the site declares `lang`. */
4
+ export function assertLang(data, lang, prefix) {
5
+ if (!data.site.langs.includes(lang)) {
6
+ throw new Error(`${prefix}: "${lang}" is not a site language (${data.site.langs.join(", ")})`);
7
+ }
8
+ }
9
+ /** Posts sorted newest first; posts with the same date keep the file's order. */
10
+ export const postsNewestFirst = (data) => [...data.posts].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
11
+ /** A post's title in `lang`, else in the post's first language. */
12
+ export const postTitle = (post, lang) => post.title[lang] ?? post.title[post.lang[0]];
13
+ /** The public link of a product: its URL, else its code repository. */
14
+ export const productLink = (p) => (p.url ?? p.codeRepository);
@@ -0,0 +1,2 @@
1
+ export { buildServer, instructionsFor, TOOL_NAMES, type McpServerOptions } from "./mcp.js";
2
+ export { createMcpHandler, DEFAULT_MAX_BODY_BYTES, type McpHandler, type McpHandlerOptions } from "./handler.js";
package/dist/server.js ADDED
@@ -0,0 +1,3 @@
1
+ // Entry point `@chrissgon/agent-ready-kit/mcp`: the read-only MCP server and its Web Request handler.
2
+ export { buildServer, instructionsFor, TOOL_NAMES } from "./mcp.js";
3
+ export { createMcpHandler, DEFAULT_MAX_BODY_BYTES } from "./handler.js";
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { SiteData } from "./schema.js";
3
+ /** Serve the read-only MCP server over `data` on stdin and stdout. Nothing else may write to stdout. */
4
+ export declare function serveStdio(data: SiteData): Promise<McpServer>;
package/dist/stdio.js ADDED
@@ -0,0 +1,9 @@
1
+ // The MCP server over stdio, for local clients that start it as a command.
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { buildServer } from "./mcp.js";
4
+ /** Serve the read-only MCP server over `data` on stdin and stdout. Nothing else may write to stdout. */
5
+ export async function serveStdio(data) {
6
+ const server = buildServer(data);
7
+ await server.connect(new StdioServerTransport());
8
+ return server;
9
+ }
@@ -0,0 +1,2 @@
1
+ /** The MCP tools, in the order the server registers them. Kept apart from mcp.ts so llms.txt can name them without loading the MCP SDK. */
2
+ export declare const TOOL_NAMES: readonly ["get_profile", "list_products", "list_posts"];
package/dist/tools.js ADDED
@@ -0,0 +1,2 @@
1
+ /** The MCP tools, in the order the server registers them. Kept apart from mcp.ts so llms.txt can name them without loading the MCP SDK. */
2
+ export const TOOL_NAMES = ["get_profile", "list_products", "list_posts"];
@@ -0,0 +1,2 @@
1
+ /** The package version, reported as the MCP server version. A test keeps it equal to package.json. */
2
+ export declare const VERSION = "0.1.0";
@@ -0,0 +1,2 @@
1
+ /** The package version, reported as the MCP server version. A test keeps it equal to package.json. */
2
+ export const VERSION = "0.1.0";
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@chrissgon/agent-ready-kit",
3
+ "version": "0.1.0",
4
+ "description": "Make a small site readable by AI agents from one data file: llms.txt, schema.org JSON-LD and a read-only MCP server.",
5
+ "keywords": [
6
+ "llms.txt",
7
+ "json-ld",
8
+ "schema.org",
9
+ "mcp",
10
+ "model-context-protocol",
11
+ "ai-agents",
12
+ "seo",
13
+ "static-site"
14
+ ],
15
+ "homepage": "https://github.com/chrissgon/agent-ready-kit#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/chrissgon/agent-ready-kit/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/chrissgon/agent-ready-kit.git"
22
+ },
23
+ "license": "MIT",
24
+ "author": "Christopher Gonçalves (https://chrissgon.dev)",
25
+ "type": "module",
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "bin": {
30
+ "agent-ready": "dist/cli.js"
31
+ },
32
+ "main": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "default": "./dist/index.js"
38
+ },
39
+ "./data": {
40
+ "types": "./dist/data.d.ts",
41
+ "default": "./dist/data.js"
42
+ },
43
+ "./llms": {
44
+ "types": "./dist/llms.d.ts",
45
+ "default": "./dist/llms.js"
46
+ },
47
+ "./jsonld": {
48
+ "types": "./dist/jsonld.d.ts",
49
+ "default": "./dist/jsonld.js"
50
+ },
51
+ "./mcp": {
52
+ "types": "./dist/server.d.ts",
53
+ "default": "./dist/server.js"
54
+ },
55
+ "./package.json": "./package.json"
56
+ },
57
+ "files": [
58
+ "dist"
59
+ ],
60
+ "publishConfig": {
61
+ "access": "public",
62
+ "provenance": true
63
+ },
64
+ "scripts": {
65
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
66
+ "typecheck": "tsc --noEmit",
67
+ "test": "vitest run",
68
+ "test:pack": "bash scripts/pack-smoke.sh",
69
+ "prepack": "npm run build"
70
+ },
71
+ "dependencies": {
72
+ "@modelcontextprotocol/sdk": "1.31.0",
73
+ "zod": "4.6.5"
74
+ },
75
+ "devDependencies": {
76
+ "@modelcontextprotocol/inspector": "2.8.0",
77
+ "@types/node": "26.6.3",
78
+ "tsx": "4.23.15",
79
+ "typescript": "7.0.2",
80
+ "vitest": "5.0.2"
81
+ }
82
+ }