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. 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:
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:
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:
# ~/.local/share/octomind/config/config.toml
[[mcp.servers]]
name = "health"
type = "stdio"
command = "node"
args = ["/path/to/health-check/index.js"]
timeout_seconds = 30Octomind 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 has to grant it. Reference the server and allow its tools:
[[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: [[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:
[[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:
echo "Is https://api.acme.com/health responding? Use the health tool." \
| octomind run ops --format jsonlYou'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_urlthat 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. For everything else, now you can build it yourself in an afternoon.
Get Octomind — and give your agent the one tool it's missing.



