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.
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
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
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
errorobject with a code, such as-32602for invalid params or-32601for 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.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.
