Build a Real MCP Server with an AI Coding Agent: a Local Decision Log Tool from Scratch

Most “build your first MCP server” tutorials teach you how to register a tool. That is the easy part now. AI coding agents can generate MCP SDK boilerplate in seconds.

The harder part is designing a tiny interface an AI agent can use reliably: when should it search, when should it fetch source material, what should be exposed as read-only context, and how do you add write actions without letting the model casually mutate local files?

So instead of another weather or calculator server, we will build a local Decision Log MCP server. It exposes Markdown decision records to Claude, Cursor, Hermes, or any MCP client. The example is small, but it has the same shape as real internal tools: search, source retrieval, resources, prompts, safe mutation, and boring-but-critical debugging.

Target reader: you are a developer who can code and you use AI coding agents, but you have not yet built a useful MCP server. This is not a production SaaS MCP server, auth tutorial, or enterprise deployment guide. It is a local-first example for learning the design and debugging habits that transfer to real tools.

If you only copy one habit from this article: design the MCP surface before you code, then verify it with MCP Inspector before connecting a chat client. Most beginner pain comes from trying to debug protocol wiring, tool design, client config, and model behavior at the same time.

The server lets an AI agent answer questions like:

  • “Why did we decide to keep the first MCP server read-only?”
  • “What decisions mention pricing?”
  • “Add a draft decision record for using Markdown as the storage format.”

It exposes:

  • a search tool,
  • a read-one-record tool,
  • a guarded dry-run write tool,
  • a read-only index resource,
  • and a prompt that teaches the model the workflow.

Download the working code:

The ZIP contains the full Node.js MCP server, sample decision records, README, and pinned Inspector scripts.


Why this example is better than another weather server

Source code: https://github.com/warble42/timtalks-code-examples/tree/main/mcp/decision-log-server. GitHub is the canonical source for this example.

Most first MCP tutorials teach syntax. Syntax is not the hard part anymore. The real MCP skill is product design for agents: creating a small surface that is safe, understandable, testable, and useful.

The hard parts are:

  1. Deciding what should be a tool, resource, or prompt.
  2. Writing tool descriptions the model can actually use.
  3. Avoiding stdio mistakes that make the server silently fail.
  4. Testing with MCP Inspector before blaming the chat client.
  5. Adding write actions without giving the model too much power.
  6. Debugging the server with an AI coding agent without letting it rewrite everything randomly.

A decision log is a good teaching example because it forces those choices.

A calculator MCP server can be one tool. A decision log needs a workflow:

  1. search records;
  2. pick a relevant id;
  3. read full context;
  4. explain the decision;
  5. optionally draft a new decision record.

A model cannot do this well from a single vague function call. It has to discover, inspect, and then explain from source material. That is where MCP design starts to matter.


The finished tool surface

Before code, define the interface the model will see.

Tools

search_decisions

Search local decision records by title, body, tag, or status.

Use when the user asks anything like:

  • “what did we decide about pricing?”
  • “find decisions related to stdio”
  • “why did we choose read-only first?”

Input:

{
  "query": "pricing",
  "limit": 5
}

Output: short matching summaries.

get_decision

Fetch the full Markdown text for one decision id.

Use after search_decisions when the model needs details or rationale.

Input:

{
  "id": "0002-pricing-scope"
}

Output: full decision record.

add_decision

Create a new Markdown decision record.

Important: it defaults to dryRun: true. The first version previews the file instead of writing it.

Input:

{
  "title": "Use Markdown decision logs",
  "context": "We need a toy MCP data source that is useful but safe.",
  "decision": "Store small decision records as Markdown files.",
  "consequences": "Easy to inspect, version, and search.",
  "tags": "mcp,docs",
  "status": "proposed",
  "dryRun": true
}

Resource

decisions://index

A read-only index of available decisions.

This is a resource, not a tool, because it is context the client/user may want to browse or attach directly.

Prompt

explain_project_decision

A reusable workflow prompt:

Search the decision log for this topic, read the most relevant record, then explain the context, decision, consequences, and open questions.

This is a prompt, not a tool, because it packages a workflow for the model rather than performing an action itself.


Step 1: Ask an AI coding agent for the design first

Do not start by asking the agent to “build an MCP server.” That gets you plausible boilerplate with weak boundaries.

Use this instead:

I want to build a local stdio MCP server in Node.js using @modelcontextprotocol/sdk.

Workflow:
I keep Markdown decision records in a ./decisions folder. I want an AI agent to answer questions like:
- “why did we keep the first MCP server read-only?”
- “what decisions mention pricing?”
- “draft a new decision record for using Markdown decision logs.”

Before writing code, propose the MCP surface.

Return:
1. Tool names, descriptions, and input schemas.
2. Which parts should be resources instead of tools.
3. Which repeatable workflow should be a prompt.
4. Safety choices for a first version.
5. Inspector commands to verify each surface.

Constraints:
- Use stdio transport first.
- Do not use HTTP/auth/deployment yet.
- Keep the first server local and small.
- Search/read tools should be read-only.
- Any write tool must default to dry-run.
- Never write ordinary logs to stdout.

Good agents should propose something close to the surface above. If they immediately generate code, stop them and ask for the interface first.

What I do not want from the agent:

  • one vague query_decisions tool that sometimes searches and sometimes reads full files;
  • a general-purpose write_file tool with arbitrary paths;
  • HTTP, OAuth, a database, a UI, or deployment in the first pass;
  • twenty overlapping tools the model cannot choose between.

This is the first habit: MCP design before MCP implementation. Ask for the product boundary before asking for code.


Step 2: Create the project

mkdir mcp-decision-log-example
cd mcp-decision-log-example
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install --save-dev @modelcontextprotocol/[email protected]
mkdir -p src decisions

Set type: module and scripts in package.json:

{
  "name": "mcp-decision-log-example",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "inspect:list-tools": "npx -y @modelcontextprotocol/[email protected] --cli node src/server.js --method tools/list",
    "inspect:search": "npx -y @modelcontextprotocol/[email protected] --cli node src/server.js --method tools/call --tool-name search_decisions --tool-arg query=pricing",
    "inspect:get": "npx -y @modelcontextprotocol/[email protected] --cli node src/server.js --method tools/call --tool-name get_decision --tool-arg id=0002-pricing-scope",
    "inspect:dry-run-add": "npx -y @modelcontextprotocol/[email protected] --cli node src/server.js --method tools/call --tool-name add_decision --tool-arg title='Use Markdown decision logs' --tool-arg context='We need a toy MCP data source that is useful but safe.' --tool-arg decision='Store small decision records as Markdown files.' --tool-arg consequences='Easy to inspect, version, and search.' --tool-arg tags='mcp,docs' --tool-arg dryRun=true",
    "inspect:list-resources": "npx -y @modelcontextprotocol/[email protected] --cli node src/server.js --method resources/list",
    "inspect:read-index": "npx -y @modelcontextprotocol/[email protected] --cli node src/server.js --method resources/read --uri decisions://index",
    "inspect:list-prompts": "npx -y @modelcontextprotocol/[email protected] --cli node src/server.js --method prompts/list",
    "inspect:get-prompt": "npx -y @modelcontextprotocol/[email protected] --cli node src/server.js --method prompts/get --prompt-name explain_project_decision --prompt-args topic=stdio"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.29.0",
    "zod": "^4.1.13"
  },
  "devDependencies": {
    "@modelcontextprotocol/inspector": "0.22.0"
  }
}

Why put Inspector commands in package.json? Because every MCP tutorial should leave the reader with reproducible tests. A chat client is not a test harness. Inspector is.

I pin Inspector to 0.22.0 because CLI flags change. That is not glamorous, but reproducibility beats “works on my cache.” This draft was verified with Node v22.23.1 and npm 10.9.8; the example is intended for Node 20+.


Step 3: Add real toy data

Create decisions/0003-stdio-logging.md:

---
id: 0003-stdio-logging
title: Never write ordinary logs to stdout in stdio MCP servers
tags: mcp,debugging,stdio
status: accepted
---

# Never write ordinary logs to stdout in stdio MCP servers

## Context

A stdio MCP server uses stdout for JSON-RPC messages. Human-readable logs, banners, progress bars, or accidental console.log output can corrupt the protocol stream.

## Decision

Development logs go to stderr. Production diagnostics should use MCP logging facilities or a separate log file.

## Consequences

- Inspector and clients can parse server messages reliably.
- Debugging output is still visible in terminal and app logs.
- Code review should search for accidental console.log calls before release.

Add a couple more records so search has something to search. The example ZIP includes:

  • 0001-keep-first-mcp-server-read-only.md
  • 0002-pricing-scope.md
  • 0003-stdio-logging.md

Use real-ish sample data. If every record says “foo bar baz,” you cannot test whether the model is making grounded choices.

This is another place where AI coding agents help. Ask:

Create three realistic Markdown decision records for this tutorial. They should cover:
1. keeping the first MCP server read-only,
2. pricing tiny products by saved pain instead of feature count,
3. stdio logging rules for MCP.

Use frontmatter fields: id, title, tags, status.
Use sections: Context, Decision, Consequences.
Keep each under 250 words.

Then read the files. Do not trust generated examples blindly. In tutorials, fake-looking data makes the whole thing feel fake.


Step 4: Implement file loading

In src/server.js:

#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DECISIONS_DIR = path.join(__dirname, '..', 'decisions');

Use a tiny frontmatter parser:

function parseFrontmatter(text) {
  if (!text.startsWith('---\n')) return { meta: {}, body: text };
  const end = text.indexOf('\n---\n', 4);
  if (end === -1) return { meta: {}, body: text };

  const raw = text.slice(4, end).trim();
  const body = text.slice(end + 5).trim();
  const meta = {};

  for (const line of raw.split('\n')) {
    const [key, ...rest] = line.split(':');
    if (!key || rest.length === 0) continue;
    meta[key.trim()] = rest.join(':').trim();
  }

  meta.tags = (meta.tags ?? '').split(',').map((tag) => tag.trim()).filter(Boolean);
  return { meta, body };
}

This is intentionally simple. The tutorial is not about YAML edge cases. If your real decision records need full YAML, ask the agent to use a proper parser and add tests.

Load files:

async function loadDecisionFile(fileName) {
  const fullPath = path.join(DECISIONS_DIR, fileName);
  const text = await fs.readFile(fullPath, 'utf8');
  const { meta, body } = parseFrontmatter(text);
  return { ...meta, fileName, body, text };
}

async function listDecisions() {
  const files = (await fs.readdir(DECISIONS_DIR))
    .filter((file) => /^\d{4}-[a-z0-9-]+\.md$/.test(file))
    .sort();
  return Promise.all(files.map(loadDecisionFile));
}

Pain point: path handling. Use paths relative to server.js, not the process working directory. Many “works in terminal, fails in client” problems are really working-directory problems.

Agent check: ask your coding agent, “Does this server depend on the current working directory? If yes, rewrite it to resolve data paths relative to the server file.”


Step 5: Register the search tool

const server = new McpServer({ name: 'decision-log-example', version: '1.0.0' });

server.registerTool(
  'search_decisions',
  {
    title: 'Search decision log',
    description: 'Search local decision records by title, body, status, or tag. Use this before answering questions about why a project decision was made.',
    inputSchema: {
      query: z.string().min(2).describe('Keyword or tag to search for, for example pricing, stdio, safety, or MCP'),
      limit: z.number().int().min(1).max(20).default(5).describe('Maximum number of matching decisions to return')
    }
  },
  async ({ query, limit }) => {
    const q = query.toLowerCase();
    const decisions = await listDecisions();
    const matches = decisions.filter((decision) => {
      const haystack = [decision.id, decision.title, decision.status, decision.body, ...(decision.tags ?? [])]
        .join('\n')
        .toLowerCase();
      return haystack.includes(q);
    }).slice(0, limit);

    return {
      content: [{
        type: 'text',
        text: matches.length
          ? matches.map(renderDecisionSummary).join('\n')
          : `No decisions matched "${query}".`
      }]
    };
  }
);

Principle: tool descriptions should teach call order, not just capability.

Bad: “Search decisions.”

Better: “Search local decision records by title, body, status, or tag. Use this before answering questions about why a project decision was made.”

If your AI coding agent generates vague descriptions, ask for a rewrite:

Rewrite the tool descriptions so an LLM knows when to use each tool and what kind of user request should trigger it. Include concrete examples in parameter descriptions.

Step 6: Register the read tool

server.registerTool(
  'get_decision',
  {
    title: 'Read one decision record',
    description: 'Fetch the full text of a decision by id. Use this after search_decisions when the user asks for details or rationale.',
    inputSchema: {
      id: z.string().regex(/^\d{4}-[a-z0-9-]+$/).describe('Decision id, for example 0003-stdio-logging')
    }
  },
  async ({ id }) => {
    const decisions = await listDecisions();
    const decision = decisions.find((item) => item.id === id);
    if (!decision) return { content: [{ type: 'text', text: `Decision ${id} was not found.` }] };
    return { content: [{ type: 'text', text: renderDecision(decision) }] };
  }
);

Why split search and get? Because the model often needs a two-step workflow. Search results should be short. Full records can be longer. Separate tools make behavior more predictable and easier to debug.

Without the split, the model may summarize from a thin index or guess from partial results. With the split, the expected behavior is explicit:

  1. search_decisions("read-only")
  2. get_decision("0001-keep-first-mcp-server-read-only")
  3. answer from the full source record.

Step 7: Add a write tool, but make it safe by default

A tutorial that never writes anything dodges an important MCP question. But a tutorial that casually gives the model write access teaches the wrong lesson.

Do not expose a general-purpose write_file tool from your first MCP server. Build the narrow mutation you actually want.

This example is a guarded mutation example, not a fully read-only server. dryRun: true is a default, not an authorization boundary. A real client/model can still request dryRun: false, so remove the tool or split preview/apply if you need strict read-only behavior.

So add_decision defaults to dry-run:

server.registerTool(
  'add_decision',
  {
    title: 'Add a decision record',
    description: 'Create a new local Markdown decision record. Defaults to dry-run so the user can inspect the file before writing.',
    inputSchema: {
      title: z.string().min(5).max(120).regex(/^[^\r\n]+$/).describe('Short one-line decision title'),
      context: z.string().min(10).max(2000).describe('Why this decision is needed'),
      decision: z.string().min(10).max(2000).describe('The decision that was made'),
      consequences: z.string().min(10).max(2000).describe('Expected trade-offs or follow-up consequences'),
      tags: z.string().max(120).regex(/^[a-zA-Z0-9, _-]+$/).default('general').describe('Comma-separated tags, for example mcp,debugging'),
      status: z.enum(['proposed', 'accepted', 'rejected']).default('proposed'),
      dryRun: z.boolean().default(true).describe('When true, preview the file without writing it')
    }
  },
  async ({ title, context, decision, consequences, tags, status, dryRun }) => {
    const decisions = await listDecisions();
    const id = `${nextId(decisions)}-${slugify(title)}`;
    const fileName = `${id}.md`;
    const tagList = tags.split(',').map((tag) => tag.trim()).filter(Boolean).join(',');
    const text = `---\nid: ${id}\ntitle: ${title}\ntags: ${tagList}\nstatus: ${status}\n---\n\n# ${title}\n\n## Context\n\n${context}\n\n## Decision\n\n${decision}\n\n## Consequences\n\n${consequences}\n`;

    if (!dryRun) {
      await fs.writeFile(path.join(DECISIONS_DIR, fileName), text, { flag: 'wx' });
    }

    return {
      content: [{
        type: 'text',
        text: `${dryRun ? 'Dry run only; no file was written.' : `Wrote ${fileName}.`}\n\n${text}`
      }]
    };
  }
);

Safety choices worth copying:

  • writes only to one known folder;
  • generated file name, not arbitrary user path;
  • dryRun default true;
  • flag: 'wx' avoids overwriting existing files;
  • one-line bounded title;
  • bounded body fields;
  • constrained tag characters;
  • structured fields instead of “write arbitrary Markdown anywhere.”

If an AI coding agent gives you a write tool with an arbitrary path parameter, reject it for a first version.


Step 8: Add a resource

server.registerResource(
  'decision-index',
  'decisions://index',
  {
    title: 'Decision index',
    description: 'A read-only index of available local decision records.',
    mimeType: 'text/markdown'
  },
  async (uri) => {
    const decisions = await listDecisions();
    return {
      contents: [{
        uri: uri.href,
        mimeType: 'text/markdown',
        text: ['# Decision index', '', ...decisions.map(renderDecisionSummary)].join('\n')
      }]
    };
  }
);

A common beginner mistake is turning everything into a tool. A tool is something the model calls to do work. A resource is context the client or user can inspect and attach.

In this example:

  • search_decisions is a tool because the model chooses to search.
  • decisions://index is a resource because it is stable, read-only context that should not require a tool call every time someone wants to browse what exists.

Step 9: Add a prompt

server.registerPrompt(
  'explain_project_decision',
  {
    title: 'Explain a project decision',
    description: 'Guide the model to search the decision log, read the relevant record, and explain the rationale clearly.',
    argsSchema: {
      topic: z.string().min(2).describe('Topic to explain, such as pricing, stdio, or read-only')
    }
  },
  async ({ topic }) => ({
    messages: [{
      role: 'user',
      content: {
        type: 'text',
        text: `Use the search_decisions tool to find decisions about "${topic}". Then call get_decision for the most relevant id. Explain the decision in plain English with: context, decision, consequences, and open questions.`
      }
    }]
  })
);

Prompts are underrated in first MCP servers. They package the correct tool sequence instead of merely exposing tools and hoping the model discovers the workflow every time.

A prompt can tell the model to use tools, but the prompt is not itself a tool.


Step 10: Connect stdio correctly

async function main() {
  // Stdio MCP rule: stdout is JSON-RPC. Keep stderr quiet unless debugging.
  if (process.env.DEBUG) console.error('decision-log-example MCP server starting on stdio');
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main().catch((error) => {
  console.error('Fatal MCP server error:', error);
  process.exit(1);
});

This is the MCP bug that feels stupid after you find it: do not write ordinary logs to stdout. Your AI coding agent may add console.log('server started'). In stdio mode, that can corrupt the protocol stream.

In development, stderr is acceptable for human-readable logs. In production, keep stderr quiet unless debugging or reporting actual failures, because some clients surface stderr prominently.

Ask the agent to check:

Search the MCP server for anything that writes to stdout. In stdio mode, stdout must be reserved for JSON-RPC. Move human-readable logs to stderr and explain each change.

Step 11: Test with Inspector

Run:

npm install
npm run inspect:list-tools
npm run inspect:search
npm run inspect:get
npm run inspect:dry-run-add
npm run inspect:list-resources
npm run inspect:read-index
npm run inspect:list-prompts
npm run inspect:get-prompt

Verified search_decisions output:

{
  "content": [
    {
      "type": "text",
      "text": "- 0002-pricing-scope: Price tiny products by saved pain, not feature count [proposed] tags=pricing, product, validation"
    }
  ]
}

Verified decisions://index output:

{
  "contents": [
    {
      "uri": "decisions://index",
      "mimeType": "text/markdown",
      "text": "# Decision index\n\n- 0001-keep-first-mcp-server-read-only: Keep the first MCP server read-only [accepted] tags=mcp, safety, tutorial\n- 0002-pricing-scope: Price tiny products by saved pain, not feature count [proposed] tags=pricing, product, validation\n- 0003-stdio-logging: Never write ordinary logs to stdout in stdio MCP servers [accepted] tags=mcp, debugging, stdio"
    }
  ]
}

Only after this works should you configure a chat client. If you see JSON parse errors in Inspector, suspect stdout pollution before you suspect Claude, Cursor, or Hermes.


Step 12: Configure a client

Most clients use a config shape like this:

{
  "mcpServers": {
    "decision-log-example": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-decision-log-example/src/server.js"]
    }
  }
}

Use an absolute path for the script. If a GUI-launched client cannot find Node, use an absolute Node path for command too, such as /usr/local/bin/node. This example does not need cwd because it resolves the decisions folder relative to server.js.

Do not debug your server through the chat UI first. The chat UI adds another layer of model behavior, caching, permissions, and client-specific UI choices.

Then ask:

Use the decision log MCP server to explain why we decided to keep the first MCP server read-only.

The model should:

  1. call search_decisions with something like read-only;
  2. find 0001-keep-first-mcp-server-read-only;
  3. call get_decision;
  4. explain the rationale.

If it does not, do not immediately rewrite the server. Debug the surface.


Debugging manually

Inspector cannot list tools

Likely server-side. Check:

  • Does node src/server.js start without import errors?
  • Did npm install the SDK version you expect?
  • Are you using ESM imports with type: module?
  • Did you accidentally write to stdout?
  • Is the server crashing before server.connect()?

Inspector lists tools, but tool calls fail

Likely implementation or schema. Check:

  • Does the data folder path resolve relative to server.js?
  • Is the tool argument name exactly what your script passes?
  • Does Zod reject your input?
  • Does the tool return MCP content, not a random object shape?

Inspector works, but the chat client does not

Likely client config or model-selection behavior. Check:

  • absolute path in config;
  • client restart after config edits;
  • environment variables available to the app;
  • tool descriptions too vague;
  • overlapping tools confusing the model;
  • user request not explicitly asking to use the server.

Debugging with an AI coding agent

The loop is:

  1. Reproduce with Inspector.
  2. Paste the exact command and output to the coding agent.
  3. Ask for the smallest fix.
  4. Re-run the same Inspector command.
  5. Only then restart/connect the chat client.

The most useful debugging prompt is not “fix this.” It is a triage prompt:

This MCP server fails during this Inspector command:

[command]

Output:
[paste exact output]

Do not rewrite the whole server. First classify the failure as one of:
1. package/import/version issue,
2. MCP schema or return-shape issue,
3. stdio/stdout logging issue,
4. file path or working-directory issue,
5. Inspector command syntax issue,
6. client config issue.

Then propose the smallest fix and the exact command to verify it.

For code review before enabling the server:

Review this MCP server before I enable it in an agent client.

Look for:
- accidental stdout writes in stdio mode;
- vague tool names/descriptions;
- schema fields without useful descriptions or examples;
- mutation tools without dry-run, overwrite protection, or path restrictions;
- relative paths likely to fail under a client;
- too many overlapping tools;
- missing Inspector verification scripts.

Return prioritized fixes. Do not edit yet.

This is where AI coding agents become the main interface. They generate the server, but they also help you inspect the agent-facing surface.


The reusable pattern

The decision log is not the point. The pattern is:

  • expose narrow search over private/local data;
  • return source records, not just summaries;
  • make stable context available as a resource;
  • package common tool sequences as prompts;
  • put guardrails around mutation;
  • verify everything with Inspector before blaming the client.

That is the real first MCP skill. Not “can I register a tool,” but “can I design a small surface an AI agent can use safely and predictably?”


Where to go next

My bias: build small boring tools that make AI agents useful inside real workflows before chasing broad automation.

Good second MCP projects are still boring:

  • expose a local runbook folder;
  • search support tickets exported to Markdown/JSON;
  • inspect the latest CI/build artifact summaries;
  • query a small product/pricing knowledge base;
  • expose research notes with source links;
  • create draft changelog entries from commits.

Do not start with a giant multi-tool platform. Start with one workflow you already understand and make it agent-accessible.

That is also the commercial lesson. “I can build an MCP server” will not be rare. Some near-term value may come from small private tools, not public MCP marketplaces: tools that expose one painful internal workflow to an agent safely.

For each candidate workflow, ask:

  • What should the model search first?
  • What source record should it fetch before answering?
  • What context can be exposed read-only?
  • What mutation, if any, needs dry-run or confirmation?
  • What prompt packages the repeatable workflow?
  • How will I verify it before connecting a client?

Knowing which painful workflow should become an MCP server — and making it safe, small, and easy to use — is where the durable value is.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Discover more from Tim Talks Tech

Subscribe now to keep reading and get access to the full archive.

Continue reading