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