Adding a New Tool
Five steps to add a new MCP tool. Each step is mechanical. The worked example below adds a hypothetical get-services-by-port tool.
Step 1: Write the handler
Add a new handler function in the appropriate tools/*.py file. Pick the file that most closely matches the domain (services.py for service-related tools, workloads.py for workload tools, etc.).
Signature: def handle_<tool_name>(ctx, arguments: dict) -> list:
The body reads ctx.pce to access the PCE client. Never import get_pce() or anything from transport/ or auth/ inside a tool handler.
# src/illumio_mcp/tools/services.py
def handle_get_services_by_port(ctx, arguments: dict) -> list:
"""Return services that include the specified port."""
port = int(arguments.get("port", 0))
if not port:
return [types.TextContent(type="text", text=json.dumps({"error": "port is required"}))]
pce = ctx.pce
services = pce.services.get(params={"max_results": 500})
matching = [
s for s in services
if hasattr(s, "service_ports") and any(
getattr(sp, "port", None) == port for sp in (s.service_ports or [])
)
]
return [types.TextContent(type="text", text=json.dumps(
[{"href": s.href, "name": s.name} for s in matching], indent=2
))]
Step 2: Add the MCP Tool definition in server.py
Add a types.Tool entry in handle_list_tools() in src/illumio_mcp/server.py. This is what MCP clients see in their tool list.
types.Tool(
name="get-services-by-port",
description="Find service definitions that include a specific port number.",
inputSchema={
"type": "object",
"properties": {
"port": {
"type": "integer",
"description": "TCP/UDP port number to search for.",
},
},
"required": ["port"],
},
),
Step 3: Add a ToolSpec entry in tools/__init__.py
Import the handler at the top of src/illumio_mcp/tools/__init__.py and add a ToolSpec entry in TOOL_REGISTRY.
Import:
from .services import (
handle_get_services,
handle_create_service,
handle_update_service,
handle_delete_service,
handle_get_services_by_port, # add this line
)
Registry entry:
TOOL_REGISTRY: dict[str, ToolSpec] = {
...
# Services
"get-services": ToolSpec(handle_get_services, roles=ALL_ROLES),
"create-service": ToolSpec(handle_create_service, roles=_OP_ADMIN, mutating=True),
"update-service": ToolSpec(handle_update_service, roles=_OP_ADMIN, mutating=True),
"delete-service": ToolSpec(handle_delete_service, roles=_OP_ADMIN, mutating=True),
"get-services-by-port": ToolSpec(handle_get_services_by_port, roles=ALL_ROLES), # add this
...
}
ToolSpec field cheatsheet:
| Field | Type | Meaning |
|---|---|---|
roles | frozenset | Who can call this tool. Use ALL_ROLES, _OP_ADMIN, or _ADMIN_ONLY. Must be non-empty. |
mutating | bool | True if the tool creates, updates, or deletes PCE state. Required for tools whose name starts with create-, update-, delete-, or provision-. |
requires_confirm | bool | True for highly destructive tools (provision, ringfence-batch). Implies mutating=True. |
unscopable | bool | True if the tool returns PCE-wide data that cannot be safely filtered per user scope. |
requires_pce | bool | False only for credential-management tools that work before a user has a PCE key. Default True. |
Step 4: Update the tool count in test_tool_metadata.py
The guard test test_count_matches_expected in tests/test_tool_metadata.py asserts the exact tool count. Bump it when you add a tool:
def test_count_matches_expected():
assert len(TOOL_REGISTRY) == 47, \ # was 46, bumped for get-services-by-port
f"Tool count drifted to {len(TOOL_REGISTRY)}; update this test if intentional"
Verify:
.venv/bin/python3 -m pytest tests/test_tool_metadata.py -v
Expected: all 6 guard tests pass.
Step 5: Add a test
Add an integration or unit test for the new tool. For tools that require a real PCE, add to the appropriate tests/test_mcp_tools.py test class. For tools you can test with a stub PCE, add to a fast unit test.
# tests/test_mcp_tools.py (integration, requires PCE)
class TestServices:
def test_get_services_by_port(self, client):
result = client.call_tool("get-services-by-port", {"port": 443})
data = json.loads(result.content[0].text)
assert isinstance(data, list)
What the dispatcher will enforce automatically
Once your ToolSpec entry is in TOOL_REGISTRY, the dispatcher handles the rest:
- Users without a role in
spec.rolesget{"error": "forbidden"}. - Users in per-user mode without PCE credentials (if
spec.requires_pce=True) get{"error": "no_pce_credentials"}. - HTTP callers without a confirm token (if
spec.requires_confirm=True) get{"error": "confirm_required"}. - Every decision is recorded in the audit log.
You do not need to add any authz code to the handler itself.
CI guards that catch common mistakes
The tests/test_tool_metadata.py guards will fail at CI time if:
- The tool is in
TOOL_REGISTRYbut has an emptyroles=set (caught byToolSpec.__post_init__). - The handler’s first parameter is not named
ctx(caught bytest_handlers_have_ctx_first_argument). - A tool whose name starts with
create-/update-/delete-/provision-does not havemutating=True(caught bytest_destructive_tool_names_are_marked_mutating). - The tool count does not match the assertion (caught by
test_count_matches_expected).