1. What we’ll build today and how it fits the app
Let’s recall our learning app: we’re building a gift selection assistant. In previous modules we already had:
- a widget in ChatGPT (Next.js 16 + Apps SDK) that shows UI, state, and can call callTool;
- a simple backend (via Apps SDK / Next.js routes) that returned gift stubs.
Now we want to externalize the “brains” of our assistant into a separate MCP server. The architecture will look like this:
flowchart TD
subgraph ChatGPT
U[User
in chat]
W["App widget
(Apps SDK)"]
end
subgraph MCP Client
C[ChatGPT MCP client]
end
subgraph OurServer[Our MCP server]
T1[Tool: suggest_gifts]
R1[Resource: gift_catalog]
P1[Prompt: birthday_template]
end
U --> W
W -- callTool --> C
C <-- JSON-RPC / HTTP --> OurServer
OurServer --> C
C --> W
That is, now:
- the model inside ChatGPT sees our MCP server as a standard set of tools/resources/prompts;
- callTool from the widget effectively becomes an internal MCP call;
- our server defines the contracts (schemas, descriptions) and implements the business logic.
By the end of this lecture you should have a separate Node/TypeScript project with an MCP server that:
- starts locally with a single command;
- registers at least one tool and one resource;
- returns meaningful data (even if via simple mocks);
- is structured so it can be evolved further.
We are not rewriting the existing backend via Apps SDK/Next.js right now: it stays as is, and we launch the MCP server as a separate service alongside it. Later you’ll be able to “plug” it into a ChatGPT App and gradually move the gift logic there instead of the old stubs.
2. Stack: TypeScript + MCP SDK + HTTP transport
We will write the MCP server in TypeScript on Node.js. The official JS/TS SDK for MCP lives in @modelcontextprotocol/sdk. It takes care of JSON-RPC, validation, and schema conversion: you describe arguments via Zod schemas, and the SDK translates them into JSON Schema that the model understands.
For transport we need an HTTP variant: ChatGPT talks to remote MCP servers over the network, not via stdio/local. The MCP spec describes a standard “streaming HTTP” format—essentially an evolution of the old HTTP+SSE pattern. In practice it’s a single HTTP endpoint that handles a request (POST/GET) and streams the response if needed. The TypeScript SDK for MCP typically already includes a ready transport for this format that you can hook into Express or Hono.
To stay focused, let’s assume we have:
- a server object McpServer from @modelcontextprotocol/sdk;
- an HTTP transport (for example, StreamableHttpServerTransport or similar) that can be wired into Express.
Exact class names may vary slightly between SDK versions, but architecturally it’s always:
- create an MCP server object;
- register tools/resources/prompts on it;
- connect the transport to your HTTP app.
3. Project structure and setup
Create a separate folder for the MCP server. It’s convenient to keep it next to the frontend app but as a separate Node project:
chatgpt-gift-app/
app/ ← Next.js + Apps SDK (widget)
mcp-server/ ← our MCP server
Inside mcp-server:
mcp-server/
src/
server.ts ← MCP server entry point
gifts.ts ← gift selection business logic
package.json
tsconfig.json
We’ll create a simple gifts.ts a bit later; for now, let’s focus on server.ts.
Assume you’ve already initialized the project:
mkdir mcp-server
cd mcp-server
npm init -y
npm install typescript ts-node-dev zod express @modelcontextprotocol/sdk
tsconfig.json is as usual (esnext modules, target node, strict). You can reuse one from any of your TS projects.
4. Extract business logic into a separate module
It’s tempting to immediately write server.registerTool(..., async () => {...}) and cram all logic there. But it’s better to separate from the start:
- a module that knows nothing about MCP, JSON-RPC, and other plumbing;
- a module that knows only about MCP but little about business logic.
In src/gifts.ts describe a simple gift suggestion function:
// src/gifts.ts
export type GiftIdea = {
id: string;
title: string;
price: number;
occasion: string;
};
export type SuggestGiftsInput = {
age: number;
relationship: "friend" | "partner" | "child" | "coworker";
budget: number;
};
export function suggestGifts(input: SuggestGiftsInput): GiftIdea[] {
// for now, just mocks
return [
{
id: "book-1",
title: "A book about a favorite hobby",
price: Math.min(input.budget, 30),
occasion: "generic",
},
{
id: "game-1",
title: "A board game for a group",
price: Math.min(input.budget, 50),
occasion: "party",
},
];
}
This function is pure: it takes parameters and returns an array of ideas. You can unit test it, reuse it elsewhere, and it does not depend on MCP. That’s the recommended approach: server wiring separate, business functions separate.
5. Create the MCP server and attach the HTTP transport
Now the entry point src/server.ts. In outline we need to:
- create an instance of the MCP server;
- register tools, resources, and prompts on it;
- start an HTTP server (e.g., Express) and attach the MCP transport.
Start with a scaffold:
// src/server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server";
import { StreamableHttpServerTransport } from "@modelcontextprotocol/sdk/transport/streamable-http";
const app = express();
// 1. Create an MCP server
const mcpServer = new McpServer({
name: "gift-assistant-mcp",
version: "0.1.0",
});
// 2. We'll register tools/resources/prompts here later
// 3. Configure transport over HTTP
const transport = new StreamableHttpServerTransport({
path: "/mcp", // single MCP endpoint
app, // embed into the Express app
});
transport.attach(mcpServer);
const PORT = process.env.PORT ?? 4000;
app.listen(PORT, () => {
console.log(`MCP server listening on http://localhost:${PORT}/mcp`);
});
Concrete transport class names may differ, but the pattern is the same: you create an HTTP endpoint and attach the MCP server as a JSON-RPC-over-HTTP/stream handler.
At this point the server does nothing useful yet, but it can already:
- perform the MCP handshake;
- answer basic discovery requests (list of tools/resources/prompts — currently empty).
The next step is to register the first tool.
6. Register the suggest_gifts tool via the MCP SDK
The official Apps SDK and MCP docs show the same pattern for tool registration: the registerTool method, to which you pass a name, a descriptor (title, description, argument schema), and a handler.
We’ve already described the SuggestGiftsInput type in gifts.ts. Now add a Zod schema so the server can validate input arguments and automatically provide the LLM with a correct JSON Schema.
// src/server.ts (fragment)
import { z } from "zod";
import { suggestGifts } from "./gifts";
const suggestGiftsInputSchema = z.object({
age: z.number().int().min(0).max(120),
relationship: z.enum(["friend", "partner", "child", "coworker"]),
budget: z.number().min(0),
});
Now register the tool:
// still in server.ts
mcpServer.registerTool(
"suggest_gifts",
{
title: "Suggest gift ideas",
description:
"Selects gift ideas by age, relationship type, and budget.",
// The SDK will convert the Zod schema to JSON Schema for the model
inputSchema: suggestGiftsInputSchema,
},
async ({ input }) => {
const ideas = suggestGifts(input);
const text = ideas
.map(
(g) =>
`• ${g.title} — ~${g.price} USD (occasion: ${g.occasion}, id: ${g.id})`
)
.join("\n");
return {
content: [
{
type: "text",
text,
},
],
// structuredContent can be consumed by the widget
structuredContent: {
ideas,
},
};
}
);
Key points:
- inputSchema is a Zod schema. The TS SDK can turn it into JSON Schema and thus automatically describe the tool to the model.
- The handler receives an object with input (whose type you get from the schema). Inside, you can call your business function.
- In the result you return content—this is the text the model will see as the result—and optionally structuredContent with a JSON structure that your widget can consume later.
If you implemented a tool via the Apps SDK in previous modules, this code should look very familiar: it’s the same pattern, only now it lives in a separate MCP server.
7. Add the gift_catalog resource for data
Tools are actions. Sometimes you also want to provide data as a resource so the model can read it, search it, or so your widget can load templates, components, and so on. MCP separately defines the resource concept with URIs, MIME types, and content.
Let’s create a simple gift_catalog resource that returns a list of available gifts. For now these will be the same mocks, but in reality it could be a DB export or a product feed.
First, the catalog itself:
// src/gifts.ts (addition)
export const giftCatalog: GiftIdea[] = [
{
id: "book-1",
title: "Programming book",
price: 25,
occasion: "learning",
},
{
id: "lego-1",
title: "LEGO set",
price: 60,
occasion: "fun",
},
];
Now register the resource on the server:
// src/server.ts (fragment)
import { giftCatalog } from "./gifts";
mcpServer.registerResource(
"gift_catalog",
{
title: "Gift catalog",
description: "Simple gift catalog for demo and debugging.",
mimeType: "application/json",
},
async () => {
return {
contents: [
{
uri: "mcp://gift-catalog",
mimeType: "application/json",
text: JSON.stringify(giftCatalog, null, 2),
},
],
};
}
);
What’s happening logically here:
- the resource name gift_catalog will be visible to the client during discovery (you’ll then see it in the resource list in the MCP Inspector);
- the descriptor contains a human-readable description and a MIME type;
- the handler returns an array of contents with a URI and text—this is the standard resource format in MCP.
Later you’ll be able to:
- read this resource from the client (for example, an agent or the inspector);
- use it as templates/data for the UI;
- run experiments: how the model uses a ready catalog to explain options to the user.
8. Register a simple prompt
The third MCP entity is prompts, predefined hints. They let you avoid repeating long system or user prompts by storing them on the server with names.
Let’s make a mini example: the birthday_gift prompt, which you can use as a “pre-filled template for a birthday gift conversation”.
// src/server.ts (fragment)
mcpServer.registerPrompt("birthday_gift", {
title: "Birthday gift helper",
description: "A request template for choosing a birthday gift.",
messages: [
{
role: "system",
content:
"You are a gift-finding assistant. Ask clarifying questions and propose several options.",
},
{
role: "user",
content:
"I need a birthday present. Ask the necessary clarifying questions and help me choose.",
},
],
});
Under the hood MCP allows clients to:
- get the list of prompts (in the inspector you’ll see birthday_gift);
- request its content and use it as a base hint for the model.
Separately, in the module about the system prompt and instructions, we discuss in detail how such prompts combine with the app’s global instructions. Here, we just want to “see” them as part of the MCP server.
9. How it all works at runtime
Let’s assemble the full picture.
When a client (for example, the MCP Inspector or ChatGPT) connects to our HTTP endpoint /mcp:
- a handshake occurs: the client and server exchange information about supported capabilities (tools/resources/prompts, etc.);
- the client calls discovery methods: it receives the list of tools, resources, prompts along with their descriptions and schemas;
- when the model decides to call a tool, it forms a JSON-RPC request with a method like tools/call or similar—the SDK on the server side turns this into an internal registerTool handler invocation;
- the handler executes business logic (in our case, suggestGifts or serving giftCatalog) and returns a result in a standardized format;
- the SDK serializes the response back to JSON-RPC and sends it to the client via the same HTTP/stream transport.
All the details of JSON-RPC, forming id, method routing, and so on remain inside @modelcontextprotocol/sdk. For you, the interface is very similar to the Apps SDK: you work with registerTool/registerResource/registerPrompt and their handlers without worrying about the protocol.
10. Local run and a first simple test
Assume you’ve added everything above. Time to run it.
In package.json you can add a script:
{
"scripts": {
"dev": "ts-node-dev src/server.ts"
}
}
Start it:
npm run dev
You should see something like this in the console:
MCP server listening on http://localhost:4000/mcp
We’ll do full inspection and manual tool calls in the next lecture via MCP Inspector / MCP Jam. Even now you can do a super-simple smoke test with curl:
curl -X POST http://localhost:4000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
This curl is an optional smoke test for those who like to look at “raw” JSON responses. In real development you almost always talk to an MCP server via an SDK rather than handcrafting JSON-RPC requests.
The exact method name depends on the protocol and SDK version, but the idea is that you’ll get a JSON list where among the tools you’ll see suggest_gifts. If the method doesn’t match—no worries: the goal of this lecture is not to memorize all the names but to make sure you aren’t afraid to look at JSON responses and can understand their structure thanks to previous lectures.
11. Connecting to our ChatGPT App and what’s next
For now, the MCP server lives on its own. In the next modules you will:
- connect it to the MCP Inspector and learn to debug tools/resources/prompts in isolation without touching ChatGPT;
- configure the ChatGPT App so it sees this MCP server as a tool source;
- move the logic that previously lived inside the Apps SDK (for example, via built-in tools) into the MCP layer;
- add auth, logging, and streaming scenarios—on top of the ready skeleton.
What matters now is that:
- you have a separate service responsible for the app’s “skills” and “data”;
- this service talks to clients via the MCP standard, not via a custom REST;
- you already know how to register tools, resources, and prompts by hand without fearing the protocol.
12. A bit about code structure and best practices
Even in a small example you can instill good habits.
First, keep the server configuration separate. Everything about name, version, logging, and transport settings (port, the /mcp path) can be extracted into a small config.ts module. Later, when you deploy to Vercel or behind an MCP gateway, you’ll add env variables and thank yourself.
Second, try to keep registerTool/registerResource/registerPrompt handlers as “thin” as possible. Schema definitions, texts, and business logic belong in separate files:
- gifts.ts—gift selection functions;
- catalog.ts—working with the product catalog;
- prompts.ts—the set of prompts.
Then server.ts becomes a sort of “MCP provider” that just wires everything together.
Third, remember that an MCP server is inherently reactive: it waits for client connections and their requests. That means any blocking or overly long operations inside tools will directly impact UX in ChatGPT. In the following modules we’ll talk about timeouts, async operations, and streaming responses; even now you should think about what can be moved to the background and what needs to respond quickly.
Insight: ChatGPT supports only part of MCP
It’s important to understand: ChatGPT Apps use MCP as transport and format but are not a full-fledged MCP client. If you read only the protocol, it’s easy to form incorrect expectations about runtime behavior.
What “pure” MCP promises:
- resources can be read dynamically on client request, not just once;
- the server can send resourceChanged/toolChanged notifications and thus “push” updates without restarting the client;
- you can build a flexible system where the set of tools/resources/prompts is controlled by configs or external state.
In the context of ChatGPT Apps this is not the case. For the app, the picture is much more static:
- when registering the App, ChatGPT reads the description of all tools and resources once;
- then this configuration is effectively cached as part of the app version;
- dynamic updates via MCP notifications are not supported—the platform simply ignores them.
13. Common mistakes when writing your first MCP server
Mistake #1: Pile all business logic directly into registerTool.
The temptation to “quickly write everything in the tool handler” is huge, especially in a learning example. But it becomes an unreadable monolith where validation, DB work, and response formatting are mixed together. It’s better to extract business functions (suggestGifts, catalog operations) into separate modules and keep the handler as “glue”.
Mistake #2: Hard-binding to specific MCP JSON method names.
Sometimes students start writing if (method === "tools/list") and parsing JSON by hand. Don’t do that: it’s the SDK’s job. The MCP spec and method names may evolve, and the SDK abstracts that away. Use registerTool, registerResource, registerPrompt and let the library decide how it looks in JSON-RPC.
Mistake #3: Ignoring transport and trying to feed ChatGPT a stdio server.
Stdio transport is great for local clients like desktop environments where the client can run the server as a subprocess. But ChatGPT communicates over HTTPS and needs an HTTP/stream endpoint. Attempts to “tunnel stdio somehow” end in pain. For a ChatGPT App, use HTTP transport (Streamable HTTP) from the start.
Mistake #4: Ignoring MIME types and resource structure.
For resources, not only the content matters but also the type (mimeType) and the URI. If you always write text/plain and blindly shove JSON strings, clients (and inspectors) will have a harder time understanding what the data is. Specify correct MIME types (application/json, text/html for UI templates, etc.) and stable URIs.
Mistake #5: Using an MCP server as a “random HTTP API”.
Sometimes there’s a temptation: “Since I already have Express, I’ll hang /api/whatever and call it directly.” Mixing the MCP endpoint with arbitrary REST is not a good idea: it complicates configuration, routing, and security. It’s simpler to have a clear contract: /mcp for MCP, separate paths for other needs, or even a different service. In production this is especially important for configuring gateways and authorization. In other words, don’t turn the MCP server into a “random HTTP API”—a set of unrelated HTTP routes outside the MCP contract.
Mistake #6: Not logging incoming and outgoing MCP messages.
Without logs the MCP server becomes a black box: “something doesn’t work but I don’t know what.” Even on the first server it makes sense to write at least compact structured logs to stderr: tool method, status, execution time. Just don’t log sensitive data and tokens—we’ll discuss that later when we get to security.
Mistake #7: Trying to debug everything via ChatGPT without an inspector.
A common scenario: a student writes an MCP server, immediately connects it to a ChatGPT App, and everything “mysteriously breaks”. Meanwhile the inspector hasn’t even been run once. As a result, it’s hard to tell whether the problem is in the protocol, the server, the Apps SDK, or model behavior. The right path is to first ensure the MCP server works correctly in isolation (via MCP Jam / Inspector), and only then connect it to the app.
GO TO FULL VERSION