Agentic Trading
Architecture

Tools and MCP

Tools available to a Skill come from two places:

Package: packages/tools Depends on: ai (v6), zod, @modelcontextprotocol/sdk

The two-source tool model

Tools available to a Skill come from two places:

  1. Built-in registry — TypeScript modules in packages/tools/src/tools/*. First-party, reviewed in code review, type-checked.
  2. MCP servers — external processes implementing the Model Context Protocol. Loaded at runtime via URL or command spec. Lets the platform integrate third-party / community tooling without releasing new builds.

The agent runtime hydrates both into a single Record<string, Tool> passed to AI SDK. The agent cannot tell which is which (intentional — uniform interface).

Built-in tool pattern

Each built-in tool is one file that default-exports a defineTool(...):

// packages/tools/src/tools/fetch-recent-bars.ts
import { tool } from 'ai';
import { z } from 'zod';
import { defineTool } from '../types';

export default defineTool({
  name: 'fetch_recent_bars',
  category: 'market_data',
  description: 'Get the most recent OHLCV bars for a symbol',
  uiHint: 'Use to read recent price action before deciding to trade',
  modes: ['read', 'write'],            // available in both chat and trading
  build: (ctx) =>
    tool({
      description: 'Get recent OHLCV bars for a Hyperliquid perp',
      inputSchema: z.object({
        symbol: z.string(),
        interval: z.enum(['1m', '5m', '15m', '1h', '4h', '1d']),
        lookback: z.number().int().min(1).max(500),
      }),
      execute: async ({ symbol, interval, lookback }) => {
        return ctx.bars.recent({ symbol, interval, lookback, asOf: ctx.asOf });
      },
    }),
});

defineTool shape

// packages/tools/src/types.ts
import type { Tool } from 'ai';

export type ToolMode = 'read' | 'write';

export type ToolContext = {
  deploymentId: string;
  skill: Skill;
  mode: ToolMode;             // 'read' for chat agent, 'write' for trading agent
  asOf: Date;                 // 'now' for live, sim time for backtest
  bars: BarsClient;
  news: NewsClient;
  funding: FundingClient;
  portfolio: PortfolioReader;
  broker: BrokerAdapter | null;   // null in chat mode
  // ... more clients as needed
};

export type ToolDefinition = {
  name: string;                // canonical id, snake_case
  category: 'market_data' | 'news' | 'portfolio' | 'execution' | 'introspection';
  description: string;         // human-readable, shown in skill-editor UI
  uiHint?: string;             // extra context for skill authors
  modes: ToolMode[];           // which modes this tool may run in
  build: (ctx: ToolContext) => Tool;
};

export function defineTool(def: ToolDefinition): ToolDefinition {
  return def;
}

Registry

// packages/tools/src/registry.ts
import fetchRecentBars from './tools/fetch-recent-bars';
import fetchNewsSentiment from './tools/fetch-news-sentiment';
import getPortfolio from './tools/get-portfolio';
import proposeOrder from './tools/propose-order';

import getRecentDecisions from './tools/introspection/get-recent-decisions';
import getDecisionSnapshot from './tools/introspection/get-decision-snapshot';
import getCurrentPosition from './tools/introspection/get-current-position';
import getPositionRisk from './tools/introspection/get-position-risk';
import getPnlSummary from './tools/introspection/get-pnl-summary';
import getNewsForWindow from './tools/introspection/get-news-for-window';
import getCommandHistory from './tools/introspection/get-command-history';
import getSkillConfig from './tools/introspection/get-skill-config';
import getEngineRejections from './tools/introspection/get-engine-rejections';

const all = [
  fetchRecentBars,
  fetchNewsSentiment,
  getPortfolio,
  proposeOrder,
  // introspection
  getRecentDecisions,
  getDecisionSnapshot,
  getCurrentPosition,
  getPositionRisk,
  getPnlSummary,
  getNewsForWindow,
  getCommandHistory,
  getSkillConfig,
  getEngineRejections,
];

export const toolRegistry = Object.fromEntries(all.map((t) => [t.name, t]));

export const tradingToolNames = all
  .filter((t) => t.modes.includes('write') && t.category !== 'introspection')
  .map((t) => t.name);

export const introspectionToolNames = all
  .filter((t) => t.category === 'introspection')
  .map((t) => t.name);

Hydration

// packages/tools/src/build.ts
export async function buildTools(
  spec: SkillToolSpec,             // { builtIn: string[], mcpServers: McpSpec[] }
  ctx: ToolContext,
): Promise<Record<string, Tool>> {
  const builtIn: Record<string, Tool> = {};
  for (const name of spec.builtIn) {
    const def = toolRegistry[name];
    if (!def) throw new Error(`Unknown tool: ${name}`);
    if (!def.modes.includes(ctx.mode)) {
      throw new Error(`Tool ${name} not allowed in mode ${ctx.mode}`);
    }
    builtIn[name] = def.build(ctx);
  }

  const mcp = await loadMcpTools(spec.mcpServers, ctx);

  return { ...builtIn, ...mcp };
}

Mode enforcement is at hydration time — a Skill cannot accidentally hand propose_order to the chat agent because the chat agent's context has mode='read' and propose_order.modes excludes 'read'.

Tool catalog API

// apps/web/app/api/tools/catalog/route.ts
export async function GET() {
  const catalog = Object.values(toolRegistry).map((t) => ({
    name: t.name,
    category: t.category,
    description: t.description,
    uiHint: t.uiHint,
    modes: t.modes,
    inputSchema: zodToJsonSchema(t.build(STUB_CTX).inputSchema),
  }));
  return Response.json(catalog);
}

The Skill editor's tool multi-select reads this. Adding a new tool to the codebase makes it appear in the editor on next build — no schema migration, no editor change.

MCP integration

MCP (Model Context Protocol) is an open standard for connecting agent runtimes to external tool/resource providers. AI SDK supports MCP clients first-class.

MCP server spec on a Skill

type McpSpec = {
  id: string;                  // platform-curated id, e.g. "hyperliquid-mcp"
  transport: 'stdio' | 'sse' | 'http';
  // For stdio:
  command?: string;
  args?: string[];
  // For sse/http:
  url?: string;
  // Common:
  env?: Record<string, string>;
  auth?: { type: 'bearer'; secretRef: string };  // secret stored in vault
  toolFilter?: string[];       // optional: only expose these tools
};

Loading at runtime

// packages/tools/src/mcp.ts
import { experimental_createMCPClient as createMCPClient } from 'ai';
import { Experimental_StdioMCPTransport as StdioTransport } from 'ai/mcp-stdio';

export async function loadMcpTools(
  specs: McpSpec[],
  ctx: ToolContext,
): Promise<Record<string, Tool>> {
  const acc: Record<string, Tool> = {};

  for (const spec of specs) {
    const transport = spec.transport === 'stdio'
      ? new StdioTransport({ command: spec.command!, args: spec.args, env: spec.env })
      : { type: spec.transport, url: spec.url! };

    const client = await createMCPClient({ transport });
    const tools = await client.tools();

    const filtered = spec.toolFilter
      ? Object.fromEntries(Object.entries(tools).filter(([k]) => spec.toolFilter!.includes(k)))
      : tools;

    for (const [name, tool] of Object.entries(filtered)) {
      const safeName = `mcp_${spec.id}__${name}`;     // namespace to avoid collisions
      acc[safeName] = wrapMcpTool(tool, ctx);
    }
  }

  return acc;
}

MCP tool safety wrapping

External MCP tools are untrusted code accessed over IPC/network. The wrapper applies:

  • Timeout per call (default 10s, configurable per spec)
  • Concurrency limit per server (avoid one slow tool blocking the agent loop)
  • Read-only enforcement in chat mode (MCP tools tagged as mutators are stripped)
  • Argument size cap (reject huge prompts being smuggled via args)
  • Result size cap (truncate huge responses)

Approved MCP catalog

In MVP, users cannot add arbitrary MCP servers — only choose from a curated catalog the platform maintains. Reason: a malicious MCP server could exfiltrate context (which may include the Skill's prompts and the deployer's portfolio).

Curated MVP catalog:

MCP ServerProvides
hyperliquid-info-mcpFunding, OI, leaderboard, top traders
coingecko-mcpCross-exchange spot prices, market caps
cryptopanic-mcpNews headlines + categorization
glassnode-mcp (paid)On-chain metrics

Phase 5 (marketplace): community MCP servers with explicit user opt-in + safety review.

Naming conventions

  • Tool names: snake_case, verb-led where possible — fetch_recent_bars, get_portfolio, propose_order
  • MCP-namespaced tools: mcp_<server_id>__<original_name> — the double underscore separates server from tool
  • Categories: controlled vocabulary — market_data, news, portfolio, execution, introspection, external (MCP)

Symbol scope

A Skill trades the explicit context.symbols set it was authored with — there is no runtime symbol discovery (removed for simplicity). The hard whitelist (risk.allowedSymbols) still applies at the engine: non-empty = authoritative whitelist, empty = unrestricted. See execution-engine.md for the SCOPE rule that enforces this.

Examples of upcoming tools

Wanted in early roadmap (not in MVP cut):

  • get_top_traders — leaderboard from Hyperliquid info API
  • get_funding_rate — current + historical funding for a symbol
  • get_open_interest — current OI across symbols
  • analyze_chart_pattern — calls a TA helper, returns named patterns + confidence
  • compute_var — value-at-risk for the current portfolio
  • read_my_prior_decisions — useful even for the trading agent, not just chat
  • simulate_action — "if I did X, what would the engine say?" — lets the agent self-check before proposing

Each is a new file in packages/tools/src/tools/. Adding one is a half-day of work + tests.

Testing tools

// packages/tools/src/tools/__tests__/fetch-recent-bars.test.ts
import fetchRecentBars from '../fetch-recent-bars';
import { makeMockContext } from '../../testing';

test('returns bars in descending order, capped at lookback', async () => {
  const ctx = makeMockContext({
    bars: { recent: vi.fn().mockResolvedValue([...]) },
  });
  const def = fetchRecentBars.build(ctx);
  const result = await def.execute({ symbol: 'BTC', interval: '5m', lookback: 10 }, {});
  expect(result).toHaveLength(10);
  expect(result[0].ts).toBeGreaterThan(result[1].ts);
});

Tools should be unit-testable in isolation. The factory pattern makes this trivial.

On this page