Build Log

Implementing an MCP Server for Real-Time AI News

Anthropic's Model Context Protocol (MCP) standardizes how AI agents communicate with external tools and datasets. It's an open standard that creates a unified way to give LLMs secure, stateful access to custom data sources. After reading the spec, I decided to implement a custom MCP Server on this site to drive the live AI News feed.

Here's how I wired up a frontend MCP Client using Server-Sent Events (SSE) directly to my Node.js backend, which acts as the MCP Server wrapping a Langflow pipeline.

The Goal: Standardized Tool Calling

I already had a SearXNG-powered Langflow flow running locally. This flow takes a query, fetches the latest AI news via SearXNG, synthesizes the results, and returns them as a JSON array. However, I didn't want to build a custom, one-off API endpoint to fetch this data.

By wrapping the Langflow execution inside an MCP Server, I gain a few significant advantages:

  • Future Proofing: Any AI agent or client that speaks MCP can now query my news feed by discovering the search_web tool automatically.
  • Standardized Schema: I no longer have to manually map complex API payloads. The MCP SDK handles JSON-RPC messaging and capabilities negotiation.
  • Stateful Connections: By using SSE for the transport layer, the connection remains alive, allowing bidirectional communication and streaming updates if needed later.

1. Setting up the MCP Server in Node.js

To build the server, I used the official @modelcontextprotocol/sdk package. The implementation lives inside my existing Express.js backend. The key is creating a new `Server` instance and registering the tools.

const { Server } = require('@modelcontextprotocol/sdk/server/index.js');

function createMcpServer() {
  const mcpServer = new Server({
    name: "rockyslabs-langflow",
    version: "1.0.0"
  }, { capabilities: { tools: {} } });

  mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
    return {
      tools: [{
        name: "search_web",
        description: "Search the web for real-time information using SearXNG.",
        inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }
      }]
    };
  });
  
  // Register the CallToolRequestSchema handler to execute the Langflow pipeline...
  
  return mcpServer;
}

2. Managing SSE Transports

MCP supports multiple transport layers. I opted for Server-Sent Events (SSE) because it works natively over standard HTTP without requiring WebSockets. The challenge with SSE is that it requires two endpoints: a GET endpoint to establish the stream, and a POST endpoint for the client to send messages back.

Because multiple users might hit the site simultaneously, the backend needs to maintain a map of active transports tied to unique session IDs.

const transports = new Map();

app.get('/mcp/sse', async (req, res) => {
  const transport = new SSEServerTransport('/mcp/messages', res);
  const mcpServer = createMcpServer();
  
  transports.set(transport.sessionId, transport);
  
  res.on('close', () => { transports.delete(transport.sessionId); });
  await mcpServer.connect(transport);
});

app.post('/mcp/messages', async (req, res) => {
  const sessionId = req.query.sessionId;
  const transport = transports.get(sessionId);
  if (transport) {
    await transport.handlePostMessage(req, res, req.body);
  }
});

One critical fix I discovered: Caddy's reverse_proxy directive can aggressively strip path prefixes or misroute traffic. To ensure the SSE stream stayed alive and routed correctly, I had to wrap the Caddy configuration in an explicit handle /mcp/* block.

3. The Vanilla JS Frontend Client

On the frontend, I skipped using a heavy SDK and wrote a lightweight, vanilla JS MCP client that interfaces directly with the SSE stream. It listens for the endpoint event to discover where to send POST requests, calls the tools/list endpoint to verify the tool exists, and then invokes tools/call.

Since Langflow flows can take anywhere from 5 to 25 seconds to scrape the web and run inference, the frontend uses a Promise-based request queue that waits patiently for the server to reply over the SSE connection.

Performance Note: Because live RAG generation is slow, the frontend aggressively caches the JSON result in localStorage for 1 hour. This means the 20-second wait only happens when you explicitly click "Refresh Signal", while normal page loads appear instant.

— Rakesh Ganesan
Rocky's Labs · 03 Jun 2026