DEV Community

Mathis Higuinen
Mathis Higuinen

Posted on

Building an MCP Server for Visa Requirements in TypeScript

What is MCP, and why should you care?

The Model Context Protocol (MCP) is an open standard created by Anthropic that lets AI agents connect to external tools and data sources through a unified interface. Think of it as USB-C for AI: one protocol, any agent. Instead of building custom integrations for Claude, GPT, Gemini, and every other model, you build one MCP server and every compatible agent can use it.

MCP defines a simple contract. Your server exposes tools (functions the AI can call) and resources (data the AI can read). The agent discovers what's available, reads the descriptions, and decides when to use them. The transport layer is pluggable -- stdio for local tools, SSE or HTTP for remote ones.

The protocol is gaining traction fast. Claude Desktop, Cursor, Windsurf, and dozens of other clients already support it. If you have an API that AI agents should be able to use, wrapping it in MCP is one of the highest-leverage things you can do right now.

Why visa requirements are perfect for MCP

Here's a problem every travel-focused AI agent has: visa requirements. Ask an LLM whether a Brazilian passport holder needs a visa for Japan, and you'll get an answer -- but it might be wrong. Models rely on training data that's months or years old, and visa policies change constantly. Thailand just launched a new 60-day visa exemption. Turkey updated its e-visa rules. The model doesn't know.

Visa data is structured, query-driven, and time-sensitive. It's exactly the kind of information that should come from a live API, not from parametric memory. An MCP server bridges that gap: the agent recognizes a visa question, calls the tool, and gets current data from a real database.

That's what we built with the Orizn Visa MCP Server -- a TypeScript MCP server backed by an API covering 39,585 passport-destination pairs in 15 languages.

Architecture overview

The stack is deliberately minimal:

  • Runtime: Node.js 18+
  • Language: TypeScript
  • MCP SDK: @modelcontextprotocol/sdk (v1.12+)
  • Transport: stdio (runs locally alongside the AI client)
  • Backend: Orizn Visa API at https://visa.orizn.app/api/v1/visa

The server exposes 5 tools and 2 resources:

Tools Description
check_visa_requirement Full visa details for a passport-destination pair
quick_visa_check Fast yes/no check (free, no API key)
get_all_destinations All destinations for one passport at once
get_visa_changes Recent visa policy updates
get_coverage_stats Database coverage statistics
Resources Description
visa://supported-languages The 15 supported language codes
visa://country-codes All 199 ISO3 country codes the API accepts

Code walkthrough

Server initialization

MCP servers start with a Server instance and a transport. We use StdioServerTransport, which communicates over stdin/stdout -- the standard for local MCP servers:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new Server(
  { name: "orizn-visa", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {} } }
);

const transport = new StdioServerTransport();
await server.connect(transport);
Enter fullscreen mode Exit fullscreen mode

You register capabilities upfront -- tools and resources -- so the client knows what to expect.

Defining tools

Tools are defined with a name, description, and JSON Schema for inputs. Here's the core visa check tool:

{
  name: "check_visa_requirement",
  description:
    "Check visa requirements between any two countries. Returns visa type " +
    "(visa-free, e-visa, visa required, etc.), allowed stay duration, " +
    "required documents, step-by-step application process, and travel tips. " +
    "Covers 39,585 passport-destination pairs in 15 languages. " +
    "Use this tool when the user asks about visa rules, entry requirements, " +
    "or whether they need a visa to visit a country.",
  inputSchema: {
    type: "object",
    properties: {
      passport: {
        type: "string",
        description: "ISO 3166-1 alpha-3 code of the passport (e.g. 'FRA').",
      },
      destination: {
        type: "string",
        description: "ISO 3166-1 alpha-3 code of the destination (e.g. 'JPN').",
      },
      lang: {
        type: "string",
        description: "Language code for the response. Defaults to 'en'.",
      },
    },
    required: ["passport", "destination"],
  },
}
Enter fullscreen mode Exit fullscreen mode

The tool handler validates inputs and calls the API:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args = {} } = request.params;

  switch (name) {
    case "check_visa_requirement": {
      const passport = validateISO3(args.passport, "passport");
      const destination = validateISO3(args.destination, "destination");
      const lang = validateLang(args.lang);
      const result = await apiFetch("", { passport, destination, lang }, apiKey, true);
      return {
        content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
      };
    }
    // ... other tools
  }
});
Enter fullscreen mode Exit fullscreen mode

Resources

Resources let the agent look up reference data without burning an API call. We expose two static resources -- supported languages and valid country codes:

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  switch (request.params.uri) {
    case "visa://supported-languages":
      return {
        contents: [{
          uri: request.params.uri,
          mimeType: "application/json",
          text: JSON.stringify(SUPPORTED_LANGUAGES_RESOURCE, null, 2),
        }],
      };
    case "visa://country-codes":
      return {
        contents: [{
          uri: request.params.uri,
          mimeType: "application/json",
          text: JSON.stringify(COUNTRY_CODES_RESOURCE, null, 2),
        }],
      };
  }
});
Enter fullscreen mode Exit fullscreen mode

This way, when the agent isn't sure of a country code, it can check the resource first instead of guessing.

API client with retry logic

The API client handles timeouts, retries on 5xx errors, and fails fast on 4xx:

async function apiFetch(path: string, params: Record<string, string>,
                        apiKey: string | undefined, requiresKey: boolean): Promise<unknown> {
  let lastError: Error | undefined;

  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);

    try {
      const response = await fetch(url.toString(), {
        method: "GET", headers, signal: controller.signal,
      });
      clearTimeout(timeout);

      if (response.ok) return await response.json();

      // Don't retry client errors -- the request itself is wrong
      if (response.status >= 400 && response.status < 500) {
        throw new McpError(ErrorCode.InvalidRequest,
          `API returned ${response.status}: ${await response.text()}`);
      }

      // 5xx: retry
      lastError = new Error(`API returned ${response.status}`);
    } catch (err) {
      clearTimeout(timeout);
      if (err instanceof McpError) throw err;
      lastError = err instanceof Error ? err : new Error(String(err));
    }
  }

  throw new McpError(ErrorCode.InternalError,
    `API request failed after ${MAX_RETRIES + 1} attempts: ${lastError?.message}`);
}
Enter fullscreen mode Exit fullscreen mode

The distinction between 4xx and 5xx is important. A 400 means the agent sent bad parameters -- retrying won't help. A 503 means the server hiccupped -- retrying might.

ISO3 validation

Country codes are validated against a hardcoded set of 199 codes before hitting the API. This catches the most common error pattern (the LLM sends "JP" instead of "JPN") at the MCP layer with a clear error message, rather than letting it propagate as a cryptic API error:

function validateISO3(code: unknown, paramName: string): string {
  const upper = (code as string).toUpperCase();
  if (!ISO3_COUNTRY_CODES.has(upper)) {
    throw new McpError(ErrorCode.InvalidParams,
      `"${paramName}" value "${code}" is not a valid ISO 3166-1 alpha-3 country code.`);
  }
  return upper;
}
Enter fullscreen mode Exit fullscreen mode

Key decisions and lessons learned

Tool descriptions are your most important code. The LLM reads them to decide when to invoke your tool. Vague descriptions mean the agent won't call your tool when it should. We explicitly state what each tool returns, what it covers (39,585 pairs, 15 languages), and when to use it versus alternatives. This isn't documentation -- it's prompt engineering.

stdout is sacred. In stdio transport, stdout carries MCP protocol messages. If you console.log() anything, you'll corrupt the protocol stream and crash the connection. All our logging goes to stderr via process.stderr.write(). This is the #1 mistake people make when building their first MCP server.

Free tier drives adoption. The quick_visa_check and get_coverage_stats tools work without an API key. This means anyone can npx orizn-visa-mcp and immediately test it. Zero friction. If they need full details, they add a key. But the first experience is instant.

Validate before you fetch. Catching "FR" vs "FRA" at the validation layer gives the agent a clear, actionable error ("not a valid ISO 3166-1 alpha-3 code") instead of a generic API 400. The agent can self-correct and retry with the right format.

Publishing and distribution

Building the server is half the work. Getting it in front of developers is the other half.

  1. npm publish -- This enables npx orizn-visa-mcp, the fastest path from discovery to running server. Set the bin field in package.json and add the shebang (#!/usr/bin/env node) to your entry point.

  2. MCP registries -- Submit to Smithery, mcp.so, and Glama. These are where developers browse for MCP servers. A smithery.yaml config file handles Smithery's hosted deployment.

  3. README as marketing -- Your README is your landing page. Include a clear one-liner, a feature table, installation instructions for every major client (Claude Desktop, Cursor, VS Code), and example outputs. Most developers decide whether to try your server in 30 seconds of scanning the README.

Try it

The server is open source and ready to use:

If you're building an AI agent that touches travel, immigration, or international logistics, plug this in and your agent gets reliable visa data instead of hallucinated guesses. And if you're building your own MCP server -- steal the patterns. The protocol is simple, the SDK is solid, and the ecosystem is growing fast.

Top comments (2)

Collapse
 
harjjotsinghh profile image
Harjot Singh

Visa requirements is a genuinely smart pick for an MCP server, and not just as a tutorial, because it's exactly the kind of domain where you must not let the model answer from memory: visa rules are precise, change frequently, and a confidently-wrong answer (you don't need a visa when you do) has real consequences at a border. Wrapping authoritative data behind MCP turns the model's job from recall (dangerous) into lookup-and-summarize (safe), so the answer is only as current as your data source. The USB-C-for-AI framing is the right pitch for the protocol, but the value here is specifically the grounding: one server, any agent, and crucially the agent stops hallucinating requirements because it's reading live rules instead of half-remembering training data. The thing I'd guard hardest is freshness, since stale visa data is its own confident-wrong failure, so the source-of-truth and its update cadence matter as much as the MCP wrapper. Ground the model in authoritative current data and the hallucination surface collapses. That turn-recall-into-grounded-lookup instinct is core to how I build agent tooling in Moonshift. Where's your visa data coming from, an official feed you can trust to stay current, or scraped sources you have to re-validate?

Collapse
 
mathis_higuinen_6db9b244c profile image
Mathis Higuinen

Hey Harjot this comment nails the exact reason I built this. The "turn recall into grounded lookup" framing is precisely the design thesis, and you're right that the freshness of the source is where the whole thing lives or dies. The MCP wrapper is the easy part; the data pipeline behind it is the actual product.
To answer your question directly: the data comes from 136 official government sources that we monitor continuously, not a one-time scrape. Every pair gets re-verified on a rolling basis and each API response carries a verified flag so the agent (and the developer) knows the record's status rather than trusting it blindly. We've tracked 4,250+ rule changes so far, and there's a dedicated /changes endpoint plus webhook alerts that surface policy updates usually within days of the source moving, not weeks.
That's the deliberate answer to the failure mode you flagged: stale visa data is its own confident-wrong, just slower and harder to notice. So freshness isn't a feature bolted on top it's the thing the whole architecture optimizes for. The 32 data points per pair (documents, process, embassy info, validity windows) are only worth exposing if they're current, otherwise you've just built a more detailed way to be wrong.
The honest hard part isn't any single official feed most governments don't publish clean machine-readable visa policy. It's the reconciliation layer: normalizing 136 inconsistent sources across 199 jurisdictions into one schema, in 15 languages, and flagging conflicts for re-check instead of silently picking one. That's where most of the engineering actually goes.
Curious what you're building at Moonshift sounds like we share the same instinct about grounding being the real moat in agent tooling.