Skip to main content
Skip to main content

MCP Protocol

The Model Context Protocol (MCP) is an open standard that lets AI agents communicate with external tools and data sources through a structured, typed interface. Instead of writing custom integrations for every AI host, a server implements MCP once and any compatible client can connect to it.

web3agent is an MCP server. Run npx web3agent and it starts a stdio MCP server that exposes 100+ blockchain tools. Claude Code, Cursor, Windsurf, and OpenCode can all connect to it without any custom code.


Why MCP Matters for Blockchain

Blockchain operations are stateful, financially consequential, and often irreversible. MCP gives web3agent a clean way to:

  • Describe tools precisely. Every tool has a name, a description, and a JSON Schema for its inputs. AI agents read these descriptions to understand what a tool does and what arguments it expects.
  • Validate inputs before execution. The schema is enforced at the boundary. Malformed arguments are rejected before any on-chain operation runs.
  • Signal risk levels. Tools carry a riskLevel field (safe, financial, destructive) that the runtime uses to decide whether to require confirmation or policy checks.
  • Return structured results. Every tool response is a typed object, not a free-form string. AI agents can parse and act on results programmatically.

Transport: stdio

web3agent uses the stdio transport. The MCP server reads JSON-RPC messages from stdin and writes responses to stdout. This is the standard transport for local MCP servers and is supported by all major AI coding tools.

# Start the MCP server
npx web3agent

# Or with environment variables
CHAIN_ID=8453 PRIVATE_KEY=0x... npx web3agent

stdout is reserved for MCP protocol messages. All internal logging goes to stderr with a [module-name] prefix. Never write to stdout from tool handlers.


Tool Registration

Every tool is defined as a ToolDefinition object and registered in src/tools/<group>/index.ts. The group's get<Group>ToolDefinitions() function returns an array of these definitions, which the runtime collects at startup.

// src/tools/<group>/index.ts
import { zodToJsonSchema } from "zod-to-json-schema";
import { createToolHandler } from "../shared/handler-factory.js";
import { z } from "zod";

const myToolSchema = z.object({
  chainId: z.number().describe("EVM chain ID"),
  address: z.string().describe("Contract address (0x-prefixed)"),
});

export function getMyGroupToolDefinitions(): ToolDefinition[] {
  return [
    {
      name: "my_tool_name",
      description: "Fetches contract metadata for the given address on the specified chain.",
      inputSchema: zodToJsonSchema(myToolSchema),
      handler: createToolHandler(myToolSchema, async (args) => {
        // args is fully typed from myToolSchema
        const result = await fetchContractMetadata(args.address, args.chainId);
        return result;
      }, "MY_TOOL_ERROR"),
      riskLevel: "safe",
    },
  ];
}

ToolDefinition Fields

FieldTypeDescription
namestringUnique tool identifier. Used by AI agents to call the tool.
descriptionstringHuman-readable description. AI agents read this to decide when to use the tool.
inputSchemaobjectJSON Schema derived from the Zod schema via zodToJsonSchema().
handlerfunctionThe actual implementation, wrapped by createToolHandler.
riskLevel"safe" | "financial" | "destructive"Controls confirmation and policy gating.

Tool Handler Factory

createToolHandler in src/tools/shared/handler-factory.ts wraps every tool handler with consistent validation and error handling. You pass it the Zod schema, the implementation function, and an error code prefix.

// What createToolHandler does internally:
function createToolHandler(schema, fn, errorCode) {
  return async (rawArgs) => {
    // 1. Parse and validate with Zod
    const parsed = schema.safeParse(rawArgs);
    if (!parsed.success) {
      return formatToolError(errorCode + "_INVALID_INPUT", parsed.error.message);
    }

    // 2. Execute the handler
    try {
      const result = await fn(parsed.data);
      return { ok: true, data: result };
    } catch (e: unknown) {
      return formatToolErrorFromUnknown(errorCode + "_FAILED", e);
    }
  };
}

This means tool implementations never need to handle validation or catch-all errors themselves. They receive typed, validated arguments and return a result or throw.


Tool Response Format

Every tool returns a CallToolResult:

// Success
{ ok: true, data: { /* typed payload */ } }

// Failure
{ ok: false, error: { code: "TOOL_NAME_FAILED", message: "..." } }

The SDK layer's invokeAndRequireData() unwraps this: it returns data on success and throws a Web3AgentError on failure. Tool handlers themselves always return the raw CallToolResult shape.


Tool Discovery

When an AI agent connects, it calls tools/list to get all available tools. The response includes each tool's name, description, and input schema. The agent uses this to understand what's available and how to call each tool.


Zod as Single Source of Truth

web3agent uses Zod for every input and output schema. This is not just a validation choice. Zod schemas serve three purposes simultaneously:

  1. Runtime validation — inputs are parsed before any handler runs
  2. JSON Schema generation — zodToJsonSchema() converts Zod schemas to the JSON Schema format MCP requires
  3. TypeScript types — z.infer<typeof schema> gives fully typed handler arguments with no duplication

Every field in every schema carries a .describe() annotation. These descriptions flow directly into the JSON Schema that AI agents read. A well-described schema means the AI agent understands what each parameter does without needing to read source code.

const swapSchema = z.object({
  fromToken: z.string().describe(
    "Source token address (0x-prefixed ERC-20) or 'native' for the chain's native token"
  ),
  toToken: z.string().describe(
    "Destination token address (0x-prefixed ERC-20) or 'native' for the chain's native token"
  ),
  fromAmount: z.string().describe(
    "Amount to swap in the source token's smallest unit (e.g. wei for ETH, 6-decimal units for USDC)"
  ),
  chainId: z.number().optional().describe(
    "EVM chain ID. Defaults to CHAIN_ID env var or 8453 (Base) if not set."
  ),
});

Risk Levels

Every tool declares a riskLevel. The runtime uses this to decide what happens before execution.

Risk LevelExamplesBehavior
safeBalance reads, price quotes, chain infoExecutes immediately, no confirmation needed
financialSwaps, transfers, order placementQueued for confirmation when CONFIRM_WRITES=true
destructiveContract deployments, approvals with large amountsQueued for confirmation, flagged in audit log

Setting CONFIRM_WRITES=false bypasses the confirmation queue for all risk levels. Only do this for autonomous agents where human approval is not part of the workflow.


Naming Conventions

Tool names follow a <integration>_<action> pattern. A few examples:

Tool NameIntegrationAction
orbs_get_quoteOrbsGet a swap quote
orbs_swapOrbsExecute a Liquidity Hub swap
lifi_get_bridge_quoteLI.FIGet a cross-chain bridge quote
blockscout_get_addressBlockscoutFetch address info from the indexer
evm_read_contractEVMRead a contract function via viem
evm_transferEVMSend a native token transfer
resolve_tokenTokensResolve a token symbol to its address
list_supported_chainsUtilityList all chains web3agent supports

Token parameters always use fromToken/toToken/fromAmount. Never srcToken, dstToken, or inputAmount. This consistency lets AI agents generalize across tools without learning per-tool naming quirks.