Dev.to AI ๐Ÿค– Ai ๐Ÿ‘ 0 ๐Ÿ“– 4 min read

Fetching Data from an MCP Server in Your Frontend

MCP servers are usually discussed as tools for AI assistants, but the same servers can feed your web app directly. A dashboard can call the same search_orders tool your agent uses, so both get one consistent data layer.

Fetching Data from an MCP Server in Your Frontend

MCP servers are usually discussed as tools for AI assistants, but the same servers can feed your web app directly. A dashboard can call the same search_orders tool your agent uses, so both get one consistent data layer. This article shows how a frontend gets that data and what to watch for.

Two ways to connect

Frontend to MCP server architecture

Option 1: an MCP client SDK in the browser. The official TypeScript SDK (@modelcontextprotocol/sdk) can run in the browser and talk to a remote server over Streamable HTTP. It's quick for demos and internal tools, but the browser then needs a token for the MCP server and the server must allow CORS.

Option 2: a thin backend proxy (recommended). Your backend runs the MCP client and exposes ordinary endpoints like /api/tools/search_orders. The frontend never sees MCP credentials, you check the user's session before every call, and you can allow only specific tools.

The JSON-RPC message format

MCP uses JSON-RPC 2.0. After an initialize handshake, the two calls a frontend cares about most look like this.

Calling a tool:

{ "jsonrpc": "2.0", "id": 7, "method": "tools/call",
  "params": { "name": "search_orders", "arguments": { "customer": "Acme" } } }

A successful result:

{ "jsonrpc": "2.0", "id": 7,
  "result": { "content": [ { "type": "text", "text": "[{\"id\":101,\"total\":250}]" } ],
              "isError": false } }

Reading a resource uses resources/read with a URI, such as { "uri": "orders://recent" }, and returns contents with text or binary data. Use tools/list and resources/list to discover what's available.

A request, step by step

Sequence of a frontend tool call

Backend proxy example (Node.js)

import express from "express";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const client = new Client({ name: "web-proxy", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(new URL(process.env.MCP_URL!)));

const ALLOWED = new Set(["search_orders", "get_order"]);
const app = express();
app.use(express.json());

app.post("/api/tools/:name", async (req, res) => {
  if (!req.headers.authorization) return res.status(401).json({ error: "Sign in first" });
  if (!ALLOWED.has(req.params.name)) return res.status(403).json({ error: "Tool not allowed" });
  try {
    const result = await client.callTool({ name: req.params.name, arguments: req.body });
    if (result.isError) return res.status(422).json({ error: result.content });
    res.json(result.content);
  } catch (e) {
    res.status(502).json({ error: "MCP server unavailable" });
  }
});

app.listen(3000);

Frontend example (React)

import { useState } from "react";

export function OrderSearch() {
  const [rows, setRows] = useState<any[]>([]);
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);

  async function search(customer: string) {
    setLoading(true); setError("");
    try {
      const res = await fetch("/api/tools/search_orders", {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${localToken()}` },
        body: JSON.stringify({ customer }),
      });
      const data = await res.json();
      if (!res.ok) throw new Error(typeof data.error === "string" ? data.error : "Tool failed");
      setRows(JSON.parse(data[0].text));
    } catch (e: any) {
      setError(e.message);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div>
      <button onClick={() => search("Acme")} disabled={loading}>Search Acme</button>
      {error && <p role="alert">{error}</p>}
      <ul>{rows.map(r => <li key={r.id}>#{r.id}: ${r.total}</li>)}</ul>
    </div>
  );
}

The same fetch call works unchanged in Vue or plain JavaScript.

Streaming responses

With Streamable HTTP, the server can answer with Server-Sent Events, sending progress notifications before the final result. Pass those through from the proxy as SSE and read them with fetch plus a stream reader (or EventSource for GET):

const res = await fetch("/api/tools/long_report/stream", { method: "POST", body: "{}" });
const reader = res.body.getReader();
const decoder = new TextDecoder();
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  showProgress(decoder.decode(value));
}

Error handling

There are three layers to handle:

  • Transport errors: network failure or HTTP 5xx. Retry with backoff and show a friendly message.
  • JSON-RPC errors: an error object with a code, such as -32602 for invalid params or -32601 for an unknown method. Usually a bug, so log it.
  • Tool errors: a normal result with isError: true. These are expected failures like "customer not found". Show them to the user.

Also add timeouts with AbortController, and cancel requests when a component unmounts.

Security checklist

  • Keep MCP credentials on the backend, not in the browser.
  • Allowlist the tools the frontend can call.
  • Validate arguments on the server and check the user's permissions.
  • Treat tool output as untrusted: never inject it as raw HTML.

An MCP server can be a shared data layer for both your agents and your UI. Put a thin proxy in front of it, speak JSON-RPC behind the scenes, and handle the three kinds of errors, and your frontend gets the same data your AI tools do, safely.

About the author

I write about Applied AI, MCP, Azure AI Search, and RAG. Follow me on Dev.to and Hashnode for more.

๐Ÿ“ฐ Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.