Sitemap
Sau Sheong

Sau Sheong⁠

I write, code.

How to Build and Deploy an MCP App

18 min readSep 3, 2026

--

A step-by-step guide to building WeatherSG in TypeScript and running its interactive weather map inside ChatGPT and Claude.

Press enter or click to view image in full size

A weather question usually ends with a sentence. Ask whether it is raining in Singapore and an assistant can tell you that several stations are recording rain. That answer is useful, though it does not show where the rain is falling. A commuter in Jurong and a cyclist in Tampines may be dealing with quite different skies.

WeatherSG turns the answer into an interactive map inside the conversation. The model chooses a tool, the server fetches current data from Singapore’s public weather APIs, and the host renders a React interface without sending the user to another tab.

I used WeatherSG as a simple project for learning MCP Apps. It has enough moving parts to show how the architecture works, while the business logic remains familiar. By the end of this guide, you will have built the server, browser interface, HTTP transport, container and production deployment. You will also connect the same endpoint to ChatGPT and Claude.

The working example is in the WeatherSG project. The production endpoint used while checking this article was https://weathersg.sausheong.dev/mcp.

What is an MCP App?

MCP stands for Model Context Protocol. It is an open protocol that gives an AI host such as ChatGPT or Claude a standard way to discover and call tools provided by another server. Instead of writing one integration for ChatGPT, another for Claude and a third for every other host, you describe the tools once through MCP.

An MCP App extends one of those tools with an interface. The tool still accepts arguments and returns data, but it can also point the host to an HTML resource. The host loads that resource in a sandboxed iframe beside the conversation. JavaScript in the iframe exchanges JSON-RPC messages with the host through the MCP Apps postMessage bridge.

This gives an MCP App three cooperating parts.

  1. The MCP tool tells the model what the application can do and returns model-readable results.
  2. The UI resource supplies the HTML, CSS and JavaScript that the user sees.
  3. The host bridge carries tool results, user actions and context between the iframe and the AI host.

The interface does not replace the conversation. The model can choose the right tool from the user’s request, while the interface handles tasks that are awkward in text. A map can be panned, a chart can be filtered and a form can collect several related values without a long exchange of prompts.

The MCP Apps specification is an optional extension to MCP. A tool should remain useful when a host cannot render its interface. This is why WeatherSG returns normal text and structured weather data as well as the map.

How MCP Apps came about

Anthropic introduced MCP on 25 November 2024 to address a growing integration problem. Models were improving, but they remained cut off from much of the data and software people needed them to use. Connecting every assistant to every data source with a custom adapter did not scale. MCP supplied a common protocol for those connections.

The first version concentrated on context and actions. Servers could expose resources and tools, and models could use their results. This worked well for retrieving a document or performing a discrete action. It was less satisfactory when people needed to inspect a large result, manipulate it directly or make a visual choice. Returning hundreds of rows as text is technically possible, but sorting, filtering and selecting them through repeated prompts is slow.

Developers began experimenting with interfaces returned by MCP tools. The community-led MCP-UI project and OpenAI’s Apps SDK showed that interactive components could sit inside an AI conversation. Their approaches were similar, but a shared specification was needed if developers were to build an interface once and run it in several hosts.

The MCP project proposed MCP Apps in November 2025. The proposal brought the earlier work together as an optional, backwards-compatible MCP extension. On 26 January 2026, MCP Apps became the first official MCP extension, with support announced for Claude, ChatGPT, Goose and Visual Studio Code.

The aim went beyond richer chat responses. MCP had standardised the connection between models and tools, but there was still a gap between what a tool could do and what a person could see or control. MCP Apps closes that gap while keeping the model involved. The host owns the conversation and security boundary, the server owns the tool and data, and the embedded interface handles direct interaction.

That separation also explains why the interface uses a sandbox and a message bridge instead of behaving like an ordinary web page. The host controls which capabilities are available, tool calls remain subject to its consent and security rules, and the same application can run in more than one compatible host. OpenAI’s MCP UI documentation describes the same standards-based arrangement for ChatGPT.

How an MCP App works from prompt to interface

Follow one request through the whole system. Suppose the user asks ChatGPT or Claude to show the Singapore weather map.

  1. The host connects to the MCP server. ChatGPT or Claude opens an MCP session with WeatherSG and asks which tools are available. The server describes each tool, its input fields and what it does.
  2. The model chooses a tool. The host gives those tool descriptions to the model. From the words “show” and “map”, the model selects show_singapore_weather_map and supplies its arguments. For example, it may set layer to all.
  3. The host calls the server. The call travels to WeatherSG through the MCP transport. In production, this is an HTTPS request to the /mcp endpoint using Streamable HTTP.
  4. The server does the work. WeatherSG fetches the forecast and rainfall readings from data.gov.sg, validates them and converts them into one WeatherSnapshot. The tool returns ordinary text for the model and structured data for the interface.
  5. The host finds the interface. The map tool’s metadata contains a ui:// resource URI. This tells the host that the result has an MCP Apps interface. If the host has not already fetched it, it asks WeatherSG for the resource and receives the bundled HTML, CSS and JavaScript.
  6. The host creates the iframe. ChatGPT or Claude places the HTML in a sandboxed iframe inside the conversation. The application cannot reach into the host page or read the host’s cookies.
  7. The bridge delivers the result. The interface connects to the host through JSON-RPC messages sent with postMessage. The MCP Apps SDK handles the connection details. When the host passes the tool result to the iframe, WeatherSG receives it through app.ontoolresult and draws the map.
  8. Further actions follow the same boundary. A filter that only changes the visible markers can run inside the iframe. If the interface needs fresh server data, it can ask the host to call another MCP tool. It can also send a follow-up message or add context for the model. These actions go through the bridge rather than accessing ChatGPT or Claude directly.

There are therefore two participants making decisions. The model interprets the request and chooses a tool. The user works with the returned interface. The host sits between both of them, controls the iframe and carries messages to the server. Your MCP server remains responsible for the application logic, data access and permissions.

What you will build

WeatherSG puts this pattern into practice with two tools. One provides the ordinary model-readable result and the other adds the map.

  • get_singapore_weather returns structured weather data for factual questions.
  • show_singapore_weather_map returns the same data and asks the host to render the interactive map.

We keep both tools because a question such as “Is it raining anywhere?” does not always need a map. A request such as “Show me where it is raining” does. Narrow tools and direct descriptions give the model a better chance of choosing correctly.

The map is progressive enhancement. A host that supports MCP Apps can render it. A host that only understands normal MCP tools still receives useful text and structured data.

Why I used TypeScript

I usually write this sort of backend in Go. I chose TypeScript for WeatherSG because the server and browser interface can then use the same language, types and build system. The WeatherSnapshot contract can travel from the weather client to the MCP tool and into React without being maintained twice.

The backend could certainly be written in Go, but the interface cannot become an ordinary server-rendered Go page. It runs inside a sandboxed iframe and communicates with ChatGPT and Claude through the MCP Apps postMessage bridge. Browser code is still required, so a Go backend would leave this small application with two languages and two build toolchains. For WeatherSG, keeping both front end and backend in TypeScript is the simpler choice.

Set up the project

You will need Node.js 20 or newer, npm, Docker and a Google Cloud account if you want to follow the production section. Use a recent version of the MCP Inspector for local protocol testing.

Strictly speaking you don’t need Google Cloud, you can use any cloud provider including Azure and AWS and so on. We just need to have a server on the Internet that’s accessible by ChatGPT or Claude.

Create the project and install the packages.

mkdir weathersg
cd weathersg
npm init -y

npm install \
@modelcontextprotocol/sdk \
@modelcontextprotocol/ext-apps \
express leaflet react react-dom zod

npm install --save-dev \
@types/express @types/leaflet @types/node \
@types/react @types/react-dom \
esbuild supertest tsx typescript vitest

Set "type": "module" in package.json, require Node.js 20 or newer, then add these scripts.

{
"scripts": {
"build": "npm run build:web && tsc -p tsconfig.json",
"build:web": "node scripts/build-web.mjs",
"dev": "npm run build:web && tsx watch src/index.ts",
"start": "node dist/src/index.js",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"engines": {
"node": ">=20"
}
}

Use this layout.

weathersg/
src/
weather/
schema.ts
client.ts
http-app.ts
index.ts
server.ts
web/
src/
main.tsx
styles.css
scripts/
build-web.mjs
tests/
Dockerfile
package.json
tsconfig.json

There are two builds here. TypeScript compiles the server into dist/src. Esbuild turns the React interface into one self-contained HTML file at dist/web/weather-app.html. The server later returns that HTML as an MCP resource.

Start with the data contract

It is tempting to fetch an API response and pass it straight to the map. That works until the upstream response changes, a field disappears, or the model receives a different data structure from the one advertised by the tool.

Define the data crossing each boundary before writing the transport or the interface. WeatherSG calls its normalised result a WeatherSnapshot.

import { z } from "zod";

export const weatherLayerSchema = z.enum([
"all",
"forecast",
"rainfall",
]);

const forecastAreaSchema = z.object({
id: z.string(),
name: z.string(),
latitude: z.number(),
longitude: z.number(),
condition: z.string(),
category: z.enum([
"clear",
"cloudy",
"showers",
"thunderstorms",
"windy",
"hazy",
"unknown",
]),
});

const rainfallStationSchema = z.object({
id: z.string(),
name: z.string(),
latitude: z.number(),
longitude: z.number(),
rainfallMm: z.number().nonnegative(),
});

export const weatherSnapshotSchema = z.object({
layer: weatherLayerSchema,
generatedAt: z.string(),
forecast: z.object({
updatedAt: z.string(),
validFrom: z.string(),
validTo: z.string(),
validPeriodText: z.string(),
areas: z.array(forecastAreaSchema),
}).nullable(),
rainfall: z.object({
observedAt: z.string(),
readingType: z.string(),
unit: z.string(),
stations: z.array(rainfallStationSchema),
}).nullable(),
summary: z.object({
headline: z.string(),
areasWithRain: z.number().int().nonnegative(),
reportingRainStations: z.number().int().nonnegative(),
}),
warnings: z.array(z.string()),
sources: z.array(z.object({
label: z.string(),
url: z.string().url(),
})),
});

export type WeatherSnapshot = z.infer<typeof weatherSnapshotSchema>;

The full project also records freshness and the wettest reporting station. Whatever fields you choose, validate data when it arrives, normalise it once, and share the output schema between the tool and the interface.

Fetch and normalise the weather

WeatherSG uses two official data.gov.sg feeds, the two-hour forecast and five-minute rainfall observations. Treat them as separate sources. One may fail while the other remains available.

The fetching helper validates every response, times out slow requests and keeps the last good value in a short cache.

async function fetchValidated<T>(
url: string,
schema: z.ZodType<T>,
sourceLabel: string,
): Promise<{ value: T; warning?: string }> {
const cached = cache.get(url);

try {
const response = await fetch(url, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(8_000),
});

if (!response.ok) {
throw new Error(`Weather service returned HTTP ${response.status}`);
}
const value = schema.parse(await response.json());
cache.set(url, {
value,
expiresAt: Date.now() + 60_000,
});
return { value };
} catch (error) {
if (cached) {
return {
value: cached.value as T,
warning: `${sourceLabel} could not be refreshed`,
};
}
throw error;
}
}

Fetch both sources together with Promise.allSettled. Do not use Promise.all here. With Promise.all, one failed feed throws away the successful result from the other feed.

const [forecastResult, rainfallResult] = await Promise.allSettled([
fetchValidated(TWO_HOUR_FORECAST_URL, forecastSchema, "Forecast"),
fetchValidated(RAINFALL_URL, rainfallSchema, "Rainfall"),
]);

Join the forecast labels to their coordinates, join rainfall readings to station metadata, and convert the result into WeatherSnapshot. Add warning messages when a source is missing or stale. A user should be able to tell the difference between “there is no rain” and “the rain feed is unavailable”.

Finish the function by parsing your own output.

return weatherSnapshotSchema.parse(snapshot);

This second validation catches mistakes in the normalisation code before they become malformed tool results.

Register the ordinary MCP tool

Create an McpServer in src/server.ts. Give the server instructions that explain when each tool is appropriate.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { getSingaporeWeather } from "./weather/client.js";
import {
weatherLayerSchema,
weatherSnapshotSchema,
} from "./weather/schema.js";

const server = new McpServer(
{ name: "weathersg", version: "0.1.0" },
{
instructions:
"Use get_singapore_weather for factual weather questions. " +
"Use show_singapore_weather_map when the user asks to see, " +
"map, compare, or explore weather across Singapore.",
},
);

Register the text-first tool.

server.registerTool(
"get_singapore_weather",
{
title: "Get Singapore weather",
description:
"Get current official two-hour forecasts and five-minute " +
"rainfall observations across Singapore.",
inputSchema: {
layer: weatherLayerSchema.default("all"),
},
outputSchema: weatherSnapshotSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
},
async ({ layer }) => {
const snapshot = await getSingaporeWeather(layer);
return {
structuredContent: snapshot,
content: [{
type: "text",
text: `${snapshot.summary.headline} Snapshot generated at ${snapshot.generatedAt}.`,
}],
};
},
);

Return both structuredContent and content. The interface consumes the structured object. The model and hosts without UI support can still use the short text result.

Tool descriptions should say what a tool does and when it should be used. Annotations describe its safety properties. They help the host reason about approval and risk, though they are declarations rather than security controls.

Turn the map into an MCP App

An MCP App connects a tool to a ui:// resource. The tool returns data. The resource returns the HTML that knows how to display that data.

Import the MCP Apps server helpers and choose a versioned resource URI.

import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";

export const WEATHER_APP_URI =
"ui://weathersg/singapore-weather-map/v1.html";

Version the URI when you make an incompatible interface change. Hosts may cache resources, and a stable URI should keep a stable meaning.

Register the map tool.

registerAppTool(
server,
"show_singapore_weather_map",
{
title: "Show Singapore weather map",
description:
"Show an interactive map of official forecasts and rainfall " +
"observations across Singapore.",
inputSchema: {
layer: weatherLayerSchema.default("all"),
},
outputSchema: weatherSnapshotSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
_meta: {
ui: { resourceUri: WEATHER_APP_URI },
"openai/toolInvocation/invoking": "Reading Singapore weather…",
"openai/toolInvocation/invoked": "Singapore weather map ready",
},
},
async ({ layer }) => {
const snapshot = await getSingaporeWeather(layer);
return {
structuredContent: snapshot,
content: [{
type: "text",
text: snapshot.summary.headline,
}],
};
},
);

The shared field _meta.ui.resourceUri is the important link. The two openai/toolInvocation strings improve ChatGPT's loading messages and do not replace the standard field.

Register the HTML resource next.

registerAppResource(
server,
"Singapore weather map",
WEATHER_APP_URI,
{ description: "Interactive map of Singapore weather" },
async () => ({
contents: [{
uri: WEATHER_APP_URI,
mimeType: RESOURCE_MIME_TYPE,
text: loadWeatherAppHtml(),
_meta: {
ui: {
prefersBorder: true,
csp: {
connectDomains: [],
resourceDomains: ["https://www.onemap.gov.sg"],
},
},
},
}],
}),
);

RESOURCE_MIME_TYPE resolves to text/html;profile=mcp-app. The content security policy is intentionally narrow. The page loads OneMap tiles, so it needs that resource domain. It does not call an API from the browser, so connectDomains stays empty.

Keep this list short. Every extra domain gives embedded code another place to send or receive data, so add only the origins the interface needs.

Build the browser interface

The MCP App runs in a sandboxed iframe. It does not read the parent page or the host’s cookies. Communication goes through the MCP Apps bridge over postMessage.

Create the app and connect it to the host.

import { App } from "@modelcontextprotocol/ext-apps";
import { createRoot } from "react-dom/client";

const app = new App(
{ name: "WeatherSG map", version: "0.1.0" },
{},
{ autoResize: true },
);

function parseSnapshot(value: unknown): WeatherSnapshot | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<WeatherSnapshot>;
if (!candidate.summary || !Array.isArray(candidate.sources)) return null;
if (!candidate.forecast && !candidate.rainfall) return null;
return candidate as WeatherSnapshot;
}

app.ontoolresult = (result) => {
const snapshot = parseSnapshot(result.structuredContent);
if (snapshot) deliverSnapshot(snapshot);
};

createRoot(document.getElementById("root")!).render(<WeatherApp />);

void app.connect().catch((error) => {
console.error("Unable to connect to the host", error);
});

Callback names may change as the extension develops, so check the current MCP Apps API reference against the package version in your lockfile. The architecture remains the same. Check incoming structured content before rendering it. The server has already applied the full Zod schema, while the browser rejects any result missing fields the interface needs.

The React component can now treat the result like ordinary application data. WeatherSG uses Leaflet with OneMap tiles, forecast markers and rainfall station markers. Layer buttons switch between the forecast and rainfall already present in the snapshot, so they do not make another network request.

Get Sau Sheong’s stories in your inbox

Join Medium for free to get updates from this writer.

Do not let the component fetch privileged data directly if the server can do it. Keeping data access behind MCP tools gives you one place for validation, authentication, logging, caching and error handling.

Bundle the interface as one HTML resource

An MCP resource is easier to serve when its scripts and styles are inlined. WeatherSG uses esbuild to bundle React, then injects the JavaScript and CSS into a small HTML shell.

The resulting file should contain everything except explicitly allowed network resources such as map tiles.

await build({
entryPoints: ["web/src/main.tsx"],
bundle: true,
minify: true,
format: "esm",
platform: "browser",
write: false,
outdir: "dist/web-bundle",
});

After building, read the generated JavaScript and CSS, insert them into script and style elements, then write dist/web/weather-app.html. The complete project does this in scripts/build-web.mjs.

Run the build before moving on.

npm run build

Open the generated HTML and check that it contains no references to local development paths. The iframe will not be able to load localhost, your source tree or a Vite development server once it is running inside a remote host.

Serve MCP over Streamable HTTP

ChatGPT and Claude need a public HTTPS endpoint. Use Streamable HTTP at /mcp.

import { createMcpExpressApp } from
"@modelcontextprotocol/sdk/server/express.js";
import { StreamableHTTPServerTransport } from
"@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createWeatherMcpServer } from "./server.js";

const app = createMcpExpressApp();

app.post("/mcp", async (request, response) => {
response.setHeader("Cache-Control", "no-store");

const server = createWeatherMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});

await server.connect(transport);

response.on("close", () => {
void transport.close();
void server.close();
});

await transport.handleRequest(request, response, request.body);
});

This example creates a stateless transport for every POST request. That suits two read-only tools with no server-side session. If your application maintains subscriptions or long-lived state, choose and test a session strategy instead of copying this pattern blindly.

Add health and readiness endpoints.

app.get("/health", (_request, response) => {
response.json({ status: "ok", service: "weathersg" });
});

app.get("/ready", (_request, response) => {
const ready = existsSync("dist/web/weather-app.html");
response.status(ready ? 200 : 503).json({
status: ready ? "ready" : "not_ready",
});
});

/health says that the process is alive. /ready says that it can serve the app. They answer different operational questions.

Read the port from the environment in src/index.ts.

const port = Number(process.env.PORT ?? 3000);
const app = createWeatherHttpApp();

app.listen(port, "0.0.0.0", () => {
console.log(`WeatherSG listening on ${port}`);
});

Test the protocol before the chat interface

Start locally.

npm run dev

Then open the MCP Inspector.

npx @modelcontextprotocol/inspector@latest

Connect it to http://localhost:3000/mcp with Streamable HTTP. Check these items in order.

  1. The server initialises without an error.
  2. Both tools appear with their input and output schemas.
  3. get_singapore_weather returns text and structured content.
  4. show_singapore_weather_map advertises the ui:// resource.
  5. The resource has the MIME type text/html;profile=mcp-app.
  6. All three layer inputs work.
  7. A bad layer fails validation.

Test the browser interface through the MCP Apps basic host as well.

git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3000/mcp"]' npm start

Open http://localhost:8080, select the map tool and call it. This separates interface faults from model tool-selection faults. Debugging both at once is needlessly confusing.

Run the automated checks before every deployment.

npm run typecheck
npm test
npm run build

The WeatherSG repository tests schema handling, partial upstream failures, MCP metadata, HTTP routes and the generated HTML bundle. The current suite contains 16 tests.

Put the server in a container

Use a two-stage image so compilers and source files do not end up in the runtime image.

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s \
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
CMD ["node", "dist/src/index.js"]

Build and run it locally.

docker build -t weathersg .
docker run --rm -p 3000:3000 weathersg

Check http://localhost:3000/health and run the Inspector against the container. This catches missing build artefacts and paths that only worked on your development machine.

Deploy to Google Cloud Run

Cloud Run is a convenient fit because it gives the container a public HTTPS address and supplies the PORT environment variable. You can of course use just plain old VM instances on any platform like Azure or AWS. Google Cloud Run is just the tool I found available to me.

These commands create billable cloud resources, so check the active project before running them.

gcloud auth login
gcloud config set project YOUR_PROJECT_ID
gcloud services enable \
run.googleapis.com \
cloudbuild.googleapis.com \
artifactregistry.googleapis.com

Deploy from the project directory.

gcloud run deploy weathersg \
--source . \
--region asia-southeast1 \
--allow-unauthenticated \
--set-env-vars PUBLISHER_NAME=WeatherSG

Cloud Run prints a service URL when the deployment finishes. Your MCP endpoint is that URL with /mcp appended.

https://YOUR_SERVICE_HOST/mcp

For a public read-only weather service, unauthenticated access is reasonable. An app that reads private data or performs actions needs authentication and authorisation before you expose it. Public HTTPS is transport security, not user identity.

Use a custom domain before public directory submission if you want a stable publisher origin. WeatherSG uses https://weathersg.sausheong.dev/mcp.

Verify the deployed service from outside the container.

curl -fsS https://YOUR_SERVICE_HOST/health
curl -fsS https://YOUR_SERVICE_HOST/ready

Then connect MCP Inspector to the production /mcp URL and repeat the tool and resource checks. During the verification for this article, the WeatherSG production endpoint returned both tools, the MCP App resource and current government weather data without warnings.

Add WeatherSG to ChatGPT

OpenAI’s current documentation still requires Developer mode for testing an unpublished MCP server. Open ChatGPT settings, choose Security and login, then enable Developer mode. Availability may depend on your account and workspace policy.

Go to ChatGPT Plugins and select Create app or the plus button. The form currently opens as New Plugin.

Press enter or click to view image in full size

Fill it in as follows.

  • For Name, enter WeatherSG.
  • For Description, enter Interactive Singapore forecasts and rainfall map.
  • For Connection, choose Server URL.
  • For MCP Server URL, enter https://YOUR_SERVICE_HOST/mcp.
  • For Authentication, choose No Auth for this public read-only example.

The placeholder in the form may end in /sse. WeatherSG uses Streamable HTTP, so enter the complete /mcp URL. Tick the acknowledgement only after checking that the server is yours and that the discovered tools match what you deployed.

Create the connection, review both tools, then start a new conversation and enable WeatherSG from the tools menu. Try these prompts.

Is it raining anywhere in Singapore right now?

Show me the Singapore weather map.

Now show only rainfall stations.

The first prompt should favour the factual tool. The second should open the map. The follow-up should call the map tool again with layer set to rainfall.

This is the completed map inside ChatGPT.

Press enter or click to view image in full size

OpenAI’s connection and testing guide is the source of truth when these labels move.

Add WeatherSG to Claude

Claude calls remote MCP servers custom connectors. For an individual account, open Customize, choose Connectors, select the plus button, then choose Add custom connector. Team and Enterprise organisations require an owner to add the connector under the organisation’s connector settings before members can connect it.

The official MCP Apps guide shows the connector form below. Its surrounding Claude navigation may differ from the current interface, though the name and remote URL fields remain the same.

Press enter or click to view image in full size
Screenshot from the official MCP Apps build guide.

Enter WeatherSG as the name and the complete production URL as the remote MCP server URL.

https://YOUR_SERVICE_HOST/mcp

WeatherSG does not use OAuth, so leave the advanced OAuth fields empty. Select Add, start a new conversation, open the plus menu, choose Connectors, and enable WeatherSG for that conversation.

Ask Claude to show the Singapore weather map. Claude on the web and Claude Desktop support MCP Apps, so the same ui:// resource can render inline or in a larger view. The connection originates from Anthropic's cloud infrastructure even when you use Claude Desktop. A server reachable only on your laptop therefore needs a public development tunnel or the separate local MCP configuration.

Anthropic’s remote custom connector guide records the latest plan and organisation rules.

You now have a working MCP App

You have now taken WeatherSG through the complete path from an external API to an MCP tool, an embedded interface, a container and a public deployment. The same /mcp endpoint works in ChatGPT and Claude because the server describes the tool and interface through the shared MCP Apps protocol.

Run the typecheck, tests, production build and deployment verifier once more before tagging a release. Keep those commands in the release process so later changes to the weather adapter, tool metadata or interface do not break the deployed application without warning.

From here, add one capability at a time. Station search, saved map views and authenticated rain alerts are natural extensions. Begin each one with its data contract and permission model, then carry it through the tool, resource, interface and deployment checks you have already built.

An MCP App is a web application whose front door is a conversation. The model chooses the tool, the host contains the interface, and your server remains responsible for the data and permissions. WeatherSG keeps those responsibilities small enough to inspect, making it a useful first MCP App.

Further reading

--

--