Lab 2 — Build the StackLog MCP Server

The most important lab of Day 1

Until now you've used tools. Now you build one.
Every line you write here will be called by an AI model
and read by the React app you build tomorrow.

Tools: Antigravity IDE + integrated terminal + MCP Inspector
Artifact: your own MCP server, pushed to GitHub
Store: a JSON file — the same data tomorrow's app will display

⚠️ Environment note (read once)

The facilitator master uses Windows + PowerShell. This worksheet is cross-platform and uses Antigravity as the MCP host.

  • Use the Antigravity integrated terminal for all commands.
  • Where a command differs by OS, both are shown: 🪟 Windows / 🍎🐧 macOS / Linux.
  • MCP registration is via Antigravity's mcp_config.json, not claude_desktop_config.json.
  • ⚠️ Check current UI: Antigravity updates often — exact menu labels may differ; find the equivalent.

What you'll build

Two tools, one JSON store:

  • create_entry — writes a journal entry (id, title, tags, content, timestamp)
  • search_entries — full-text search over the store, returns matches
AI agent ──calls──▶ your MCP server ──writes──▶ stacklog-entries.json
 (Antigravity)      (create_entry /              (StackLog's memory)
                     search_entries)

Phase 1 · Scaffold the project (15 min)

Step 1 — Create the project folder

In the Antigravity integrated terminal:

🪟  cd $env:USERPROFILE\stacklog-workshop
🍎🐧 cd ~/stacklog-workshop
mkdir stacklog-mcp
cd stacklog-mcp
npm init -y

✅ A package.json now exists in stacklog-mcp.

Step 2 — Replace package.json

Open package.json and replace the entire contents:

{
  "name": "stacklog-mcp",
  "version": "1.0.0",
  "description": "StackLog MCP server — journal entry management",
  "type": "module",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "dev":   "tsx src/index.ts",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0",
    "zod": "^3.22.4"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.0.0",
    "tsx": "^4.0.0"
  }
}

Step 3 — Install dependencies

npm install

node_modules created, no errors.
⚠️ If you see ERESOLVE / peer-dependency warnings: npm install --legacy-peer-deps

Step 4 — Create tsconfig.json

New file tsconfig.json in stacklog-mcp:

{
  "compilerOptions": {
    "target":           "ES2022",
    "module":           "Node16",
    "moduleResolution": "Node16",
    "outDir":           "./dist",
    "rootDir":          "./src",
    "strict":           true,
    "esModuleInterop":  true,
    "skipLibCheck":     true
  },
  "include": ["src/**/*"]
}

Step 5 — Create the source file

🪟  New-Item -ItemType Directory src ; New-Item src\index.ts
🍎🐧 mkdir src && touch src/index.ts

Checkpoint: structure is stacklog-mcp/src/index.ts, package.json, tsconfig.json, node_modules/
Confirm the SDK installed: the folder node_modules/@modelcontextprotocol/sdk should exist.

Phase 2 · Imports, storage, create_entry (45 min)

Step 6 — Imports and storage layer

Open src/index.ts. Type the first section:

import { Server }               from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import * as fs   from 'fs';
import * as path from 'path';
import { randomUUID } from 'crypto';

// ── Types ───────────────────────────────────────────────────────────────────
interface Entry {
  id:        string;
  title:     string;
  content:   string;
  tags:      string[];
  timestamp: string;
}

// ── Storage ───────────────────────────────────────────────────────────────
// Cross-platform: HOME on macOS/Linux, USERPROFILE on Windows.
const STORE_PATH = path.join(
  process.env.HOME || process.env.USERPROFILE || '.',
  'stacklog-entries.json'
);

Step 6 — storage layer (cont.)

function loadEntries(): Entry[] {
  try {
    if (fs.existsSync(STORE_PATH)) {
      const data = fs.readFileSync(STORE_PATH, 'utf-8');
      return JSON.parse(data) as Entry[];
    }
  } catch (e) {
    console.error('Error loading entries:', e);
  }
  return [];
}

function saveEntries(entries: Entry[]): void {
  fs.writeFileSync(STORE_PATH, JSON.stringify(entries, null, 2));
}

What each piece does (2-minute read):

  • Server + StdioServerTransport — the MCP SDK classes; Server handles the protocol, StdioServerTransport is the stdio transport from the MCP session.
  • CallToolRequestSchema / ListToolsRequestSchema — validators for the two request types the server handles.
  • STORE_PATH — the JSON file where entries live (in your home dir). You'll replace this with SQLite tomorrow.

Step 7 — The create_entry tool definition

Add after the storage helpers:

// ── Tool definitions ─────────────────────────────────────────────────────────
const CREATE_ENTRY_TOOL = {
  name: 'create_entry',
  description:
    'Creates a new journal entry in the StackLog store. ' +
    'Call this when the user wants to save a note, insight, research ' +
    'finding, or any log item. Returns the new entry ID on success.',
  inputSchema: {
    type: 'object',
    properties: {
      title: {
        type: 'string',
        description: 'Short headline for the entry. Maximum 80 characters.',
      },
      content: {
        type: 'string',
        description: 'Full body text for the entry. Markdown is supported.',
      },
      tags: {
        type: 'array',
        items: { type: 'string' },
        description:
          'Optional list of lowercase topic tags. ' +
          'Example: ["react", "lab2", "mcp"]',
      },
    },
    required: ['title', 'content'],
  },
} as const;

Step 7 (cont.)

🧠 Why this schema works — this is the heart of the lab:

  • "…in the StackLog store" tells the model where it writes (not the filesystem).
  • "Call this when the user wants to save…" tells the model when to call it.
  • "Returns the new entry ID" tells the model what to expect back.
  • "Maximum 80 characters" is a constraint the model honours.
  • required: ['title','content'] means the model can't omit them → no empty entries.

You're programming the model's behaviour with prose.

Step 8 — Server setup + create_entry handler

// ── Server setup ─────────────────────────────────────────────────────────────
const server = new Server(
  { name: 'stacklog-mcp', version: '1.0.0' },
  { capabilities: { tools: {} } }
);

// List all available tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [CREATE_ENTRY_TOOL],   // ← we add SEARCH_ENTRIES_TOOL in Phase 3
}));

Step 8 — create_entry handler (cont.)

// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

Step 8 — create_entry case (cont.)

  // ── create_entry ──────────────────────────────────────────────────────────
  if (name === 'create_entry') {
    const { title, content, tags = [] } = args as {
      title:    string;
      content:  string;
      tags?:    string[];
    };

    const entries = loadEntries();
    const entry: Entry = {
      id:        randomUUID(),
      title,
      content,
      tags,
      timestamp: new Date().toISOString(),
    };
    entries.push(entry);
    saveEntries(entries);

Step 8 — create_entry response (cont.)

    return {
      content: [{
        type: 'text',
        text: `Entry created. ID: ${entry.id}. ` +
              `Title: "${title}". ` +
              `Total entries in store: ${entries.length}.`,
      }],
    };
  }

  throw new Error(`Unknown tool: ${name}`);
});

Step 8 — start the server (cont.)

// ── Start server ──────────────────────────────────────────────────────────────
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error('StackLog MCP server running on stdio');
}

main().catch(console.error);

Step 9 — Run it and verify it starts

npx tsx src/index.ts

✅ Expected: StackLog MCP server running on stdio — it's now waiting for JSON-RPC on stdin. Ctrl+C to stop.
⚠️ Cannot find module → check import paths end in .js (required by Node16 resolution, even for .ts).
⚠️ Cannot use import statement → confirm "type": "module" is in package.json.

Phase 3 · Add search_entries (20 min)

Step 10 — search_entries definition

Add after CREATE_ENTRY_TOOL:

const SEARCH_ENTRIES_TOOL = {
  name: 'search_entries',
  description:
    'Searches the StackLog store for entries matching a query. ' +
    'Use this to retrieve previously saved notes, find entries on a ' +
    'specific topic, or list recent activity. Returns matching entries ' +
    'with ID, title, tags, timestamp, and a content preview.',
  inputSchema: {
    type: 'object',
    properties: {
      query: {
        type: 'string',
        description:
          'Search terms matched against title, content, and tags. ' +
          'Case-insensitive. Pass an empty string to return all entries.',
      },
      limit: {
        type: 'number',
        description: 'Maximum results to return. Default: 10.',
      },
    },
    required: ['query'],
  },
} as const;

Step 11 — Register both tools

Update the ListToolsRequestSchema handler:

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [CREATE_ENTRY_TOOL, SEARCH_ENTRIES_TOOL],   // ← add SEARCH_ENTRIES_TOOL
}));

Step 12 — search_entries handler

Add this block before the throw new Error(...) line:

  // ── search_entries ────────────────────────────────────────────────────────
  if (name === 'search_entries') {
    const { query, limit = 10 } = args as {
      query:   string;
      limit?:  number;
    };

    const entries  = loadEntries();
    const qLower   = query.toLowerCase();

    const matched = query
      ? entries.filter(e =>
          e.title.toLowerCase().includes(qLower)   ||
          e.content.toLowerCase().includes(qLower) ||
          e.tags.some(t => t.toLowerCase().includes(qLower))
        )
      : entries;                   // empty query returns all entries

    const results = matched.slice(0, limit);

    if (results.length === 0) {
      return {
        content: [{ type: 'text', text: `No entries found matching "${query}".` }],
      };
    }

Step 12 — search_entries return (cont.)

    // Return a preview — not full content (saves context window space)
    return {
      content: [{
        type: 'text',
        text: JSON.stringify(
          results.map(e => ({
            id:        e.id,
            title:     e.title,
            tags:      e.tags,
            timestamp: e.timestamp,
            preview:   e.content.slice(0, 200) +
                       (e.content.length > 200 ? '...' : ''),
          })),
          null, 2
        ),
      }],
    };
  }

Step 12 (cont.)

🧠 Why a preview, not full content: the model's context window is finite. Returning 10 full entries could inject tens of thousands of tokens and crowd out reasoning. The 200-char preview is enough to judge relevance. Tomorrow you'll add a get_entry tool + SQLite for fetching one entry in full — the proper fix.

Tool Design — Beyond the Schema

In the MCP session, name · description · schema decided reliability. Building real tools adds four more rules (Google, Agent Tools):

Rule In your StackLog server
Publish tasks, not API calls create_entry is an action the agent takes, not "POST /entries"
Describe what, not how the description says what it's for — never "call the store API"
One tool, one job create_entry and search_entries stay separate, not one manage_entries
Design for concise output the 200-char preview — never dump full records into context

A tool is a contract the model reads at runtime. The clearer the task, the more reliably it gets called.

Error Messages Are Instructions, Too

A tool's error goes straight back into the model's context — so write it for the model, not just a log:

// ✗ a dead end — the model can't recover
throw new Error('not found');

// ✓ tells the model its next move
return { isError: true, content: [{ type: 'text',
  text: 'No entry with that ID. Call search_entries first to get a valid ID.' }] };

A good error turns a dead end into the agent's next step — the same "prose programs behaviour" idea, applied to failure.

Step 13 — Verify both tools in MCP Inspector

Run the server:

npx tsx src/index.ts

In a second terminal tab (the + in the Antigravity terminal panel):

npx @modelcontextprotocol/inspector

Connect via stdio. Then:

✅ Both tools appear: create_entry (title, content, tags) and search_entries (query, limit)
✅ Fire create_entrytitle:"Test entry", content:"Hello StackLog" → returns a UUID + count
✅ Fire search_entriesquery:"Test" → JSON array with one result

💡 MCP Inspector is host-agnostic — it talks to your server directly, so this step is identical to the original guide. It's the cleanest way to test before involving Antigravity.

Phase 4 · Register in Antigravity + end-to-end test (8 min)

Step 14 — Add StackLog to Antigravity's MCP config

Open Antigravity's MCP config. The reliable route:

Agent panel → "…" (Additional Options)MCP ServersManage MCP ServersView raw config

⚠️ Check current UI: labels vary by build. The file is mcp_config.json under ~/.gemini/antigravity/ (Windows: C:\Users\<you>\.gemini\antigravity\mcp_config.json). Using View raw config opens the correct file regardless of path.

Add the stacklog entry to mcpServers (alongside filesystem / ddg-search or mock-search from Lab 1):

{
  "mcpServers": {
    "filesystem":  { "...": "existing entry from Lab 1" },
    "ddg-search":  { "...": "existing entry from Lab 1 (or mock-search)" },

    "stacklog": {
      "command": "npx",
      "args": [
        "tsx",
        "ABSOLUTE_PATH_TO/stacklog-workshop/stacklog-mcp/src/index.ts"
      ]
    }
  }
}

Step 14 — register & refresh (cont.)

Get your absolute path:

🪟  in src/: cd          (prints full path; append \index.ts; double every \ → \\)
🍎🐧 in src/: pwd         (prints full path; append /index.ts)

💡 IDE shortcut: right-click index.tsCopy Path. On Windows, replace every \ with \\ in the JSON.
⚠️ npx tsx runs TypeScript directly (no build step) — best for the lab. In production: npm run build then point at dist/index.js.

After saving: Settings → Customizations → Installed MCP Servers → Refresh — or restart Antigravity. The server won't load until you do.

Step 15 — End-to-end test in Antigravity

The core test exercises only your two tools — it must pass regardless of the network:

Create a StackLog entry titled 'TypeScript Benefits' with content
"Type safety catches bugs at compile time; great tooling speeds up
large codebases." Tag it with [typescript, backend].
Then search StackLog for 'typescript' and confirm the entry is in
the results.

This exercises your Lab 2 code end-to-end:

  • create_entry (your code) → writes the entry
  • search_entries (your code) → reads it back

The model just called code you wrote 30 minutes ago.

Step 15 — bonus: three-server chain (cont.)

Bonus (only if search is working): prepend a search step to chain three servers:

Search the web for the top 2 benefits of TypeScript for backend
development. Then create a StackLog entry titled 'TypeScript Benefits'
with the findings as the content. Tag it with [typescript, backend].
Finally, search StackLog for 'typescript' and confirm the entry
is in the results.
  • web search (ddg-search or mock-search from Lab 1)
  • create_entry (your Lab 2 code)
  • search_entries (your Lab 2 code)

⚠️ Don't let search block the lab. If DuckDuckGo is rate-limited, run the core test above (no search) — that's the actual Lab 2 deliverable. The three-server chain is a bonus, and works fully offline if you point it at mock-search.

✅ Lab 2 success criteria

Verify all three:

  • [ ] create_entry returns a new UUID and a total count ≥ 1
  • [ ] stacklog-entries.json exists in your home dir — open it, show the JSON
  • [ ] search_entries returns a JSON array containing the entry you just created
🪟  type $env:USERPROFILE\stacklog-entries.json
🍎🐧 cat ~/stacklog-entries.json

"This file is StackLog's memory. Tomorrow's React app reads exactly this data structure."

Phase 5 · Push to GitHub (2 min)

Step 16 — Commit and push

🪟  cd $env:USERPROFILE\stacklog-workshop
🍎🐧 cd ~/stacklog-workshop
git add stacklog-mcp/
git commit -m "lab2: StackLog MCP server with create_entry and search_entries"
git push origin main

Lab 2 complete when stacklog-mcp is visible on GitHub.

Extension challenge — summarise_week (if you finish early)

Harder — it needs date-range filtering. Add a SUMMARISE_WEEK_TOOL definition (with start_date / end_date in YYYY-MM-DD) and a handler that filters loadEntries() by timestamp within the range (remember to make end_date inclusive by adding one day in ms). The full reference is in the starter repo's EXTENSION.md.

Troubleshooting (top 6)

  1. Cannot find module '@modelcontextprotocol/sdk/...'npm install didn't run, or an import path is missing .js. Node16 resolution needs explicit .js even on .ts files.
  2. Cannot use import statement in a module → add "type": "module" to package.json.
  3. Antigravity can't find the tool → path in mcp_config.json is relative/forward-slashed; use the absolute path (Windows: \\). And Refresh / restart Antigravity after editing. Confirm tsx is available (npm install -g tsx if needed).
  4. create_entry runs but no file appearsSTORE_PATH resolved somewhere unexpected; the code logs nothing by default, so add a console.error(STORE_PATH) at startup to see where it writes.
  5. Strict-mode type errors → keep the args as {…} casts; args arrives as unknown from the SDK.
  6. search_entries empty though entries exist → the running server's STORE_PATH differs from where create_entry wrote. Log it at startup; both must match; restart and re-create.

Bridge to Lab 3

You built and connected your own MCP server. The model called your code.
In Lab 3 we push further: one prompt, four tools, zero human input between the first call and the final result — the full agent loop, running live.

Before the break: keep the server runnable (npx tsx src/index.ts), keep stacklog-entries.json open in the IDE (it grows during Lab 3), and reopen MCP Inspector at the start of Lab 3 to watch the tool-call chain.