Tools and MCP
Tools available to a Skill come from two places:
Package:
packages/toolsDepends on:ai(v6),zod,@modelcontextprotocol/sdk
The two-source tool model
Tools available to a Skill come from two places:
- Built-in registry — TypeScript modules in
packages/tools/src/tools/*. First-party, reviewed in code review, type-checked. - 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 Server | Provides |
|---|---|
hyperliquid-info-mcp | Funding, OI, leaderboard, top traders |
coingecko-mcp | Cross-exchange spot prices, market caps |
cryptopanic-mcp | News 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 APIget_funding_rate— current + historical funding for a symbolget_open_interest— current OI across symbolsanalyze_chart_pattern— calls a TA helper, returns named patterns + confidencecompute_var— value-at-risk for the current portfolioread_my_prior_decisions— useful even for the trading agent, not just chatsimulate_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.