Build a local TypeScript server
Create the server factory
Build one function that registers capabilities and returns a fresh server for both local and remote transports.
An MCP server has two layers, and I want you to keep them apart in your head from the start.
- capabilities define what clients can discover and call: tools, resources, prompts
- a transport moves protocol messages between client and server: stdio or HTTP
The capabilities don’t care how the messages arrive. So we put them in one function, and let each transport call that function.
Create src/server.ts:
import { McpServer } from '@modelcontextprotocol/server'
import * as z from 'zod/v4'
import { notes } from './notes.js'
export function createServer() {
const server = new McpServer({
name: 'project-notes',
version: '1.0.0'
})
return server
}
createServer() is our factory. It’s the only place where we’ll register tools, resources, and prompts. Both transports will call it, so their public surface can’t drift apart. Add a tool here and it shows up on stdio and on HTTP at the same time.
The z and notes imports are unused for now. We’ll need both in the next lesson.
Notice the .js extension in ./notes.js. The source file is notes.ts, but with NodeNext TypeScript wants the extension the file will have at runtime. If you write ./notes instead, npm run check fails:
error TS2835: Relative import paths need explicit file extensions in ECMAScript imports
Put the .js back and the error goes away.
Why a factory and not a single instance
The SDK creates servers in a specific way. serveStdio() takes our factory and pins one result to one connection. createMcpHandler() calls it again for each HTTP request. Every caller gets a fresh instance.
So the McpServer object is the wrong place for durable application state. This would be a mistake:
let currentUser = ''
A module-level variable like this can be shared between callers, or vanish when an instance restarts. Put durable notes in a database. When we add authorization, pass the verified identity through the request context instead.
Run npm run check. The server now has a name and a version but exposes nothing. I like this state. Nothing is available until we register it on purpose, one capability at a time.
Lesson completed