@softure-ai/agent-ready
v0.2.0
Published
SOFTURE AI agent-ready module: everything an AI agent uses to discover and connect to an app, from configuration plus its MCP server: API catalog, OpenAPI, Link header, OAuth discovery additions and auth.md, MCP server and A2A cards, Agent Skills, AI cata
Readme
@softure-ai/agent-ready
Depends on: core. Works next to @softure-ai/mcp-access (OAuth) and @softure-ai/seo (robots.txt, Markdown).
1. What it provides
Everything an AI agent uses to find an app and connect to its MCP server, built from configuration and from the server itself, so no document is kept by hand:
| Document | Path | Standard |
|---|---|---|
| API catalog | /.well-known/api-catalog | RFC 9727 (RFC 9264 linkset) |
| OpenAPI of the MCP endpoint | /openapi.json | OpenAPI 3.1, the JSON-RPC envelope |
| Link header of the home page | / | RFC 8288: api-catalog, service-desc, service-doc, describedby |
| OAuth discovery additions | in the issuer's metadata | RFC 8414 / RFC 9728: jwks_uri, service_documentation, agent_auth, resource_documentation |
| JWKS | /.well-known/jwks.json | RFC 7517, an empty set for opaque tokens |
| auth.md | /auth.md | WorkOS auth.md, built from the issuer's metadata |
| MCP server card | /.well-known/mcp/server-card.json | SEP-2127 and SEP-1649 in one card |
| A2A agent card | /.well-known/agent-card.json | A2A 1.0, one interface with the MCP binding |
| Agent Skills | /.well-known/agent-skills/index.json, …/<name>/SKILL.md | Agent Skills Discovery v0.2.0, SHA-256 digests |
| AI catalog | /.well-known/ai-catalog.json | ARD 1.0, with 2 to 5 representative queries per entry |
| Web Bot Auth | /.well-known/http-message-signatures-directory and signed fetch | RFC 9421, Ed25519 |
| DNS-AID | _index._agents.<domain>, _mcp._agents.<domain> | SVCB (RFC 9460), checked by the CLI |
| WebMCP | inline script or component | document.modelContext.registerTool |
Cards, skills and the AI catalog read the server's own initialize and tools/list on every request, through the
transport a client uses, with an anonymous server: a tool added to the server appears everywhere without an edit,
and no account data can reach a public document. Guards (/testing), a deploy.json verify manifest and the
agent-ready check CLI keep the documents true after a deploy.
2. Installation
npm install @softure-ai/agent-readyPeer dependency: @modelcontextprotocol/server (the app's MCP server is built with it). Entries:
| Entry | Loads in | What |
|---|---|---|
| @softure-ai/agent-ready | anywhere, next.config.ts included | agentReady(), origins, every document builder, nextHeaders, buildVerifyManifest |
| /next | route handlers | the serve* handlers |
| /server | Node | Web Bot Auth signing and the directory, MCP introspection |
| /webmcp | the browser | WebMCP runtime and the boot script; no imports at all |
| /testing | the app's tests | guards |
The package also installs the agent-ready command (npx agent-ready …), for an operator, not for the app at
runtime:
| Command | What |
|---|---|
| agent-ready web-bot-auth key | prints a new Ed25519 seed for WEB_BOT_AUTH_PRIVATE_KEY (refuses a terminal; section 4, Web Bot Auth) |
| agent-ready dns-aid check --domain <domain> --record <label>=<host> | checks the published DNS-AID records (section 4, DNS-AID) |
| agent-ready check <url> | re-downloads every skill against its digest and verifies the signature directory (section 4, After a deploy) |
3. Configuration
// softure.config.ts
import { agentReady } from "@softure-ai/agent-ready";
import { getAuthorizationServerMetadata, resolveMcpOrigins } from "@softure-ai/mcp-access/server";
const config = defineSoftureConfig({
appOrigin: process.env.APP_ORIGIN,
modules: [
// ...
agentReady({
apexOrigin: "https://example.com",
name: "com.example/app",
title: "Example",
description: "Example keeps a person's notes; an assistant can read and add them.",
provider: { organization: "Example Ltd" },
oauth: {
authorizationServerMetadata: (request) => getAuthorizationServerMetadata(config, resolveMcpOrigins(config, request)),
manualTokenPath: "/mcp",
},
skills: [{ name: "notes", description: "Work with a person's notes.", body: (origins) => `\n# Notes\n\nSee ${origins.apexOrigin}/notes.\n` }],
markdown: true,
}),
],
});| Option | Default | What |
|---|---|---|
| appOrigin | the config's appOrigin | host of the MCP endpoint and every OAuth URL |
| apexOrigin | the app origin | host of documentation, cards and catalogs |
| resolveAppOrigin | none | the app origin of one request, ahead of the config's origins block (prefer that block, which mcp-access reads too) |
| name | required | server card name, reverse-DNS namespace and name |
| title, description, provider.organization | required | what agents show |
| mcp.path | /api/mcp | the endpoint's path |
| mcp.server | none | the MCP server factory; prefer createAgentReadyRoutes({ createServer }) (§4), which keeps the server out of every script that loads the config |
| mcp.protocolVersions | the SDK's | versions the server card announces |
| scopes | mcp:read, mcp:write | the scopes the endpoint checks |
| oauth.authorizationServerMetadata | none | the issuer's RFC 8414 metadata for a request; without oauth, auth.md and the JWKS answer 404 |
| oauth.manualTokenPath | none | a manual token page on the app host |
| oauth.lifetimes | mcp-access's | the lifetimes auth.md states; with @softure-ai/mcp-access in the config and its OAuth on, its own lifetimes, else none (auth.md points at expires_in) |
| serviceDoc | { path: "/" } | the page for people on the apex |
| openapi.version | 1.0.0 | bump it when the endpoint's contract changes |
| apiCatalog.statusPath | none | a public health path listed as status |
| skills | [] | the app's own skills |
| mcpSkill | {} | the generated connection skill (<name>-mcp); false drops it |
| catalog.queries | built from the title | 2 to 5 queries per AI catalog entry: mcp, a2a, api-catalog or a skill name |
| a2a | { enabled: true } | the A2A card; skillTags(tool) adds tags |
| webBotAuth | WEB_BOT_AUTH_PRIVATE_KEY, WEB_BOT_AUTH_RETIRED_PUBLIC_KEYS | names of the key variables |
| dnsAid | _index → apex, _mcp → app | records under _agents.<domain> |
| markdown | false | / answers Accept: text/markdown (seo); adds describedby |
Origins. MCP and OAuth URLs are on the app host, documents on the apex, a root document's own identity on the host
it was asked on. Origins come from Host and X-Forwarded-Proto, never from request.url (a standalone server
reports http://0.0.0.0:3000 there).
OAuth with @softure-ai/mcp-access. mcp-access serves the authorization server and protected resource documents;
agent-ready adds its keys through mcp-access's extensions and builds auth.md from the same metadata, so both name the
same endpoints:
mcpAccess({
serverName: "example",
resourceOrigins: ["https://example.com"], // the apex's root metadata names the apex as resource
oauth: {
enabled: true,
metadata: {
authorizationServer: createAgentAuthExtension({ apexOrigin: "https://example.com" }),
protectedResource: () => buildResourceDocumentation("https://example.com"),
},
},
}),mcp-access takes its issuer from core's resolveAppOrigin (the config's appOrigin and origins block, or its own
resolveAppOrigin): leave agent-ready's appOrigin unset, or set it to the same value, and put a per-request origin
in the config's origins block (core README, "Request origins"), which both modules read. The provider is called with a
request addressed to the resolved app origin, so mcp-access computes the same issuer; an issuer on another origin is a
setup bug, and the OAuth documents answer 500 with a log line naming both origins instead of sending agents to a
resource the issuer refuses.
auth.md states mcp-access's lifetimes (oauth.authorizationCodeLifetimeMinutes, accessTokenLifetimeMinutes,
refreshTokenLifetimeDays): declare them once there, and set oauth.lifetimes here only for another issuer.
auth.md lists every endpoint from the issuer's metadata; its prose (registration rules, rotating refresh tokens, error codes) describes an mcp-access issuer.
4. Mounting
One line per file:
// app/.well-known/api-catalog/route.ts
export { serveApiCatalog as GET } from "@softure-ai/agent-ready/next";
// app/openapi.json/route.ts
export { serveOpenApi as GET } from "@softure-ai/agent-ready/next";
// app/auth.md/route.ts
export { serveAuthMd as GET } from "@softure-ai/agent-ready/next";
// app/.well-known/jwks.json/route.ts
export { serveJwks as GET } from "@softure-ai/agent-ready/next";
// app/.well-known/mcp/server-card.json/route.ts
export { serveMcpServerCard as GET } from "@softure-ai/agent-ready/next";
// app/.well-known/agent-card.json/route.ts
export { serveA2aAgentCard as GET } from "@softure-ai/agent-ready/next";
// app/.well-known/agent-skills/index.json/route.ts
export { serveAgentSkillsIndex as GET } from "@softure-ai/agent-ready/next";
// app/.well-known/agent-skills/[name]/SKILL.md/route.ts
export { serveAgentSkill as GET } from "@softure-ai/agent-ready/next";
// app/.well-known/ai-catalog.json/route.ts
export { serveAiCatalog as GET } from "@softure-ai/agent-ready/next";
// app/.well-known/http-message-signatures-directory/route.ts
export { serveSignatureDirectory as GET } from "@softure-ai/agent-ready/next";The server card, the A2A card, the skills index, the MCP skill and the AI catalog introspect the app's MCP server.
Build those handlers around the factory the MCP endpoint already uses, so the server stays out of
softure.config.ts (a bundler that reads the config would otherwise pull the server and the database client into every
script and the proxy):
// lib/agent-ready-routes.ts
import { createAgentReadyRoutes } from "@softure-ai/agent-ready/next";
import { createServer } from "./mcp-server"; // the same factory as createMcpRoute({ createServer })
export const agentReadyRoutes = createAgentReadyRoutes({ createServer });
// app/.well-known/mcp/server-card.json/route.ts, and likewise the four other files
import { agentReadyRoutes } from "@/lib/agent-ready-routes";
export const GET = agentReadyRoutes.serveMcpServerCard;The factory gets { userId: "00000000-0000-0000-0000-000000000000", canWrite, tokenId: "agent-ready-discovery" }
(McpDiscoveryIdentity, the shape of mcp-access's McpServerIdentity): canWrite is mcp-access's allowWrites
when that module is configured, else true; no tool is called. The plain exports use mcp.server from the config;
without a factory anywhere, those five documents answer 500 with a log line naming the fix, and the rest still answer.
Add export const dynamic = "force-dynamic"; to each file when the config reads the environment at runtime: every
handler reads the request's host, and a page rendered at build time would keep one origin.
// next.config.ts: the Link header of the home page (relative links, true on every host)
import { nextHeaders } from "@softure-ai/agent-ready";
import type { NextConfig } from "next";
const nextConfig: NextConfig = { async headers() { return [...nextHeaders({ markdown: true })]; } };
// app/layout.tsx: <link rel="ai-catalog"> in the head
import { buildAiCatalogLink } from "@softure-ai/agent-ready";
const aiCatalog = buildAiCatalogLink(); // <link rel={aiCatalog.rel} href={aiCatalog.href} type={aiCatalog.type} />
// seo's robots.other: the Agentmap line
import { buildAgentmapDirective } from "@softure-ai/agent-ready";
seo({ robots: { other: buildAgentmapDirective("https://example.com") } });A proxy that lists the paths an apex serves must let these paths through.
Web Bot Auth. createSignedFetch(fetch, { agentOrigin: apex }) (/server) signs every outgoing request when a key
is set, and sends it unsigned otherwise; pass it wherever code takes a fetch (seo's IndexNow fetchImpl). Generate
a key with npx agent-ready web-bot-auth key > web-bot-auth.env (it refuses to print to a terminal); rotate by moving
the old public key to WEB_BOT_AUTH_RETIRED_PUBLIC_KEYS for a while.
WebMCP. In a client component, register tools with registerWebMcpTool(findModelContext(), tool, signal). A
tool's inputSchema is JSON Schema or a zod schema, converted with its own ~standard.jsonSchema (the input side,
draft 2020-12, as the MCP SDK does); toWebMcpInputSchema(schema) returns what the browser gets. A page
without a client component puts buildWebMcpBootScript({ flag, paths, pathPatterns, tools }) into an inline
<script> in <head>; each tool's execute is JavaScript source (args, h) => … with
h = { ok, error, fetchMarkdown, stripFrontmatter }.
Two tools come ready-made, for a client component to register (or for the boot script, below):
"use client";
import { createFindTermTool, createSearchSiteTool, findModelContext, registerWebMcpTool } from "@softure-ai/agent-ready/webmcp";
useEffect(() => {
const modelContext = findModelContext();
if (modelContext === null) return;
const controller = new AbortController();
registerWebMcpTool(modelContext, createSearchSiteTool(), controller.signal);
registerWebMcpTool(modelContext, createFindTermTool(), controller.signal);
return () => controller.abort();
}, []);createSearchSiteTool({ pages?, maxPages?, name?, description?, fetchImpl? })registerssearchSite({ query, limit? }). It reads seo's/sitemap.xml(orpages: { sitemap }, or a fixedpages: { paths }), takes each<loc>as a path on the page's own origin, reads the firstmaxPages(50) as Markdown (seo'sAccept: text/markdown) and answers the pages holding every word of the query, best first (a title match weighs ten body matches), with title, path and a snippet. Case and diacritics are ignored. A page that fails is skipped and counted; only a failed sitemap, or every page failing, is an error.createFindTermTool({ glossary?, name?, description?, fetchImpl? })registersfindTerm({ term }). It reads theDefinedTerms of the glossary page's JSON-LD (blog's/blog/glossary,buildGlossaryJsonLd), matches the exact name, then the term page's slug, then a single partial match, and answers the term page's Markdown (its description when that page fails). Several partial matches are listed.
Both read each page once per page load and keep it, except what failed.
A page without a client component registers the same tools from the boot script: searchSiteScriptTool(options) and
findTermScriptTool(options) take the same options (without fetchImpl: the page's fetch reads) and return a
WebMcpScriptTool with the same definition and answers.
buildWebMcpBootScript({ flag: "__siteWebMcp", paths: ["/", "/pricing"], pathPatterns: ["^/blog/"], tools: [searchSiteScriptTool(), findTermScriptTool()] });The answers and input descriptions are English. An app with its own translation passes messages (any subset of
SEARCH_SITE_MESSAGES / FIND_TERM_MESSAGES; {placeholders} are filled in) and, for searchSite, locale, whose
Intl.PluralRules pick a form of a count-dependent line ({ one, few, many, other }, other required);
description stays its own option. The module ships no translation.
searchSiteScriptTool({
locale: "cs",
messages: { matches: { one: "…{count}…", few: "…{count}…", other: "…{count}…" }, noMatch: "…{query}… ({scope})" },
});DNS-AID. Publish the lines of resolveDnsAidRecords(options, origins).map(formatZoneLine) at the DNS provider,
turn on DNSSEC, then check them:
npx agent-ready dns-aid check --domain example.com --record _index=example.com --record _mcp=app.example.comAfter a deploy. buildVerifyManifest({ host: "apex" | "app", options, origins, signatureDirectory, webMcp })
returns verify.routes for deploy.json (@softure-ai/deploy): status, type and quoted markers per document, one
route per Link relation, POST <mcp> → 401 with resource_metadata. npx agent-ready check https://example.com
re-downloads every skill against its digest and verifies the directory's signature for the host.
5. Migrations and tables
None: the module has no database schema.
6. Environment variables
| Variable | Required | What |
|---|---|---|
| WEB_BOT_AUTH_PRIVATE_KEY | no | Ed25519 seed (base64url JWK d); without it requests go unsigned and the directory answers 404 |
| WEB_BOT_AUTH_RETIRED_PUBLIC_KEYS | no | retired public keys (x), comma-separated, still published |
The names follow webBotAuth.privateKeyEnv and webBotAuth.retiredKeysEnv. A malformed value is logged by its
name, never its value.
7. Switches
None.
8. Appearance
None: every output is a machine format.
9. Copy
The dictionaries are empty: the documents are machine formats in English. Their prose follows title,
description, openapi.* and serviceDoc.title.
10. Hooks
None. The token issuer's metadata comes in through oauth.authorizationServerMetadata; the keys go back out through
createAgentAuthExtension and buildResourceDocumentation.
11. GDPR
The module stores nothing and builds every document from configuration and an anonymous server, so it holds no
personal data. Guard it in the app with expectNoAccountData, which also fails on any UUID unless allowed.
12. Limitations
- The JWKS is empty: tokens are opaque. An issuer that signs JWTs publishes its own keys.
- auth.md names agentic registration without the person (identity assertions, anonymous registration) as unsupported, because the issuer does not run it.
- The A2A card has one interface with the
MCPbinding: the app has no A2A message endpoint. - DNS-AID uses
alpn="h2"and nomandatorykeys; the records and DNSSEC are the DNS provider's to publish. - Guards check
app/on disk: paths served by a proxy orrewritesgo inservedElsewhere. - Markdown for agents (
Accept: text/markdown) is seo's; this module only links to it.
Parity with the reference implementation
The package was extracted from an app that served each document by hand. Every check the reference had is kept: document shapes and media types per scanner check, the RFC 9421 B.1.4 key vector verified by the reference Web Bot Auth library, the DNS-AID wire and presentation cases, auth.md endpoints against the issuer's real metadata, the origin matrix behind a proxy, and the guards for catalog targets, skill digests and account data. What changed: the cards are introspected instead of listing tools by hand, auth.md is built from the issuer's metadata instead of repeated constants, and the scan by an agent-readiness checker runs in the app's adoption, since it needs a deployment.
