Model Context Protocol (MCP) - A Closer Look

In my previous post, I provided a high-level overview of the Model Context Protocol (MCP), an open standard developed by Anthropic to standardize interactions between large language models (LLMs) and external data sources and tools. We explored MCP’s core components, including MCP hosts, clients, and servers, and discussed how MCP facilitates integration, enabling AI applications to access and utilize external resources.
This follow-up aims to help you set up your own MCP Server and also discuss the progress, challenges, and issues with the MCP specification. The main motivation was the surprising lack of solid SSE transport examples in the spec and SDK docs. This post offers a working pattern for wiring up tools
, using a couple of toy examples to keep it simple. I haven’t explored prompts
or resources
yet (still unsure about their intended usage or how useful they are), but the integration pattern should be similar.
I assume you know how to use an LLM to call tools without MCP. You should still be able to follow most of this post, but if you're unsure about LLM tool-calling, I recommend looking up how to do it after.
The reference implementation is located at:
https://github.com/rodocite/mcp-node-sse-reference
MCP Server Reference Implementation
Core Implementation
The implementation consists of three primary components:
MCP Server: Using the MCP SDK, manages tool registration and execution
SSE Transport Layer: Facilitates communication between clients and server
Tool Registry: Provides a light abstraction for registering tools
There are general NodeJS implementation details that I go over in the readme if you want to dig into the implementation a bit more, but they’re not specific to MCP.
MCP Server
Here, we just initialize the server and run the tool registration.
// File: src/mcp/server.ts
const mcpServer = new McpServer({
name: "example-server",
version: "1.0.0",
});
export function initializeMcpServer(): void {
registerTools(mcpServer);
console.log("MCP Server initialized");
}
SSE Transport Layer
Connection Establishment
The /sse endpoint sets up the initial SSE connection, receives headers, and creates the session. The system begins by creating a transport and assigning it a unique session ID. It then registers a cleanup handler in case the client disconnects. After that, the transport is connected to the MCP server. Once everything’s set up, a confirmation message is sent back to the client to let the client know that the server is ready to send and receive messages.
// File: src/routes/sse.ts
export async function sseConnectionHandler(req, res): Promise<void> {
const transport = createSseTransport("/messages", res);
// Handle client disconnection
res.on("close", async () => {
removeTransport(transport.sessionId);
console.log(`Client disconnected: ${transport.sessionId}`);
});
try {
await getMcpServer().connect(transport);
await transport.send({
jsonrpc: "2.0",
method: "sse/connection",
params: { message: "SSE Connection established" },
});
} catch (err) {
console.error("Error connecting to server:", err);
if (!res.writableEnded) {
res.writeHead(500).end("Error connecting to server");
}
}
}
Message Handling
SSE is a unidirectional messaging transport. So after the connection is setup, we need a way to send messages from the Client → Server. We use a POST /messages* endpoint for that.
Once connected, subsequent communication flows through the /messages* endpoint.
// File: src/routes/sse.ts
export async function sseMessagesHandler(
req, res, url
): Promise<void> {
const urlObj = url.startsWith("http")
? new URL(url)
: new URL(url, `http://${req.headers.host || "localhost"}`);
const sessionId = urlObj.searchParams.get("sessionId");
if (!sessionId) {
res.writeHead(400).end("No sessionId");
return;
}
const activeTransport = getTransport(sessionId);
if (!activeTransport) {
res.writeHead(400).end("No active transport");
return;
}
try {
await activeTransport.handlePostMessage(req, res);
} catch (err) {
console.error("Error handling message:", err);
if (!res.writableEnded) {
res.writeHead(500).end("Internal server error");
}
}
}
Transport Management
Not too important to know in context of this post. This is just a light abstraction to show how you could start to manage multiple transport sessions connecting to the MCP Server.
// File: src/mcp/transport.ts
// Store active transports by sessionId
const activeTransports: Record<string, SSEServerTransport> = {};
export function createSseTransport(
path: string,
res: ServerResponse
): SSEServerTransport {
const transport = new SSEServerTransport(path, res);
activeTransports[transport.sessionId] = transport;
return transport;
}
export function getTransport(
sessionId: string
): SSEServerTransport | undefined {
return activeTransports[sessionId];
}
export function removeTransport(sessionId: string): void {
delete activeTransports[sessionId];
}
Tool Registry
Simple tool registration abstraction
// File: src/mcp/tools/index.ts
export function registerTools(server: McpServer): void {
for (const tool of tools) {
server.tool(tool.name, tool.description, tool.schema, tool.handler);
console.log(`Registered tool: ${tool.name}`);
}
}
Tool Schema
Tools in src/mcp/tools/ are declared using the following schema. If you want to try adding a new tool, create a new file for it and add it to src/mcp/tools/index.ts
// File: src/mcp/tools/calculator.ts
export const calculatorTool: McpTool = {
// Unique identifier that the LLM will use to route to the tool
name: "calculator",
// Human readable description that should be descriptive
// The LLM will also use this to determine whether the tool should be used
description: "Performs basic math operations",
// Schema maps the inputs to be used for the tool
// MCP SDK forces you to use Zod for validations
schema: {
operation: z.enum(["add", "subtract", "multiply", "divide"]),
a: z.number(),
b: z.number(),
},
handler: async (args: Record<string, any>, _extra: any) => {
// Implementation logic
return {
content: [{ type: "text", text: "Result..." }]
};
}
};
Running the MCP Server
Running the MCP Server via npm run dev should get you the following output:
mcp-node-sse-reference git:(main) npm run dev
> node-mcp-sse-server-reference@0.1.0 dev
> tsx watch src/index.ts
Registered tool: calculator
Registered tool: weather
MCP Server initialized
Server initialized and routes configured
Server running at http://localhost:3001
You should be able to register the MCP Server on any MCP-compatible Host or Client running on your local machine at this point. I recommend using the MCP Inspector to play around with the server.
Or even adding it to Cursor or your other favorite AI-native IDE and getting the agent to call the tools:
MCP Host & Client
Though the MCP Inspector and Cursor are MCP Hosts that embed MCP Clients, for the sake of completeness, let’s quickly go over what the MCP code for a Host and Client looks like:
/**
* MCP Reference Host->Client
*
*/
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse";
import { URL } from "url";
import { Client } from "@modelcontextprotocol/sdk/client/index";
const MCP_SERVER_URL = "http://localhost:3001";
/**
* Host application entry point
*/
async function main() {
console.log(`Initializing MCP client for server: ${MCP_SERVER_URL}`);
// Initialize MCP client
const client = new Client(
{
name: "mcp-node-sse-server-reference",
version: "1.0.0",
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
},
}
);
try {
console.log("Connecting to MCP server...");
// Create a basic transport
const transport = new SSEClientTransport(new URL(`${MCP_SERVER_URL}/sse`));
await client.connect(transport);
console.log("Connected to MCP server successfully");
// List available tools
const tools = await client.listTools();
console.log(`Available tools: ${JSON.stringify(tools, null, 2)}`);
console.log("\n🎉 MCP client initialized and ready for interaction");
} catch (error) {
console.error("Failed to connect to MCP server:", error);
process.exit(1);
}
}
// Run the application
main().catch((error) => {
console.error("Unhandled error:", error);
process.exit(1);
});
This code illustrates a Host provisioning an MCP Client which connects to an MCP Server without any authentication using the MCP typescript-sdk.
Running the above code will get you an output that looks like this:
Initializing MCP client for server: http://localhost:3001
Connecting to MCP server...
Connected to MCP server successfully
Available tools: {
"tools": [
{
"name": "calculator",
"description": "Calculator tool - performs basic math operations",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": [
"add",
"subtract",
"multiply",
"divide"
]
},
"a": {
"type": "number"
},
"b": {
"type": "number"
}
},
"required": [
"operation",
"a",
"b"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "weather",
"description": "Weather tool - gets the weather for a given location",
"inputSchema": {
"type": "object",
"properties": {
"location": {
"type": "string"
}
},
"required": [
"location"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
]
}
🎉 MCP client initialized and ready for interaction
Note: I’m only logging the tools here, but that list should be injected to your LLM’s system-prompt so your Agent knows how to route your tools based on what you’re asking it to do and what arguments are needed to call it. The description of the tool is particularly important for telling your LLM when to use the tool as well as the inputSchema.
That’s it! For local projects, you should now be able to start experimenting with MCP and developing your own patterns.
Challenges and Observations
Since the specification is still evolving, I wanted to take a moment to share some challenges I faced along the way and discuss my observations.
Protocol Ambiguity and Early Implementation Noise
Particularly around the authN and authZ part of the spec, there is a lot of discussion around how it should be done. See:
https://github.com/modelcontextprotocol/specification/issues/205
https://github.com/modelcontextprotocol/specification/issues/195
While the MCP spec intentionally leaves room for flexibility, much of the tooling I’ve seen builds opinionated infrastructure on top of ambiguous areas rather than clarifying them. Sidecar patterns and early open-source frameworks have emerged prematurely, creating noise at a time when the community would benefit more from focused spec discussion. It often feels like framework opportunism is outpacing protocol maturity.
Aaron Parecki’s recent blog post, Let’s fix OAuth in MCP makes a strong case for reinforcing the idea that cleaner boundaries through the spec and thoughtful discussions, not bundled frameworks, is what should guide implementation at this stage.
Current Lack of DevRel and Governance
https://modelcontextprotocol.io/development/roadmap#governance
Governance structures are beginning to take shape, with a focus on community-led development and transparent standardization. However, the day-to-day experience still feels under-supported. There is limited roadmap visibility and no clear DevRel presence to guide implementers through ambiguity.
These early efforts at formal governance and activity in Github Discussions are encouraging, but early adopters are left improvising around core questions that should be settled collaboratively.
SSE Limitations
Server-Sent Events (SSE) was the transport chosen for my reference implementation due the lack of SSE examples, but it introduces several limitations:
Unidirectionality
SSE only supports server → client communication. This required a custom workaround using a POST /messages* endpoint
to allow client → server messaging.
Lack of header support post-handshake
After the initial connection is established, SSE does not support sending additional HTTP headers. This creates friction for workflows that require mid-stream re-authentication or token refresh, since those typically depend on headers.
Compatibility Concerns
Many tools and environments may not have first-class support for SSE, making it an awkward default for modern HTTP systems that lean toward WebSockets.
Sloppy SDK Implementations and Examples
The official SDKs feels hastily assembled. Key responsibilities like session management, tool invocation, and even basic transport handling are scattered or incomplete. Rather than enabling clean integrations, it often introduces friction forcing developers to reverse-engineer the intended flow or bypass the SDK altogether.
The examples in the spec docs prioritize local STDIO-based setups, which aren’t representative of how most developers will deploy MCP in real-world. This focus leaves gaps in guidance for the more common and scalable remote integration patterns.
Subscribe to my newsletter
Read articles from Rodolfo Yabut directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
