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

  1. Key Takeaways
  2. Runtime Architecture
  3. Skill Package and Runtime Policy
  4. MCP to Eino Tool Adapter
  5. Request-Scoped Agent
  6. Trace Design
  7. Permission Guard
  8. Nested Skill Calls
  9. Testing Strategy
  10. Best Practices
  11. FAQ
  12. 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, and skill.child.run should 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.

graph TB U["User Request"] --> R["SkillRuntime"] R --> L["SkillLoader"] R --> P["Policy Resolver"] R --> M["MCP Tool Resolver"] R --> A["Eino Agent Factory"] M --> T["MCP to Eino Tool Adapters"] T --> G["Tool Guard"] T --> C["MCP Client"] A --> E["Eino Agent Loop"] E --> CM["ChatModel"] E --> T R --> O["Trace Recorder"] E --> O T --> O

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:

markdown
---
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:

yaml
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:

  1. The model sees a smaller and more relevant tool surface.
  2. The runtime has a deterministic policy boundary without inventing non-standard Skill fields.
  3. 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.

go
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:

go
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:

  • Info exposes name, description, and schema.
  • InvokableRun executes the tool.
  • ToolGuard enforces 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.

go
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:

text
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:

go
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.

go
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.

go
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_depth
  • max_total_tool_calls
  • allowed_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:

go
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:

yaml
- 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.