Chapter 7: MCP as the Universal Bridge¶
A language model is a text-in, text-out system. It cannot run a SQL query. It cannot take a Proxmox snapshot. It cannot send an alert to an Asterisk extension. It produces text describing what it would do, and then a human or a downstream system decides whether to actually do it.
The Model Context Protocol, MCP, is the layer that closes that gap. It defines a standard way for a language model to declare the tools it can call and to receive the results of those calls. The model says "call this tool with these arguments," and the MCP server executes the call. The result comes back as structured data the model can reason about.
This chapter documents how MCP is integrated in Bob and how to extend it with a new tool. The end-to-end walkthrough in the last section should be completable in thirty minutes by a developer who has not worked with Bob before.
What MCP Solves¶
Before MCP, the integration between an LLM and external systems looked like one of three things.
The first approach was prompt injection: include all the data the model might need in the prompt, let it analyze it, and parse the response to decide what to do. This works for simple cases. It breaks down when you need to take multiple steps (get data, analyze it, fetch more data based on the analysis, act), because each step requires a round trip through the human or the orchestrating code.
The second approach was custom tool calling: define a JSON schema for the tools the model can call, add that schema to the context, and parse {"tool": "...", "args": {...}} blocks out of the model's response. This works and was what Bob used before MCP. The problem is that every agent needs to implement this parsing logic differently, and every model handles the tool-calling format differently.
What the custom approach looked like in practice: the system prompt included a section that described available tools in plain text, the model was instructed to respond with a specific JSON structure when it wanted to call a tool, and the agent code had a parsing function that tried to extract tool calls from the model's response. That parsing function was about 80 lines of regex and JSON parsing logic, and it broke in two or three specific ways depending on which model was responding. qwen2.5 wrapped the tool call in markdown code fences. llama3.1 sometimes put the tool call inside a prose paragraph. One model version added a trailing comma to the JSON. Each quirk required a fix in the parsing code, and each fix was version-specific, so upgrading a model meant re-testing the parsing layer.
The transition to MCP cost one weekend. The bespoke parsing logic went away. The protocol handles the serialization and deserialization on both sides. The models that support MCP natively use the same wire format. The models that do not support it natively get a system prompt that describes the MCP tool schema, which is at least standardized XML even if the model has to be taught to use it. Upgrading from one Ollama model to another after MCP: no parsing code changes, zero.
The third approach, MCP, standardizes the tool interface. The model talks to the MCP server over a standard protocol. The server handles authentication, argument validation, and execution. The model receives structured results. Any model that speaks MCP can use any MCP server's tools without custom integration code.
Bob's Tool Architecture¶
Bob has 234 tools in total: 24 inline tools baked directly into the agent and 210 additional tools loaded dynamically from 70+ feature modules at startup. The inline set covers the core operations the agent needs on every task: reading files, running whitelisted shell commands, querying memory, managing Docker containers, scanning the network, and calling any registered MCP server through the mcp_call tool.
The mcp_call tool is the gateway to 43 registered MCP servers covering infrastructure (Proxmox, Docker, systemd), databases (SQLite, PostgreSQL, MSSQL, MySQL), network management (nmap, DNS), notifications, and more. The agent's system prompt includes a dynamically generated summary of every MCP server's available tools, grouped by category, so the LLM knows what it can ask for before it starts reasoning.
For SQL Server specifically, the mcp_call tool reaches the MSSQL MCP server which exposes 19 tools covering full CRUD, schema inspection, and diagnostics including active blocking detection, top CPU queries, backup freshness, wait statistics, and ad-hoc parameterized queries. The SQL Server connection uses a dedicated service account with VIEW SERVER STATE, VIEW DATABASE STATE, and write access to BookOfBob only. Not sysadmin. Not db_owner on production databases. The principle of least privilege is not a compliance checkbox here; it is the practical limit on what the agent can do even in a worst-case scenario.
The authentication model is worth a specific note because the open finding in NETWORK_SCAN.md for anonymous LDAP binds on nas at .20 is a reminder of what happens when authentication is not explicit. Anonymous binds on the LDAP service mean any host on the LAN can query directory information without credentials. The Bob toolchain does not repeat that mistake. MCP server access requires Ward's approval via the web dashboard before Bob can call any tools on that server. A request flows through the mcp_request endpoint, Ward approves or denies in the dashboard, and only approved servers are callable.
Every tool call is subject to the authority matrix. Read-only calls (listing containers, reading files, querying network state) execute at the auto tier with no notification. Mutating calls (restarting services, writing files outside Bob's workspace) require Ward's approval via a Kanban card in the web dashboard's Todo lane. The blast radius of every rule is documented in authority_matrix.json. If Bob encounters an action that does not match any rule, the default tier is ask. It queues the action for human review rather than proceeding.
MCP Architecture in Bob¶
flowchart LR
llm[Ollama LLM] -- "THOUGHT/ACTION" --> react[ReAct Loop]
react -- "mcp_call" --> dispatch[MCP Dispatch]
dispatch -- "approval check" --> auth[mcp_state.json]
dispatch -- "route" --> tools{43 MCP Servers}
tools --> mssql[MSSQL MCP server]
tools --> prox[Proxmox MCP server]
tools --> net[Network tools]
tools --> notify[Notification tools]
mssql --> db-host[db-host .14:50003]
prox --> proxmox[Proxmox .2]
net --> dns[dns-primary/dns-secondary .222/.221]
notify --> hermes[Hermes webhook]
The MCP dispatch module is a native Python layer. No subprocess overhead. It communicates with MCP servers over stdio or network sockets as configured in mcp_servers.json. MCP server approval state is tracked in mcp_state.json. Bob maintains a pool of persistent MCP server connections for low-latency tool calls. The catalog of all 43 servers and their available tools is rebuilt on demand via /api/mcp/refresh-catalog.
Adding a New Tool: End-to-End Walkthrough¶
This walkthrough adds a new dynamic tool, sql_index_analysis, that returns fragmentation statistics for all indexes in a given database. Bob loads dynamic tools at startup from feature modules: Python packages that expose a get_tools() function. Adding a capability is three files and a registration entry. The whole process takes about twenty-five minutes the first time.
Prerequisites: Python 3.13+, access to the Bob agent host, read access to the repository at http://10.0.0.80:3000/hyp3rsoft/bob.
Step 1: Create the Feature Module (5 minutes)¶
Create a directory sql_index_analysis/ in the Bob project root and add tools.py:
# sql_index_analysis/tools.py
import pymssql
def get_tools() -> list[dict]:
return [
{
"name": "sql_index_analysis",
"fn": _sql_index_analysis,
"description": (
"Query index fragmentation statistics for a SQL Server database. "
"Returns indexes with fragmentation above the threshold. "
"Use this to identify indexes that need REBUILD or REORGANIZE."
),
}
]
def _sql_index_analysis(database_name: str, min_fragmentation_pct: float = 10.0) -> str:
"""
Returns index fragmentation data for a database.
Safe: read-only, no modification of database state.
"""
import json, os
host = os.environ.get("MSSQL_HOST", "10.0.0.14")
port = int(os.environ.get("MSSQL_PORT", 50003))
user = os.environ.get("MSSQL_USER", "bob_agent")
password = os.environ.get("MSSQL_PASSWORD", "")
try:
conn = pymssql.connect(host, user, password, database_name, port=port)
cursor = conn.cursor(as_dict=True)
cursor.execute(
"""
SELECT
OBJECT_NAME(ips.object_id) AS table_name,
i.name AS index_name,
ips.avg_fragmentation_in_percent,
ips.page_count,
ips.index_type_desc
FROM sys.dm_db_index_physical_stats(
DB_ID(%(db)s), NULL, NULL, NULL, 'LIMITED'
) ips
JOIN sys.indexes i
ON ips.object_id = i.object_id
AND ips.index_id = i.index_id
WHERE ips.avg_fragmentation_in_percent > %(pct)s
AND ips.page_count > 100
ORDER BY ips.avg_fragmentation_in_percent DESC
""",
{"db": database_name, "pct": min_fragmentation_pct}
)
rows = cursor.fetchall()
conn.close()
return json.dumps({
"success": True,
"database": database_name,
"fragmented_indexes": rows,
"count": len(rows),
}, default=str)
except Exception as exc:
return json.dumps({"success": False, "error": str(exc)})
# source: bob@c549c88
Add __init__.py to make it a package:
Step 2: Register the Module (2 minutes)¶
Open self_agent.py and find the _register_module_tools() function. Add the module name to the modules_with_tools list:
modules_with_tools = [
# ... existing modules ...
"sql_index_analysis", # add this line
]
# source: bob@c549c88
Step 3: Add a Test (10 minutes)¶
Create tests/tools/test_sql_index_analysis.py:
from unittest.mock import patch
import json
from sql_index_analysis.tools import _sql_index_analysis
def test_returns_fragmented_indexes():
mock_rows = [{
"table_name": "Orders",
"index_name": "IX_Orders_CustomerId",
"avg_fragmentation_in_percent": 45.2,
"page_count": 1024,
"index_type_desc": "NONCLUSTERED INDEX",
}]
with patch("sql_index_analysis.tools.pymssql") as mock_pymssql:
mock_conn = mock_pymssql.connect.return_value
mock_conn.cursor.return_value.fetchall.return_value = mock_rows
result = json.loads(_sql_index_analysis("AdventureWorks", 30.0))
assert result["success"] is True
assert result["count"] == 1
assert result["fragmented_indexes"][0]["avg_fragmentation_in_percent"] == 45.2
def test_handles_connection_error():
import pymssql
with patch("sql_index_analysis.tools.pymssql") as mock_pymssql:
mock_pymssql.connect.side_effect = pymssql.OperationalError("connection failed")
result = json.loads(_sql_index_analysis("AdventureWorks"))
assert result["success"] is False
assert "connection failed" in result["error"]
# source: bob@c549c88
Run the tests:
cd /home/ward/Projects/08-MY-AGENTS/Bob
.venv/bin/python -m pytest tests/tools/test_sql_index_analysis.py -v
# source: bob@c549c88
Step 4: Restart the Web Server (2 minutes)¶
# The web server loads all feature modules at startup
pkill -f "uvicorn web_server"
.venv/bin/python -m uvicorn web_server:app --host 0.0.0.0 --port 8000 &
# Verify the tool registered successfully
curl -s http://localhost:8000/api/stats | python3 -c \
"import sys,json; d=json.load(sys.stdin); print('tools:', d.get('tool_count','check /api/tools'))"
# source: bob@c549c88
Step 5: Test with a Live Chat Call (5 minutes)¶
curl -s http://localhost:8000/api/chat \
-H "Content-Type: application/json" \
-d '{
"message": "Check index fragmentation in the BookOfBob database on db-host and tell me what needs attention.",
"model": "gemma4:26b"
}' | python3 -m json.tool
# source: bob@c549c88
Bob's ReAct loop will reason about the request, call sql_index_analysis with the appropriate arguments, observe the fragmentation data, and synthesize a diagnostic response. You should see the THOUGHT / ACTION / OBSERVATION trace in the response stream.
The walkthrough above describes the happy path. There are several failure modes a first-time implementer will encounter.
The most common one is the tool not appearing after restart. If the tool registered without errors, check whether the module name was added to the correct modules_with_tools list and that the __init__.py exports get_tools. Run python -c "from sql_index_analysis import get_tools; print(get_tools())" to verify the import chain before restarting the server.
The second common failure mode is a database connection error when the tool actually runs. The unit test mocks the connection entirely, so it passes regardless of credential validity. The live call does not mock the connection. Check that MSSQL_HOST, MSSQL_PORT, MSSQL_USER, and MSSQL_PASSWORD are set correctly in the environment before assuming the tool code is wrong.
The third failure mode is the agent describing index fragmentation analysis in its response without calling the tool. This means the LLM recognized the task but did not select sql_index_analysis from the tool list. It often means the description string in get_tools() does not match the language in the request well enough. Revise the description to include the key terms the user is likely to use ("fragmentation," "REBUILD," "defragment") and test again.
Tool Design Guidelines¶
A few principles from building the existing tool set:
-
Keep tools atomic. One tool does one thing.
sql.run_diagnosticruns a query and returns results. It does not analyze the results. The model does the analysis. Mixing execution and analysis in a single tool makes the tool harder to test and harder to trust. -
Fail loudly. A tool that returns
{"success": false, "error": "connection refused"}is better than one that returns empty results or swallows exceptions. The model needs to know when a tool failed so it can reason about the failure, not silently treat missing data as a clean bill of health. -
Log every call. Every tool call in Bob is logged. The
outcomes.jsonfile records every autonomous action with goal ID, action taken, result, status, and duration. Thellm_snoop.jsonlfile captures every LLM call across all processes (prompt, response, model, timing, and caller). Thenotify_log.jsonlcaptures every outbound notification. Every significant action has at least one durable record. -
Respect the authority matrix. Every tool that takes a mutating action should check authority via
check_authoritybefore executing. Tools that might change system state outside Bob's workspace should land atnotifyorasktier, notauto. The authority matrix inauthority_matrix.jsondefines what Bob can do autonomously versus what requires Ward's explicit approval via the Kanban board. -
Return structured data, not narrated summaries. A tool that returns
{"fragmentation_pct": 45.2, "index_name": "IX_Orders_CustomerId"}gives the model something to reason with. A tool that returns"The Orders table has a nonclustered index called IX_Orders_CustomerId that is 45% fragmented"gives the model something to regurgitate. The difference matters at the reasoning layer: structured data enables the model to compare values, rank findings, and make logical decisions. Narrated summaries push the model toward pattern-matching on text, which is less reliable than arithmetic on numbers. Every tool in Bob returns the former.
The MCP architecture has no natural ceiling on what Bob can call. Any system reachable from the agent host, any SQL Server instance, any Proxmox VM, any service with an HTTP API, can become a tool. The constraint is discipline: every new tool is a new attack surface if the input validation is weak, and a new liability if the logging is missing.
Part 3 starts with the question that follows from all of this working: what happens when Bob is capable enough to take over more of the operational load, and how do you hand that over safely?