TL;DR
The clean way to implement Skill execution with Eino and MCP is to treat a Skill as a policy-scoped runtime unit, not a script that directly calls external systems. The Skill declares what it may use. The runtime resolves MCP tools, adapts them into Eino Tools, creates a request-scoped Agent, and traces the full execution chain with OpenTelemetry. This guide uses an ecommerce order-triage Skill as the running example, because it shows why an Agent needs controlled access to business data such as orders, payments, inventory, risk signals, and refund policy.
Table of Contents
- Key Takeaways
- Runtime Architecture
- Skill Package and Runtime Policy
- MCP to Eino Tool Adapter
- Request-Scoped Agent
- Trace Design
- Permission Guard
- Nested Skill Calls
- Testing Strategy
- Best Practices
- FAQ
- Related Resources
Key Takeaways
- Skill is instructions + metadata + runtime policy: the Skill document describes behavior, while your runtime policy declares allowed tools and limits.
- MCP is a tool source: MCP tools should be adapted into Eino Tools instead of being hardcoded inside Skill text.
- Request-scoped Agents are pragmatic: user, tenant, region, model, and trace metadata can be injected per request.
- Trace is a runtime feature:
skill.run,agent.loop,llm.call,tool.call, andskill.child.runshould be connected in one trace. - Guardrails must be code: allowlists, schema validation, side-effect control, timeout, retry, and audit policy cannot rely on prompts alone.
Runtime Architecture
A Skill starts as instructions, but a production Skill runtime must also manage tools, policy, limits, tracing, and failure behavior.
The boundaries should be explicit:
| Layer | Responsibility | Should avoid |
|---|---|---|
| Skill document | Task behavior, examples, domain rules | Credentials, raw clients, permission logic |
| Runtime policy | Allowed MCP tools, limits, output contract | Long-form reasoning instructions |
| Runtime | Load Skill, render prompt, build Agent, attach tools | Concrete external API implementation |
| Tool adapter | Convert MCP descriptor into Eino Tool and call MCP | Prompt-only safety checks |
| Guard layer | Permission, schema, quota, side-effect checks | Model reasoning |
| Trace layer | Execution spans and metadata | Full sensitive payloads by default |
This architecture turns tool use into a controlled capability surface. The model still decides when to use tools, but the runtime decides which tools exist and whether a call is allowed.
Skill Package and Runtime Policy
There is no universal industry-standard "Skill manifest" that defines fields such as allowed_mcp. A safer production convention is to separate the human-facing Skill package from your runtime-enforced policy.
Use SKILL.md for the portable Skill instructions and metadata:
---
name: commerce-order-triage
description: Diagnose ecommerce order issues with read-only business data MCP tools.
---
# Commerce Order Triage
Use this Skill when a support operator or merchant-ops user asks why an order
is delayed, payment is abnormal, fulfillment is blocked, or refund handling is
unclear. The Skill should read business data, compare signals, and return an
actionable diagnosis. It must not mutate orders, issue refunds, send messages,
or change inventory.
## Workflow
1. Identify the order ID, region, merchant ID, and buyer ID from the request.
2. Read order, payment, inventory, risk, and refund-policy data as needed.
3. Cross-check timestamps, state transitions, payment events, stock allocation,
shipment blockers, and refund eligibility.
4. Return the likely root cause, evidence, confidence, and recommended next
action for support or operations.
## MCP Usage Guidance
- Use `commerce_order.get_order_detail` first when the request contains an order
ID and the current order state, merchant, buyer, region, or fulfillment status
is unknown.
- Use `commerce_payment.get_payment_events` when the order state mentions
pending payment, paid-but-not-confirmed, chargeback, authorization expiry, or
payment/refund mismatch.
- Use `commerce_inventory.get_sku_stock` when fulfillment is blocked, shipment
is delayed, allocation failed, or the order contains pre-sale or multi-warehouse
SKUs.
- Use `commerce_risk.get_user_risk_profile` when the order is held, manually
reviewed, high value, recently changed address, or has unusual buyer behavior.
- Use `commerce_refund.get_refund_policy` only when the user asks about refund
eligibility, cancellation, return windows, or compensation.
- Do not call MCP tools when the user has already supplied enough order,
payment, stock, and policy facts to answer from the conversation.
- Never call write, refund, cancel, inventory-adjustment, notification, or
destructive MCP tools in this Skill. If an action is required, return the
recommended operation and ask for an explicit action workflow.
Then keep tool permissions in a separate runtime policy file owned by your execution engine:
apiVersion: qubittool.ai/v1alpha1
kind: SkillRuntimePolicy
metadata:
name: commerce-order-triage
skillFile: SKILL.md
spec:
toolAccess:
mcp:
- server: commerce_order
tool: get_order_detail
mode: read
- server: commerce_payment
tool: get_payment_events
mode: read
- server: commerce_inventory
tool: get_sku_stock
mode: read
- server: commerce_risk
tool: get_user_risk_profile
mode: read
- server: commerce_refund
tool: get_refund_policy
mode: read
limits:
maxSteps: 18
maxToolCalls: 12
timeoutMs: 120000
maxChildSkillDepth: 1
output:
format: markdown
requireSummary: true
This gives you three benefits:
- The model sees a smaller and more relevant tool surface.
- The runtime has a deterministic policy boundary without inventing non-standard Skill fields.
- Reviewers can audit what the Skill is allowed to do separately from the instruction text.
Use strict JSON Schema for MCP input schemas. For schema authoring and debugging, JSON Formatter and JSON Schema Generator help catch malformed arguments before they become runtime failures.
MCP to Eino Tool Adapter
The key implementation is an adapter that converts MCP descriptors into Eino-compatible tools.
type MCPToolDescriptor struct {
ServerName string
ToolName string
Description string
InputSchema json.RawMessage
}
type MCPClient interface {
CallTool(ctx context.Context, server, tool string, args json.RawMessage) (json.RawMessage, error)
}
type ToolGuard interface {
Validate(ctx context.Context, req ToolCallRequest) error
}
type ToolCallRequest struct {
RequestID string
SkillName string
ServerName string
ToolName string
Args json.RawMessage
}
Then expose the MCP tool as an Eino Tool:
type MCPToolAdapter struct {
desc MCPToolDescriptor
client MCPClient
guard ToolGuard
tracer trace.Tracer
}
func (t *MCPToolAdapter) Info(ctx context.Context) (*schema.ToolInfo, error) {
params, err := schemaFromJSON(t.desc.InputSchema)
if err != nil {
return nil, err
}
return &schema.ToolInfo{
Name: "mcp_" + t.desc.ServerName + "_" + t.desc.ToolName,
Description: t.desc.Description,
Parameters: params,
}, nil
}
func (t *MCPToolAdapter) InvokableRun(ctx context.Context, args string) (string, error) {
ctx, span := t.tracer.Start(ctx, "tool.call",
trace.WithAttributes(
attribute.String("tool.type", "mcp"),
attribute.String("mcp.server", t.desc.ServerName),
attribute.String("mcp.tool", t.desc.ToolName),
attribute.String("args_hash", hashString(args)),
),
)
defer span.End()
rawArgs := json.RawMessage(args)
req := ToolCallRequest{
RequestID: requestIDFromContext(ctx),
SkillName: skillNameFromContext(ctx),
ServerName: t.desc.ServerName,
ToolName: t.desc.ToolName,
Args: rawArgs,
}
if err := t.guard.Validate(ctx, req); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "tool guard rejected")
return "", err
}
output, err := t.client.CallTool(ctx, t.desc.ServerName, t.desc.ToolName, rawArgs)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return "", err
}
span.SetAttributes(attribute.Int("output_bytes", len(output)))
span.SetStatus(codes.Ok, "")
return string(output), nil
}
The exact Eino Tool interface can vary by version, but the shape should remain the same:
Infoexposes name, description, and schema.InvokableRunexecutes the tool.ToolGuardenforces policy before the MCP call leaves the process.- The adapter records spans without logging sensitive payloads.
When MCP responses are deeply nested, use JSON to Go to generate typed structs for post-processing. It is better than spreading map[string]any across your runtime.
Request-Scoped Agent
Create the Agent per request unless profiling proves it is too expensive. This keeps dynamic state explicit.
type SkillRuntime struct {
Loader SkillLoader
MCPResolver MCPToolResolver
Prompt PromptRenderer
Guard ToolGuard
Tracer trace.Tracer
}
type SkillRunRequest struct {
RequestID string
UserID string
Region string
SkillName string
Input string
Model model.ChatModel
}
func (r *SkillRuntime) RunSkill(ctx context.Context, req SkillRunRequest) (*SkillRunResult, error) {
ctx, span := r.Tracer.Start(ctx, "skill.run",
trace.WithAttributes(
attribute.String("request_id", req.RequestID),
attribute.String("skill_name", req.SkillName),
attribute.String("user_id", req.UserID),
attribute.String("region", req.Region),
),
)
defer span.End()
ctx = withRequestMetadata(ctx, req)
skill, err := r.Loader.Load(ctx, req.SkillName)
if err != nil {
span.RecordError(err)
return nil, err
}
mcpTools, err := r.MCPResolver.Resolve(ctx, skill.Policy.ToolAccess.MCP)
if err != nil {
span.RecordError(err)
return nil, err
}
tools := make([]tool.BaseTool, 0, len(mcpTools))
for _, desc := range mcpTools {
tools = append(tools, NewMCPToolAdapter(desc, r.Guard, r.Tracer))
}
systemPrompt := r.Prompt.Render(skill, PromptData{
UserID: req.UserID,
Region: req.Region,
Tools: mcpTools,
Limits: skill.Policy.Limits,
})
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Model: req.Model,
Tools: tools,
SystemPrompt: systemPrompt,
MaxSteps: skill.Policy.Limits.MaxSteps,
})
if err != nil {
span.RecordError(err)
return nil, err
}
out, err := agent.Run(ctx, req.Input)
if err != nil {
span.RecordError(err)
return nil, err
}
span.SetStatus(codes.Ok, "")
return &SkillRunResult{Content: out.Content}, nil
}
This pattern is useful for multi-tenant and multi-region systems. The runtime can choose region-specific MCP endpoints, tenant-specific allowlists, and model policies before constructing the Agent.
Trace Design
A useful Skill trace should show the entire execution chain:
skill.run
skill.load
prompt.render
agent.loop
llm.call
tool.call: mcp.commerce_order.get_order_detail
tool.call: mcp.commerce_payment.get_payment_events
tool.call: mcp.commerce_inventory.get_sku_stock
llm.call
skill.output
For Eino internals, use callbacks:
func BuildTraceHandler(tracer trace.Tracer) callbacks.Handler {
return callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
ctx, span := tracer.Start(ctx, info.Name,
trace.WithAttributes(
attribute.String("component", string(info.Type)),
attribute.String("run_name", info.Name),
attribute.String("request_id", requestIDFromContext(ctx)),
),
)
return context.WithValue(ctx, spanKey{}, span)
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if span := spanFromContext(ctx); span != nil {
span.SetAttributes(attribute.Int("output_size", sizeOf(output)))
span.SetStatus(codes.Ok, "")
span.End()
}
return ctx
}).
Build()
}
Recommended attributes:
| Attribute | Purpose |
|---|---|
request_id |
Join logs, traces, and user reports |
skill_name |
Identify the executing Skill |
skill_version |
Debug version-specific behavior |
model |
Compare behavior and cost |
tool.type |
mcp, skill, local, or retriever |
mcp.server |
MCP server identity |
mcp.tool |
MCP tool identity |
args_hash |
Debug repeated calls without storing arguments |
output_bytes |
Detect oversized tool responses |
retry_count |
Separate flaky dependencies from model behavior |
Do not store full prompts, user inputs, MCP arguments, or MCP responses in traces by default. Store hashes, sizes, and schema versions. Use controlled sampling if full payload capture is required.
Permission Guard
Prompt instructions are not security boundaries. The runtime must enforce policy in code.
type CompositeGuard struct {
SkillPolicy SkillPolicyStore
UserPolicy UserPolicyStore
Validator JSONSchemaValidator
SideEffects SideEffectPolicy
Quota QuotaLimiter
}
func (g *CompositeGuard) Validate(ctx context.Context, req ToolCallRequest) error {
if !g.SkillPolicy.Allows(req.SkillName, req.ServerName, req.ToolName) {
return ErrToolNotAllowedBySkill
}
if !g.UserPolicy.Allows(userIDFromContext(ctx), req.ServerName, req.ToolName) {
return ErrToolNotAllowedByUser
}
if err := g.Validator.Validate(req.ServerName, req.ToolName, req.Args); err != nil {
return err
}
if err := g.SideEffects.Check(ctx, req); err != nil {
return err
}
return g.Quota.Allow(ctx, req)
}
Classify side effects explicitly:
| Mode | Examples | Default policy |
|---|---|---|
| Read-only | Query order detail, payment events, stock allocation, risk profile | Allow if listed in runtime policy |
| Write | Add support note, create compensation ticket, update order tag | Require policy and confirmation |
| Destructive | Cancel order, issue refund, adjust inventory, revoke voucher | Deny by default |
| External communication | Send buyer message, notify merchant, trigger webhook | Require explicit workflow gate |
MCP annotations such as read-only or destructive hints are useful, but they should be inputs to your local policy engine, not the final source of truth.
Nested Skill Calls
To let one Skill call another, expose SkillRunner itself as an Eino Tool.
type SkillTool struct {
runtime *SkillRuntime
tracer trace.Tracer
}
func (t *SkillTool) InvokableRun(ctx context.Context, args string) (string, error) {
ctx, span := t.tracer.Start(ctx, "skill.child.run")
defer span.End()
req, err := parseChildSkillRequest(args)
if err != nil {
span.RecordError(err)
return "", err
}
if err := validateChildSkillPolicy(ctx, req); err != nil {
span.RecordError(err)
return "", err
}
result, err := t.runtime.RunSkill(ctx, req.ToSkillRunRequest())
if err != nil {
span.RecordError(err)
return "", err
}
return result.Content, nil
}
Before enabling nested Skills, enforce:
max_depthmax_total_tool_callsallowed_child_skills- cycle detection
- output-size limits
- inherited trace context
Nested Skills are useful for decomposition, but without limits they can create recursive loops and unpredictable cost.
Testing Strategy
Test the runtime in layers:
| Layer | Test target | What to verify |
|---|---|---|
| Unit | Runtime policy parser | Required fields, invalid allowlists, default limits |
| Unit | MCP adapter | Schema conversion, guard rejection, timeout, error mapping |
| Unit | Prompt renderer | Metadata injection and prompt stability |
| Integration | Fake MCP server | tools/list, tools/call, malformed responses |
| Agent test | Eino Agent with mock model | Tool selection and loop termination |
| Trace test | OTel exporter | Span names, parent-child links, attributes |
| Eval | Real model scenarios | Success rate, unnecessary tools, refusal behavior |
Example policy test:
func TestMCPToolAdapterRejectsUnauthorizedTool(t *testing.T) {
adapter := NewMCPToolAdapter(
MCPToolDescriptor{
ServerName: "commerce_refund",
ToolName: "issue_refund",
Description: "Issue a refund for an order",
},
fakeClient{},
denyAllGuard{},
noopTracer{},
)
_, err := adapter.InvokableRun(context.Background(), `{"order_id":"792318","amount":12900}`)
if !errors.Is(err, ErrToolNotAllowedBySkill) {
t.Fatalf("expected policy rejection, got %v", err)
}
}
For behavior regression, maintain a small golden dataset:
- name: "delayed ecommerce order triage"
skill: "commerce_order_triage"
input: "Order 792318 was paid yesterday but is still not shipped. Diagnose why."
expected_tools:
- "mcp_commerce_order_get_order_detail"
- "mcp_commerce_payment_get_payment_events"
- "mcp_commerce_inventory_get_sku_stock"
forbidden_tools:
- "mcp_commerce_refund_issue_refund"
- "mcp_commerce_order_cancel_order"
max_tool_calls: 6
Best Practices
Keep Skill files declarative. Use the Skill document for instructions, examples, and decision rules. Use the runtime policy for tool access and limits. Use Go code for permission checks.
Prefer request-scoped construction. Create Agents per request unless profiling proves construction overhead matters. This keeps metadata, tool surfaces, and trace context explicit.
Expose small tool surfaces. A Skill with five relevant tools is usually more reliable than one with dozens of loosely related tools.
Return structured output. MCP tools should return stable JSON objects with fields like status, data, error_code, and message.
Trace every boundary. At minimum, trace Skill load, prompt render, LLM calls, MCP calls, child Skill calls, and final output generation.
Hash sensitive inputs. Use args_hash, input_hash, and output_bytes instead of full payloads in traces.
Fail closed for side effects. Read-only tools can be allowlisted. Write, publish, delete, and external communication tools need explicit workflow gates.
FAQ
What is the difference between a Skill and an MCP tool?
A Skill is a task-level behavior package: instructions, policy, limits, and expected output. An MCP tool is a concrete external capability exposed through the Model Context Protocol. A Skill may use many MCP tools.
Should MCP descriptors be loaded at startup or per request?
Cache descriptors at startup or with a short TTL, but filter allowed tools per request. Descriptor caching improves performance; request-level filtering preserves policy isolation.
Can this architecture use Eino Graph instead of an ADK Agent?
Yes. Use Graph when the flow is deterministic. Use a ReAct-style Agent when tool choice and sequencing are dynamic. Many systems use Graph for the outer workflow and Agent for the flexible inner loop.
How do you control cost?
Set max_steps, max_tool_calls, per-tool timeout, output-size limits, and token budgets. Record token usage and tool count in trace attributes.
Is nested Skill execution safe?
It is safe only with depth limits, allowlists, cycle detection, and shared quotas. Without those controls, nested Skills can recurse or consume unpredictable cost.
Related Resources
- Eino Core Components: ChatModel, Tool, and Retriever in Practice
- Eino Streaming and Callback System
- Eino Production Deployment and Observability in Practice
- MCP Protocol Deep Dive: Complete Guide
- The Art of AI Agent Tools: Best Practices for MCP Tools
- MCP Tools Directory
- JSON Formatter
- JSON to Go Converter