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.
- 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, notclaude_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.jsonnow exists instacklog-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_modulescreated, no errors. ⚠️ If you seeERESOLVE/ 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 foldernode_modules/@modelcontextprotocol/sdkshould 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;Serverhandles the protocol,StdioServerTransportis 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 inpackage.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_entrytool + 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) andsearch_entries(query, limit) ✅ Firecreate_entry→title:"Test entry",content:"Hello StackLog"→ returns a UUID + count ✅ Firesearch_entries→query:"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 Servers → Manage MCP Servers → View raw config
⚠️ Check current UI: labels vary by build. The file is
mcp_config.jsonunder~/.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.ts→ Copy Path. On Windows, replace every\with\\in the JSON. ⚠️npx tsxruns TypeScript directly (no build step) — best for the lab. In production:npm run buildthen point atdist/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 entrysearch_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-searchormock-searchfrom 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_entryreturns a new UUID and a total count ≥ 1 - [ ]
stacklog-entries.jsonexists in your home dir — open it, show the JSON - [ ]
search_entriesreturns 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-mcpis 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)
Cannot find module '@modelcontextprotocol/sdk/...'→npm installdidn't run, or an import path is missing.js. Node16 resolution needs explicit.jseven on.tsfiles.Cannot use import statement in a module→ add"type": "module"topackage.json.- Antigravity can't find the tool → path in
mcp_config.jsonis relative/forward-slashed; use the absolute path (Windows:\\). And Refresh / restart Antigravity after editing. Confirmtsxis available (npm install -g tsxif needed). create_entryruns but no file appears →STORE_PATHresolved somewhere unexpected; the code logs nothing by default, so add aconsole.error(STORE_PATH)at startup to see where it writes.- Strict-mode type errors → keep the
args as {…}casts;argsarrives asunknownfrom the SDK. search_entriesempty though entries exist → the running server'sSTORE_PATHdiffers from wherecreate_entrywrote. 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.