Day 1 D1-Lab2 Lab 90 minutes

Stop consuming tools. Start building them.

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.


What you'll build

Two tools, one JSON store:

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):


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:

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.

⚠️ 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:

🪟  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.