From 456e5c6bc887fd28252d45228e5a0e424f8c9590 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Mon, 1 Jun 2026 05:01:54 -0700 Subject: [PATCH 1/5] Add well-known api-catalog, OpenAPI spec, and MCP server card --- astro.config.mjs | 12 ++ src/lib/discovery.ts | 222 +++++++++++++++++++++++++++++++ src/pages/health.ts | 11 ++ src/wellknown/api-catalog.ts | 13 ++ src/wellknown/mcp-server-card.ts | 13 ++ src/wellknown/openapi.ts | 12 ++ 6 files changed, 283 insertions(+) create mode 100644 src/lib/discovery.ts create mode 100644 src/pages/health.ts create mode 100644 src/wellknown/api-catalog.ts create mode 100644 src/wellknown/mcp-server-card.ts create mode 100644 src/wellknown/openapi.ts diff --git a/astro.config.mjs b/astro.config.mjs index c7a7130..da4168c 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -23,6 +23,18 @@ const wellKnownSkills = { pattern: '/.well-known/agent-skills/[name]/[file]', entrypoint: './src/wellknown/skill.ts', }); + injectRoute({ + pattern: '/.well-known/api-catalog', + entrypoint: './src/wellknown/api-catalog.ts', + }); + injectRoute({ + pattern: '/.well-known/openapi.json', + entrypoint: './src/wellknown/openapi.ts', + }); + injectRoute({ + pattern: '/.well-known/mcp/server-card.json', + entrypoint: './src/wellknown/mcp-server-card.ts', + }); }, }, }; diff --git a/src/lib/discovery.ts b/src/lib/discovery.ts new file mode 100644 index 0000000..32a3576 --- /dev/null +++ b/src/lib/discovery.ts @@ -0,0 +1,222 @@ +export const SITE_ORIGIN = 'https://sembr.org'; + +export const DISCOVERY_PATHS = { + apiCatalog: '/.well-known/api-catalog', + openApi: '/.well-known/openapi.json', + mcpServerCard: '/.well-known/mcp/server-card.json', + agentSkillsIndex: '/.well-known/agent-skills/index.json', + llmsTxt: '/llms.txt', + health: '/health', + sitemap: '/sitemap-index.xml', +} as const; + +export function discoveryLinkHeader(): string { + return [ + `; rel="sitemap"`, + `; rel="api-catalog"`, + `; rel="service-desc"; type="application/vnd.oai.openapi+json"`, + `; rel="describedby"; type="text/markdown"`, + `; rel="describedby"; type="application/json"`, + ].join(', '); +} + +export function apiCatalogLinkset() { + const origin = SITE_ORIGIN; + + return { + linkset: [ + { + anchor: `${origin}/`, + 'service-desc': [ + { + href: `${origin}${DISCOVERY_PATHS.openApi}`, + type: 'application/vnd.oai.openapi+json', + }, + ], + 'service-doc': [ + { + href: `${origin}${DISCOVERY_PATHS.llmsTxt}`, + type: 'text/markdown', + }, + { + href: `${origin}/`, + type: 'text/html', + }, + ], + status: [ + { + href: `${origin}${DISCOVERY_PATHS.health}`, + type: 'application/json', + }, + ], + }, + { + anchor: `${origin}/.well-known/agent-skills/`, + 'service-desc': [ + { + href: `${origin}${DISCOVERY_PATHS.openApi}#/paths/~1.well-known~1agent-skills~1index.json/get`, + type: 'application/vnd.oai.openapi+json', + }, + ], + 'service-doc': [ + { + href: `${origin}${DISCOVERY_PATHS.agentSkillsIndex}`, + type: 'application/json', + }, + ], + status: [ + { + href: `${origin}${DISCOVERY_PATHS.health}`, + type: 'application/json', + }, + ], + }, + ], + }; +} + +export function openApiDocument() { + const origin = SITE_ORIGIN; + + return { + openapi: '3.1.0', + info: { + title: 'SemBr.org Content API', + version: '1.0.0', + description: + 'Machine-readable access to the Semantic Line Breaks specification and published agent skills.', + }, + servers: [{ url: origin }], + paths: { + '/': { + get: { + summary: 'SemBr specification', + description: + 'Returns the SemBr specification. Use Accept: text/markdown for the source document or Accept: text/html for the rendered page.', + parameters: [ + { + name: 'Accept', + in: 'header', + schema: { + type: 'string', + enum: ['text/markdown', 'text/html'], + }, + }, + ], + responses: { + '200': { + description: 'SemBr specification document', + content: { + 'text/markdown': {}, + 'text/html': {}, + }, + }, + }, + }, + }, + '/llms.txt': { + get: { + summary: 'SemBr specification (llms.txt)', + description: 'Returns the SemBr specification as Markdown.', + responses: { + '200': { + description: 'SemBr specification', + content: { + 'text/markdown': {}, + }, + }, + }, + }, + }, + '/.well-known/agent-skills/index.json': { + get: { + summary: 'Agent skills index', + description: 'Lists SemBr agent skills available for discovery.', + responses: { + '200': { + description: 'Agent skills discovery document', + content: { + 'application/json': {}, + }, + }, + }, + }, + }, + '/.well-known/agent-skills/{name}/SKILL.md': { + get: { + summary: 'Agent skill document', + parameters: [ + { + name: 'name', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'Agent skill Markdown document', + content: { + 'text/markdown': {}, + }, + }, + '404': { + description: 'Skill not found', + }, + }, + }, + }, + '/health': { + get: { + summary: 'Service health', + responses: { + '200': { + description: 'Health status', + content: { + 'application/json': {}, + }, + }, + }, + }, + }, + }, + }; +} + +export function mcpServerCard() { + return { + protocolVersion: '2025-11-25', + serverInfo: { + name: 'sembr.org', + version: '0.0.1', + description: + 'Semantic Line Breaks specification, llms.txt, and published agent skills exposed via WebMCP on the homepage.', + }, + transport: { + type: 'webmcp', + endpoint: `${SITE_ORIGIN}/`, + }, + capabilities: { + tools: true, + resources: false, + prompts: false, + }, + authentication: { + required: false, + }, + tools: [ + { + name: 'get_specification', + description: 'Fetch the SemBr specification as Markdown from /llms.txt.', + }, + { + name: 'list_agent_skills', + description: 'List published SemBr agent skills from the well-known index.', + }, + { + name: 'get_agent_skill', + description: 'Fetch a specific SemBr agent skill by name.', + }, + ], + }; +} diff --git a/src/pages/health.ts b/src/pages/health.ts new file mode 100644 index 0000000..5d52117 --- /dev/null +++ b/src/pages/health.ts @@ -0,0 +1,11 @@ +import type { APIRoute } from 'astro'; + +export const prerender = true; + +export const GET: APIRoute = () => + new Response(JSON.stringify({ status: 'ok' }) + '\n', { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-cache', + }, + }); diff --git a/src/wellknown/api-catalog.ts b/src/wellknown/api-catalog.ts new file mode 100644 index 0000000..4ff32a8 --- /dev/null +++ b/src/wellknown/api-catalog.ts @@ -0,0 +1,13 @@ +import type { APIRoute } from 'astro'; +import { apiCatalogLinkset } from '../lib/discovery'; + +export const prerender = true; + +export const GET: APIRoute = () => + new Response(JSON.stringify(apiCatalogLinkset(), null, 2) + '\n', { + headers: { + 'Content-Type': + 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"', + 'Cache-Control': 'public, max-age=300', + }, + }); diff --git a/src/wellknown/mcp-server-card.ts b/src/wellknown/mcp-server-card.ts new file mode 100644 index 0000000..7b3e96e --- /dev/null +++ b/src/wellknown/mcp-server-card.ts @@ -0,0 +1,13 @@ +import type { APIRoute } from 'astro'; +import { mcpServerCard } from '../lib/discovery'; + +export const prerender = true; + +export const GET: APIRoute = () => + new Response(JSON.stringify(mcpServerCard(), null, 2) + '\n', { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'public, max-age=300', + 'Access-Control-Allow-Origin': '*', + }, + }); diff --git a/src/wellknown/openapi.ts b/src/wellknown/openapi.ts new file mode 100644 index 0000000..c387795 --- /dev/null +++ b/src/wellknown/openapi.ts @@ -0,0 +1,12 @@ +import type { APIRoute } from 'astro'; +import { openApiDocument } from '../lib/discovery'; + +export const prerender = true; + +export const GET: APIRoute = () => + new Response(JSON.stringify(openApiDocument(), null, 2) + '\n', { + headers: { + 'Content-Type': 'application/vnd.oai.openapi+json; charset=utf-8', + 'Cache-Control': 'public, max-age=300', + }, + }); From 0f955e9e4d319137f8fe923e73a1be7c2e41abbd Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Mon, 1 Jun 2026 05:01:56 -0700 Subject: [PATCH 2/5] Add Link response headers to homepage --- src/pages/index.astro | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pages/index.astro b/src/pages/index.astro index 24e0eb9..7cef916 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -2,6 +2,7 @@ export const prerender = false; import { getEntry, render } from "astro:content"; +import { discoveryLinkHeader } from "../lib/discovery"; import Layout from "../layouts/Layout.astro"; const entry = await getEntry("specification", "sembr"); @@ -10,15 +11,19 @@ if (!entry) { throw new Error("SemBr specification not available."); } +const linkHeader = discoveryLinkHeader(); + if (!Astro.request.headers.get("accept")?.includes("text/html")) { return new Response(entry.body, { headers: { "Content-Type": "text/markdown; charset=utf-8", + Link: linkHeader, Vary: "Accept", }, }); } +Astro.response.headers.set("Link", linkHeader); Astro.response.headers.set("Vary", "Accept"); const { Content } = await render(entry); From c83d047db2b385c60428d016624edf559a3991cc Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Mon, 1 Jun 2026 05:01:56 -0700 Subject: [PATCH 3/5] Declare Content Signals in robots.txt --- public/robots.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/public/robots.txt b/public/robots.txt index 7b27322..b377fca 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,4 +1,5 @@ User-agent: * Allow: / +Content-Signal: ai-train=no, search=yes, ai-input=no Sitemap: https://sembr.org/sitemap-index.xml From cf05ca95ab3384d9d864c32d8219fe7f7f964039 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Mon, 1 Jun 2026 05:01:56 -0700 Subject: [PATCH 4/5] Expose WebMCP tools on page load --- src/layouts/Layout.astro | 1 + src/scripts/webmcp.ts | 108 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/scripts/webmcp.ts diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 6d3932f..1d4245e 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -92,5 +92,6 @@ const structuredData = {
+ diff --git a/src/scripts/webmcp.ts b/src/scripts/webmcp.ts new file mode 100644 index 0000000..d223cab --- /dev/null +++ b/src/scripts/webmcp.ts @@ -0,0 +1,108 @@ +type ModelContextTool = { + name: string; + description: string; + inputSchema: Record; + execute: (args: Record) => Promise; + annotations?: { readOnlyHint?: boolean }; +}; + +type ModelContext = { + registerTool: (tool: ModelContextTool, options?: { signal?: AbortSignal }) => void; +}; + +declare global { + interface Navigator { + modelContext?: ModelContext; + } +} + +function getModelContext(): ModelContext | undefined { + if (!('modelContext' in navigator) || !navigator.modelContext) { + return undefined; + } + + const { registerTool } = navigator.modelContext; + if (typeof registerTool !== 'function') { + return undefined; + } + + return navigator.modelContext; +} + +async function fetchText(url: string): Promise { + const response = await fetch(url, { headers: { Accept: 'text/markdown, application/json' } }); + if (!response.ok) { + throw new Error(`Request failed (${response.status}) for ${url}`); + } + return response.text(); +} + +function registerSembrTools(modelContext: ModelContext, signal: AbortSignal): void { + const options = { signal }; + + modelContext.registerTool( + { + name: 'get_specification', + description: 'Fetch the SemBr specification as Markdown from /llms.txt.', + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true }, + async execute() { + return fetchText('/llms.txt'); + }, + }, + options, + ); + + modelContext.registerTool( + { + name: 'list_agent_skills', + description: 'List published SemBr agent skills from the well-known index.', + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true }, + async execute() { + return fetchText('/.well-known/agent-skills/index.json'); + }, + }, + options, + ); + + modelContext.registerTool( + { + name: 'get_agent_skill', + description: 'Fetch a specific SemBr agent skill by name.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Agent skill identifier, for example "sembr-reformat".', + }, + }, + required: ['name'], + }, + annotations: { readOnlyHint: true }, + async execute({ name }) { + if (typeof name !== 'string' || name.length === 0) { + throw new Error('The "name" argument is required.'); + } + return fetchText(`/.well-known/agent-skills/${encodeURIComponent(name)}/SKILL.md`); + }, + }, + options, + ); +} + +const controller = new AbortController(); +const modelContext = getModelContext(); + +if (modelContext) { + registerSembrTools(modelContext, controller.signal); +} + +window.addEventListener( + 'pagehide', + () => { + controller.abort(); + }, + { once: true }, +); From 8b987406ab0404510b486bbc7eb5737d39bfa679 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Mon, 1 Jun 2026 05:08:46 -0700 Subject: [PATCH 5/5] Fix WebMCP script TypeScript module augmentation --- src/scripts/webmcp.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/scripts/webmcp.ts b/src/scripts/webmcp.ts index d223cab..04a88c0 100644 --- a/src/scripts/webmcp.ts +++ b/src/scripts/webmcp.ts @@ -106,3 +106,5 @@ window.addEventListener( }, { once: true }, ); + +export { };