AIScan cover: Serve your MCP card on Astro, with the Astro logo and three steps: add the endpoint, keep prerender off, exempt middleware.
AIScan cover: Serve your MCP card on Astro, with the Astro logo and three steps: add the endpoint, keep prerender off, exempt middleware.
AI Readiness

How to publish /.well-known/mcp/server-card.json on Astro

Serve /.well-known/mcp/server-card.json on Astro with a public file or an endpoint, keep prerender off for headers, and stop middleware redirecting the card.

AAIScan Content Team 6 Oct 2026 5 min read
#MCP#server card#Astro#well-known

This guide covers P2 · Capabilities.

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 toDo thisTime
Serve a fixed card with the least codePut the JSON in public/.well-known/mcp/server-card.json2 min
Send CORS and cache headers from Astro itselfAdd src/pages/.well-known/mcp/server-card.json.ts with export const prerender = false5 min
Keep a login redirect off the cardLet /.well-known/ through at the top of src/middleware.ts2 min
Prove it worksRun npx aiscan-cli yoursite.com and read check P21 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 in curl -si and add the /.well-known/ early return.
  • 200 but no CORS header. The endpoint was prerendered. Add export const prerender = false and build with an adapter.
  • 404 on the file. The folder must be spelled .well-known and sit under public/ or src/pages/. A folder named well-known serves /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