---
title: "Getting Started with MCP Apps in Node.js"
slug: getting-started-with-mcp-apps-in-node-js
url: https://listedarticles.com/articles/getting-started-with-mcp-apps-in-node-js
canonical_url: https://masteringjs.substack.com/p/getting-started-with-mcp-apps-in
content_type: tutorial
language: en
published_at: 2026-09-16T00:00:00.000Z
updated_at: 2026-09-22T00:21:13.964Z
author: "Valeri Karpov"
author_url: https://thecodebarbarian.com/
authored_by: human
publisher: "Mastering JS"
publisher_url: https://masteringjs.substack.com/
topics: ["Programming", "AI", "AI Agents", "Tutorials"]
license: all-rights-reserved
word_count: 1901
reading_minutes: 8
citation: "Valeri Karpov, Mastering JS. \"Getting Started with MCP Apps in Node.js.\" 16 Sept 2026. https://masteringjs.substack.com/p/getting-started-with-mcp-apps-in (all-rights-reserved)"
# The full text follows. The web page shows an extract and sends readers
# to the source above; quote the citation and link the canonical URL.
---

# Getting Started with MCP Apps in Node.js

> Valeri Karpov (Mastering JS) walks through MCP Apps in Node.js: from a basic get-time tool to rendering interactive maps and widgets inside Claude.

Model Context Protocol (MCP) is one of the most common ways to extend the functionality of LLMs. If you want Claude to be able to automatically mark completed tasks as closed in Linear or want Codex to pull issue comments from a GitHub repo, that’s where MCP comes in. But basic MCP tools only support returning structured data, typically JSON or text, and the LLM decides how to interpret and present the structured data.

MCP apps allow your MCP tools to render HTML - you can render tables, charts, maps, or even a full web application. In this post, we’ll build a couple of simple MCP apps in Node.js using the official SDK and connect it to Claude - focusing on minimal examples just to get something working end-to-end.

## What We’re Building

At a high level, an MCP server exposes a set of “tools” that the LLM can call and a set of “resources” that the LLM can load. The lifecycle of a traditional MCP tool call looks like this:

LLM → tool call → MCP server → structured result → LLM

For example, the LLM might call a `getOrders` tool. The MCP server fetches the orders and returns JSON, then the LLM turns that JSON into a response for the user.

An MCP App adds a UI resource to that flow:

**LLM → tool call → MCP server → result + UI resource → rendered app**

The tool still does the actual work. The difference is that the tool can point the MCP client to a resource containing HTML, CSS, and JavaScript. Instead of asking the LLM to describe a table of orders, for example, you can render an actual interactive table.

So the mental model is:

- **Tools are actions and data.** They let the model do things like query a database, create an issue, or fetch the current time.
- **Resources are content the client can load.** With MCP Apps, a resource can contain the UI for presenting and interacting with a tool.
- **MCP Apps connect the two.** A tool tells the client which UI resource should be used to render its output.

With that in mind, let’s build a minimum viable MCP App that displays the current server time.

## Hello MCP App

An MCP App is an HTML file that contains everything it needs to render in the client. MCP Apps typically bundle all the HTML, CSS, and JS in one file because MCP clients render the resource in a heavily sandboxed iframe. The sandboxed iframe doesn’t have the ability to load a `/app.js` or `/style.css` and has a deny-by-default CSP, which means every external request needs to be explicitly whitelisted.

In practice, this means building an MCP App feels a lot like building a self-contained web page. However, the tricky part is that you also need to import the MCP App SDK `import { App } from “@modelcontextprotocol/ext-apps”` , so you need to bundle a JS file and then bundle that JS file into your HTML *unless* you load the MCP App SDK through a CDN. We don’t recommend loading through a CDN because that prevents offline access.

First, let’s create an Express server that will serve the MCP setup. The Express server will expose a `POST /mcp` endpoint that will represent the MCP server and listen on port 3001.

```
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
  registerAppTool,
  registerAppResource,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import cors from "cors";
import express from "express";
import fs from "node:fs/promises";
import path from "node:path";
const server = new McpServer({
  name: "My MCP App Server",
  version: "1.0.0",
});
// Expose the MCP server over HTTP
const expressApp = express();
expressApp.use(cors());
expressApp.use(express.json());
// POST /mcp endpoint
expressApp.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });
  res.on("close", () => transport.close());
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});
// Listen on port 3001
expressApp.listen(3001, (err) => {
  if (err) {
    console.error("Error starting server:", err);
    process.exit(1);
  }
  console.log("Server listening on http://localhost:3001/mcp");
});
```
Next, let’s register a tool to get the server time. While it is technically possible to define a resource without a tool, MCP Apps are typically structured around tools and their associated resources. So first, implement a `Get Time` tool that returns the current server time. The tool has an associated `resourceUri` that defines the mapping between the tool and the corresponding resource.

```
// The ui:// scheme tells hosts this is an MCP App resource.
// The path structure is arbitrary; organize it however makes sense for your app.
const resourceUri = "ui://get-time/mcp-app.html";
// Register the tool that returns the current time
registerAppTool(
  server,
  "get-time",
  {
    title: "Get Time",
    description: "Returns the current server time.",
    inputSchema: {},
    _meta: { ui: { resourceUri } },
  },
  async () => {
    const time = new Date().toISOString();
    return {
      content: [{ type: "text", text: time }],
    };
  },
);
```
Next, the `registerAppResource()` function lets you register a resource. Resources are identified by their `resourceUri` and a tool can have one associated UI resource. The resource’s callback function is responsible for returning the resource contents: the URI, the MIME type, and the complete HTML.

```
// Register the resource that serves the bundled HTML
registerAppResource(
  server,
  resourceUri,
  resourceUri,
  { mimeType: RESOURCE_MIME_TYPE },
  async () => {
    const html = await fs.readFile(
      path.join(import.meta.dirname, "dist", "mcp-app.html"),
      "utf-8",
    );
    return {
      contents: [
        { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
      ],
    };
  },
);
```
The the `dist/mcp-app.html` file is the bundled HTML file that contains the inlined JavaScript. The HTML itself is mostly boilerplate and scaffolding for the `src/mcp-app.js` file that gets bundled along with it.

```
<!-- mcp-app.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Get Time App</title>
  </head>
  <body>
    <p>
      <strong>Server Time:</strong>
      <code id="server-time">Loading...</code>
    </p>
    <button id="get-time-btn">Get Server Time</button>
    <script type="module" src="./mcp-app.js"></script>
  </body>
</html>
```
The `src/mcp-app.js` file that gets bundled with `src/mcp-app.html` is as follows. Creating the `new App()` and calling `app.connect()` is critical: MCP clients will not render the MCP App until you call `app.connect()`. If you do not, clients like Claude will never render the resource.

```
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "get-time", version: "1.0.0" });
function render(content) {
  const text = (content ?? [])
    .filter((block) => block.type === "text")
    .map((block) => block.text)
    .join(" ");
  document.getElementById("server-time").textContent = text || "(no result)";
}
// Result of the tool call that triggered this widget
app.addEventListener("toolresult", (params) => render(params.content));
document.getElementById("get-time-btn").addEventListener("click", async () => {
  const result = await app.callServerTool({ name: "get-time", arguments: {} });
  render(result.content);
});
// Completes the ui/initialize handshake; the host shows the widget once this resolves
await app.connect();
```
Resources can call any MCP tool directly - calling MCP tools is the preferred way to “make API requests” from resources. This file registers an event handler which makes clicking the “get time button” trigger a `get-time` tool call.

## Bundling and Testing the MCP App

Next, we’ll use Vite to build a single HTML bundle, including the bundled `src/mcp-app.js` inlined in a script tag in `dist/mcp-app.html`. The `vite-plugin-singlefile` plugin is specifically designed to inline all JS and CSS into a single HTML file, which is exactly what we need.

```
import path from "node:path";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
  root: "src",
  plugins: [viteSingleFile()],
  build: {
    outDir: path.resolve(import.meta.dirname, "dist"),
    emptyOutDir: true,
    rollupOptions: {
      input: path.resolve(import.meta.dirname, "src", "mcp-app.html"),
    },
  },
});
```
Run `vite build` and Vite will produce a `dist/mcp-app.html` file that contains the full bundled HTML.

Once you have the `dist/mcp-app.html` file you’re ready to run the MCP App! Given that both Claude and Claude Desktop require https for MCP servers, the easiest way to test is to set up an ngrok tunnel to your MCP server and connect Claude to your ngrok URL. Go to https://claude.ai, open Settings → Connectors → Add Custom Connector, and add your ngrok URL.

Once your custom connector is set up, start a new chat and run an obvious prompt like “get time on MCP Test 2” - replace “MCP Test 2” with whatever the name of your custom connector is. When you run this prompt, you should see Claude running the `get-time` tool with an `</>` icon next to it to indicate there’s an HTML widget. If you inspect the widget in Chrome DevTools, you’ll see the widget is rendered in an iframe pointing to a `claudemcpcontent.com` URL.

.

Click on the “Get Server Time” button and you’ll get the updated server time each time.

## Rendering a Leaflet Map

Getting the current server time is a good “Hello World” example just to get something working end-to-end, but not very useful in production. The current build setup is good for much more than just wiring up some basic buttons: you can bring a substantial amount of modern JavaScript directly into your Claude chat. For example, with a little extra work, you can also render Leaflet maps in your MCP apps.

Add `leaflet@^1.9.4` to your `package.json`, and replace your `mcp-app.js` with the following code, which uses Leaflet to display the response from the `get-location` tool.

```
import { App } from "@modelcontextprotocol/ext-apps";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
const app = new App({ name: "location-map", version: "1.0.0" });
const locationName = document.getElementById("location-name");
const coordinates = document.getElementById("coordinates");
const refreshButton = document.getElementById("refresh-location");
const map = L.map("map", {
  zoomControl: true,
  attributionControl: true,
}).setView([20, 0], 2);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
  maxZoom: 19,
  attribution:
    '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
function renderLocation(result) {
  const { latitude, longitude, city, regionName, country } =
    result.structuredContent;
  const label = [city, regionName, country].filter(Boolean).join(", ");
  map.setView([latitude, longitude], 12);
  L.circleMarker([latitude, longitude], {
    radius: 9,
    color: "#fff",
    weight: 3,
    fillColor: "#2563eb",
    fillOpacity: 1,
  })
    .addTo(map)
    .bindPopup(label)
    .openPopup();
  locationName.textContent = label;
  coordinates.textContent = `${latitude.toFixed(4)}, ${longitude.toFixed(4)}`;
}
async function refreshLocation() {
  const result = await app.callServerTool({
    name: "get-location",
    arguments: {},
  });
  renderLocation(result);
}
app.addEventListener("toolresult", renderLocation);
refreshButton.addEventListener("click", refreshLocation);
await app.connect();
```
On the backend, the `get-location` tool makes an API request to the `ip-api.com` API, which returns the location of the requester’s IP address. Hook up Claude to this MCP server and ask it to display the current location:

This returns the location of the MCP server, NOT the user’s actual IP address. The calling user’s IP address doesn’t seem to be available in LLM client MCP calls. For example, Claude.ai does set an X-Forwarded-For header on MCP requests, but the IP address looks to be from Claude’s GCP instances:

## Moving On

MCP apps extend MCP with user-friendly UIs. Returning JSON is enough for many workflows, but some data is just much easier to understand visually. Displaying GeoJSON coordinate pairs on a map is more useful than listing them in a table. More complex tools can benefit from tables, forms, charts, and other interactive elements directly in your chat window.

The nice part is that you don’t need a fundamentally different architecture to get there. Your MCP tool still handles the underlying operation; the MCP App is an additional presentation layer that runs directly inside a supporting LLM client. Once you have the bundling and `App` handshake set up, you can use much of the same JavaScript ecosystem you would use in a normal web application.

We’re exploring adding MCP apps to Mongoose Studio - render Mongoose Studio’s tables, charts, and maps directly in your LLM client of choice. Mongoose Studio already has its own chat tab for displaying tables, charts, and maps; but MCP apps let us bring this same UI to the user’s preferred clients. If that sounds useful, check out Mongoose Studio on GitHub and give the repo a star. It’s the easiest way to support the project and helps us gauge interest as we explore features like MCP App support.
