Table of contents
Astro serves a file under public/ as it is and builds anything in src/pages/ through its own router, and an MCP server card can live in either place. The two behave differently in ways that only show up when you curl the result. This guide uses Astro 7.3.6 and @astrojs/node 11.1.7, and every response below was captured from a real astro build and a running server.
The card is the JSON document at /.well-known/mcp/server-card.json that tells a crawler where your MCP server lives. The schema is still a draft, so read Publish an MCP Server Card So Agents Can Find Your Tools first. This post covers only the Astro mechanics for check P2, which AIScan grades. The Next.js version of this guide covers the same check on a different framework.
Quick summary
| If you want to | Do this | Time |
|---|---|---|
| Serve a fixed card with the least code | Put the JSON in public/.well-known/mcp/server-card.json | 2 min |
| Send CORS and cache headers from Astro itself | Add src/pages/.well-known/mcp/server-card.json.ts with export const prerender = false | 5 min |
| Keep a login redirect off the card | Let /.well-known/ through at the top of src/middleware.ts | 2 min |
| Prove it works | Run npx aiscan-cli yoursite.com and read check P2 | 1 min |
Pick a route: public file or endpoint
Option 1: a file in public/. Create public/.well-known/mcp/server-card.json with your card. Astro copies it into the build output untouched. Under @astrojs/node it came back as Content-Type: application/json; charset=utf-8 with Cache-Control: public, max-age=0. You cannot set other headers on it from Astro.
Option 2: an endpoint. Create src/pages/.well-known/mcp/server-card.json.ts. A folder that starts with a dot is fine here, and astro build listed the route as /.well-known/mcp/server-card.json:
// src/pages/.well-known/mcp/server-card.json.ts
import type { APIRoute } from "astro";
export const prerender = false;
const card = {
serverInfo: { name: "example-mcp", version: "1.0.0" },
description: "Search the example catalog",
transport: { type: "streamable-http", url: "https://example.com/mcp" },
tools: [{ name: "search", description: "Search the catalog" }],
};
const headers = {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=3600",
};
export const GET: APIRoute = () => Response.json(card, { headers });
export const OPTIONS: APIRoute = () => new Response(null, { status: 204, headers });
Replace the example values with your real server name, endpoint and tools. The serverInfo and transport field names follow the shape in AIScan's own P2 guidance. They are a convention, not a ratified schema.
Do not drop prerender = false
This is the Astro-specific trap. Without that one line, a static build runs the endpoint once at build time and writes the result to dist/.well-known/mcp/server-card.json. The headers object is thrown away, because a plain file has nowhere to keep it. I served that output with astro preview and got Content-Type: application/json but Cache-Control: no-cache and no Access-Control-Allow-Origin at all. The card looks fine in a terminal and fails for a browser-based client.
With export const prerender = false and an adapter (here output: "server" with @astrojs/node), the same endpoint returned access-control-allow-origin: * and cache-control: public, max-age=3600, and the OPTIONS export answered a preflight with 204 and the same headers. The endpoints guide says you must opt out of prerendering per endpoint in static mode, and that is the opt-out. If you stay fully static, either accept the plain file or set the headers in your host's own configuration.
Keep middleware off the card
Astro middleware runs for endpoints, so a session check in src/middleware.ts catches the card too. I tested a six-line version:
// src/middleware.ts (the broken version)
import { defineMiddleware } from "astro:middleware";
export const onRequest = defineMiddleware((context, next) => {
if (!context.cookies.has("session") && context.url.pathname !== "/login") {
return context.redirect("/login");
}
return next();
});
The endpoint answered 302 Found with location: /login. A scanner that follows the redirect lands on an HTML page, which is not a card. Fix it with one early return:
if (context.url.pathname.startsWith("/.well-known/")) return next();
After a rebuild the card returned 200 with a JSON content type, while / still redirected to /login. The middleware guide describes the context object used here.
One more result worth knowing: with the broken middleware still in place, the public/ file returned 200. The Node adapter serves files from the client build folder before middleware runs, so Option 1 is immune to this redirect and Option 2 is not.
Verify the card
Run AIScan first. From any terminal:
npx aiscan-cli yoursite.com
Find check P2 · MCP Server Card in the Capabilities group. It should show as passing. By hand, the equivalent is one request:
curl -si https://yoursite.com/.well-known/mcp/server-card.json
Expected output, trimmed:
HTTP/2 200
content-type: application/json
access-control-allow-origin: *
cache-control: public, max-age=3600
{"serverInfo":{"name":"example-mcp","version":"1.0.0"},"description":"Search the example catalog", ...}
The trailing slash is not consistent. astro preview on a static build answered both forms with 200, while the Node server returned 404 for /.well-known/mcp/server-card.json/. Publish and link the version without the slash.
When the card still fails
- 302 to a login page. Your middleware is catching the card. Look for a
location:header incurl -siand add the/.well-known/early return. - 200 but no CORS header. The endpoint was prerendered. Add
export const prerender = falseand build with an adapter. - 404 on the file. The folder must be spelled
.well-knownand sit underpublic/orsrc/pages/. A folder namedwell-knownserves/well-known/.... - Works locally, fails in production. Some CDNs and reverse proxies drop paths that start with a dot. If the Node server serves the card and your live domain does not, the layer in front is the cause.
Where to go next
Run npx aiscan-cli yoursite.com, confirm check P2, then browse the other fixes in the AIScan guides. The capabilities checks reference lists P1 to P4 and E2. If you also want agents to read your Astro pages as Markdown, see Markdown for AI agents on Astro.
Frequently asked questions
Where does the MCP server card go in an Astro project?
Either public/.well-known/mcp/server-card.json for a plain file, or src/pages/.well-known/mcp/server-card.json.ts for an endpoint. Both are served at /.well-known/mcp/server-card.json on Astro 7.3.6.
Does Astro allow a folder named .well-known inside src/pages?
Yes. astro build listed /.well-known/mcp/server-card.json as a route. Only files and folders that start with an underscore are skipped by the router.
Why does my server card redirect to /login?
Astro middleware runs for endpoints, so a session check in src/middleware.ts redirects the card too (a 302 with a location: /login header). Return next() early when the path starts with /.well-known/ and rebuild.
Why does the card have no CORS header after I added one?
The endpoint was prerendered. A static build runs it once and writes a plain file, which drops the headers object. Add export const prerender = false and build with an adapter such as @astrojs/node.
Why does curl return the wrong Cache-Control for my endpoint?
Same cause. On astro preview of a static build the card came back with Cache-Control: no-cache instead of the value set in code. Opt the endpoint out of prerendering or set the header in your host's configuration.
Why does a browser-based client get a 404 or 405 from the card?
An endpoint that exports only GET does not answer a CORS preflight. Export an OPTIONS function that returns 204 with the same CORS headers, and make sure the route is not prerendered.
Does the trailing slash matter on the card URL?
It can. astro preview on a static build answered both forms with 200, but the Node server returned 404 for the version with a slash. Publish and link the version without it.
How do I confirm AIScan check P2 passes?
Run npx aiscan-cli yoursite.com and read check P2 in the Capabilities group. By hand, request the /.well-known/mcp/server-card.json path of your site with curl -si and look for a 200 status with a JSON content type.
Related guides
Platform League Table: WordPress, Next.js, Astro and Shopify Across 728 Sites
Every few weeks someone asks which platform produces the most agentready sites. WordPress, Next.js, Astro and Shopify all have fans, and a ranked table is the answer people expect. We have the data…
How to return a real 404 with a recoverable body on Astro
Astro gives you a real 404 for an unmatched URL, and then lets you lose it the moment a dynamic route says "not found" without saying 404. The page looks right, the status line says 200, and agents…
UCP profiles: Shopify serves one, most other stores don't
Shopify stores answer on /.wellknown/ucp. Almost nobody else does. We checked 238 commerceactive sites in the AIScan corpus on 10 October 2026: 12 of 17 Shopify catalogue stores pass check M1 the…
How to publish /.well-known/mcp/server-card.json on Docusaurus
Docusaurus serves anything in static/ asis, so the MCP server card needs one file and no plugin. This guide covers check P2 on Docusaurus. I built a site on Docusaurus 3.10.2 and ran the dev server…
