Custom Middleware
Custom middleware lets you intercept tool calls with before/after hooks. Middleware is registered by the tool process during the handshake and dispatched by the Go binary.
How it works
- Tool process registers middleware during startup (after listing tools, before handshake-complete)
- When a
tools/callrequest arrives, the Go binary sends intercept requests to the tool process - Before phase: middleware runs in priority order (lowest first). Each can modify arguments or reject the call
- The tool handler runs
- After phase: middleware runs in reverse priority order. Each can modify the result
Python
from protomcp import tool, ToolResultfrom protomcp.middleware import middleware
@middleware("audit_log", priority=10)def audit_log(phase, tool_name, args_json, result_json, is_error): if phase == "before": print(f"[AUDIT] Calling {tool_name} with {args_json}") return {} # no modifications elif phase == "after": print(f"[AUDIT] {tool_name} returned (error={is_error}): {result_json}") return {}
@middleware("rate_limiter", priority=5)def rate_limiter(phase, tool_name, args_json, result_json, is_error): if phase == "before": if is_rate_limited(tool_name): return {"reject": True, "reject_reason": "Rate limit exceeded"} return {}
@tool("greet")def greet(name: str) -> ToolResult: return ToolResult(result=f"Hello, {name}!")Handler return values
| Key | Type | Description |
|---|---|---|
reject | bool | Reject the call (before phase only) |
reject_reason | str | Reason for rejection |
arguments_json | str | Modified arguments JSON (before phase) |
result_json | str | Modified result JSON (after phase) |
TypeScript
import { tool, ToolResult, middleware } from 'protomcp';import { z } from 'zod';
middleware('audit_log', 10, (phase, toolName, argsJson, resultJson, isError) => { if (phase === 'before') { console.log(`[AUDIT] Calling ${toolName} with ${argsJson}`); return {}; } else { console.log(`[AUDIT] ${toolName} returned (error=${isError}): ${resultJson}`); return {}; }});
middleware('rate_limiter', 5, (phase, toolName) => { if (phase === 'before') { if (isRateLimited(toolName)) { return { reject: true, rejectReason: 'Rate limit exceeded' }; } } return {};});
tool({ name: 'greet', description: 'Greet someone', args: z.object({ name: z.string() }), handler({ name }) { return new ToolResult({ result: `Hello, ${name}!` }); },});Go
The Go SDK supports middleware via the Middleware() function:
package main
import ( "github.com/msilverblatt/protomcp/sdk/go/protomcp")
func main() { protomcp.Middleware("audit_log", 10, func(phase, toolName, argsJSON, resultJSON string, isError bool) map[string]interface{} { if phase == "before" { log.Printf("[AUDIT] Calling %s with %s", toolName, argsJSON) } return nil })
// ... register tools and run}Rust
The Rust SDK supports middleware via the middleware() function:
use protomcp::{middleware, tool, ToolResult};
middleware("audit_log", 10, |phase, tool_name, args_json, result_json, is_error| { if phase == "before" { eprintln!("[AUDIT] Calling {} with {}", tool_name, args_json); } Default::default()});Priority
Middleware priority determines execution order:
- Before phase: lowest priority number runs first
- After phase: highest priority number runs first (reverse order)
This ensures that outermost middleware (e.g., auth at priority 1) wraps innermost middleware (e.g., logging at priority 100).
Argument modification
Middleware in the before phase can modify the tool arguments by returning arguments_json:
@middleware("inject_defaults", priority=50)def inject_defaults(phase, tool_name, args_json, result_json, is_error): if phase == "before": import json args = json.loads(args_json) if "timeout" not in args: args["timeout"] = 30 return {"arguments_json": json.dumps(args)} return {}Rejection
Middleware in the before phase can reject a call entirely:
@middleware("block_dangerous", priority=1)def block_dangerous(phase, tool_name, args_json, result_json, is_error): if phase == "before" and tool_name in BLOCKED_TOOLS: return {"reject": True, "reject_reason": f"Tool {tool_name} is blocked"} return {}When a call is rejected, the tool handler does not run and no after-phase middleware executes.