Create Your First MCP App

Published: (December 12, 2025 at 06:05 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

TL;DR

MCP Apps bring interactive UIs to conversational agents and other MCP clients. This tutorial shows how to create a simple yet powerful app (source code here) that can serve as a template for larger projects. The app will display the last flights that arrived at an airport.

We’ll build the app in three steps:

  1. Create an MCP server
  2. Register a tool
  3. Register a resource and connect it to the tool

Step 1 – Initialize the Project

npm init --yes && npm pkg set type="module"
tsc --init

Create a .gitignore file:

echo -e "node_modules\ndist" > .gitignore

Install the required dependencies:

npm i @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps express zod \
    && npm i -D @types/express nodemon

Step 2 – Create the MCP Server

Create server.ts:

// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { registerGetFlightsTool } from "./tools/get-flights.js";
import { registerFlightCardResource } from "./resources/flight-card/flight-card.js";

const server = new McpServer({
  name: "My First MCP App",
  version: "0.0.1",
});

// Register tools and resources.
registerGetFlightsTool(server);
registerFlightCardResource(server);

// Set up Express server to handle MCP requests.
const app = express();
app.use(express.json());

app.use("/mcp", async (req, res, next) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });

  res.on("close", () => {
    transport.close();
  });

  await server.connect(transport);
  await transport.handleRequest(req, res, req.body).catch(next);
});

// Start the server.
app.listen(3000, () => {
  console.log("MCP server listening on http://localhost:3000/mcp");
});

Development workflow with nodemon

Create nodemon.json:

{
  "watch": ["."],
  "ext": "ts,html,css",
  "ignore": ["dist", "node_modules"],
  "exec": "tsc && node dist/server.js"
}

Add a dev script to package.json:

{
  "scripts": {
    "dev": "nodemon"
  }
}

Make sure the compiled files go to the dist folder by uncommenting (or adding) the following line in tsconfig.json:

{
  "compilerOptions": {
    "outDir": "./dist"
  }
}

Run the server:

npm run dev

Test the server with MCP Jam

npx @mcpjam/inspector@latest

Add a server with URL http://localhost:3000/mcp. The connection should be successful.

Step 3 – Create the Get Flights Tool

Create tools/get-flights.ts:

// tools/get-flights.ts
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import z from "zod";
import { flightCardResourceUri } from "../resources/flight-card/flight-card.js";

export function registerGetFlightsTool(server: McpServer) {
  server.registerTool(
    "get-flights",
    {
      description: "Retrieves flight arrivals for a given airport code",
      inputSchema: {
        code: z.string().describe("The ICAO airport code, e.g. 'KJFK'"),
      },
    },
    async (input: { code: string }) => {
      // Mock flight data – replace with a real API call in production.
      const mockFlights = [
        { flightNumber: "AA100", airline: "American Airlines" },
        { flightNumber: "DL200", airline: "Delta Airlines" },
        { flightNumber: "UA300", airline: "United Airlines" },
      ];

      return {
        content: [{ type: "text", text: JSON.stringify(mockFlights, null, 2) }],
        structuredContent: { flights: mockFlights },
      };
    }
  );
}

The tool is already registered in server.ts (see Step 2). After restarting the server, the tool can be invoked from any MCP‑compatible chat playground.

Step 4 – Add a UI with Vite and the Flight‑Card Resource

Install additional dependencies

npm i vite vite-plugin-singlefile glob

Vite configuration

Create vite.config.js:

// vite.config.js
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [viteSingleFile()],
  build: {
    outDir: "dist",
    emptyOutDir: true,
    rollupOptions: {
      input: {
        "flight-card": "resources/flight-card/mcp-app/flight-card-mcp-app.html",
        // Add more HTML resources here as needed
      },
    },
  },
});

Update nodemon.json to build the UI before starting the server:

{
  "watch": ["."],
  "ext": "ts,html,css",
  "ignore": ["dist", "node_modules"],
  "exec": "vite build && tsc && node dist/server.js"
}

Flight‑Card resource implementation

Create resources/flight-card/flight-card.ts:

// resources/flight-card/flight-card.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import fs from "node:fs/promises";
import path from "node:path";

export const flightCardResourceUri = "ui://flight-card.html";

export function registerFlightCardResource(server: McpServer) {
  server.registerResource(
    flightCardResourceUri,
    flightCardResourceUri,
    {},
    async () => {
      const html = await fs.readFile(
        path.join(import.meta.dirname, "mcp-app/flight-card-mcp-app.html"),
        "utf-8"
      );

      return {
        contents: [
          {
            uri: flightCardResourceUri,
            mimeType: "text/html;profile=mcp-app",
            text: html,
          },
        ],
      };
    }
  );
}

The resource is already registered in server.ts (see Step 2).

Flight‑Card HTML template

Create resources/flight-card/mcp-app/flight-card-mcp-app.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Flight Cards</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      background: #f0f2f5;
      padding: 40px;
    }
    .container {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
      gap: 1rem;
    }
    .card {
      background: #fff;
      border-radius: 8px;
      padding: 1rem;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }
    .flight-number {
      font-weight: bold;
      font-size: 1.2rem;
    }
    .airline {
      color: #555;
    }
  </style>
</head>
<body>
  <h1>Flight Cards</h1>
  <div id="cards" class="container"></div>

  <script>
    // Placeholder script – replace with real rendering logic.
    const mockFlights = [
      { flightNumber: "AA100", airline: "American Airlines" },
      { flightNumber: "DL200", airline: "Delta Airlines" },
      { flightNumber: "UA300", airline: "United Airlines" },
    ];

    const container = document.getElementById("cards");
    mockFlights.forEach((f) => {
      const card = document.createElement("div");
      card.className = "card";
      card.innerHTML = `
        <div class="flight-number">${f.flightNumber}</div>
        <div class="airline">${f.airline}</div>
      `;
      container?.appendChild(card);
    });
  </script>
</body>
</html>

(You can replace the inline script with a separate flight-card-mcp-app.ts module if desired.)

Final Check

  1. Run the development server:

    npm run dev
  2. Open MCP Jam (npx @mcpjam/inspector@latest) and connect to http://localhost:3000/mcp.

  3. Invoke the get‑flights tool with an airport code (e.g., KJFK).

  4. The tool returns mock flight data, and the UI defined in the flight‑card resource renders it as interactive cards.

Congratulations! You’ve built a functional MCP app with a backend tool and a front‑end UI. Use this project as a foundation for more complex integrations.

Back to Blog

Related posts

Read more »

Experimental Hono auth npm package

What I’m Building I’m creating an auth package that developers can drop into their app without writing the usual boilerplate login, register, JWT, email verifi...