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);
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"],
},
}
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
}
});
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),
}],
};
}
});
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}`);
}
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;
}
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.
npm publish -- This enables
npx orizn-visa-mcp, the fastest path from discovery to running server. Set thebinfield inpackage.jsonand add the shebang (#!/usr/bin/env node) to your entry point.MCP registries -- Submit to Smithery, mcp.so, and Glama. These are where developers browse for MCP servers. A
smithery.yamlconfig file handles Smithery's hosted deployment.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:
- GitHub: github.com/MattJeff/orizn-mcp-server
- npm: npmjs.com/package/orizn-visa-mcp
-
Run it:
npx orizn-visa-mcp - API docs: visa.orizn.app
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)
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?
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.