---
title: "How to publish /.well-known/mcp/server-card.json on Astro"
slug: mcp-server-card-astro
published: 2026-10-06T13:16:56.929012+00:00
updated: 2026-10-06T13:19:12.770611+00:00
author: "AIScan Content Team"
author_url: https://aiscan.site/about
category: "AI Readiness"
tags: MCP, server card, Astro, well-known, check:P2, platform:astro
description: "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."
url: https://aiscan.site/blog/mcp-server-card-astro
---

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](https://aiscan.site/blog/publish-mcp-server-card) first. This post covers only the Astro mechanics for check P2, which AIScan grades. The [Next.js version](https://aiscan.site/blog/mcp-server-card-nextjs) 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`:

```ts
// 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](https://aiscan.site/docs/checks/capabilities). 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](https://docs.astro.build/en/guides/endpoints/) 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:

```ts
// 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:

```ts
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](https://docs.astro.build/en/guides/middleware/) 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:

```bash
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:

```bash
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](https://aiscan.site/guides). The [capabilities checks reference](https://aiscan.site/docs/checks/capabilities) 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](https://aiscan.site/blog/markdown-for-ai-agents-astro).

