# Your AI Agent Is Missing One Tool. Build It as an MCP Server

> A hands-on tutorial: build a small custom MCP server, register it with Octomind, scope it to a role, and guard it — in about an hour, with code you can run.

Every so often your AI agent hits a wall that isn't about intelligence — it's about reach. It can reason perfectly about your staging environment but can't actually check if it's up. It understands your ticketing system but can't read a ticket. The model is fine. It's just missing a tool.

Adding one is straightforward. MCP (the Model Context Protocol) is the standard way to hand an AI agent a new capability, and any process that speaks it plugs into Octomind. We covered the [protocol itself in the MCP Tools Deep Dive](https://octomind.run/blog/mcp-tools-deep-dive). This post is the hands-on version — we'll build a small but real server, wire it in, lock it down, and end with something your agent can actually use.

Our example: a **health-check** tool. The agent can ask "is `https://api.acme.com/health` responding?" and get a real answer. Useful, self-contained, and safe enough to show every step without hand-waving.

## Step 1: Write the Server

Any MCP-speaking process works; we'll use TypeScript and the official SDK. The whole server is one file:

```typescript
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';

const server = new Server(
	{ name: 'health-check', version: '1.0.0' },
	{ capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
	tools: [
		{
			name: 'check_url',
			description: 'Check whether an HTTP(S) URL is reachable. Returns status code and latency.',
			inputSchema: {
				type: 'object',
				properties: {
					url: { type: 'string', description: 'Full http(s) URL to check' }
				},
				required: ['url']
			}
		}
	]
}));
```

Notice how much care goes into the **description**. That string is the entire basis on which the model decides whether to call your tool. "Check whether an HTTP(S) URL is reachable" is a far better description than "URL tool" — the model reads it like a teammate reading an API doc.

Now the handler. The single most important habit when building tools for an AI: **validate the input.** The model generates the arguments, and it will occasionally hand you something malformed or unsafe. Check before you act:

```typescript
server.setRequestHandler(CallToolRequestSchema, async (request) => {
	const { url } = request.params.arguments as { url: string };

	// The model generated this — never trust it blindly.
	let parsed: URL;
	try {
		parsed = new URL(url);
	} catch {
		return { isError: true, content: [{ type: 'text', text: `Not a valid URL: ${url}` }] };
	}
	if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
		return { isError: true, content: [{ type: 'text', text: 'Only http(s) URLs are allowed.' }] };
	}

	const start = Date.now();
	try {
		const res = await fetch(parsed, { method: 'HEAD', signal: AbortSignal.timeout(5000) });
		const ms = Date.now() - start;
		return {
			content: [
				{ type: 'text', text: `${parsed.href} → ${res.status} ${res.statusText} (${ms}ms)` }
			]
		};
	} catch (e) {
		return {
			isError: true,
			content: [{ type: 'text', text: `Unreachable: ${(e as Error).message}` }]
		};
	}
});

const transport = new StdioServerTransport();
await server.connect(transport);
```

That's the entire server. Notice we return `isError: true` for bad input rather than throwing — the model sees the error text and can correct itself, which is exactly what you want from a tool that an autonomous agent is driving.

## Step 2: Register It with Octomind

Octomind talks to external tools over `stdio` (a local subprocess) or `http`. Ours is local, so add a `stdio` entry to your config:

```toml
# ~/.local/share/octomind/config/config.toml
[[mcp.servers]]
name = "health"
type = "stdio"
command = "node"
args = ["/path/to/health-check/index.js"]
timeout_seconds = 30
```

Octomind launches the process, asks it for its tool list, and `check_url` is now part of the agent's vocabulary. If you'd rather keep concerns separate, drop this in its own file — anything matching `mcp-*.toml` in the config directory is merged in and loaded last as an override, so `mcp-health.toml` works just as well as editing the main config.

## Step 3: Scope It to a Role

A registered server isn't automatically available everywhere — a [role](https://octomind.run/blog/lock-down-ai-agent-permissions) has to grant it. Reference the server and allow its tools:

```toml
[[roles]]
name = "ops"
temperature = 0.2
top_p = 0.7
top_k = 20
welcome = "Ops agent ready."
system = "You help diagnose service health. Working directory: {{CWD}}"

[roles.mcp]
server_refs = ["core", "filesystem", "health"]
allowed_tools = ["core:*", "filesystem:view", "health:*"]
```

Now `octomind run ops` has the health checker alongside read access and orchestration tools — but no shell, no file editing. The tool exists exactly where it should and nowhere it shouldn't. That's least privilege, and it's the whole reason permissions are a property of the role rather than a global switch.

## Step 4: Guard the Edges — and Know Where Guardrails Reach

One boundary to understand before you reach for project [guardrails](/docs/usage/18-guardrails): `[[guard]]` rules match tool calls **by capability name** — the tap-declared bundle that owns the tool. A server you registered yourself in `[[mcp.servers]]` isn't owned by any capability, so a guard can't target `check_url`. For your own tools, the validation you wrote in Step 1 _is_ the wall — that's exactly why it lives in the server. Never want the agent probing internal addresses? Add the loopback/private-range check right next to the protocol check, in code.

Guardrails still cover everything else the ops role can reach. If it should never read env files while diagnosing, that's one rule in `.agents/guardrails.toml`:

```toml
[[guard]]
match   = "filesystem-read(path=\\.env)"
message = "Refusing to read .env files."
```

If the agent points the `view` tool at a `.env` file, the call is blocked before it runs and the agent gets told why. Two layers, each where it can actually enforce: your server polices its own tool; guardrails police the capability-owned tools around it.

## Step 5: Test It Like the Model Will

Talk to it the way the agent does — over stdin, non-interactively — and watch it work:

```bash
echo "Is https://api.acme.com/health responding? Use the health tool." \
  | octomind run ops --format jsonl
```

You'll see the agent call `check_url`, get back a real status and latency, and report it. Try a bad input ("check ftp://foo") and confirm it returns the validation error instead of crashing. Ask it to read a `.env` file and confirm the guard blocks it. Three cases — happy path, bad input, blocked policy — and you've verified the loop end to end.

## The Principles That Generalize

The health checker is a toy, but the shape is exactly how I build real ones:

- **One tool, one job.** A `check_url` that also formatted reports would be harder for the model to use correctly than two clean tools. Keep them sharp.
- **Descriptions are the API.** The model picks tools by reading descriptions. Write them for a smart reader who's never seen your code.
- **Validate everything.** The arguments come from a language model. Treat every input as untrusted, return clear errors, never panic.
- **Validate in the server, scope at the role.** The server defines what's possible and polices its own inputs; the role defines who gets it; guardrails cover the capability-owned tools around it.

Swap the body of `check_url` and you've got a server that reads tickets, queries a metrics API, kicks a deploy, or looks up a customer — any capability your agent is currently missing. The plumbing never changes; only the one function in the middle does.

And if your missing capability is _understanding your own codebase_, you don't need to build anything — that one's already solved by [Octocode's MCP server](https://octomind.run/blog/octocode-mcp-setup-claude-cursor). For everything else, now you can build it yourself in an afternoon.

**[Get Octomind](https://octomind.run)** — and give your agent the one tool it's missing.
