English

Before MCP, every LLM app that needed to talk to Jira, Postgres, or an internal API wrote its own bespoke integration - a slightly different function-calling schema, a slightly different auth flow, repeated for every client and every tool. MCP standardizes that boundary: one protocol for how an LLM application discovers what a tool server offers and calls it, so a tool server built once works with any MCP-compatible client. It's a small, boring-in-a-good-way spec, and that's exactly why it's spreading fast.

The Client/Server Model

MCP splits responsibilities cleanly:

  • MCP servers expose capabilities - tools, resources, and prompts - over a standard JSON-RPC interface
  • MCP clients (embedded inside an LLM application - Claude Desktop, an IDE, a custom agent runtime) connect to one or more servers, list what they offer, and let the model invoke them
  • Transport is typically stdio for local servers or HTTP with server-sent events for remote ones
┌─────────────┐        JSON-RPC over stdio/HTTP        ┌──────────────┐
│  LLM Client │ ──────────────────────────────────────▶│  MCP Server  │
│ (agent app) │◀────────────────────────────────────── │ (your tools) │
└─────────────┘                                         └──────────────┘

The client never hardcodes what a server can do - it calls tools/list, resources/list, and prompts/list at connection time and builds its available capability set dynamically. That's the whole trick: add a tool to the server, and every client that connects to it sees it immediately, no client-side redeploy required.

Tools vs. Resources vs. Prompts

These three primitives get conflated constantly, but they serve distinct purposes:

  • Tools - actions with side effects or computation, invoked by the model (create_ticket, run_query, send_email)
  • Resources - read-only data the client can fetch and inject into context (a file's contents, a database schema, a config document) - think of these as addressable context, not actions
  • Prompts - reusable, parameterized prompt templates the server exposes so multiple clients don't reinvent the same instructions

A common mistake is exposing everything as a tool. A file's contents should usually be a resource - the client fetches it once and puts it in context, rather than round-tripping a "read_file" tool call through the model for every reference.

Exposing an Internal API as an MCP Tool

Here's a minimal MCP server wrapping an internal ticketing API, written with the Python SDK:

from mcp.server.fastmcp import FastMCP
import httpx

mcp = FastMCP("ticketing-tools")

@mcp.tool()
async def create_ticket(title: str, description: str, priority: str = "normal") -> dict:
    """Create a support ticket in the internal ticketing system."""
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://ticketing.internal/api/tickets",
            json={"title": title, "description": description, "priority": priority},
            headers={"Authorization": f"Bearer {get_service_token()}"},
        )
        response.raise_for_status()
        return response.json()

@mcp.resource("ticket://{ticket_id}")
async def get_ticket(ticket_id: str) -> str:
    """Fetch a ticket's current state as a readable resource."""
    async with httpx.AsyncClient() as client:
        response = await client.get(f"https://ticketing.internal/api/tickets/{ticket_id}")
        return response.json()

if __name__ == "__main__":
    mcp.run(transport="stdio")

Notice the docstrings aren't decoration - the client reads the tool and resource descriptions directly to decide when to use them, the same way a Bedrock action group or an OpenAI function schema relies on its description field. Vague descriptions produce vague tool selection regardless of which protocol you're using underneath.

Wiring a Client to Multiple Servers

An agent runtime typically connects to several MCP servers at once - one for internal tools, one for a shared filesystem, one for a database - and merges their exposed tools into a single list the model sees.

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

const ticketingClient = new Client({ name: 'agent-runtime', version: '1.0.0' });
await ticketingClient.connect(
  new StdioClientTransport({ command: 'python', args: ['ticketing_server.py'] }),
);

const dbClient = new Client({ name: 'agent-runtime', version: '1.0.0' });
await dbClient.connect(
  new StdioClientTransport({ command: 'node', args: ['postgres_server.js'] }),
);

const [ticketingTools, dbTools] = await Promise.all([
  ticketingClient.listTools(),
  dbClient.listTools(),
]);

const allTools = [...ticketingTools.tools, ...dbTools.tools];
// pass allTools into your model's tool-calling request as usual

Security and Permission Boundaries

MCP makes tool discovery trivial, which means the security question shifts from "how do we wire this up" to "what should this agent actually be allowed to touch." A few boundaries we enforce in every deployment:

  • Per-server scoping - a server only exposes tools relevant to its domain; don't build one mega-server with database writes, email sending, and file deletion all in the same process
  • Human confirmation for destructive tools - the client layer, not the server, should require explicit approval before invoking anything irreversible (deletes, payments, external sends)
  • Least-privilege service tokens - the credentials an MCP server uses to call the underlying API should be scoped tighter than a human operator's, since the model is the one deciding when to use them
  • Input validation at the server boundary - never trust that the model produced well-formed arguments; validate against the tool's schema before executing
@mcp.tool()
async def delete_record(record_id: str) -> dict:
    """Delete a record. Requires explicit human confirmation upstream."""
    if not is_confirmed_by_operator(record_id):
        raise PermissionError("Deletion requires operator confirmation")
    return await perform_delete(record_id)

The protocol itself doesn't enforce any of this - MCP standardizes discovery and invocation, not authorization policy. That part is still entirely your responsibility to build around it.

Why This Matters More Than It Looks

Before a standard like this, "give the agent access to our internal systems" meant a custom integration per client framework - one for LangChain, one for a custom Python agent, one for whatever the next framework turns out to be. MCP servers are framework-agnostic by design: build ticketing_server.py once, and it works whether the client is Claude Desktop, a custom TypeScript agent runtime, or an internal CLI tool your team writes next quarter.

That reusability is the actual payoff. Not a smarter model, not a cleverer prompt - just fewer integrations to maintain as the number of tools and the number of client applications both grow independently of each other.

Posted onaiwith tags:
0
0
0
0