Skip to main content
Glama
SutariH

postapop-demo

by SutariH

Build your own MCP server for a SaaS product

A 45-minute hands-on lab. You build a Model Context Protocol (MCP) server for a demo of Postapop, a social media scheduling SaaS. When you finish, Claude can read a brand's post calendar, save draft posts and report post results through your server.

This is a teaching demo with mock data. The server reads and writes an in-memory data set for a made-up plant shop called Moss & Fern. It does not call the real Postapop API, and it needs no account or API key.

1. What you will build

You build one MCP server with three tools: list_scheduled_posts, create_draft_post and get_post_results. Claude calls these tools when a user asks things like "What is scheduled for next week?" The server does not hold the product logic. It translates between the model and the product's API. In this lab, a small mock module stands in for that API, so you can see exactly where a real HTTP call would go.

+-----------------+    MCP messages    +---------------+    API calls      +-----------------+
|  Claude         | <----------------> |  Your MCP     | <---------------> |  Your product's |
|  (MCP client)   |   stdio or HTTP    |  server       |  (a mock in this  |  API            |
+-----------------+                    +---------------+   lab)            +-----------------+

Related MCP server: vibemarketing-mcp

2. What MCP is and why it matters

A language model can only work with what is in its context. To act on a product, such as a scheduling app, the model needs a way to ask the product for data and to ask it to do things.

MCP is an open protocol for that connection. An MCP server describes a set of tools: a name, a description, and a JSON Schema for the inputs. An MCP client, such as Claude Desktop or Claude Code, shows those descriptions to the model. When the model decides to use a tool, the client sends the call to the server and gives the result back to the model.

The benefit is reuse. You write one server for your product, and any MCP client can use it. You do not write a separate plugin for each AI app.

A server can offer three kinds of things: tools (actions the model can call), resources (data the client can read) and prompts (templates the user can pick). This lab uses tools, because tools are what most product integrations need first.

Two facts shape everything in this lab:

  • The model reads your tool names, descriptions and input schemas. They are part of the prompt. Clear text gives better tool use.

  • The model acts on what your tools return. A clear result or a clear error sentence lets the model take the next step without help.

3. Prerequisites and setup

You need:

  • Node.js 22 or later. Check with node --version.

  • An MCP client. Claude Desktop or Claude Code. You connect it in step 5. Until then, you test with the MCP Inspector, which runs through npx.

  • Git, to move between checkpoints.

Clone the repository and install the dependencies:

git clone <this-repository-url> postapop-mcp-lab
cd postapop-mcp-lab
npm install

Each step of the lab is a Git tag: step-1 to step-7. Every tag is a working checkpoint where the tests pass. You can follow the lab in two ways:

  • Type along. Start a branch of your own at step 1 with git switch -c my-lab step-1, then write the code for each next step yourself. If you get stuck, compare your files with the next tag: git diff step-3 -- src/.

  • Read along. Check out each tag in turn and read what changed: git checkout step-3, then git diff step-2 step-3.

All steps use the same dependencies, so one npm install is enough.

The lab pins exact versions of every dependency, so that the code and the expected output in this README match. Before you use this code for a real product, update the dependencies and run npm audit.

4. The steps

Each step ends with a checkpoint: "At this point you should see this." The test counts in a checkpoint are for that step's tag. On main, which is step 7, npm test runs all 42 tests.

Step

What you add

Minutes

Setup

Clone and install

4

1

A server with no tools, over stdio

5

2

Mock data and a read-only tool

9

3

A write tool with validation and helpful errors

9

4

Results, and descriptions that models read well

6

5

Build it and connect Claude

5

6

The same server over HTTP

7

Total

45

7

After the lab: design notes and challenges

on your own

Step 1: A server with no tools (5 minutes)

Goal: start an MCP server that a client can connect to, and see it in the MCP Inspector.

An MCP server has two parts: the server object, which knows the tools, and a transport, which moves messages. Keep them apart. In step 6 you connect the same server to a second transport.

src/server.ts creates the server. The name and version are what a client sees when it connects.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

export const SERVER_INFO = { name: 'postapop-demo', version: '0.1.0' };

export function createServer() {
  const server = new McpServer(SERVER_INFO);

  // Steps 2 to 4 register tools here.

  return server;
}

src/stdio.ts connects the server to the stdio transport. With stdio, the client starts your server as a child process and talks to it through standard input and standard output.

import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createServer } from './server.js';

const server = createServer();
await server.connect(new StdioServerTransport());

// stdout carries the protocol messages. Log to stderr only.
console.error('Postapop demo MCP server is running on stdio.');

Note the last line. Standard output belongs to the protocol. If you write console.log('hello') in a stdio server, the client receives hello where it expects a JSON-RPC message, and the connection breaks. Use console.error for all logging.

test/helpers.ts connects a real MCP client to the server in memory, with InMemoryTransport.createLinkedPair(). The tests use it to call the server without a process or a network.

At this point you should see this. Run the tests:

npm test
 Test Files  1 passed (1)
      Tests  1 passed (1)

Then ask the server for its tools with the Inspector in CLI mode:

npx @modelcontextprotocol/inspector --cli npx tsx src/stdio.ts --method tools/list
Postapop demo MCP server is running on stdio.
{
  "tools": []
}

The first line is the server's log on stderr. The JSON is the server's answer: no tools yet. To use the Inspector in the browser instead, leave out --cli and --method tools/list, open the address it prints, and select Connect.

Step 2: Mock data and a read-only tool (9 minutes)

Goal: add the product's API (a mock) and a first tool, list_scheduled_posts, that answers "What is scheduled next week?"

Keep the product out of the MCP layer

Before you write a tool, decide where the product rules live. They do not belong in the MCP server. The server is an adapter: it turns a tool call into an API call, and an API result into text for the model. If the product rules lived in the tools, you would have two copies of them: one in the product and one in the server. They would drift apart.

This step adds three files for the product side:

  • src/scheduling-api.ts is the contract. It defines the types (Channel, Post, Workspace), the SchedulingApi interface and an ApiError class. The tools only know this file.

  • src/demo-data.ts is the mock data: the Moss & Fern workspace, three channels and thirteen posts. Post times are relative to "now", so the calendar always has upcoming posts.

  • src/demo-api.ts implements SchedulingApi in memory. Each method is where a real client would call your HTTP API. The rules live here too: the default time window, and the 31-day limit.

export interface SchedulingApi {
  getWorkspace(): Promise<Workspace>;
  listPosts(query: PostListQuery): Promise<PostList>;
}

The methods return promises, although the mock answers at once. A real API call is asynchronous, so the tools are ready for one.

ApiError is for errors the API reports on purpose, such as a window that is too long. Its message is a plain sentence. In step 3 you see why that matters.

throw new ApiError(
  'window_too_long',
  `The window is ${days} days long, but the longest allowed window is ${MAX_WINDOW_DAYS} days. Ask for a shorter window, or make one call per month.`,
);

Register the tool

src/tools/list-scheduled-posts.ts registers the tool with server.registerTool(name, config, handler). The config has four parts, and the model reads three of them:

  • description says what the tool does, when to use it, and what it never does. Step 4 covers how to write it.

  • inputSchema is a set of zod schemas. The SDK turns them into JSON Schema for the model, and it checks every call against them before your handler runs.

  • annotations are hints for the client. readOnlyHint: true says the tool changes nothing, so a client may decide to run it without asking the user first. (destructiveHint and idempotentHint describe changes, so they matter only when readOnlyHint is false. That is why this tool leaves them out.) openWorldHint: false says the tool works inside a closed system (one workspace), not the open internet.

  • title is a human-readable name that clients can show in their interface.

inputSchema: {
  status: z
    .enum(['upcoming', 'drafts', 'published'])
    .default('upcoming')
    .describe('Which posts to list.'),
  from: isoTime
    .optional()
    .describe('Start of the time window, ISO 8601 with an offset, for example 2026-11-02T00:00:00-05:00.'),
  to: isoTime
    .optional()
    .describe('End of the time window (not included), ISO 8601 with an offset.'),
},

isoTime is z.iso.datetime({ offset: true, error: '…' }). This is the zod 4 API. It accepts 2026-11-02T09:00:00-05:00 and 2026-11-02T09:00:00Z, and it rejects a time with no offset. Without an offset, "9:00" could mean 9:00 anywhere. The custom error text tells the model how to fix a bad value.

The handler calls the API, shapes the result for the model and catches every error:

async ({ status, from, to }) => {
  try {
    const [workspace, list] = await Promise.all([
      api.getWorkspace(),
      api.listPosts({ status, from, to }),
    ]);

    return jsonResult({
      workspace: workspace.name,
      time_zone: workspace.timeZone,
      status,
      window: list.window,
      channels: workspace.channels,
      posts: list.posts.map((post) => formatPost(post, workspace)),
      ...(list.posts.length === 0 && { note: `There are no ${status} posts in this window.` }),
    });
  } catch (error) {
    return toErrorResult(error);
  }
},

Three choices in this handler help the model:

  • The result includes the channel list. The model needs channel ids for create_draft_post in step 3, and now it can find them.

  • The result includes the workspace time zone. Times are UTC, and the description tells the model to convert them before it talks to the user.

  • An empty list comes with a sentence. A model reads "There are no upcoming posts in this window." more reliably than [].

toErrorResult lives in src/tools/results.ts. It turns an ApiError into a result with isError: true and the error's sentence. Any other error is a bug, so it logs the details to stderr and gives the model a short sentence with no internals.

Finally, createServer now takes the API as an argument, and src/stdio.ts passes it createDemoApi(). The server does not create its own API client. That is what lets the tests give it a mock with a fixed clock.

At this point you should see this. Run the tests:

npm test
 Test Files  2 passed (2)
      Tests  11 passed (11)

Call the tool with the Inspector:

npx @modelcontextprotocol/inspector --cli npx tsx src/stdio.ts \
  --method tools/call --tool-name list_scheduled_posts

You get JSON with "workspace": "Moss & Fern", the three channels and four upcoming posts. The times depend on today's date. Now try a bad window:

npx @modelcontextprotocol/inspector --cli npx tsx src/stdio.ts \
  --method tools/call --tool-name list_scheduled_posts \
  --tool-arg from=2026-11-01T00:00:00Z --tool-arg to=2026-12-15T00:00:00Z
{
  "content": [
    {
      "type": "text",
      "text": "The window is 44 days long, but the longest allowed window is 31 days. Ask for a shorter window, or make one call per month."
    }
  ],
  "isError": true
}
{"error":{"code":"tool_is_error","message":"Tool 'list_scheduled_posts' returned isError:true."}}

The Inspector adds the last line and exits with a non-zero code, because the tool reported an error. The error itself is a tool result, not a protocol failure. The model reads the sentence and can try again with a shorter window.

Step 3: A write tool with validation and helpful errors (9 minutes)

Goal: let Claude save a draft post, and make every refusal easy for the model to fix.

A write tool needs more care than a read tool. Ask three questions before you write it:

  1. What is the smallest action that is useful? Here it is "save a draft". Scheduling and publishing reach real audiences, so they stay out of this tool. The user does those in the product.

  2. What happens if the model calls it twice? Two drafts. That is harmless, but the description must say so, and the annotations must not claim the tool is idempotent.

  3. What can go wrong, and what should the model do then? Each answer becomes one error sentence.

The API side

createDraft is a new method on SchedulingApi. The mock in src/demo-api.ts checks two product rules. Every channel id must exist, and the text must fit every chosen network. Bluesky allows 300 characters.

function checkTextLength(text: string, channel: Channel) {
  const limit = TEXT_LIMITS[channel.network];
  const length = countCharacters(text);
  if (length > limit) {
    throw new ApiError(
      'text_too_long',
      `This draft is ${length} characters long, but ${channel.network} allows ${limit}. Cut at least ${length - limit} characters, or leave the ${channel.network} channel (${channel.id}) out of this draft.`,
    );
  }
}

The message does the arithmetic for the model. "Too long" makes the model guess how much to cut. "Cut at least 112 characters" gives it a target. The message also offers a second way out: leave Bluesky out of the draft.

The unknown-channel error lists the valid ids, so the model can correct itself in one more call:

There is no channel with id "ch_twitter". The channel ids in this workspace are ch_instagram (Instagram), ch_linkedin (LinkedIn), and ch_bluesky (Bluesky).

countCharacters counts what people see as characters (grapheme clusters), not UTF-16 code units. JavaScript's text.length counts "é" as two when it is written as "e" plus a combining accent. Bluesky counts it as one. One of the tests checks this.

The tool side

src/tools/create-draft-post.ts follows the same pattern as step 2. Three parts are new.

The input schema carries its own error messages. If the model sends empty text, the message says what to do. The 2,000-character cap is part of this tool's contract: it keeps inputs to a sane size before any API call. The network limits (300 for Bluesky) are product rules, so they stay in the API.

text: z
  .string()
  .trim()
  .min(1, 'text is empty. Write the post text, then call the tool again.')
  .max(2000, 'text is longer than 2,000 characters. Shorten it, then call the tool again.')
  .describe('The full post text, 1 to 2,000 characters.'),
channel_ids: z
  .array(z.string().min(1))
  .max(10)
  .default([])
  .describe(
    'The channels the post is meant for, for example ["ch_linkedin"]. Leave it out to save a draft with no channel yet.',
  ),

The annotations describe a write tool:

annotations: {
  readOnlyHint: false,    // it changes data
  destructiveHint: false, // it only adds; it never deletes or overwrites
  idempotentHint: false,  // a second call creates a second draft
  openWorldHint: false,   // it stays inside the workspace
},

A client can use these hints to decide when to ask the user for permission. They are hints, not security. A client must not trust annotations from a server it does not trust, and your API must enforce its own rules either way.

The result ends with a note: "Saved as a draft. It is not scheduled and will not be published." Without it, a model may tell the user "Your post is scheduled", which is false.

At this point you should see this. Run the tests:

npm test
 Test Files  3 passed (3)
      Tests  21 passed (21)

Save a draft with the Inspector:

npx @modelcontextprotocol/inspector --cli npx tsx src/stdio.ts \
  --method tools/call --tool-name create_draft_post \
  --tool-arg 'text=Our autumn repotting workshop is back.' \
  --tool-arg 'channel_ids=["ch_linkedin"]'

The text of the result, unescaped, is:

{
  "id": "post_101",
  "status": "draft",
  "text": "Our autumn repotting workshop is back.",
  "channels": [
    {
      "id": "ch_linkedin",
      "network": "LinkedIn"
    }
  ],
  "note": "Saved as a draft. It is not scheduled and will not be published. The user can review and schedule it in the app."
}

Then try channel_ids=["ch_twitter"] and read the error sentence. Each Inspector CLI call starts a new server process, and the data lives in memory, so every call starts from the same mock data. That is why the draft id is post_101 every time.

Step 4: Results, and descriptions that models read well (6 minutes)

Goal: add get_post_results, and learn to write tool text for the model, not for a developer.

The tool

get_post_results takes one input, days, which must be 7, 30 or 90. In zod 4, z.literal([7, 30, 90]) accepts any of the three, and the SDK sends it to the model as a JSON Schema enum:

days: z
  .literal([7, 30, 90], { error: 'days must be 7, 30 or 90.' })
  .default(30)
  .describe('How many days to look back: 7, 30 or 90.'),

The API method getResults returns one row per post and channel. Some numbers are null. Bluesky does not report impressions, and the newest posts have numbers that the networks have not reported yet.

This is the trap. A model that sees "impressions": null may say "this post had no impressions". A model that sees numbers from three networks may add them up into one total. Both answers are wrong. Instagram, LinkedIn and Bluesky count in different ways, so a sum means nothing. The tool guards against both mistakes in two places:

  • the description: "Never treat null as zero: say the number is not reported." and "never add numbers across networks";

  • the result itself, in a how_to_read field, next to the numbers the model reads.

{
  "workspace": "Moss & Fern",
  "period": { "days": 7, "from": "2026-10-26T12:00:00.000Z", "to": "2026-11-02T12:00:00.000Z" },
  "how_to_read": "null means the network has not reported this number. It is not zero. Each network counts differently, so compare posts within one network and never add numbers across networks.",
  "posts": [
    {
      "id": "post_006",
      "published_at": "2026-11-01T17:00:00.000Z",
      "text": "Rainy day at the shop. The kettle is on and the calatheas look happy. Open unti…",
      "results": [
        { "channel_id": "ch_bluesky", "network": "Bluesky", "impressions": null, "likes": 21, "comments": 3, "shares": null }
      ]
    }
  ]
}

Why say it twice? The description is read when the model chooses a tool. The result is read when the model writes its answer, often many turns later. Put a warning where the model needs it.

Writing descriptions that models read well

The model decides which tool to call, and with which inputs, from the name, the description and the input schema. Nothing else. Write them as you would write instructions for a new colleague who cannot ask you questions.

Use this checklist for every tool:

  1. Name: verb plus noun, in snake_case. list_scheduled_posts says what it does. posts or query does not. Tool names share one list with tools from other servers, so make them specific.

  2. First sentence: what the tool does. "Saves a new draft post in the workspace's calendar."

  3. When to use it, in the user's words. "Use it to answer questions such as "What is scheduled next week?"" This helps the model match a request to a tool.

  4. What it never does. "It never schedules or publishes anything." This sentence prevents the most expensive mistake, and it lets the model reassure the user.

  5. The limits. 31 days, 300 characters for Bluesky, 7, 30 or 90 days. If the model knows a limit, it does not have to learn it from an error.

  6. How to read the result. Times are UTC; null is not zero.

  7. How the tools fit together. "Get them from the channels list in a list_scheduled_posts result."

  8. Each parameter: format and one example. "ISO 8601 with an offset, for example 2026-11-02T00:00:00-05:00."

  9. Short. Every tool description goes into the model's context on every turn. Cut sentences that do not change what the model does.

Compare a weak description with the one in this lab:

Weak

Better

"Gets post analytics."

"Returns the numbers (impressions, likes, comments, shares) for posts published in the last 7, 30 or 90 days, one row per post and channel, newest post first. Use it when the user asks how posts performed or which post did best. A null number means the network has not reported it yet, or does not report it at all. Never treat null as zero: say the number is not reported. Networks count in different ways, so never add numbers across networks. Compare posts within one network. This tool only reads."

The server also sends instructions when a client connects. They apply to all tools, so they hold the advice that spans tools. Some clients show them to the model; treat them as a helpful extra, and keep each tool description complete on its own.

const INSTRUCTIONS = [
  'This server connects to a demo workspace for Moss & Fern, a small plant shop, in a social media scheduling app. All data is mock data.',
  'Read before you write: call list_scheduled_posts to see the calendar and the channel ids before you create a draft.',
  'These tools never schedule or publish a post. The user does that in the app.',
].join(' ');

const server = new McpServer(SERVER_INFO, { instructions: INSTRUCTIONS });

test/tool-contract.test.ts checks the text the model reads: every tool has a title, a description of a useful length that says when to use it, and explicit annotations. If you add a tool and forget one of these, the test fails.

At this point you should see this. Run the tests:

npm test
 Test Files  5 passed (5)
      Tests  31 passed (31)

List the tools with the Inspector and read them as the model would:

npx @modelcontextprotocol/inspector --cli npx tsx src/stdio.ts --method tools/list

You see three tools. Each has a description, an inputSchema in JSON Schema and annotations. For get_post_results, the schema of days is { "default": 30, "type": "number", "enum": [7, 30, 90] }.

Step 5: Build it and connect Claude (5 minutes)

Goal: compile the server, test it as a child process, and connect Claude Desktop or Claude Code.

Until now, tsx ran the TypeScript directly. That is good for development. For a client that starts your server many times, use compiled JavaScript: it starts faster and does not need tsx.

tsconfig.build.json compiles only src/ into dist/:

npm run build

This writes dist/stdio.js and the files it imports.

Next, test the server the way a client uses it: as a child process that talks over stdin and stdout. scripts/smoke-stdio.ts starts the server with StdioClientTransport from the SDK and calls each tool once:

npm run smoke              # runs src/stdio.ts through tsx
npm run smoke -- --built   # runs dist/stdio.js

test/stdio.test.ts does the same inside the test suite. It catches the most common stdio bug: a console.log that writes to stdout and breaks the protocol.

Then connect a client. Section 5, "Connecting to Claude Desktop and Claude Code", has the full instructions. In short:

  • Claude Desktop: add the server to claude_desktop_config.json with the absolute path to dist/stdio.js, then quit and restart Claude Desktop.

  • Claude Code: from the lab folder, run claude mcp add postapop-demo -- node dist/stdio.js.

At this point you should see this. Run the smoke test:

npm run smoke
Postapop demo MCP server is running on stdio.
Tools: list_scheduled_posts, create_draft_post, get_post_results
list_scheduled_posts: ok: 4 posts
create_draft_post: ok: draft post_101
create_draft_post: error: This draft is 320 characters long, but Bluesky allows 300. Cut at least 20 characters, or leave the Bluesky channel (ch_bluesky) out of this draft.
get_post_results: ok: 3 posts

The first line is the server's stderr, which the smoke script passes through. npm test now reports Test Files 6 passed (6) and Tests 32 passed (32).

In Claude, ask: "What's scheduled for Moss & Fern next week?" Claude asks for permission to use list_scheduled_posts (unless you allowed it before), calls it, and lists the upcoming posts.

Step 6: The same server over HTTP (7 minutes)

Goal: serve the same tools over Streamable HTTP, protected by a token and an Origin check.

stdio works when the client and the server run on the same machine and the client starts the server. HTTP works when the server runs on its own, for example as part of your product. MCP's HTTP transport is called Streamable HTTP. The client sends each JSON-RPC message as a POST request, and the server answers with JSON or with a stream of events.

You do not change a single tool. createServer(api) is the same function that src/stdio.ts calls. Only the transport is new. This is why you kept the server and the transport apart.

Stateless: one server per request

src/http-server.ts uses the simplest mode: stateless. The server keeps no session between requests. For every POST /mcp, it creates a new MCP server and a new transport, handles that one request, and closes both:

const server = createServer(api);
const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: undefined, // stateless: no session ids
  enableJsonResponse: true,      // answer with plain JSON, not an event stream
});
res.on('close', () => {
  void transport.close();
  void server.close();
});

await server.connect(transport);
await transport.handleRequest(req, res, body);

The api object is created once, outside the request handler. The MCP server is stateless, but the product is not: a draft that one request saves is there for the next request. One of the tests checks this. In a real product, the state lives in your database, behind your API.

Stateless mode fits tools that answer one request at a time. You need sessions only for features such as server-to-client notifications. Stateless servers are also easy to scale, because any instance can answer any request.

Three checks before any tool runs

The handler checks each request in this order:

  1. Origin. A web page that you open in a browser can try to send requests to 127.0.0.1. This is called DNS rebinding. Browsers always send an Origin header with such requests. Command-line clients and desktop apps do not. So the rule is: if a request has an Origin header, the origin must be in LAB_ALLOWED_ORIGINS, or the server answers 403.

  2. Token. The request must carry Authorization: Bearer <token>, and the token must equal LAB_DEMO_TOKEN. Otherwise the server answers 401 with a WWW-Authenticate: Bearer header. The comparison runs in constant time (timingSafeEqual on two SHA-256 hashes), so response times do not reveal how much of a guess was right.

  3. Method. A stateless server accepts only POST. GET (to open a stream) and DELETE (to end a session) get 405.

The server also binds to 127.0.0.1, not 0.0.0.0. Other machines on your network cannot reach it. A production server should also check the Host header against the host names it expects; this lab keeps to the Origin and token checks.

The token is a demo shortcut. One shared token means every client is the same user, and anyone with the token has full access. A real product needs per-user authorization, so that each request acts for one user with that user's permissions. For a remote MCP server, use OAuth as the MCP authorization specification describes. "Extend it yourself" has a challenge for this.

Configuration from the environment

src/http.ts is the entry point. It reads its settings from environment variables, and it loads .env if one exists (process.loadEnvFile(), built into Node.js). If LAB_DEMO_TOKEN is missing, it stops with a sentence that says how to fix it. It never prints the token.

Create your .env from the example. .env is in .gitignore, so you cannot commit it by accident.

cp .env.example .env
openssl rand -hex 32   # paste the output after LAB_DEMO_TOKEN= in .env
npm run start:http
Postapop demo MCP server is listening on http://127.0.0.1:3333/mcp

At this point you should see this. Run the tests:

npm test
 Test Files  7 passed (7)
      Tests  42 passed (42)

test/http.test.ts starts the HTTP server on port 0, which means "any free port", so the tests never clash with a server you run yourself.

With the server running, open a second terminal in the lab folder and load the token into your shell: export $(grep '^LAB_DEMO_TOKEN=' .env). Then call a tool without the token:

curl -i -X POST http://127.0.0.1:3333/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_post_results","arguments":{"days":7}}}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
...
{"jsonrpc":"2.0","error":{"code":-32001,"message":"Unauthorized. Send the header Authorization: Bearer <token>."},"id":null}

Now add the token:

curl -i -X POST http://127.0.0.1:3333/mcp \
  -H "Authorization: Bearer $LAB_DEMO_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_post_results","arguments":{"days":7}}}'

You get HTTP/1.1 200 OK and a JSON-RPC result. Its content[0].text is the same JSON that the tool returns over stdio. Add -H "Origin: https://example.com" to the same command, and the answer is 403 Forbidden.

To connect Claude Code to the HTTP server, pass the token as a header:

claude mcp add --transport http postapop-demo-http http://127.0.0.1:3333/mcp \
  --header "Authorization: Bearer $LAB_DEMO_TOKEN"

Claude Code stores this header in its configuration file on your machine. That is fine for a local demo token. Do not do it with a real credential that other people share.

Step 7: Make it yours (after the lab)

The step-7 tag adds no code. It adds TEACHING_NOTES.md for instructors who run this lab, and the license. You now have a complete server. Here is the map:

src/
  scheduling-api.ts    the contract: types, the SchedulingApi interface, ApiError
  demo-data.ts         mock data for Moss & Fern
  demo-api.ts          the mock API: where real HTTP calls would go
  server.ts            createServer(api): server instructions and tool registration
  tools/               one file per tool, plus helpers for results and errors
  stdio.ts             entry point for stdio
  http-server.ts       Streamable HTTP: Origin check, token check, stateless transport
  http.ts              entry point for HTTP: reads the environment, binds 127.0.0.1
scripts/smoke-stdio.ts calls each tool through a real child process
test/                  in-memory, stdio and HTTP tests (42 in total)
examples/              a Claude Desktop config block

Next, read "Design notes" (section 8), then pick a challenge from "Extend it yourself" (section 7).

5. Connecting to Claude Desktop and Claude Code

Both clients start your server as a child process over stdio. They need the command to run and its arguments. Build first, so the command can be plain node:

npm run build

Find the two absolute paths you need:

pwd            # the lab folder, for example /Users/you/code/postapop-mcp-lab
which node     # the Node.js binary, for example /usr/local/bin/node

Claude Desktop

  1. In Claude Desktop, open Settings, then Developer, then Edit Config. This opens claude_desktop_config.json. On macOS it is in ~/Library/Application Support/Claude/. On Windows it is in %APPDATA%\Claude\.

  2. Add the server. examples/claude_desktop_config.json has this block:

    {
      "mcpServers": {
        "postapop-demo": {
          "command": "node",
          "args": ["/ABSOLUTE/PATH/TO/postapop-mcp-lab/dist/stdio.js"]
        }
      }
    }

    Replace /ABSOLUTE/PATH/TO/postapop-mcp-lab with the output of pwd. If the file already has an mcpServers object, add postapop-demo inside it. Do not create a second mcpServers key.

  3. Quit Claude Desktop completely and open it again. Closing the window is not enough. Claude Desktop reads the config file only at startup.

  4. Open the tools menu in the chat box. You see postapop-demo with three tools.

Claude Desktop does not start the server from your shell, so it may not find node on your PATH, especially if you use a version manager such as nvm. If the server does not start, set "command" to the full output of which node.

Claude Code

From the lab folder, run:

claude mcp add postapop-demo -- node dist/stdio.js

Everything after -- is the command that starts the server. Claude Code starts it from the folder you run claude in, so the relative path works when you work in this folder. To use the server from any folder, add it at user scope with an absolute path:

claude mcp add --scope user postapop-demo -- node /ABSOLUTE/PATH/TO/postapop-mcp-lab/dist/stdio.js

While you change the code, you can skip the build step and run the TypeScript directly:

claude mcp add postapop-demo -- npx tsx src/stdio.ts

Each command adds a server named postapop-demo, so use one of them. To switch, remove the old entry first with claude mcp remove postapop-demo.

Check the connection with claude mcp list, or type /mcp inside a Claude Code session. Claude Code starts the server when a session starts, so start a new session after you change the server.

To connect Claude Code to the HTTP transport instead, see the end of step 6.

6. Common errors and fixes

The client disconnects at once, or reports invalid JSON, over stdio. Something wrote to stdout. Over stdio, stdout carries only protocol messages, so one console.log breaks the connection. Use console.error for all logs. Check your dependencies too: a library that prints a banner to stdout causes the same failure. Do not start the server with npm start in a client config either, because npm prints its own lines to stdout. Use node dist/stdio.js. test/stdio.test.ts catches this problem.

Claude Desktop shows no tools, and its log says "Cannot find module". The path in claude_desktop_config.json is wrong. It must be absolute: ~ and relative paths do not work there. Check that dist/stdio.js exists; if it does not, run npm run build. Claude Desktop writes a log for each server. On macOS, read ~/Library/Logs/Claude/mcp-server-postapop-demo.log. On Windows, look in %APPDATA%\Claude\logs\.

The server fails with a syntax error, or npm warns about an unsupported engine. Your Node.js is older than version 22. Run node --version. Claude Desktop can find a different node than your terminal does, for example an old system version. Put the absolute path from which node in "command".

TypeError: Cannot read properties of undefined (reading 'datetime'). The code uses the zod 4 API, but zod 3 is installed. z.iso.datetime(), z.literal([7, 30, 90]) and the error option exist only in zod 4. Run npm ls zod; it must show zod@4.4.3. In zod 3 tutorials you see z.string().datetime() and { message: '...' } instead.

npm run start:http stops with "LAB_DEMO_TOKEN is missing". The HTTP server needs a token. Copy .env.example to .env and set LAB_DEMO_TOKEN to a long random value: openssl rand -hex 32. Variables that you set in the shell win over .env. Claude Desktop and Claude Code do not read your shell profile, so pass variables to a stdio server in the client config ("env" in Claude Desktop, -e NAME=value in claude mcp add).

The HTTP server answers 401 Unauthorized. The request has no Authorization header, the header lacks the Bearer prefix, or the token does not match. Look for quotes or spaces around the value in .env. Some clients react to a 401 by starting an OAuth sign-in, which this demo does not support. The Inspector, for example, reports auth_required. Pass the header explicitly: --header "Authorization: Bearer <token>".

The HTTP server answers 403 Forbidden. The origin ... is not allowed. The request came from a web page, so it has an Origin header that is not in the allowed list. If you trust that page, for example a local tool at http://localhost:6274, add its exact origin to LAB_ALLOWED_ORIGINS and restart the server.

The HTTP server answers 406 Not Acceptable. The request lacks Accept: application/json, text/event-stream. The Streamable HTTP transport requires both types, even when the server answers with plain JSON. MCP clients send this header for you; add it to curl.

EADDRINUSE when the HTTP server starts. Another program uses port 3333. Set LAB_HTTP_PORT to another port.

A new tool, or a changed description, does not appear in the client. Clients read the tool list when they start the server. Quit Claude Desktop completely and open it again. In Claude Code, start a new session. If the client runs dist/stdio.js, run npm run build first, or the client still runs the old code.

The tool result says "Input validation error". The model sent arguments that do not match the input schema. The SDK checks them before your handler runs, and it returns the problem to the model with your custom zod message inside. If this happens often, the tool description or a parameter description is unclear. Improve the text, not only the schema.

7. Extend it yourself

Each challenge practices one idea from the lab. Start each one with a test.

  1. Add schedule_post, with a confirmation step. Scheduling reaches a real audience, so treat it as a bigger action than a draft. Design it in two calls: the first returns a preview and a short-lived confirmation id; the second, with that id, schedules the post. Then decide its annotations. It is not read-only. Is it destructive? It deletes no data, but nobody can take a published post back from the people who saw it. Should openWorldHint be true, because the post goes to public networks? Write your reasons into the description.

  2. Add find_next_free_slot, with time zones. Given a channel and a day, return the next hour with no scheduled post, in the workspace time zone (America/New_York). Accept and return ISO 8601 times with offsets. Test a day when daylight saving time starts or ends: the offset changes between -04:00 and -05:00.

  3. Replace the mock with a real HTTP client. Write src/http-api.ts that implements SchedulingApi with fetch, reads DEMO_API_URL and DEMO_API_KEY from the environment, and stops each request after a few seconds with AbortSignal.timeout(). Turn each HTTP error into an ApiError with a sentence the model can act on. The tools do not change.

  4. Replace the shared token with per-user OAuth. Follow the MCP authorization specification: publish protected resource metadata, check that each access token was issued for your server (its audience), and map it to one user and their workspace. Never pass the user's token on to another API. Let the HTTP handler give each request an API client that acts for that one user.

  5. Add a resource or a prompt. Expose the brand's style guide as a resource, for example demo://workspace/style-guide, so a client can attach it to a conversation. Or add a weekly_report prompt that tells the model to call get_post_results with days: 7 and to summarize each network on its own.

  6. Make an action safe to repeat with an idempotency key. Add an optional idempotency_key to create_draft_post. If the same key arrives twice, the API returns the first draft and does not create a second one. This matters when a client retries after a timeout. Then decide whether idempotentHint can become true.

  7. Return structured results. Add an outputSchema to get_post_results and return structuredContent next to the text. Clients that understand structured output can then use the numbers directly.

8. Design notes

The server holds no business logic. The product rules, such as the 31-day window and the Bluesky limit, live behind SchedulingApi. The MCP layer checks the shape of the inputs, calls the API and shapes the results for the model. If the rules lived in the tools, the product's web app and the MCP server could disagree about what is allowed. With the rules behind one API, there is one source of truth, and you can replace the mock with a real client without touching a tool.

Errors are sentences. A model cannot act on a stack trace, and a stack trace can leak internals. Each expected error returns isError: true and a short sentence that says what went wrong and what to do next: "Cut at least 76 characters, or leave the Bluesky channel (ch_bluesky) out of this draft." With that, the model usually fixes the call by itself, or asks the user one clear question. Unexpected errors go to stderr with full details for you, and the model gets a short, safe sentence.

Read tools and action tools are separate. list_scheduled_posts and get_post_results only read, and readOnlyHint: true tells the client so. A client can let the user approve them once and run them freely after that. create_draft_post is the only action, and it is small: it saves a draft and nothing else. A small action surface makes mistakes cheap. Each action tool you add should do one thing, say in its description what it never does, and carry honest annotations.

Annotations are hints, not security. A client uses them to decide when to ask the user. A server can lie in its annotations, so a client must not trust them from an unknown server, and your API must enforce every rule itself.

Security notes.

  • Secrets come only from environment variables. .env.example lists the names with no values. .env is in .gitignore.

  • Never log a secret. The HTTP server reports a missing token, but it never prints one.

  • Over stdio, stdout is the protocol. Log to stderr.

  • The HTTP server binds to 127.0.0.1, checks Origin against an allow list, and compares tokens in constant time.

  • A shared token is for demos only. A real remote server needs per-user authorization, such as OAuth.

  • Treat tool inputs as untrusted. The model writes them, and a user, or text the model has read, can steer the model. Check inputs in the schema, and again in the API.

  • Treat tool results as data, not instructions. A post text could contain words that look like instructions to the model. Keep that in mind when a tool returns text that users or third parties wrote.

License

MIT. See LICENSE. Moss & Fern and all data in this repository are made up.

Available Tools

3 tools
create_draft_postCreate a draft postA

Saves a new draft post in the workspace's calendar. Use it when the user asks you to write, draft or prepare a post and wants to keep it. This tool only saves a draft. It never schedules or publishes anything, and nobody outside the workspace sees the draft. Each call creates a new draft, so call it once per post. If the user wants changes, show the new text first and save it only when they agree. channel_ids are optional. Get them from the channels list in a list_scheduled_posts result. Bluesky allows at most 300 characters. If the text is too long for a channel, the tool refuses the draft and says how many characters to cut.

NameRequiredDescriptionDefault
textYesThe full post text, 1 to 2,000 characters.
channel_idsNoThe channels the post is meant for, for example ["ch_linkedin"]. Leave it out to save a draft with no channel yet.

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Goes well past the annotations: annotations only say it is a non-read-only, non-idempotent, non-destructive write. The description adds the privacy envelope ('nobody outside the workspace sees the draft'), the non-idempotency consequence in plain language ('each call creates a new draft'), and the failure mode ('refuses the draft and says how many characters to cut'), including the Bluesky 300-character rule.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loaded with the action and scope, and most sentences carry distinct information. 'channel_ids are optional' partly restates the schema default, and the guidance sentence about showing new text is slightly buried mid-paragraph, but overall there is little waste.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

With no output schema, the description covers failure behavior and side effects well for a two-parameter create tool. It stops short of saying what a successful call returns (e.g., a draft id for later retrieval), which is the one thing an agent might still need to infer.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is already 100%, so the baseline is 3. The description still earns credit by explaining where channel_ids values come from ('the channels list in a list_scheduled_posts result') and by explaining the per-channel length constraint that is not expressed in the 2,000-char schema maxLength.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource with scope: 'Saves a new draft post in the workspace's calendar.' The draft-only nature immediately distinguishes it from the read-oriented siblings get_post_results and list_scheduled_posts.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Gives an explicit trigger ('when the user asks you to write, draft or prepare a post and wants to keep it'), explicit exclusions ('never schedules or publishes anything'), a cardinality rule ('call it once per post') and a confirmation workflow ('show the new text first and save it only when they agree').

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

get_post_resultsGet post resultsA
Read-only

Returns the numbers (impressions, likes, comments, shares) for posts published in the last 7, 30 or 90 days, one row per post and channel, newest post first. Use it when the user asks how posts performed or which post did best. A null number means the network has not reported it yet, or does not report it at all. Never treat null as zero: say the number is not reported. Networks count in different ways, so never add numbers across networks. Compare posts within one network. This tool only reads.

NameRequiredDescriptionDefault
daysNoHow many days to look back: 7, 30 or 90.

TDQS

A4.3/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already declare readOnlyHint and openWorldHint, and the description reinforces this ('This tool only reads'). It adds real non-annotation behavior: null semantics (unreported vs never-reported), the rule not to sum across networks, and the ordering of results. Missing pagination/result-size limits keeps it from a 5.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Return shape, usage trigger, null-handling rules and read-only note are all front-loaded and each sentence carries information an agent would otherwise guess wrong on. No filler or restated title.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

There is no output schema, and the description compensates by enumerating the returned metrics, row granularity, ordering, and null meaning. For a one-parameter read tool this covers everything needed to call and interpret results correctly.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100% with a single enum-typed 'days' parameter defaulting to 30, so the schema already does the work. The description mirrors the 7/30/90 window but adds no syntax or default-behavior detail beyond the schema. Baseline 3 applies.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource ('Returns the numbers (impressions, likes, comments, shares) for posts published in the last 7, 30 or 90 days'), plus granularity (one row per post and channel) and ordering (newest post first). The siblings (create_draft_post, list_scheduled_posts) are clearly different operations, so an agent can route unambiguously.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicit trigger is given: 'Use it when the user asks how posts performed or which post did best.' No explicit when-not or named alternative, but the sibling set (drafting, scheduled posts) makes the boundary obvious enough. Slightly short of a 5, which would name the alternative for scheduled/future posts.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

list_scheduled_postsList scheduled postsA
Read-only

Lists posts in the workspace's social media calendar. Use it to answer questions such as "What is scheduled next week?" or "What did we publish this month?", and to look up channel ids before you call create_draft_post. status "upcoming" (the default) returns scheduled posts, "published" returns posts that went out, and "drafts" returns drafts that have no time yet. Without from and to, upcoming covers the next 14 days and published covers the last 14 days. A window can be at most 31 days long. Drafts ignore from and to. Times in the result are UTC. Convert them to the workspace time zone, which the result also gives, when you talk to the user. This tool only reads. It never changes, schedules or publishes a post.

NameRequiredDescriptionDefault
toNoEnd of the time window (not included), ISO 8601 with an offset.
fromNoStart of the time window, ISO 8601 with an offset, for example 2026-11-02T00:00:00-05:00.
statusNoWhich posts to list.upcoming

TDQS

A5/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already declare readOnlyHint=true, and the description reinforces it while adding behavior annotations don't capture: the 31-day maximum window, 14-day default ranges per status, the fact that 'to' is exclusive, that drafts ignore the window, and that result times are UTC against a workspace time zone included in the response. That is substantial added context beyond the structured fields.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loaded with purpose and the questions it answers, then rules ordered by likely need (status modes, defaults, window cap, drafts, time zone, read-only reassurance). Every sentence carries a distinct constraint or instruction with no filler.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

With no output schema, the description still tells the agent what the result contains (UTC timestamps plus the workspace time zone) and instructs it to convert before speaking to the user. Combined with default ranges and the 31-day cap, nothing needed to call this correctly is missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100%, but the schema's status description is only "Which posts to list," and the description goes well beyond it by defining upcoming/published/drafts and their default date ranges. It also clarifies the from/to window limit and that drafts ignore both parameters, adding real semantics the schema does not carry.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description opens with a specific verb and resource plus scope: "Lists posts in the workspace's social media calendar." It also distinguishes itself from the sibling create_draft_post (read-only lookup of channel ids for that call), so an agent can route without opening either schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

It gives concrete question patterns the tool answers ("What is scheduled next week?"), explains each status mode's meaning, states the default windows for upcoming vs published when from/to are omitted, and notes that drafts ignore from/to. It also states the key when-not boundary: this tool never changes, schedules or publishes a post.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 3 tool updatesv0.1.0
    • First observedcreate_draft_post
    • First observedget_post_results
    • First observedlist_scheduled_posts

TDQS

A4.4/5.0

Scored across 3 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: creating drafts, reading performance results, and listing calendar posts. The descriptions explicitly distinguish read-only operations from the draft-creation action, so an agent can select correctly.

Naming Consistency5/5

All three tools use consistent snake_case verb_noun naming: create_draft_post, get_post_results, list_scheduled_posts. The verbs are predictable and align with their actions.

Tool Count4/5

Three tools is on the low side but appropriate for the narrow demonstrated workflow of drafting, listing, and reading results. Each tool earns its place without redundancy.

Completeness2/5

The surface supports creating drafts and reading calendar/results, but lacks editing, deleting, scheduling, and publishing operations. For a social media calendar, these are significant gaps that would block common user requests.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • F
    license
    A
    quality
    F
    maintenance
    Enables managing Buffer social media posts via Claude, including creating, scheduling, and viewing posts across connected accounts through Buffer's GraphQL API.
    7
    4
    -
  • A
    license
    A
    quality
    C
    maintenance
    Enables drafting, scheduling, and publishing social posts on Vibe Marketing workspace through chat in Claude Code, Claude Desktop, or Cursor.
    14
    MIT