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.
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.
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.
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);
js
import { useState } from "react";
export function OrderSearch() {
const [rows, setRows] = useState<any[]>([]);
const [error, setError] = useState("");
const [, set] = useState(false);
async function search(customer: string) {
set(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 {
set(false);
}
}
return (
<div>
<button onClick={() => search("Acme")} disabled={}>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.
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));
}
There are three layers to handle:
error object with a code, such as -32602 for invalid params or -32601 for an unknown method. Usually a bug, so log it.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.
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.
I write about Applied AI, MCP, Azure AI Search, and RAG. Follow me on Dev.to and Hashnode for more.