Day 5

MCP servers and complete feature development

You ship
Requests in Postgres, analytics on the intake funnel, and a database MCP server and a PostHog MCP server, both used from Claude Code.

What you'll build today

After Day 4, WorkBot has a full request flow, but the requests live in the server's memory. A restart loses them, and a second server would never see them. Claude Code reads Linear, GitHub and Figma through MCP (Model Context Protocol) servers those companies built, but not your own data. Nobody can tell yet whether people who open the intake form then file a request.

Today you fix all 3. You move requests into Postgres, so they last. Then you build your own MCP servers, small programs that give Claude Code tools it can call. One reads WorkBot's database and cannot change it. The other reads PostHog, a product analytics service, where WorkBot now records each step from opening the form to changing a request's status.

You learn how a server exposes tools and data to a model and how to make it safe by design. You also learn that a finished feature includes tests, a health check, error pages and analytics. By the end, requests survive a restart, and Claude Code answers questions about your data and analytics.

By the end of the day you can

  • Understand how MCP (Model Context Protocol) is built: servers that offer tools, resources and prompts
  • Build custom MCP servers from scratch in TypeScript
  • Practice complete feature development: code, tests, analytics and monitoring

Topics

MCP Fundamentals

  • What MCP is: a standard for giving AI tools access to external data and systems
  • MCP architecture: a server exposes tools and resources, and Claude Code, on the client side, uses them
  • 3 MCP primitives:
    • Tools: functions the model can call, such as querying a database or creating a ticket
    • Resources: data the model can read, such as a file, a database schema or a configuration
    • Prompts: reusable prompt templates the server provides
  • MCP transport: stdio for a local server that Claude Code starts itself, HTTP for a remote one. Older remote servers use SSE (Server-Sent Events)

Pre-built MCPs

  • Context7: pulls current library documentation into Claude Code's context while you build
  • File system MCP server: read, write and search files outside the project directory
  • Database MCP servers: query Postgres, SQLite and other databases straight from Claude Code
  • Finding MCP servers: where to look, and how to judge one before you use it

Building Custom MCP Servers

  • An MCP server in TypeScript, built with @modelcontextprotocol/sdk
  • Defining tools: a name, a description, an input schema in Zod, and a handler function
  • Defining resources: URI templates, which are resource addresses with parameters, and read handlers
  • Registering your server and testing it locally
  • Connecting it to Claude Code: an entry in .mcp.json

Complete Feature Development

  • "Done" means more than code: tests, analytics, monitoring, error handling and documentation
  • Analytics: tracking events with PostHog, a product analytics service. Which events to track, and how to name them
  • Error handling patterns: error boundaries, fallback UI, error reporting
  • Monitoring: health checks, performance tracking, alerts on errors

Build

Build exercise 1: Database MCP Server

Features shipped today

  • WorkBot: Requests stored in Postgres, and a database MCP server over that database, used from Claude Code while building. Analytics on the intake funnel (viewing the form, submitting a request, viewing it, changing its status), checked through the PostHog MCP server

Build an MCP server that wraps your project's database:

  • Tool: query: runs a read-only SQL query and returns the results
  • Tool: schema: returns the full database schema, with tables, columns, types and relationships
  • Tool: describe-table: returns details about one table, including its row count and sample rows
  • Resource: tables: lists all tables as a resource Claude Code can browse

Connect it to Claude Code. Check that it works by asking Claude Code questions about your data while you implement features.

Build exercise 2: PostHog MCP Server

Build an MCP server that connects to PostHog analytics:

  • Tool: get-events: queries recent events by name, visitor or time range
  • Tool: get-event-definitions: lists every event name the project has seen, and when each was last seen
  • Tool: check-event-fired: checks whether a specific event was captured in the last N minutes

Then use it in a real workflow:

  1. Run WorkBot on localhost
  2. Go through the intake funnel: open the form, file a request, open it, change its status
  3. Ask Claude Code to use the PostHog MCP server to check that the right events fired
  4. Have Claude Code find the gaps: missing events, missing properties, steps with no event
  5. Let Claude Code add the missing analytics events
  6. Repeat the funnel, and check that the new events fire

Step by step

Move requests into Postgres

The MCP server you build next needs real tables to read, and analytics needs request ids that persist, so the day starts with the database. Postgres holds the data, and the same SQL also runs on an embedded version of Postgres, for tests and for local runs without a database server.

Study first

  • postgres.js and PGlite. postgres.js is a Postgres client library for Node, and PGlite is Postgres compiled to WebAssembly, so the SQL in this project runs on both.

Build

  1. Install a local Postgres. On macOS, run brew install postgresql@18, brew services start postgresql@18 and createdb workbot, and on Linux use your distribution's package. Then set DATABASE_URL=postgres://localhost/workbot in .env.local.
  2. Write src/lib/db.ts with one function, query(text, params), which runs one statement with $1 to $n parameters and returns the rows as objects. With a postgres:// URL it uses postgres.js. Without one, it opens an embedded PGlite database in .data/pg, and tests pass memory:// for a database held in memory. At startup it runs the schema, a list of idempotent statements in src/lib/schema.ts, which are statements that are safe to run again.
  3. Split the request data model. Put the schemas, constants and labels in src/lib/request-model.ts, which holds no database code, so Client Components may import it. Keep the queries in src/lib/requests.ts.
  4. Rewrite createRequest, listRequests, getRequest and updateRequest as SQL, and parse every row through the WorkRequest schema.
  5. Make /api/health run select 1 and answer 503 when the database is unreachable. Add error.tsx and not-found.tsx, which give the app an error boundary and a 404 page.

Done when

  • pnpm test passes on the in-memory database, and a request filed in the app is still there after you restart pnpm dev.
  • /api/health reports the database.
The health check and the table
The health check reports the database. The requests table exists on the local Postgres, created by the schema at startup.
Technical notes
  • Route bundles do not share module instances, so keep the connection on globalThis, as the in-memory list was. An embedded database can only be open in one process.
  • Write created_at as "createdAt" in the select list, so rows arrive in the shape the schema expects and you need no mapping code.
  • The id is a uuid column with a default, so WorkBot answers an id that is not a UUID as not found, before the query runs.
  • Each schema statement is idempotent, such as create table if not exists, so the list can run on every start against any earlier state. Never edit an earlier statement, because databases that already ran it would not see the edit. Add each change as a new statement at the end.
  • The health test runs against the in-memory database, so it checks only the shape of the report. To check that the server can reach the database, open /api/health after a deploy.

Build a database MCP server

With the data in Postgres, Claude Code can answer questions about it while you build, as long as it has a safe way in. An MCP server offers tools the model can call and resources it can read, and this one opens the database read-only.

Study first

  • The MCP server quickstart for TypeScript, which covers tools, resources, and stdio, the transport Claude Code uses for a local server.
  • The server side of the TypeScript SDK, which means McpServer, registerTool with a Zod input schema, registerResource, and StdioServerTransport. The SDK also offers prompts, and resource templates, which are URIs with parameters, but this server needs neither.
  • How Claude Code runs a local server from .mcp.json. The entry gives a command and its arguments, Claude Code starts the server when it is needed, and the entry for this server is in the technical notes.

Build

  1. Write src/mcp/database.ts, exporting databaseServer(query) with 3 tools and a resource. query runs one statement and returns up to 100 rows, and schema returns columns and constraints from information_schema and pg_constraint. describe-table returns a table's columns, its row count and 5 sample rows, and the tables resource lives at db://tables.
  2. Test every tool in process. Connect the SDK's Client over InMemoryTransport, create a request, and call each tool.
  3. Write the entry point, mcp/database.ts at the repository root, which opens a read-only connection to DATABASE_URL and serves on stdio.
  4. In Claude Code, run /mcp to confirm the server is connected, then ask how many requests each team has.

Done when

  • The in-process test passes, and Claude Code answers a question about your data by calling the query tool.
Claude Code using the database server
In the reference run, a headless session started with claude -p was asked to describe the requests table. The answer comes from the describe-table and query tools, which read the local Postgres and write nothing.
Technical notes
  • The .mcp.json entry follows. Node's --env-file-if-exists flag loads .env.local into the server's process, which is how the server gets DATABASE_URL.
{
  "mcpServers": {
    "database": {
      "command": "node",
      "args": ["--env-file-if-exists=.env.local", "--import", "tsx", "mcp/database.ts"]
    }
  }
}
  • Enforce read-only access in the database, not in the prompt. The connection sets default_transaction_read_only, so Postgres rejects a delete, whatever SQL the model writes.
  • describe-table checks the table name against the list of tables before it puts the name into the SQL. Postgres cannot take an identifier, such as a table name, as a parameter.
  • A tool returns an error as a result, with isError: true and the message as text, so the model can read the error and try again instead of the call failing.
  • stdio is the transport for a server that Claude Code starts itself, while the remote servers from Day 3 speak HTTP, as the MCP transports page describes. An embedded PGlite database can only be open in one process, so the stdio server refuses to start without a postgres:// URL.

Track the intake funnel

Now that requests persist, you can follow them through the product. A funnel is the series of steps a user takes toward a goal. Four events mark WorkBot's intake funnel: the form viewed, a request submitted, a request viewed, and a status changed. In the reference run they are request_form_viewed, request_submitted, request_viewed and request_status_changed. Each event carries record ids and never identifies a person.

Study first

  • PostHog's capture endpoint, POST {host}/i/v0/e/. Its JSON body holds the project key, a distinct_id for the visitor, the event name, properties and a timestamp.
  • The after function from next/server, which runs work after the response is sent, or after prerendering, within the platform's limit on function duration.

Build

  1. Write src/proxy.ts, which Next.js 16 runs before each matching request, as its Proxy guide explains. Earlier versions of Next.js called this file middleware. It gives every visitor a visitor cookie holding a crypto.randomUUID(), and its matcher skips /api, /_next and the favicon.
  2. Write src/lib/analytics.tsx. Its track(event, properties) posts to PostHog inside after() and does nothing without a POSTHOG_KEY, and its Track component posts a page-view event from inside a Suspense boundary.
  3. Call track from the submit and status actions and from the detail page, and render Track on the form page. Name events in snake_case, and make properties name records, such as requestId and team.
  4. Set POSTHOG_KEY, then run the funnel locally. Open the form, file a request, open it, and change its status.

Done when

  • The 4 events appear in PostHog's activity view, and the 3 events after the form carry the request id as a property.
  • No event carries personal data.
Technical notes
  • after() keeps the capture off the response path, so the response does not wait for PostHog's answer.
  • The cookie is httpOnly, and the id identifies a browser profile, so a visitor who clears cookies gets a new id.
  • Track lives inside Suspense because, with Cache Components, cookies() is request-time work and must run inside a boundary. The page around it still prerenders.

Build a PostHog MCP server

Events only help if you can check them right after a change, so the last part closes that loop. A second MCP server, over PostHog's API, lets a Claude Code session ask which events fired and then add what is missing.

Study first

  • PostHog's project API. GET {api host}/api/projects/{id}/events/ takes event, distinct_id and after filters, and /event_definitions/ sits beside it. Both need Authorization: Bearer <personal key>, because the project key writes events and a personal key reads them.

Build

  1. Write src/mcp/posthog.ts, exporting posthogServer(api) with 3 tools. get-events filters by name, visitor and time window, and get-event-definitions lists the event names PostHog has seen. check-event-fired answers in one sentence whether an event was captured in the last N minutes. The server strips PostHog's own $ properties from what it returns.
  2. Write a test against a fake fetch that asserts the URLs the server builds and the shape it returns.
  3. Write the entry point mcp/posthog.ts, which serves on stdio and reads POSTHOG_PROJECT_ID and POSTHOG_PERSONAL_KEY from .env.local. Add its .mcp.json entry, shaped like the database one.
  4. Run the funnel again, then ask Claude Code which funnel events fired in the last 10 minutes, which step has no event, and which event is missing a property. Let it add what is missing, run the funnel again, and ask again.

Done when

  • Claude Code reports the 4 events from PostHog through the server, with their properties, and the second run shows what Claude Code added.
Technical notes
  • The server takes fetch as a parameter, so the test needs no network, while the stdio entry point passes nothing and gets the real fetch.
  • check-event-fired exists because "did it fire" is the question asked most, and one sentence is easier for the model to act on than a list of events.
  • The personal key never leaves .env.local. The project key is write-only by design, and WorkBot sends it from the server, not the browser.

Resources