hldays

Building hldays #8: Making an API agent-friendly with MCP

Questions like "is Friday a working day in Braga?" or "what's 10 business days from today in Porto?" are exactly what people ask an AI assistant. The assistant usually has no idea about municipal holidays, so it guesses. I wanted hldays to be the thing it asks instead.

That's why the API has an MCP server. This post is about how it works and the small design decisions that make it usable by an agent, not just by a developer reading docs.

My bet is that more and more of these questions will go to an assistant rather than into someone's code, and an API that only speaks REST would miss them.

What MCP is, briefly

The Model Context Protocol is a standard way for an AI client (Claude Code, Claude Desktop, other agents) to discover and call tools on a server. The client asks the server for its tool list, each tool has a name, a description and a JSON Schema for its arguments, and the model decides when to call which tool.

hldays serves MCP at POST /v1/mcp. It's the Streamable HTTP transport, stateless, JSON responses only: no sessions, no server-sent events. That fits a Cloudflare Worker well, because every request is independent anyway.

The tools

There are seven, all read-only:

  • list_countries: supported countries with coverage statistics
  • find_jurisdiction: search a place by name, returns ids
  • get_holidays: all holidays that apply in a place for a year, inherited ones included
  • holidays_on_date: which places in a country have a holiday on a date
  • is_business_day: whether a date is a working day in a place
  • add_business_days: add or subtract business days, skipping weekends and holidays
  • get_observances: religious and cultural observances for a date or a year

Each one is a few lines in src/mcp.ts. Here's is_business_day:

{
  name: "is_business_day",
  description: "Whether a date is a business day in a jurisdiction (not a weekend or holiday), and which holidays fall on it.",
  inputSchema: {
    type: "object",
    properties: { jurisdiction: jurisdictionArg, date: str("Date YYYY-MM-DD."), country: countryArg, types: typesArg },
    required: ["jurisdiction", "date"],
  },
  path: (a) => `/v1/business-day?${qs({ jurisdiction: a.jurisdiction, date: a.date, country: a.country, types: a.types, include_sources: false })}`,
},

The interesting part is the last line. A tool doesn't contain any holiday logic. It just turns its arguments into a REST path.

Tools call the REST API, not the engine

When a tools/call comes in, the MCP handler builds the path and calls the app itself with app.fetch, forwarding a short list of headers from the original request:

const FORWARD = ["X-RapidAPI-Proxy-Secret", "X-RapidAPI-Subscription", "CF-Connecting-IP", "User-Agent"];
app.post("/v1/mcp", (c) =>
  handleMcp(c.req.raw, (path) => {
    const headers = new Headers();
    for (const h of FORWARD) {
      const v = c.req.header(h);
      if (v) headers.set(h, v);
    }
    return app.fetch(new Request(new URL(path, c.req.url), { headers }), c.env);
  }),
);

This is in-process: no network hop, the Worker just routes a new Request through its own Hono app. But that request goes through the same middleware as any other /v1/* call, so:

  • Auth is the same. A wrong RapidAPI proxy secret gets a 401 on MCP just like on REST.
  • Plans are the same. A free caller asking get_holidays for Braga gets national holidays and a notice, not Braga's own holiday.
  • Rate limits are the same. The MCP request itself isn't counted, but each REST call a tool makes is, keyed on the caller's forwarded IP.

The alternative was to call the core engine directly from each tool. It would have been slightly faster, but I'd have had to copy plan gating, parameter validation and error handling into a second place, and sooner or later the two would drift. With this design the MCP server is about 175 lines and has no opinion of its own about holidays.

The one thing to remember: when a REST route starts reading a new header, it has to be added to FORWARD, or MCP callers silently lose it. There's a note about that in the repo's working notes, because it's the kind of bug a test wouldn't catch until it happens.

Designing for a model, not a person

A developer reads the docs once. A model reads the tool descriptions every time, and that's all it knows. A few choices came out of that.

Names in, ids out

Every tool that takes a place accepts either an id or a name:

const jurisdictionArg = str("Jurisdiction id (e.g. PT-M-1106) or name (e.g. Lisboa, Porto). Use find_jurisdiction if unsure.");

A model will happily pass "Porto" and that works: names resolve to the most specific level, so "Porto" is the municipality, not the district. The whole rule is one sort in src/core/calendar.ts:

const LEVEL_RANK: Record<Level, number> = { municipality: 0, subdivision: 1, country: 2 };

resolve(ref: string, country?: string): Jurisdiction | undefined {
  const byId = this.byId.get(ref.toUpperCase());
  if (byId) return byId;
  const candidates = (this.bySlug.get(slugify(ref)) ?? []).filter(
    (j) => !country || j.country === country.toUpperCase(),
  );
  return candidates.sort((a, b) => LEVEL_RANK[a.level] - LEVEL_RANK[b.level])[0];
}

Ids win first, then the slugified name, so a model that passes pt-m-0303 in lower case still lands on Braga. Preferring the municipality is a guess about intent: someone asking about "Porto" is usually asking about the city. But names are ambiguous (Portugal has two Lagoas and two Calhetas), so the description points to find_jurisdiction "if unsure", and that tool's description says it "returns ids usable in other tools". The search is accent-insensitive, so "evora" finds Évora.

Here's the live result for "braga", trimmed to the first two:

{"data":[
  {"id":"PT-M-0303","name":"Braga","level":"municipality","country":"PT","parent":"PT-03"},
  {"id":"PT-03","name":"Braga","level":"subdivision","kind":"district","country":"PT","parent":"PT"}
]}

The municipality and the district share a name. With the level field next to each id, the model can pick the right one, or ask.

Compact by default

On REST, holidays come with their source: URL, quote, date checked. That's the whole point of hldays, but it's also a lot of text in a model's context window for a question like "is the 24th a working day?". So the MCP tools send include_sources=false unless the model asks, and the argument says what it costs:

const includeSourcesArg = { type: "boolean", description: "Include the official source and quote for each holiday. Default false (compact)." };

Errors the model can fix

If a tool call fails because of bad input, the API's error goes back as a tool result with isError: true, not as a protocol error:

// API errors (bad date, unknown place...) go back to the model as tool errors so it can correct itself.
return rpcResult(id, { content: [{ type: "text", text: body }], isError: !res.ok });

The body is the same JSON a REST client gets, with a stable code and a readable message:

{"error":{"code":"unknown_jurisdiction","message":"No jurisdiction matches 'atlantis'"}}

In practice a model reads that, calls find_jurisdiction, and tries again. A JSON-RPC error would more likely end the turn with "the tool failed".

Say when the answer is incomplete

This is the one I care about most. On the free plan, municipal holidays are hidden. So for Braga on 24 June 2026, which is Braga's municipal holiday (São João), the free plan answers:

{
  "jurisdiction": {"id":"PT-M-0303","name":"Braga","level":"municipality","country":"PT","parent":"PT-03"},
  "date": "2026-06-24",
  "business_day": true,
  "holidays": [],
  "observances": [],
  "notice": {
    "code": "municipal_requires_paid_plan",
    "message": "Municipal holidays are not included on the free plan. To include them, subscribe to a paid plan: https://rapidapi.com/...",
    "upgrade_url": "https://rapidapi.com/..."
  }
}

business_day: true is wrong for anyone who lives there. Without the notice, an agent would pass that on with confidence. With it, the model can say "it's not a national holiday, but municipal holidays aren't included on this plan". The server's instructions tell it to look for exactly that:

Responses may include a notice when the caller's plan excludes municipal holidays.

On a paid plan the same call returns business_day: false and the holiday, with status: "verified".

The tool descriptions also say up front which parts need a paid plan ("Municipal holidays require a paid plan", "Requires a paid plan" on get_observances), so the model isn't surprised by a 403.

Small things

  • Every tool is annotated readOnlyHint: true, so clients know nothing here changes state.
  • types and traditions are enums in the schema, not free text, so the model can't invent a type.
  • add_business_days says "The start date is not counted", because off-by-one is the first thing anyone gets wrong with business days.

Connecting it

With a RapidAPI key, in Claude Code:

claude mcp add --transport http hldays https://<rapidapi-host>/v1/mcp \
  --header "X-RapidAPI-Key: <your key>" --header "X-RapidAPI-Host: <rapidapi-host>"

Without a key, https://api.hldays.dev/v1/mcp also works, on the free plan and its rate limit, the same as direct REST calls.

Testing it

The MCP tests in test/mcp.test.ts use app.request like the REST tests: initialize and version negotiation, tools/list matching the TOOLS array, every tool called once, plan gating through a BASIC header, and a wrong secret getting a 401.

The one I like most checks the header forwarding end to end. A direct caller (no RapidAPI secret) claims to be on the PRO plan and calls get_holidays through MCP:

const keys: string[] = [];
const env = { RAPIDAPI_PROXY_SECRET: "s3cret", ANON_LIMITER: { limit: async ({ key }: { key: string }) => (keys.push(key), { success: true }) } };
const headers = { "CF-Connecting-IP": "203.0.113.7", "X-RapidAPI-Subscription": "PRO" };
// ... tools/call get_holidays { jurisdiction: "lisboa", year: 2026 } ...
expect(data.data.map((x: any) => x.date)).not.toContain("2026-06-13");
expect(data.notice.code).toBe("municipal_requires_paid_plan");
expect(keys).toEqual(["203.0.113.7"]);

Three things are checked at once: the fake plan header is ignored (Lisbon's 13 June holiday stays hidden), the notice comes through the tool result, and the rate limiter was called exactly once, with the caller's IP rather than the Worker's own. If CF-Connecting-IP ever dropped out of FORWARD, every MCP user would share one rate-limit bucket, and this test would catch it. When I first added the server I also checked it with the official MCP TypeScript SDK client, to be sure a real client could talk to it.

Limitations

  • The server's instructions string said "currently Portugal: all 308 municipalities" until I wrote this post; there are 6 countries now. Hard-coded coverage in a prompt goes stale silently, so it now just tells the model to call list_countries, which reads the live data.
  • It's tools only: no MCP resources or prompts. I haven't found a case where they'd help yet.
  • No OAuth. Auth is the RapidAPI key in headers, which works in Claude Code and other clients that let you set headers, but not in every MCP client.

I also haven't seen real MCP traffic yet. So far the only agent using it is mine, in Claude Code. If you connect it to another client, I'd like to hear how it goes.

Have you exposed an API over MCP? I'm curious whether you wrapped your REST routes like this or wrote tools against your core logic directly.

Next in the series: the architecture, TypeScript on Cloudflare Workers, and how the API is served.

hldays.dev · Docs