18. Tool Protocols and Tool Servers¶
Why does this matter?¶
As agents grow, tools often turn into services. Instead of embedding tool code directly into the agent, you can run tools as separate processes or services. That requires protocols for communication, versioning, and security.
In this chapter we'll look at tool server patterns: stdio, HTTP APIs, gRPC, schema versioning, and authentication/authorization. We'll also cover two standard protocols: MCP (Model Context Protocol) for connecting agents to tools, and A2A (Agent-to-Agent) for agents talking to each other.
A real-world case¶
Situation: You have 50+ tools. Some are written in Go, some in Python, and some are external services. Embedding all of them into a single agent binary is impractical.
Problems:
- Different languages require different integration approaches
- Tool updates require redeploying the agent
- No isolation between tools
- It's hard to scale individual tools
Solution: Tool servers: each tool runs as a separate process/service. The agent communicates over a standard protocol (stdio, HTTP, or gRPC). Tools can be updated and scaled independently, and isolated for security.
Junior employee model. Authn/authz at the tool-server level is an "office badge." The mere fact that the agent knows the service URL doesn't give it the right to use it: you need a token, a restricted set of operations, and auditing. One tool server = one department: its own access level, its own logs, its own area of responsibility. MCP/A2A don't override these rules — they standardize how they are applied. See Preface → Mental Model and Ch. 17: Security and Governance.
The theory in plain words¶
Tool server architecture¶
Agent Runtime:
- Manages the conversation flow
- Calls tools via a protocol
- Handles tool responses
Tool Server:
- Implements the tool logic
- Exposes a protocol interface
- Can be a separate process/service
Protocol:
- The communication contract
- Request/response format
- Error handling
Protocol types¶
1. stdio Protocol:
- The tool runs as a subprocess
- Communication over stdin/stdout
- Simple; good for local tools
2. HTTP Protocol:
- The tool runs as an HTTP service
- REST API interface
- Good for distributed systems
3. gRPC Protocol:
- The tool runs as a gRPC service
- A strict contract via Protobuf (IDL)
- Type safety and backward-compatible schema evolution
- A rich ecosystem in Go: client/server code generation, interceptors, reflection
- Built-in mechanisms: TLS/mTLS, authentication via metadata, deadlines, retries, load balancing
- Observability: integration with tracing/metrics/logging
- A practical choice for production tool servers
How it works (step by step)¶
Step 1: The tool protocol interface¶
type ToolServer interface {
ListTools() ([]ToolDefinition, error)
ExecuteTool(name string, args map[string]any) (any, error)
}
type ToolDefinition struct {
Name string
Description string
Schema json.RawMessage
Version string
}
Step 2: Stdio protocol¶
// The tool server reads from stdin, writes to stdout
type StdioToolServer struct {
tools map[string]Tool
}
func (s *StdioToolServer) Run() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
var req ToolRequest
json.Unmarshal(scanner.Bytes(), &req)
result, err := s.ExecuteTool(req.Name, req.Args)
resp := ToolResponse{
Result: result,
Error: errString(err),
}
json.NewEncoder(os.Stdout).Encode(resp)
}
}
type ToolRequest struct {
Name string
Args map[string]any
}
type ToolResponse struct {
Result any
Error string
}
Step 3: The agent calls the tool server¶
func executeToolViaStdio(toolName string, args map[string]any) (any, error) {
cmd := exec.Command("tool-server")
stdin, _ := cmd.StdinPipe()
stdout, _ := cmd.StdoutPipe()
cmd.Start()
req := ToolRequest{Name: toolName, Args: args}
json.NewEncoder(stdin).Encode(req)
stdin.Close()
var resp ToolResponse
json.NewDecoder(stdout).Decode(&resp)
cmd.Wait()
if resp.Error != "" {
return nil, fmt.Errorf(resp.Error)
}
return resp.Result, nil
}
Step 4: HTTP protocol¶
// The tool server as an HTTP service
func (s *HTTPToolServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req ToolRequest
json.NewDecoder(r.Body).Decode(&req)
result, err := s.ExecuteTool(req.Name, req.Args)
resp := ToolResponse{
Result: result,
Error: errString(err),
}
json.NewEncoder(w).Encode(resp)
}
Step 5: gRPC protocol¶
gRPC provides a strict contract via Protocol Buffers. The service definition:
syntax = "proto3";
package tools.v1;
service ToolServer {
rpc ListTools(ListToolsRequest) returns (ListToolsResponse);
rpc ExecuteTool(ExecuteToolRequest) returns (ExecuteToolResponse);
}
message ListToolsRequest {
string version = 1; // Protocol version
}
message ListToolsResponse {
repeated ToolDefinition tools = 1;
}
message ExecuteToolRequest {
string tool_name = 1;
string version = 2;
bytes arguments = 3; // JSON-serialized arguments
}
message ExecuteToolResponse {
bytes result = 1;
string error = 2;
}
message ToolDefinition {
string name = 1;
string description = 2;
string schema = 3; // JSON Schema
string version = 4;
repeated string compatible_versions = 5;
}
Advantages of gRPC for tool servers:
- Strict contract: Protobuf guarantees type safety and schema evolution without breaking changes
- Go ecosystem: automatic client/server generation, interceptors for authn/authz, health checks
- Security: built-in TLS/mTLS support, authentication via metadata (tokens, API keys)
- Reliability: deadlines, retries, load balancing at the client level or via a service mesh
- Observability: integration with OpenTelemetry, gRPC metrics, structured logging
Step 6: Schema versioning¶
type ToolDefinition struct {
Name string
Version string
Schema json.RawMessage
CompatibleVersions []string
}
func (s *ToolServer) GetToolDefinition(name string, version string) (*ToolDefinition, error) {
tool := s.tools[name]
if tool.Version == version {
return &tool, nil
}
// Check compatibility
for _, v := range tool.CompatibleVersions {
if v == version {
return &tool, nil
}
}
return nil, fmt.Errorf("incompatible version")
}
MCP (Model Context Protocol)¶
What is MCP¶
MCP (Model Context Protocol) is an open standard from Anthropic for connecting LLM agents to tools and data. Instead of every agent writing its own integration with every service, MCP defines a single protocol. A tool written as an MCP server works with any agent that supports MCP.
Analogy: USB for AI tools. One connector — any device.
Architecture¶
Host (IDE / chatbot / CI pipeline)
└── MCP Client ──── JSON-RPC 2.0 ──── MCP Server
├── Resources (data to read)
├── Tools (actions)
└── Prompts (prompt templates)
Host — the application the agent runs in (an IDE, a chatbot, a CI/CD pipeline). One host can connect to several MCP servers at once.
MCP Client — a component of the host. It establishes a connection to the MCP server over JSON-RPC 2.0.
MCP Server — a separate process or service. It exposes tools, data, and templates through a standard protocol.
The three primitives¶
| Primitive | Analogue | What it does | Example |
|---|---|---|---|
| Resources | GET | Data to read. The agent requests it — the server returns it | File contents, a SQL query result, metrics |
| Tools | POST | Actions with side effects. They require confirmation | Create a ticket, start a deploy, send an alert |
| Prompts | Template | Ready-made prompts for common tasks | A code review template, incident analysis |
Transport¶
MCP supports two types of transport:
stdio — the host launches the MCP server as a subprocess. Communication over stdin/stdout. Simple, no network required. Suitable for local tools: CLI utilities, file operations, IDE plugins.
Streamable HTTP — the client sends JSON-RPC requests over HTTP POST. The server can respond via SSE (Server-Sent Events) to stream results. Suitable for remote servers and production environments.
Example: An MCP server in Go¶
A minimal MCP server that exposes a tool for deploying services.
The JSON-RPC 2.0 types — the foundation of the MCP protocol:
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id"`
Result any `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
We define the tool via JSON Schema. The LLM uses the description and schema to generate arguments:
type MCPTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
}
var deployTool = MCPTool{
Name: "deploy_service",
Description: "Deploy a service to the specified environment",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"service": {"type": "string", "description": "Service name"},
"env": {"type": "string", "enum": ["staging", "production"]}
},
"required": ["service", "env"]
}`),
}
Handling requests. The MCP server answers three key methods — initialize, tools/list, and tools/call:
func handleRequest(req JSONRPCRequest) JSONRPCResponse {
switch req.Method {
case "initialize":
// Handshake: the server reports the protocol version and its capabilities.
// The spec version is negotiated during the handshake — use the current one
return JSONRPCResponse{
JSONRPC: "2.0", ID: req.ID,
Result: map[string]any{
"protocolVersion": "YYYY-MM-DD",
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{
"name": "deploy-server", "version": "1.0.0",
},
},
}
case "tools/list":
// The agent calls this on connection to discover the available tools
return JSONRPCResponse{
JSONRPC: "2.0", ID: req.ID,
Result: map[string]any{"tools": []MCPTool{deployTool}},
}
case "tools/call":
// Tool call: the agent passes the name and arguments
var params struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
json.Unmarshal(req.Params, ¶ms)
result, err := executeDeploy(params.Arguments)
if err != nil {
return JSONRPCResponse{
JSONRPC: "2.0", ID: req.ID,
Result: map[string]any{
"content": []map[string]any{{"type": "text", "text": err.Error()}},
"isError": true,
},
}
}
return JSONRPCResponse{
JSONRPC: "2.0", ID: req.ID,
Result: map[string]any{
"content": []map[string]any{{"type": "text", "text": result}},
},
}
default:
return JSONRPCResponse{
JSONRPC: "2.0", ID: req.ID,
Error: &RPCError{Code: -32601, Message: "method not found"},
}
}
}
Running over stdio. The loop here is the same as in the stdio Tool Server from the start of the chapter: the scanner reads a line from stdin, json.Unmarshal parses the request, the handler builds a response, and the encoder writes it to stdout. MCP doesn't invent a new transport — it merely wraps messages in JSON-RPC 2.0. The differences from a hand-rolled protocol: a mandatory handshake (initialize) with negotiation of the spec version before the first call, standard methods (tools/list, tools/call, resources/list) instead of a free-form format, and a content-based response format (content + isError instead of the result/error pair).
Example: An MCP client¶
The client launches the MCP server as a subprocess and calls tools:
type MCPClient struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Scanner
nextID int
}
func NewMCPClient(serverPath string) (*MCPClient, error) {
cmd := exec.Command(serverPath)
stdin, _ := cmd.StdinPipe()
stdoutPipe, _ := cmd.StdoutPipe()
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start MCP server: %w", err)
}
client := &MCPClient{
cmd: cmd,
stdin: stdin,
stdout: bufio.NewScanner(stdoutPipe),
}
// Initialization is a mandatory first step.
// The protocol version is negotiated in the handshake — substitute the current spec version
_, err := client.call("initialize", map[string]any{
"protocolVersion": "YYYY-MM-DD",
"clientInfo": map[string]any{"name": "my-agent", "version": "1.0.0"},
})
return client, err
}
func (c *MCPClient) call(method string, params any) (json.RawMessage, error) {
c.nextID++
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: c.nextID,
Method: method,
}
if params != nil {
req.Params, _ = json.Marshal(params)
}
// Send the request to the server's stdin
data, _ := json.Marshal(req)
fmt.Fprintf(c.stdin, "%s\n", data)
// Read the response from the server's stdout
if !c.stdout.Scan() {
return nil, fmt.Errorf("server did not respond")
}
var resp JSONRPCResponse
json.Unmarshal(c.stdout.Bytes(), &resp)
if resp.Error != nil {
return nil, fmt.Errorf("RPC error %d: %s", resp.Error.Code, resp.Error.Message)
}
result, _ := json.Marshal(resp.Result)
return result, nil
}
Using the client:
func main() {
client, err := NewMCPClient("./deploy-server")
if err != nil {
log.Fatal(err)
}
defer client.cmd.Process.Kill()
// Get the list of tools
tools, _ := client.call("tools/list", nil)
fmt.Println("Tools:", string(tools))
// Call a tool
result, _ := client.call("tools/call", map[string]any{
"name": "deploy_service",
"arguments": map[string]any{"service": "api-gateway", "env": "staging"},
})
fmt.Println("Result:", string(result))
}
When to use MCP vs direct HTTP/gRPC¶
| Criterion | MCP | Direct HTTP/gRPC |
|---|---|---|
| Agent consumes the tool | Yes — MCP is built for this | Requires manual integration |
| Tools for multiple agents | One server — any MCP client | Each agent writes its own client |
| Network, scale, language agnosticism | Conditional: stdio — local and simple, Streamable HTTP — for remote servers | Yes — HTTP/gRPC when you need a full-fledged API for other services |
| Complex business logic | No — better as a separate service | Yes |
| IDEs and developer tools | Yes — most IDEs support MCP | No |
Rule of thumb: If an LLM agent needs the tool — use MCP. If it's an API for other services — use HTTP/gRPC.
MCP vs skills vs regular tools¶
Three mechanisms solve different problems — don't substitute one for another:
- Regular tools (ch. 03) — the default for your own operations: deploys, queries against your own database, internal APIs. You control the code, the schema and the result.
- MCP — when integrating third-party systems or when you want a ready-made ecosystem of servers: browser, file system, ticket tracker. One protocol — any server works with any agent.
- Skills (ch. 09) — when knowledge matters more than code: instruction packages in
SKILL.mdformat with progressive disclosure; the agent loads them only when the task calls for it.
A word of caution: 20+ connected MCP servers is the same disease as 20+ hand-written wrappers. Each server brings tools whose names and descriptions eat into the context budget (ch. 03, ch. 13). Connect only the servers whose tools the agent actually needs; for everything else look toward skills and progressive disclosure — load on demand, not all at once.
The FilteredContent invariant for large results. MCP tool results can be bulky: file contents, table dumps, search output. The invariant from ch. 09 applies here too: the full result goes into
Content(for UI and audit), while the model's context receives a filtered version. An MCP server doesn't know your context budget — the host does the filtering.
A2A (Agent-to-Agent Protocol)¶
What is A2A¶
A2A (Agent-to-Agent) is an open protocol from Google for agents to interact with each other. MCP solves the problem "agent ↔ tool". A2A solves a different problem — "agent ↔ agent".
Why is this needed? When there are many agents built by different teams, you need a standard way to discover each other and delegate tasks. A2A provides this out of the box.
Key concepts¶
Agent Card — a discovery document in JSON format describing an agent's capabilities: name, description, supported input/output modes, skills. It is published according to the rules of the current A2A version (see a2a-protocol.org); any agent can fetch the card and learn what tasks another agent can solve.
Task — a unit of work. One agent creates the task, another executes it. A task has a life cycle with well-defined statuses.
Message — communication within a task. It contains parts (Part): text, files, structured data.
Artifact — the result of executing a task. The executing agent returns artifacts as they become ready.
Task life cycle¶
submitted ──→ working ──→ completed
│ ──→ failed
│ ──→ canceled
▼
input-required ──→ working ──→ ...
- submitted — the task has been created and is waiting to be processed
- working — the agent is working on the task
- input-required — the agent needs additional information from the caller
- completed — the task is done, artifacts are ready
- failed — the task ended with an error
- canceled — the task was canceled by the caller
Example: Agent card and an A2A server¶
The Agent Card — a description of the agent's capabilities:
// The Agent Card is a discovery document; the publication path follows the rules of the current A2A version
const agentCardPath = "/.well-known/agent-card.json"
type AgentCard struct {
Name string `json:"name"`
Description string `json:"description"`
URL string `json:"url"`
Version string `json:"version"`
Capabilities Capabilities `json:"capabilities"`
Skills []Skill `json:"skills"`
}
type Capabilities struct {
Streaming bool `json:"streaming"`
PushNotifications bool `json:"pushNotifications"`
}
// Skill is a specific task the agent knows how to solve
type Skill struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
InputModes []string `json:"inputModes"` // "text", "file", "data"
OutputModes []string `json:"outputModes"`
}
Types for working with tasks:
type Task struct {
ID string `json:"id"`
Status TaskStatus `json:"status"`
Messages []Message `json:"messages,omitempty"`
Artifacts []Artifact `json:"artifacts,omitempty"`
}
type TaskStatus struct {
State string `json:"state"` // submitted, working, input-required, completed, failed, canceled
Message string `json:"message,omitempty"`
}
type Message struct {
Role string `json:"role"` // "user" or "agent"
Parts []Part `json:"parts"`
}
type Part struct {
Type string `json:"type"` // "text", "file", "data"
Text string `json:"text,omitempty"`
Data any `json:"data,omitempty"`
}
type Artifact struct {
Name string `json:"name"`
Parts []Part `json:"parts"`
}
An A2A server processes tasks from other agents:
type A2AServer struct {
card AgentCard
tasks sync.Map // taskID → *Task
}
func (s *A2AServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case agentCardPath:
// Discovery: any agent can fetch the card
json.NewEncoder(w).Encode(s.card)
case "/tasks/send":
// Receiving a task from another agent
var req struct {
ID string `json:"id"`
Message Message `json:"message"`
}
json.NewDecoder(r.Body).Decode(&req)
task := &Task{
ID: req.ID,
Status: TaskStatus{State: "working"},
Messages: []Message{req.Message},
}
s.tasks.Store(req.ID, task)
// Execute the task asynchronously
go s.processTask(task)
json.NewEncoder(w).Encode(task)
case "/tasks/get":
// Check the task status
taskID := r.URL.Query().Get("id")
if task, ok := s.tasks.Load(taskID); ok {
json.NewEncoder(w).Encode(task)
} else {
http.Error(w, "task not found", http.StatusNotFound)
}
}
}
func (s *A2AServer) processTask(task *Task) {
// Extract the task text
input := task.Messages[0].Parts[0].Text
// The agent does its work...
result := fmt.Sprintf("Analyzed: %s", input)
// Update the task with the result
task.Artifacts = []Artifact{
{Name: "result", Parts: []Part{{Type: "text", Text: result}}},
}
task.Status = TaskStatus{State: "completed"}
}
Usage — a client discovers the agent and sends a task:
func discoverAndDelegate(agentURL string, taskText string) (*Task, error) {
// 1. Fetch the Agent Card
resp, err := http.Get(agentURL + agentCardPath)
if err != nil {
return nil, fmt.Errorf("discovery failed: %w", err)
}
var card AgentCard
json.NewDecoder(resp.Body).Decode(&card)
resp.Body.Close()
fmt.Printf("Agent: %s — %s\n", card.Name, card.Description)
// 2. Send the task
taskReq := map[string]any{
"id": "task-001",
"message": Message{
Role: "user",
Parts: []Part{{Type: "text", Text: taskText}},
},
}
body, err := json.Marshal(taskReq)
if err != nil {
return nil, err
}
resp, err = http.Post(agentURL+"/tasks/send", "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("task submission failed: %w", err)
}
var task Task
json.NewDecoder(resp.Body).Decode(&task)
resp.Body.Close()
// 3. Poll the status until completion
for task.Status.State == "working" {
time.Sleep(time.Second)
resp, err = http.Get(agentURL + "/tasks/get?id=" + task.ID)
if err != nil {
return nil, fmt.Errorf("poll failed: %w", err)
}
json.NewDecoder(resp.Body).Decode(&task)
resp.Body.Close()
}
return &task, nil
}
When to use A2A vs the Supervisor/Worker pattern¶
| Criterion | A2A | Supervisor/Worker |
|---|---|---|
| Agents from different teams | Yes — a standard discovery protocol | No — requires shared code |
| Different frameworks | Yes — the protocol is implementation-agnostic | No — a shared framework |
| Simple orchestration | Overkill | Yes — easier to implement |
| Dynamic discovery | Yes — via the Agent Card | No — agents are defined statically |
| One team, one repository | Overkill | Yes — direct calls are simpler |
Rule of thumb: A2A is needed when agents are built by independent teams and must discover each other dynamically. For agents inside a single system, direct orchestration is enough (see Chapter 07: Multi-Agent Systems).
Protocol Comparison Table¶
| stdio | HTTP | gRPC | MCP | A2A | |
|---|---|---|---|---|---|
| Use case | Local tools | Distributed services | Production APIs | Tools for LLM agents | Agent-to-agent interaction |
| Latency | Minimal (IPC) | Moderate (network + HTTP/1.1) | Low (HTTP/2 + binary format) | Depends on transport (stdio or HTTP) | Moderate (HTTP) |
| Implementation complexity | Low | Moderate | High (protobuf, codegen) | Moderate (JSON-RPC 2.0) | Moderate-high |
| Tool discovery | None | Swagger / OpenAPI | Reflection, service mesh | tools/list, resources/list |
Agent Card (discovery document) |
| Streaming | Line-by-line via stdout | SSE, WebSocket | Bidirectional streams | SSE (Streamable HTTP) | SSE |
| Contract typing | None (free-form JSON) | JSON Schema / OpenAPI | Strict (Protobuf IDL) | JSON Schema (for tools) | JSON Schema |
| When to choose | CLI utilities, IDE plugins | External APIs, webhooks | Microservices, high load | Tools for LLM agents | Multi-agent systems |
Authentication: examples¶
HTTP — Bearer Token:
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if !strings.HasPrefix(token, "Bearer ") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
apiKey := strings.TrimPrefix(token, "Bearer ")
if !isValidKey(apiKey) {
http.Error(w, "invalid token", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
MCP — authentication via OAuth 2.1:
MCP uses OAuth 2.1 (IETF draft-ietf-oauth-v2-1) for remote servers (the Streamable HTTP transport); the exact flow and requirements are in the current version of the specification (modelcontextprotocol.io). The client obtains a token through the standard OAuth flow and passes it in the Authorization header:
// MCP client with authentication for the HTTP transport
type AuthenticatedMCPClient struct {
serverURL string
token string
client *http.Client
}
func (c *AuthenticatedMCPClient) call(method string, params any) (json.RawMessage, error) {
body, _ := json.Marshal(JSONRPCRequest{
JSONRPC: "2.0",
ID: 1,
Method: method,
Params: mustMarshal(params),
})
req, _ := http.NewRequest("POST", c.serverURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("token expired, re-authentication required")
}
var rpcResp JSONRPCResponse
json.NewDecoder(resp.Body).Decode(&rpcResp)
if rpcResp.Error != nil {
return nil, fmt.Errorf("RPC error: %s", rpcResp.Error.Message)
}
result, _ := json.Marshal(rpcResp.Result)
return result, nil
}
For the stdio transport, authentication is usually unnecessary — the server runs locally as a subprocess of the host.
Common Mistakes¶
Mistake 1: No versioning¶
Symptom: Tool updates break the agent.
Cause: There is no versioning, and the agent expects the old interface.
Solution: Version your tool schemas and check compatibility.
Mistake 2: No authentication¶
Symptom: Unauthorized access to tools.
Cause: No authn/authz for tool servers.
Solution: Implement authentication (API keys, tokens).
Exercises¶
Exercise 1: Implement a stdio tool server¶
Create a tool server that communicates over stdio:
Expected result:
- Reads requests from stdin
- Executes tools
- Writes responses to stdout
Acceptance Criteria / Checklist¶
Passing:
- You understand the tool server architecture
- You can implement the stdio protocol
- You can implement the HTTP protocol
- You understand the advantages of gRPC for production tool servers
- You understand schema versioning
- You understand MCP: architecture, the three primitives, transports
- You can write an MCP server and an MCP client in Go
- You understand A2A: the Agent Card and the task lifecycle
- You know when to choose MCP, A2A, HTTP, or gRPC
Failing:
- No versioning — updates break compatibility
- No authentication — a security risk
- Using MCP as a public high-load API instead of HTTP/gRPC
- Using A2A where direct orchestration is enough
Relation to Other Chapters¶
- Chapter 03: Tools and Function Calling — Tool execution basics
- Chapter 07: Multi-Agent Systems — Agent orchestration patterns (Supervisor/Worker and others)
- Chapter 17: Security and Governance — Security for tool servers
What's Next?¶
Once you understand tool protocols, move on to:
- 19. Observability and Tracing — Learn production observability