Build your first MCP-powered agent: tools, servers, and safety rails
Wire a model to real tools through the Model Context Protocol. You'll build a working MCP server, connect it to an agent host, and add permission scopes and guardrails before anything can touch your files.
Most agent tutorials bury you in orchestration frameworks before you've plugged a model into a single real tool. The Model Context Protocol (MCP) — Anthropic's open standard for connecting models to data and actions — inverts that. Build a small server that exposes a few capabilities, point an agent host at it, and you have a working agent. The trick is building it safely from the start: scoped tools, validated inputs, and explicit permission checkpoints, not a sandbox-you'll-add-later.
The mental model in two minutes#
MCP has three roles:
- Host — the app the user interacts with (Claude Code, Claude Desktop, Cursor, VS Code, Continue, and others).
- Client — lives inside the host; maintains a 1:1 connection to each server.
- Server — a program you write that exposes capabilities to the model.
Servers expose three kinds of primitives:
- Tools — functions the model can call (search a database, create a file, send a message). These are where agency lives.
- Resources — read-only data the model can pull (a document, a database schema, a log file).
- Prompts — reusable templates that structure interactions (e.g. "review this PR with our team's checklist").
Servers also support elicitation: the server can ask the user for missing input mid-conversation through the client — a natural place for human approval steps, as we'll see.
Two transports exist. Stdio runs the server as a local subprocess — simplest for a first server, and the default for anything running on your own machine. Streamable HTTP exposes the server over the network, which is how remote or shared servers work (that's where OAuth 2.1 auth enters the picture).
Step 1: Build the server#
We'll use the official TypeScript SDK. The whole thing is one file.
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
Now the server. Note two deliberate choices: every tool gets a Zod input schema (so the model can't send you malformed garbage), and dangerous operations are gated behind an approval check we'll add in a moment.
// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "file-agent-server",
version: "1.0.0",
});
// Safe, read-only tool: listing files in a scoped directory
server.registerTool(
"list-files",
{
title: "List files",
description: "List files inside the allowed workspace directory",
inputSchema: z.object({
subdir: z.string().optional().describe("Subdirectory, relative only"),
}),
},
async ({ subdir }) => {
const { readdir } = await import("fs/promises");
const { join, resolve } = await import("path");
const root = "/home/user/agent-workspace";
const target = resolve(join(root, subdir ?? "."));
// Rail 1: path confinement — no escaping the workspace
if (!target.startsWith(root + "/") && target !== root) {
return { content: [{ type: "text", text: "Error: path outside workspace" }] };
}
const files = await readdir(target);
return { content: [{ type: "text", text: files.join("\n") }] };
}
);
// Destructive tool: writing a file, gated by elicitation
server.registerTool(
"write-note",
{
title: "Write note",
description: "Write a text note into the workspace (requires user approval)",
inputSchema: z.object({
filename: z.string().regex(/^[\w.-]+\.txt$/).describe("Safe filename"),
text: z.string().max(5000).describe("Note content"),
}),
},
async ({ filename, text }) => {
// Rail 2: ask the human before touching the filesystem
const approved = await requestApproval(
`Write ${text.length} chars to ${filename}?`
);
if (!approved) {
return { content: [{ type: "text", text: "Aborted: user declined" }] };
}
const { writeFile } = await import("fs/promises");
await writeFile(`/home/user/agent-workspace/${filename}`, text);
return { content: [{ type: "text", text: `Wrote ${filename}` }] };
}
);
async function requestApproval(message: string): Promise<boolean> {
// In a real server, use the MCP elicitation primitive
// (elicit/request) to ask the user through the client UI.
// This placeholder keeps the example runnable while you
// wire up client-side elicitation support.
console.error(`APPROVAL REQUESTED: ${message}`);
return process.env.AGENT_AUTO_APPROVE === "yes";
}
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");
Two conventions worth adopting early:
- Log to stderr, never stdout. On stdio, stdout is the protocol channel — a stray
console.logcorrupts every message.console.erroris safe. - Keep servers narrow. Three to ten tools per server is the practical sweet spot; too many tools and the model starts picking the wrong one. Group related capabilities together and split domains into separate servers.
Step 2: Connect it to an agent host#
A server nobody connects to is a paperweight. Configuration differs slightly per host, but the shape is always the same: a command to launch, plus arguments.
For Claude Code (CLI), the fastest path:
claude mcp add file-agent -- node /path/to/my-mcp-server/server.js
For Claude Desktop or similar hosts, add an entry to the MCP config (typically claude_desktop_config.json):
{
"mcpServers": {
"file-agent": {
"command": "node",
"args": ["/path/to/my-mcp-server/server.js"]
}
}
}
For VS Code, the same shape lives under a servers key in .vscode/mcp.json rather than mcpServers. Then restart the host (fully quit from the tray for desktop apps — this catches everyone the first time).
To verify, ask the host something like "What MCP tools do you have available?" and then "List the files in the workspace." You should see the model discover list-files and call it — that moment, the model reaching outside its training data into a capability you wrote, is the whole point.
Step 3: Permission scopes, not vibes#
Giving a model tools without a permission model is how demos become incident reports. Layers that actually work:
1. Least-privilege tool design. Notice list-files is read-only and confined to one directory. Design tools so their blast radius is small by construction — the path-confinement check runs regardless of what the model sends. Validation schemas are your first firewall: constrain filenames, cap string lengths, use enums instead of free text wherever possible.
2. Human approval for side effects. The write-note tool demonstrates the pattern: anything that changes the world (writing files, sending messages, spending money) should pass through an approval gate. MCP's elicitation primitive is the proper mechanism — the server sends a structured request and the client renders a UI for the user to accept, decline, or supply missing details. Wire it in before you add any tool with side effects; a demo flag like AGENT_AUTO_APPROVE is fine for testing and a liability in production.
3. Secrets stay on your side. Tools that call external APIs need credentials. For local stdio servers, credentials come from the environment — not hardcoded, not pasted into prompts, and never echoed in tool descriptions or outputs (those get read by the model and can leak into its context). For shared setups, prefer an OS keychain or vault over environment variables, which any process can inspect.
4. Scope escalation for remote servers. If your server runs over HTTP and serves multiple users, the MCP authorization profile uses OAuth 2.1: the server acts as a resource server, the client discovers the authorization server via Protected Resource Metadata, tokens are bound to the server's audience, and scopes start minimal with step-up challenges (insufficient_scope) when the client needs more. You don't need all of this for a local tutorial server — but know it exists, because "I'll expose this to the team" is the moment security debt gets expensive.
Step 4: Audit what the agent does#
Add structured logging to every tool handler — timestamp, tool name, arguments, outcome. Two reasons: when the model does something surprising you need a trail, and when you later add rate limits or anomaly detection you'll need the data. A simple JSON-lines log to a file outside the workspace is enough to start.
What you've built#
A model that can inspect a directory you control, and — with your explicit approval — write to it. That's a real agent: perception (tools + resources), action (tool calls), and a human in the loop where it matters.
From here, the useful next steps are: expose a resource for something the model reads often (a project README, a schema), so it stops wasting tool calls on reads; add a prompt template for your most repeated workflow; and graduate the approval placeholder to real elicitation so your agent asks you before it acts. Keep the tool count small, the blast radius smaller, and the human approval loop intact — that's the difference between a powerful agent and a dangerous one.