DEV Community

Yonyon
Yonyon

Posted on

How I made my website fully agent-readable: an MCP server + NLWeb /ask in Next.js

How I made my website fully agent-readable: an MCP server + NLWeb /ask in Next.js

Most websites are built for humans with browsers. Increasingly, the visitor is an AI agent — Claude, a custom assistant, some autonomous tool — and it doesn't want your hero animation. It wants structured, machine-readable answers and actions it can take.

I rebuilt my own site, yonyon.ai, to be readable and usable by agents end to end: a live /ask endpoint (Microsoft's NLWeb shape), a real MCP server at /mcp, in-browser WebMCP tools, plus llms.txt and a .well-known/ discovery tree. This post is the worked example — the three pieces that did the heavy lifting, with the actual code.

The trap: locale routing eats your agent endpoints

My site is internationalized with next-intl. Its middleware matches every path and routes it into a [locale] segment. So the moment I added a bare /ask route for agents, the i18n middleware grabbed it first and tried to render /[locale]/ask404. Same problem would hit /mcp.

Agents expect bare, unprefixed paths (/ask, /mcp) — not /en/ask. The fix is to intercept those paths in the proxy/middleware, before the i18n matcher runs, and rewrite them onto the real API route. rewrite() (not redirect()) preserves the HTTP method and body, so a POST /ask stays a POST:

// src/proxy.ts  (next-intl middleware entrypoint)
import createMiddleware from "next-intl/middleware";
import { NextResponse, type NextRequest } from "next/server";
import { routing } from "./i18n/routing";

const intlMiddleware = createMiddleware(routing);

export default function proxy(request: NextRequest) {
  // Rewrite the bare /ask onto the /api/ask route handler BEFORE next-intl
  // pulls it into the [locale] tree (which would 404). rewrite() keeps the
  // method + body, so POST /ask works.
  if (request.nextUrl.pathname === "/ask") {
    return NextResponse.rewrite(new URL("/api/ask", request.url));
  }
  return intlMiddleware(request) as NextResponse;
}

export const config = {
  // Skip /api, /_next, static files, etc. — only run on page-ish paths.
  matcher: "/((?!api|studio|trpc|_next|_vercel|.*\\..*).*)",
};
Enter fullscreen mode Exit fullscreen mode

That's the whole alias trick: bare agent paths are matched first and rewritten into /api/*, so they never reach the locale router. Add a line per endpoint you want to expose unprefixed.

A dependency-free MCP server

The Model Context Protocol is how agents discover and call your tools. The official SDK is great — but it peer-depends on zod 3, and my repo is on zod 4. Rather than fight the dependency tree, I hand-rolled the handler. MCP's Streamable-HTTP transport is just JSON-RPC 2.0 over POST, and I only needed a handful of methods: initialize, tools/list, tools/call, ping. Zero dependency risk:

// src/app/api/mcp/route.ts
export const dynamic = "force-dynamic";

const SERVER_INFO = { name: "yonyon.ai", version: "1.0.0" };
const PROTOCOL_VERSION = "2025-06-18";

const TOOLS = [
  {
    name: "ask_yonatan",
    description: "Ask about Yonatan Gross's work, projects, or experience.",
    inputSchema: {
      type: "object",
      properties: { question: { type: "string", maxLength: 2000 } },
      required: ["question"],
      additionalProperties: false,
    },
    // Behavioural hints so agents can reason about side effects:
    annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
  },
  // browse_projects, book_intro_call ...
];

const rpc = (id, result) => Response.json({ jsonrpc: "2.0", id, result });
const rpcError = (id, code, message) =>
  Response.json({ jsonrpc: "2.0", id, error: { code, message } });

export async function POST(req: Request) {
  const { id, method, params } = await req.json();

  if (method === "initialize")
    return rpc(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO });

  if (method === "tools/list") return rpc(id, { tools: TOOLS });

  if (method === "tools/call") {
    const { name, arguments: args } = params;
    if (!TOOLS.some((t) => t.name === name))
      return rpcError(id, -32602, `Unknown tool: ${name}`); // structured error, not soft text
    return rpc(id, await callTool(name, args));
  }

  if (method === "ping") return rpc(id, {});
  return rpcError(id ?? null, -32601, `Method not found: ${method}`);
}
Enter fullscreen mode Exit fullscreen mode

Two details that matter for agent ergonomics:

  • Tool annotations (readOnlyHint, destructiveHint, openWorldHint) let a planner decide whether a tool is safe to call autonomously.
  • An unknown tool returns a structured JSON-RPC error (code + message), not a friendly text blob — so agents branch on it programmatically instead of string-matching.

Discovery: llms.txt + .well-known

Endpoints are useless if nothing can find them. Two conventions cover it:

/llms.txt — a human- and agent-readable map of the site, modeled on robots.txt but for LLMs: who you are, what an agent can do here, and the exact endpoints:

# yonyon.ai — Yonatan Gross

> AI Platform Engineer & Backend Developer. Builds production AI systems
> end-to-end: RAG pipelines, multi-agent architectures, MCP servers.

## What an agent can do here
- Ask about my work — POST /ask with {"query":"..."}  (NLWeb, no auth)
- Browse projects, book a free 15-min intro call
- MCP server at /mcp (initialize, tools/list, tools/call)
Enter fullscreen mode Exit fullscreen mode

The .well-known/ tree carries the machine-readable specs:

public/.well-known/
├── mcp.json                 # points agents at /mcp
├── mcp/server-card.json     # MCP server identity card
├── openapi.json             # OpenAPI for the HTTP endpoints
├── api-catalog              # RFC 9727 linkset of all APIs
└── agent-skills/            # discrete "skills" (ask, browse, book) as SKILL.md
Enter fullscreen mode Exit fullscreen mode

Because these live in public/, they're served as plain static files — bypassing the locale router entirely, so there's no dotpath/404 issue. I also emit RFC 8288 Link headers (rel="describedby" → llms.txt, rel="service-desc" → openapi.json) so an agent that only fetches headers still discovers everything.

The payoff

You can verify it live:

curl -s https://yonyon.ai/ask \
  -H 'content-type: application/json' \
  -d '{"query":"What does Yonatan build?"}'
Enter fullscreen mode Exit fullscreen mode

That single-shot, no-auth, agent-facing endpoint returns a grounded answer from the same RAG-backed model that powers the site chat. An agent can then call the MCP book_intro_call tool and act.

Building agent-ready surfaces is also just good practice for the corpus AI models train on — the more structured and citable your presence, the more likely a model recalls your work. I package these patterns (and a lot more) as OrchestKit, my open-source Claude Code agent framework — 111 skills, 37 agents, 211 hooks.

If you're making something agent-readable and want a second pair of eyes, the front door at yonyon.ai is itself the demo — ask it a question, or grab a free 15-minute intro call.


Top comments (0)