diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md
new file mode 100644
index 0000000..b9beec4
--- /dev/null
+++ b/docs/cli/mcp.md
@@ -0,0 +1,63 @@
+---
+title: stardust mcp
+description: Serve a built site's docs to AI clients over the Model Context Protocol (stdio).
+---
+
+# stardust mcp
+
+Serve a built Stardust site to AI clients (Claude Desktop, Cursor, and other [MCP](https://modelcontextprotocol.io/) clients) over the stdio transport. See [MCP Server](/features/mcp) for the full guide.
+
+## Usage
+
+```bash
+stardust mcp [dir] [options]
+```
+
+`[dir]` is the built site directory. When omitted, it resolves from `build.outDir` in `stardust.yaml`, falling back to `dist`.
+
+The site must be built first — the server reads the `llms.json` manifest and per-page `.md` files that `stardust build` emits (with `build.llms` enabled, the default).
+
+## Examples
+
+```bash
+# Build, then serve dist/ over MCP
+stardust build
+stardust mcp dist
+
+# Inside a project, resolve the output dir from stardust.yaml
+stardust mcp
+```
+
+## What it exposes
+
+- **`list_pages`** tool — the table of contents (path, title, description), with an optional path-prefix filter.
+- **`search_docs`** tool — full-text search across the documentation.
+- **`read_page`** tool — the full markdown of a page by its path.
+- **Resources** — one `text/markdown` resource per page.
+
+## Serve over HTTP
+
+By default the server speaks the **stdio** transport (clients launch it as a subprocess). Pass `--http` to serve a live **Streamable HTTP** `/mcp` endpoint instead — for self-hosters who run the binary as a service:
+
+```bash
+stardust mcp dist --http --port 8080
+# → Endpoint: http://localhost:8080/mcp
+```
+
+It binds to `localhost` and validates the `Origin` header (DNS-rebinding guard); loopback origins and non-browser clients are always allowed, other browser origins need `--allow-origin`. See [MCP Server](/features/mcp) for details.
+
+## Options
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `[dir]` | `build.outDir` (else `dist`) | Built site directory to serve |
+| `-c`, `--config` | `stardust.yaml` | Config file, used only to resolve the output dir when `[dir]` is omitted |
+| `--http` | off | Serve Streamable HTTP (a live `/mcp` endpoint) instead of stdio |
+| `-p`, `--port` | `8080` | Port for `--http` |
+| `--host` | `localhost` | Bind host for `--http` |
+| `--allow-origin` | — | Allowed browser `Origin` for `--http` (repeatable; `*` for any) |
+
+## Notes
+
+- In stdio mode, communication is JSON-RPC on **stdin/stdout** and all logs go to **stderr**, keeping stdout a clean protocol channel. In `--http` mode, requests come over `POST /mcp`.
+- The server is **read-only** and holds no state between runs.
diff --git a/docs/features/llm-output.md b/docs/features/llm-output.md
index 3a69fd6..e43cefc 100644
--- a/docs/features/llm-output.md
+++ b/docs/features/llm-output.md
@@ -70,6 +70,34 @@ https://example.com/guide.md → its markdown source
Readers get the same thing through the **Copy page as Markdown** button at the
top of each page — one click to paste a page into an AI chat.
+## Machine-Readable Manifest (llms.json)
+
+Alongside `llms.txt`, Stardust writes `llms.json` — a structured index of every
+page with its `path`, `title`, `description`, absolute `url`, and the `md` path
+to its raw markdown:
+
+```json
+{
+ "name": "My Project",
+ "description": "…",
+ "url": "https://example.com",
+ "generator": "stardust",
+ "pages": [
+ { "path": "/guide", "title": "Guide", "description": "…",
+ "url": "https://example.com/guide", "md": "/guide.md" }
+ ]
+}
+```
+
+Where `llms.txt` is written for a model to read, `llms.json` is written for a
+program to parse: a remote agent can fetch it, enumerate pages, and pull exactly
+the `.md` files it needs — consuming the whole site with no server to run. It is
+also what powers the [MCP Server](/features/mcp): `stardust mcp` serves a built
+site to Claude, Cursor, and other AI clients straight from this manifest.
+
+Pages with `llm: false` are excluded here too. The manifest is written whenever
+`build.llms` is enabled (the default).
+
## Use Cases
### AI Chatbots
diff --git a/docs/features/mcp.md b/docs/features/mcp.md
new file mode 100644
index 0000000..0b3cb48
--- /dev/null
+++ b/docs/features/mcp.md
@@ -0,0 +1,89 @@
+---
+title: MCP Server
+description: Serve any built site's docs to Claude, Cursor, and other AI clients over the Model Context Protocol — locally, air-gapped, no service to run.
+---
+
+# MCP Server
+
+Every hosted docs platform now auto-generates an [MCP](https://modelcontextprotocol.io/) server so AI assistants can search and read the docs. Stardust brings that to **static, self-hosted sites**: `stardust mcp` turns the binary you already have into an MCP server over any built site — no service to run, works air-gapped.
+
+A Claude or Cursor user points their client at `stardust mcp
` and gets:
+
+- **`list_pages`** — the table of contents (path, title, description), optionally filtered by a path prefix.
+- **`search_docs`** — full-text search across your documentation, returning matching pages with snippets.
+- **`read_page`** — the full markdown of any page by its path.
+- **Resources** — every page exposed as a `text/markdown` resource, so clients can browse and attach pages directly.
+
+The server reads a **built** site — its `llms.json` manifest and the per-page `.md` files that `stardust build` already emits. It never re-parses your markdown and needs no `stardust.yaml` at serve time, so it can serve any built site, even one you didn't build.
+
+## Usage
+
+Build your site, then serve it:
+
+```bash
+stardust build # emits dist/, including llms.json + per-page .md
+stardust mcp dist # serves dist/ over MCP on stdio
+```
+
+With no directory argument, `stardust mcp` resolves the output directory from `stardust.yaml` (falling back to `dist`), so inside a project you can just run:
+
+```bash
+stardust mcp
+```
+
+The server speaks the [stdio transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports): it reads JSON-RPC requests on stdin and writes responses on stdout. All logging goes to stderr, so stdout stays a clean protocol channel.
+
+## Connect a client
+
+MCP clients launch the server as a subprocess. Point yours at the `stardust` binary with `mcp` and your built directory.
+
+**Claude Desktop / Cursor** (`claude_desktop_config.json` or the client's MCP settings):
+
+```json
+{
+ "mcpServers": {
+ "my-docs": {
+ "command": "stardust",
+ "args": ["mcp", "/absolute/path/to/dist"]
+ }
+ }
+}
+```
+
+Restart the client and your docs appear as a connected server — search and read your guides without leaving the chat.
+
+## Serve over HTTP (self-hosting)
+
+Hosted docs platforms expose a live `https://yoursite/mcp` endpoint because they run a server for you. If **you** run a server — a VPS, on-prem box, or air-gapped host — `--http` gives you the same thing:
+
+```bash
+stardust mcp dist --http --port 8080
+# → Endpoint: http://localhost:8080/mcp
+```
+
+This is the MCP [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports): a single `/mcp` endpoint answering JSON-RPC over `POST`. Point any MCP client that accepts a URL at `http://your-host:8080/mcp`.
+
+
+A **pure static host** (GitHub Pages, Netlify, a plain CDN) can't serve `--http` — a live endpoint needs a running process. Those deployments use the static [`llms.json` + `.md` files](/features/llm-output) instead (see below). `--http` is for when you actually run the binary as a service.
+
+
+**Security.** The server binds to `localhost` by default and validates the `Origin` header to block [DNS-rebinding](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#security-warning) attacks: loopback origins and non-browser clients (which send no `Origin`) are always allowed; other browser origins must be listed with `--allow-origin`. To expose it beyond localhost, set `--host 0.0.0.0` and add each browser origin (`--allow-origin https://app.example.com`, or `--allow-origin '*'` to accept any), ideally behind your own TLS/auth proxy. The server is read-only and stateless.
+
+## Options
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `[dir]` | `build.outDir` (else `dist`) | The built site directory to serve |
+| `-c`, `--config` | `stardust.yaml` | Config file, used only to resolve the output dir when `[dir]` is omitted |
+| `--http` | off | Serve Streamable HTTP (a live `/mcp` endpoint) instead of stdio |
+| `-p`, `--port` | `8080` | Port for `--http` |
+| `--host` | `localhost` | Bind host for `--http` |
+| `--allow-origin` | — | Allowed browser `Origin` for `--http` (repeatable; `*` for any) |
+
+## Consume without a server
+
+The same build also produces a static, machine-readable manifest — `llms.json` — plus per-page `.md` files and `llms.txt`. Remote agents that can't launch a subprocess can fetch these directly over HTTP, no MCP server required. See [LLM-Friendly Output](/features/llm-output).
+
+
+The MCP server is **read-only** and requires a built site. Run `stardust build` (with `build.llms` enabled, the default) first — the manifest and per-page markdown it emits are what the server serves.
+
diff --git a/lib/src/cli/cli_runner.dart b/lib/src/cli/cli_runner.dart
index ade1945..73f8abd 100644
--- a/lib/src/cli/cli_runner.dart
+++ b/lib/src/cli/cli_runner.dart
@@ -10,6 +10,7 @@ import 'commands/clean_command.dart';
import 'commands/dartdoc_command.dart';
import 'commands/dev_command.dart';
import 'commands/init_command.dart';
+import 'commands/mcp_command.dart';
import 'commands/new_command.dart';
import 'commands/openapi_command.dart';
import 'commands/serve_command.dart';
@@ -30,6 +31,7 @@ class StardustCliRunner extends CommandRunner {
addCommand(CleanCommand());
addCommand(OpenApiCommand());
addCommand(DartdocCommand());
+ addCommand(McpCommand());
argParser.addFlag(
'version',
diff --git a/lib/src/cli/commands/mcp_command.dart b/lib/src/cli/commands/mcp_command.dart
new file mode 100644
index 0000000..4109b7a
--- /dev/null
+++ b/lib/src/cli/commands/mcp_command.dart
@@ -0,0 +1,116 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:args/command_runner.dart';
+import 'package:path/path.dart' as p;
+import 'package:shelf/shelf.dart' as shelf;
+import 'package:shelf/shelf_io.dart' as shelf_io;
+
+import '../../config/config_loader.dart';
+import '../../core/file_system.dart';
+import '../../mcp/docs_source.dart';
+import '../../mcp/mcp_http_server.dart';
+import '../../mcp/mcp_server.dart';
+import '../../utils/exceptions.dart';
+import '../../utils/logger.dart';
+import '../../version.dart';
+
+/// Serve a built Stardust site's docs to AI clients (Claude Desktop, Cursor)
+/// over MCP — stdio by default, or Streamable HTTP with `--http`.
+class McpCommand extends Command {
+ final FileSystem fileSystem;
+
+ @override
+ final name = 'mcp';
+
+ @override
+ final description = 'Serve a built site to AI clients over MCP (stdio, or --http)';
+
+ @override
+ String get invocation => 'stardust mcp [dir]';
+
+ McpCommand({FileSystem? fileSystem}) : fileSystem = fileSystem ?? const LocalFileSystem() {
+ argParser
+ ..addOption('config',
+ abbr: 'c', help: 'Path to stardust.yaml (used only to resolve the output dir)', defaultsTo: 'stardust.yaml')
+ ..addFlag('http', help: 'Serve over Streamable HTTP (a live /mcp endpoint) instead of stdio', negatable: false)
+ ..addOption('port', abbr: 'p', help: 'Port for --http', defaultsTo: '8080')
+ ..addOption('host', help: 'Host to bind for --http', defaultsTo: 'localhost')
+ ..addMultiOption('allow-origin',
+ help: 'Allowed browser Origin for --http (repeatable; "*" for any). '
+ 'Loopback origins and non-browser clients are always allowed.')
+ ..addFlag('verbose', abbr: 'v', help: 'Verbose logging (to stderr)', negatable: false);
+ }
+
+ @override
+ Future run() async {
+ final args = argResults;
+ if (args == null) return 1;
+
+ final http = args['http'] == true;
+ // In stdio mode, stdout is the protocol channel, so logs go to stderr only.
+ // In HTTP mode, stdout is free — log there like `stardust serve`.
+ final logger = http
+ ? Logger(onLog: stdout.writeln, onError: stderr.writeln)
+ : Logger(onLog: stderr.writeln, onError: stderr.writeln);
+ final configPath = args['config'] as String;
+
+ final dir = switch (args.rest) {
+ [final dir, ...] => dir,
+ [] when await fileSystem.fileExists(configPath) =>
+ p.normalize((await ConfigLoader.load(configPath, logger: logger)).build.outDir),
+ [] => 'dist',
+ };
+
+ try {
+ final source = await DocsSource.load(dir, fileSystem: fileSystem, logger: logger);
+ final server = McpServer(source, name: source.siteName, version: version, logger: logger);
+
+ if (http) {
+ final port = int.tryParse(args['port'] as String);
+ if (port == null || port < 0 || port > 65535) {
+ stderr.writeln('❌ Invalid port: ${args['port']}');
+ return 1;
+ }
+ return _serveHttp(source, server, logger,
+ host: args['host'] as String, port: port, allowedOrigins: (args['allow-origin'] as List).toSet());
+ }
+
+ logger.log('📡 stardust mcp — serving ${source.pages.length} pages from $dir/ over stdio');
+ final lines = stdin.transform(utf8.decoder).transform(const LineSplitter());
+ await server.serve(lines, stdout.writeln);
+ return 0;
+ } on ContentException catch (e) {
+ stderr.writeln('❌ ${e.message}');
+ return 1;
+ }
+ }
+
+ Future _serveHttp(
+ DocsSource source,
+ McpServer server,
+ Logger logger, {
+ required String host,
+ required int port,
+ required Set allowedOrigins,
+ }) async {
+ final mcp = McpHttpServer(server, allowedOrigins: allowedOrigins, logger: logger);
+ final handler = const shelf.Pipeline().addMiddleware(shelf.logRequests()).addHandler(mcp.handle);
+ final httpServer = await shelf_io.serve(handler, host, port);
+
+ logger.log('');
+ logger.log('📡 stardust mcp — serving ${source.pages.length} pages over MCP (Streamable HTTP)');
+ logger.log(' ➜ Endpoint: http://$host:$port/${McpHttpServer.endpoint}');
+ logger.log('');
+
+ ProcessSignal.sigint.watch().listen((_) async {
+ logger.log('\n👋 Shutting down...');
+ await httpServer.close();
+ exit(0);
+ });
+
+ await Completer().future;
+ return 0;
+ }
+}
diff --git a/lib/src/generator/site_generator.dart b/lib/src/generator/site_generator.dart
index d1d92bb..92a42a8 100644
--- a/lib/src/generator/site_generator.dart
+++ b/lib/src/generator/site_generator.dart
@@ -90,6 +90,7 @@ class SiteGenerator {
if (config.build.llms.enabled) {
await _generateLlms(pagesWithNav);
await _generateLlmsFull(pagesWithNav);
+ await _generateManifest(pagesWithNav);
}
if (config.seo.ogImage == null && !config.devMode) {
@@ -511,6 +512,33 @@ class SiteGenerator {
logger.log('🤖 Generated llms-full.txt');
}
+ /// A machine-readable page index (`llms.json`) — the static manifest remote
+ /// agents can consume without a server, and the input `stardust mcp` reads to
+ /// serve a built site. Each `md` points at the verbatim source `_writePageMarkdown`
+ /// emits, so tools can fetch clean markdown per page.
+ Future _generateManifest(List pages) async {
+ final urls = UrlResolver(config);
+ final manifest = {
+ 'name': config.name,
+ if (config.description case final desc?) 'description': desc,
+ if (config.url case final url?) 'url': url,
+ 'generator': 'stardust',
+ 'pages': [
+ for (final page in pages.where((page) => page.frontmatter['llm'] != false))
+ {
+ 'path': page.path,
+ 'title': page.title,
+ if (page.description case final desc?) 'description': desc,
+ if (urls.absolute(page.path) case final abs?) 'url': abs,
+ 'md': page.path == '/' ? '/index.md' : '${page.path}.md',
+ },
+ ],
+ };
+ await fileSystem.writeFile(
+ p.join(outputDir, 'llms.json'), '${const JsonEncoder.withIndent(' ').convert(manifest)}\n');
+ logger.log('🤖 Generated llms.json');
+ }
+
Future _generateRedirects(List pages) async {
final hasConfigRedirects = config.build.redirects.isNotEmpty;
final hasFrontmatterRedirects = pages.any((p) => p.redirectFrom.isNotEmpty);
diff --git a/lib/src/mcp/docs_source.dart b/lib/src/mcp/docs_source.dart
new file mode 100644
index 0000000..fb29fab
--- /dev/null
+++ b/lib/src/mcp/docs_source.dart
@@ -0,0 +1,178 @@
+import 'dart:convert';
+
+import 'package:path/path.dart' as p;
+
+import '../core/file_system.dart';
+import '../utils/exceptions.dart';
+import '../utils/logger.dart';
+
+/// One documentation page discovered from a built site's `llms.json` manifest.
+class DocPage {
+ final String path;
+ final String title;
+ final String? description;
+ final String? url;
+
+ /// Site-root-relative path to the verbatim markdown, e.g. `/introduction.md`.
+ final String mdFile;
+
+ const DocPage({
+ required this.path,
+ required this.title,
+ this.description,
+ this.url,
+ required this.mdFile,
+ });
+}
+
+/// A search hit: the matched page plus a short snippet of surrounding text.
+class DocHit {
+ final DocPage page;
+ final String snippet;
+
+ const DocHit(this.page, this.snippet);
+}
+
+/// Reads a **built** Stardust site (its `llms.json` manifest + per-page `.md`
+/// files) and answers docs queries — the read-only source `stardust mcp` serves.
+/// It never re-parses markdown or needs `stardust.yaml`: everything comes from
+/// the build output, so it serves any built site, air-gapped.
+class DocsSource {
+ final String dir;
+ final FileSystem fileSystem;
+ final Logger logger;
+ final String siteName;
+ final String? siteDescription;
+ final String? siteUrl;
+ final List pages;
+
+ final Map _byPath;
+ final Map _bodies = {};
+
+ DocsSource._({
+ required this.dir,
+ required this.fileSystem,
+ required this.logger,
+ required this.siteName,
+ this.siteDescription,
+ this.siteUrl,
+ required this.pages,
+ }) : _byPath = {for (final page in pages) page.path: page};
+
+ /// Loads the manifest at `/llms.json`. Throws [ContentException] with an
+ /// actionable message when the directory hasn't been built.
+ static Future load(String dir, {FileSystem? fileSystem, Logger logger = const Logger()}) async {
+ final fs = fileSystem ?? const LocalFileSystem();
+ final manifestPath = p.join(dir, 'llms.json');
+ if (!await fs.fileExists(manifestPath)) {
+ throw ContentException(
+ 'No llms.json in "$dir" — run `stardust build` (with build.llms enabled) first, or pass the built site directory.',
+ );
+ }
+
+ final decoded = switch (jsonDecode(await fs.readFile(manifestPath))) {
+ final Map decoded => decoded,
+ _ => throw ContentException('Malformed llms.json in "$dir".'),
+ };
+
+ final pages = [];
+ if (decoded['pages'] case final List rawPages) {
+ for (final raw in rawPages) {
+ if (raw case {'path': final String path, 'title': final String title, 'md': final String md}) {
+ pages.add(DocPage(
+ path: path,
+ title: title,
+ description: _asString(raw['description']),
+ url: _asString(raw['url']),
+ mdFile: md,
+ ));
+ }
+ }
+ }
+
+ return DocsSource._(
+ dir: dir,
+ fileSystem: fs,
+ logger: logger,
+ siteName: _asString(decoded['name']) ?? 'Documentation',
+ siteDescription: _asString(decoded['description']),
+ siteUrl: _asString(decoded['url']),
+ pages: pages,
+ );
+ }
+
+ /// The verbatim markdown for [path], or null when no such page exists (or its
+ /// `.md` file is missing from the build).
+ Future readPage(String path) async {
+ if (_byPath[path] case final page?) return _body(page);
+ return null;
+ }
+
+ /// Full-text search over titles, descriptions, and page bodies. Deterministic:
+ /// ranked by weighted term frequency, ties broken by manifest order.
+ Future> search(String query, {int limit = 10}) async {
+ final terms = _terms(query);
+ if (terms.isEmpty) return const [];
+
+ final scored = <({DocPage page, int score, int index, String snippet})>[];
+ for (var i = 0; i < pages.length; i++) {
+ final page = pages[i];
+ final body = await _body(page) ?? '';
+ final title = page.title.toLowerCase();
+ final lowerBody = body.toLowerCase();
+ final desc = page.description?.toLowerCase() ?? '';
+
+ var score = 0;
+ for (final term in terms) {
+ score += _count(title, term) * 5;
+ score += _count(desc, term) * 2;
+ score += _count(lowerBody, term);
+ }
+ if (score > 0) {
+ scored.add((page: page, score: score, index: i, snippet: _snippet(body, terms, page.description)));
+ }
+ }
+
+ scored.sort((a, b) {
+ final byScore = b.score.compareTo(a.score);
+ return byScore != 0 ? byScore : a.index.compareTo(b.index);
+ });
+ return [for (final s in scored.take(limit)) DocHit(s.page, s.snippet)];
+ }
+
+ Future _body(DocPage page) async {
+ if (_bodies.containsKey(page.path)) return _bodies[page.path];
+ final file = p.join(dir, page.mdFile.startsWith('/') ? page.mdFile.substring(1) : page.mdFile);
+ final content = await fileSystem.fileExists(file) ? await fileSystem.readFile(file) : null;
+ _bodies[page.path] = content;
+ return content;
+ }
+
+ static List _terms(String query) =>
+ query.toLowerCase().split(RegExp(r'[^a-z0-9]+')).where((t) => t.isNotEmpty).toSet().toList();
+
+ static int _count(String haystack, String term) => term.isEmpty ? 0 : term.allMatches(haystack).length;
+
+ String _snippet(String body, List terms, String? description) {
+ final lower = body.toLowerCase();
+ var idx = -1;
+ for (final term in terms) {
+ final at = lower.indexOf(term);
+ if (at >= 0 && (idx < 0 || at < idx)) idx = at;
+ }
+
+ final raw = switch (idx) {
+ >= 0 => _window(body, idx),
+ _ => description ?? (body.length > 180 ? '${body.substring(0, 180)}…' : body),
+ };
+ return raw.replaceAll(RegExp(r'\s+'), ' ').trim();
+ }
+
+ String _window(String body, int idx) {
+ final start = idx - 40 < 0 ? 0 : idx - 40;
+ final end = start + 180 > body.length ? body.length : start + 180;
+ return '${start > 0 ? '…' : ''}${body.substring(start, end)}${end < body.length ? '…' : ''}';
+ }
+
+ static String? _asString(Object? value) => value is String ? value : null;
+}
diff --git a/lib/src/mcp/mcp_http_server.dart b/lib/src/mcp/mcp_http_server.dart
new file mode 100644
index 0000000..84cc256
--- /dev/null
+++ b/lib/src/mcp/mcp_http_server.dart
@@ -0,0 +1,56 @@
+import 'package:shelf/shelf.dart' as shelf;
+
+import '../utils/logger.dart';
+import 'mcp_server.dart';
+
+/// The MCP [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports)
+/// over an [McpServer]: a single `/mcp` endpoint answering JSON-RPC POSTs. This
+/// is the "run it as a service" path for self-hosters (VPS, on-prem, air-gapped)
+/// who want a live endpoint; a pure static host uses the static `llms.json`/`.md`
+/// files instead.
+///
+/// The server is stateless (no `Mcp-Session-Id`) and has no server-initiated
+/// messages, so `GET` returns 405. It validates the `Origin` header to prevent
+/// DNS-rebinding attacks: loopback origins and non-browser clients (no `Origin`)
+/// are always allowed; other browser origins must be listed in [allowedOrigins].
+class McpHttpServer {
+ /// The single endpoint path clients POST to.
+ static const endpoint = 'mcp';
+
+ final McpServer server;
+
+ /// Extra browser origins to accept (exact match, or `*` for any).
+ final Set allowedOrigins;
+ final Logger logger;
+
+ McpHttpServer(this.server, {Set? allowedOrigins, this.logger = const Logger()})
+ : allowedOrigins = allowedOrigins ?? const {};
+
+ Future handle(shelf.Request request) async {
+ if (request.url.path != endpoint) return shelf.Response.notFound('Not found — POST to /$endpoint.');
+ if (!_originAllowed(request.headers['origin'])) return shelf.Response.forbidden('Origin not allowed.');
+
+ return switch (request.method) {
+ 'POST' => await _post(request),
+ // No server-initiated stream, and no sessions to terminate.
+ 'GET' || 'DELETE' => shelf.Response(405, headers: const {'allow': 'POST'}),
+ _ => shelf.Response(405, headers: const {'allow': 'POST'}),
+ };
+ }
+
+ Future _post(shelf.Request request) async {
+ final response = await server.handle(await request.readAsString());
+ // A JSON-RPC notification/response (no id) yields no reply → 202 Accepted.
+ if (response == null) return shelf.Response(202);
+ return shelf.Response.ok(response, headers: const {'content-type': 'application/json'});
+ }
+
+ bool _originAllowed(String? origin) {
+ if (origin == null) return true;
+ if (allowedOrigins.contains('*') || allowedOrigins.contains(origin)) return true;
+ return switch (Uri.tryParse(origin)?.host) {
+ 'localhost' || '127.0.0.1' || '::1' => true,
+ _ => false,
+ };
+ }
+}
diff --git a/lib/src/mcp/mcp_server.dart b/lib/src/mcp/mcp_server.dart
new file mode 100644
index 0000000..1cf7064
--- /dev/null
+++ b/lib/src/mcp/mcp_server.dart
@@ -0,0 +1,221 @@
+import 'dart:convert';
+
+import '../utils/logger.dart';
+import 'docs_source.dart';
+
+/// A minimal, dependency-free MCP server over the stdio transport: newline-
+/// delimited JSON-RPC 2.0 on stdin/stdout, per the MCP spec. It exposes a
+/// [DocsSource] through three tools (`list_pages`, `search_docs`, `read_page`)
+/// and one resource per page, so a Claude/Cursor user can browse and search a
+/// built site's docs.
+///
+/// The protocol core is [handle] (pure — one request string in, one response
+/// string or null out), kept separate from the [serve] IO loop for testing.
+class McpServer {
+ /// The protocol version advertised when a client doesn't request one.
+ static const defaultProtocolVersion = '2025-06-18';
+
+ final DocsSource source;
+ final String name;
+ final String version;
+ final Logger logger;
+
+ McpServer(this.source, {required this.name, this.version = 'dev', this.logger = const Logger()});
+
+ /// Reads one JSON-RPC message per line and writes each response line. Ends
+ /// when [input] closes (e.g. the client terminates the subprocess).
+ Future serve(Stream input, void Function(String) output) async {
+ await for (final line in input) {
+ if (line.trim().isEmpty) continue;
+ if (await handle(line) case final response?) output(response);
+ }
+ }
+
+ /// Handles one raw request and returns the response JSON, or null for
+ /// notifications (JSON-RPC messages without an `id`).
+ Future handle(String requestJson) async {
+ final Object? decoded;
+ try {
+ decoded = jsonDecode(requestJson);
+ } catch (_) {
+ return jsonEncode(_error(null, -32700, 'Parse error'));
+ }
+ if (decoded is! Map) return jsonEncode(_error(null, -32600, 'Invalid Request'));
+
+ final id = decoded['id'];
+ final isNotification = !decoded.containsKey('id');
+ final params = switch (decoded['params']) {
+ final Map params => params,
+ _ => const {},
+ };
+
+ try {
+ final result = await _dispatch(decoded['method'], params);
+ if (identical(result, _noResponse) || isNotification) return null;
+ return jsonEncode({'jsonrpc': '2.0', 'id': id, 'result': result});
+ } on _McpError catch (e) {
+ return isNotification ? null : jsonEncode(_error(id, e.code, e.message));
+ } catch (e) {
+ logger.error('mcp: ${decoded['method']} failed: $e');
+ return isNotification ? null : jsonEncode(_error(id, -32603, 'Internal error'));
+ }
+ }
+
+ Future