Skip to main content
Tools & Extensions

Tools

Tools are capabilities the model can call while executing a task. The Qoder Agent SDK supports two kinds of tools:
  • Built-in tools: Provided by Qoder CLI, such as reading files, searching, executing commands, and invoking subagents.
  • Custom tools: defined by SDK users and exposed to the model as in-process MCP servers—TypeScript uses tool() + createSdkMcpServer(), Python uses the @tool() decorator + create_sdk_mcp_server().
This page focuses on custom tools. For more MCP server integration options see MCP integration; for the full permission system see Permissions; for the full built-in tool list see SDK References.

Built-in Tools

With built-in tools you don't implement anything yourself—options control which tools this session can see, which are pre-approved, and which are denied.
query({
  prompt: 'Read this repository and summarize risks in the authentication module. Do not modify files.',
  options: {
    auth: accessTokenFromEnv(),
    cwd: '/path/to/project',
    tools: ['Read', 'Grep', 'Glob'],
    allowedTools: ['Read', 'Grep', 'Glob'],
  },
});
Common built-in tools include Read, Edit, Write, Bash, Glob, Grep, WebFetch, WebSearch, Agent, and more. Tool names are determined by the underlying Qoder CLI; permission configuration should use the tool names the CLI exposes to the model. See SDK References for the full list, names, and input/output structures.

Custom Tools

Define a custom tool when you want the model to call your own business capability, such as order lookup, internal knowledge base search, approval system calls, or read-only database access. Custom tools usually involve three steps:
  1. Create the tool: TypeScript uses tool(); Python decorates an async def handler with @tool().
  2. Register it on an in-process MCP server: createSdkMcpServer() / create_sdk_mcp_server().
  3. Wire it up via the MCP servers config in options, and control invocation with permission settings.

Custom Tool Integration Steps

First, here is a complete minimal example. The following sections then explain each step.
import {
  accessTokenFromEnv,
  createSdkMcpServer,
  query,
  tool,
} from '@qodercn-ai/qodercn-agent-sdk';
import { z } from 'zod';

const lookupOrder = tool(
  'lookup_order',
  'Look up an order by order ID.',
  {
    orderId: z.string().describe('Order ID, such as O-1001'),
  },
  async ({ orderId }) => {
    const order = await orders.find(orderId);

    if (!order) {
      return {
        isError: true,
        content: [{ type: 'text', text: `Order not found: ${orderId}` }],
      };
    }

    return {
      content: [{ type: 'text', text: JSON.stringify(order) }],
    };
  },
  { annotations: { readOnlyHint: true } },
);

const orderTools = createSdkMcpServer({
  name: 'orders',
  tools: [lookupOrder],
});

const messages = query({
  prompt: 'Check the status of order O-1001 and summarize it in one sentence.',
  options: {
    auth: accessTokenFromEnv(),
    mcpServers: { orders: orderTools },
    allowedTools: ['mcp__orders__lookup_order'],
  },
});

for await (const message of messages) {
  if (message.type === 'result') {
    console.log(message.result);
  }
}

Step 1: Create the tool

This step defines the tool itself: its name, description, input parameters, execution logic, and metadata.

Parameters

TypeScript's tool() takes 5 parameters; Python's @tool() decorator takes 4:
ParameterRequiredMeaning
nameYesUnique identifier of the tool within its MCP server
descriptionYesModel-facing description: when to use the tool, what it does, what it returns
inputSchema / input_schemaYesTool input parameters: TypeScript takes a Zod raw shape; Python supports a simple dict, TypedDict, or a full JSON Schema dict
handler (TypeScript)YesAsync function that receives parsed arguments and returns a CallToolResult; in Python the decorated async def is the handler
extras.annotations (TypeScript) / annotations (Python)NoMCP tool annotations such as readOnlyHint, destructiveHint, openWorldHint
Python tool handlers must be async functions, usually taking one args dict. If the handler declares a second positional parameter, the SDK passes a ToolInvocationContext whose signal is an asyncio.Event set when the CLI cancels the in-flight tool call—useful for long tasks to stop proactively:
tool(
  'search_docs',
  'Search internal product documentation.',
  { query: z.string().describe('Search keywords') },
  async ({ query }) => ({ content: [{ type: 'text', text: query }] }),
);

Configure Input Parameters

The two SDKs declare input schemas differently; both are normalized to MCP-protocol JSON Schema. TypeScript: pass a Zod raw shape—a field object, not z.object(...):
{
  query: z.string().describe('Search keywords'),
  maxResults: z.number().int().min(1).max(10).optional()
    .describe('Maximum number of snippets to return'),
  source: z.enum(['docs', 'tickets', 'wiki']).default('docs')
    .describe('Where to search'),
}
NeedTypeScript syntax
Required stringz.string().describe('...')
Optional parameterz.string().optional().describe('...')
Default valuez.number().default(5)
Enumz.enum(['docs', 'tickets'])
Numeric rangez.number().min(1).max(10)
Python: three styles are supported. Style 1: a simple dict, for a handful of simple parameters. Keys are parameter names, values are Python types; every key is required in this style. Use typing.Annotated to attach descriptions:
from typing import Annotated


input_schema = {
    "query": Annotated[str, "Search keywords"],
    "max_results": Annotated[int, "Maximum number of snippets to return"],
}
Python formJSON Schema meaning
str{"type": "string"}
int{"type": "integer"}
float{"type": "number"}
bool{"type": "boolean"}
list[str]String array
dictObject
Annotated[T, "..."]Adds description to T's schema
Style 2: TypedDict, for many fields, optional fields, or reusable type definitions. Mark optional fields with NotRequired (importable from typing on Python 3.11+; use typing_extensions on 3.10):
from typing import Annotated, TypedDict

from typing_extensions import NotRequired


class SearchInput(TypedDict):
    query: Annotated[str, "Search keywords"]
    max_results: NotRequired[Annotated[int, "Maximum snippets to return"]]


@tool("search_docs", "Search internal product documentation.", SearchInput)
async def search_docs(args):
    limit = args.get("max_results", 5)
    return {"content": [{"type": "text", "text": f"{args['query']} ({limit})"}]}
Style 3: a full JSON Schema dict, for enums, numeric ranges, string format constraints, or nested objects:
input_schema = {
    "type": "object",
    "properties": {
        "query": {"type": "string", "description": "Search keywords"},
        "source": {
            "type": "string",
            "enum": ["docs", "tickets", "wiki"],
            "description": "Where to search",
        },
        "max_results": {"type": "integer", "minimum": 1, "maximum": 10},
    },
    "required": ["query"],
}

Configure Tool Metadata

annotations carry MCP tool annotations. The SDK puts them on the MCP tool definition, and the CLI can use them for scheduling, permissions, or status display.
tool(
  'search_docs',
  'Search internal product documentation.',
  { query: z.string().describe('Search keywords') },
  async ({ query }) => ({ content: [{ type: 'text', text: query }] }),
  {
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      openWorldHint: false,
    },
  },
);
Common fields:
FieldTypeMeaning
titlestringHuman-readable tool title
readOnlyHintbooleanMarks the tool as read-only, changing no state
destructiveHintbooleanMarks the tool as potentially modifying or deleting data
openWorldHintbooleanMarks the tool as reaching external systems or the network
maxResultSizeChars (Python)intPassed to the CLI via _meta["anthropic/maxResultSizeChars"] to relax the tool output length limit
Note: these fields do not replace permission configuration. Whether a tool may run is still decided by tools, allow/deny rules, the permission mode, the permission callback, and hooks. The annotations echoed in MCP status may also use the CLI-projected names readOnly, destructive, openWorld instead of the raw MCP *Hint names; keep a mapping on your side if the host UI needs them.

Step 2: Register with an MCP Server

createSdkMcpServer() / create_sdk_mcp_server() registers one or more tools as a same-process MCP server. The server name becomes part of the full tool name, so keep it short and stable.
const kbTools = createSdkMcpServer({
  name: 'kb',
  version: '1.0.0',
  tools: [searchDocs],
});
FieldHow to set itDescription
nameFor example kb, ordersServer name; forms full tool names like mcp__{name}__{tool}
versionFor example '1.0.0'Informational version, optional
tools[searchDocs, lookupOrder]Tools registered to this server
Python's create_sdk_mcp_server() validates synchronously: server name / tool names / tool descriptions must be non-empty strings, and tool names must be unique within a server.

Step 3: Attach to query()

Once the server is in the MCP servers config, the CLI discovers its tools and calls back into your handler through the SDK when the model needs them.
query({
  prompt: 'Search docs for the refund policy and summarize it.',
  options: {
    auth: accessTokenFromEnv(),
    mcpServers: { kb: kbTools },
    allowedTools: ['mcp__kb__search_docs'],
  },
});
The full custom tool name format is:
mcp__{serverName}__{toolName}
For example, with server name orders and tool name lookup_order, the full tool name is mcp__orders__lookup_order. This full name is used in tool allowlists/denylists, permission callbacks, hooks matchers, and subagent tools configuration. Python's QoderSDKClient multi-turn sessions use the same mcp_servers configuration:
from qodercn_agent_sdk import QoderSDKClient


options = QoderAgentOptions(
    auth=qodercli_auth(),
    mcp_servers={"kb": kb_tools},
    allowed_tools=["mcp__kb__search_docs"],
)

async with QoderSDKClient(options=options) as client:
    await client.query("Search docs for the refund policy.")

    async for message in client.receive_response():
        print(message)

Controlling Tool Permissions

When the model calls tools, the SDK provides multiple permission layers. You can decide:
  • Which tools are provided to the current session.
  • Which tools are allowed by default.
  • Which tools are explicitly denied.
  • Whether the host application should make a dynamic decision before each tool call.

Permission Control Overview

Method (TypeScript / Python)EffectGranularityWhen to use
toolsLimits the visible tool set for this sessionSessionNarrow the tools the model can see at the source
allowedTools / allowed_tools, disallowedTools / disallowed_toolsPre-approve or deny specific toolsPer toolYou know exactly which tools to allow or deny
permissionMode / permission_modeSet the session's default permission policyGlobalQuickly switch plan mode, auto-accept edits, bypass, etc.
canUseTool / can_use_toolRun custom logic before every invocationPer callDecisions depend on argument contents
hooks.PreToolUseIntercepts tool calls through the hooks lifecycleCallYou already use hooks and want shared auditing or blocking
These approaches compose. A common pattern: narrow the visible tool set with tools, set static rules with allow/deny, then use the permission callback for argument-level decisions.

Approach 1: tool set plus allow/deny rules

tools controls the session's visible tool set; allow/deny rules control permissions. Custom MCP tools must use full tool names.
// Only expose read/search tools to this session.
query({
  prompt: 'Analyze the repository without editing files.',
  options: {
    tools: ['Read', 'Glob', 'Grep'],
    allowedTools: ['Read', 'Glob', 'Grep'],
  },
});

// Explicitly deny high-risk tools.
query({
  prompt: 'Review the project and report issues.',
  options: {
    disallowedTools: ['Bash', 'Write', 'Edit'],
  },
});

// Use full names for custom MCP tools.
query({
  prompt: 'Check order O-1001.',
  options: {
    mcpServers: { orders: orderTools },
    allowedTools: ['mcp__orders__lookup_order'],
  },
});

// Disable all tools. The model can only answer from its context.
query({
  prompt: 'Explain what this SDK does at a high level.',
  options: { tools: [] },
});
When the same tool matches both allow and deny rules, the deny rule takes precedence.

Approach 2: permission mode

The permission mode sets the whole session's default permission behavior with one line.
query({
  prompt: 'Refactor the code.',
  options: {
    permissionMode: 'acceptEdits',
  },
});
ModeEffect
defaultStandard permission behavior; sensitive operations follow rules or runtime policy
acceptEditsAuto-accepts file edits; other sensitive operations still follow the permission policy
bypassPermissionsSkips permission checks; requires the explicit skip confirmation as well
yoloCompatibility alias of bypassPermissions; also requires explicit confirmation
planPlan mode, for having the model produce a proposal first
dontAskNo interactive prompts; operations not pre-approved or allowed by rules are denied
autoThe runtime decides allow or deny automatically

Approach 3: permission callback

The permission callback (canUseTool / can_use_tool) runs before each tool call. Return allow or deny based on the tool name, argument contents, and approval context.
query({
  prompt: 'Check order O-1001.',
  options: {
    auth: accessTokenFromEnv(),
    mcpServers: { orders: orderTools },
    allowedTools: ['mcp__orders__lookup_order'],
    async canUseTool(toolName, input, options) {
      if (toolName !== 'mcp__orders__lookup_order') {
        return {
          behavior: 'deny',
          message: 'Only order lookup is allowed in this workflow.',
          toolUseID: options.toolUseID,
        };
      }

      return {
        behavior: 'allow',
        updatedInput: input,
        toolUseID: options.toolUseID,
      };
    },
  },
});
Common return values:
ReturnEffect
allowExecutes with the original arguments
allow + updated inputExecutes with replaced tool arguments
deny + messageRejects; the model sees the reason and can try another way
deny + message + interrupt: trueRejects and interrupts the current agent loop
When using custom tools in a subagent, use the full tool name as well:
query({
  prompt: 'Use the order-support agent to check order O-1001.',
  options: {
    auth: accessTokenFromEnv(),
    mcpServers: { orders: orderTools },
    allowedTools: ['Agent'],
    agents: {
      'order-support': {
        description: 'Handles order lookup and explains order status.',
        prompt: 'Use order tools to answer order status questions clearly.',
        tools: ['mcp__orders__lookup_order'],
      },
    },
  },
});

Method 4: hooks.PreToolUse

If you already use the hooks system, use PreToolUse to intercept or audit tool calls in one place.
query({
  prompt: 'Run the test command.',
  options: {
    allowedTools: ['Bash'],
    hooks: {
      PreToolUse: [
        {
          matcher: 'Bash',
          hooks: [
            async (input) => {
              const command = (input.tool_input as { command: string }).command;
              if (command.includes('rm -rf')) {
                return {
                  hookSpecificOutput: {
                    hookEventName: 'PreToolUse',
                    permissionDecision: 'deny',
                    permissionDecisionReason: 'rm -rf is not allowed',
                  },
                };
              }

              return {
                hookSpecificOutput: {
                  hookEventName: 'PreToolUse',
                  permissionDecision: 'allow',
                },
              };
            },
          ],
        },
      ],
    },
  },
});
PreToolUse's permissionDecision can be allow, deny, ask, or defer. See Permissions for the full permission strategy.

How the SDK Handles Tool Errors

Tool handlers have several error paths.

Business failures: return an explicit error flag

For expected business failures, return the error flag (isError: true in TypeScript, is_error: True in Python). The SDK converts the result into an MCP CallToolResult for the CLI; the model sees the failure content and may retry or choose another approach.
return {
  isError: true,
  content: [{
    type: 'text',
    text: JSON.stringify({
      error: 'VALIDATION_ERROR',
      message: 'Only SELECT statements are allowed.',
    }),
  }],
};
Good cases for the error flag:
  • Arguments are valid, but no business result exists, such as an order not found.
  • A security policy rejects execution, such as only allowing SELECT queries.
  • An external service returns a business error that can be explained.

Unexpected Exception: Handler Throws

If the handler throws, the MCP layer converts the exception into an error result, and the agent loop won't crash from an ordinary tool exception. But the model usually only sees the exception message—less controlled in format and content than an explicit error flag.
const toolThatMayThrow = tool(
  'fetch_user',
  'Fetch a user by ID.',
  { userId: z.string() },
  async ({ userId }) => {
    const response = await userService.fetch(userId);
    if (!response.ok) {
      throw new Error('User service failed');
    }
    return { content: [{ type: 'text', text: await response.text() }] };
  },
);
Recommendation: use the explicit error flag for expected business failures; throw only for truly unexpected exceptions.

Malformed returns: wrapped as errors by the SDK (Python)

The Python SDK performs a runtime fallback check on handler return values:
  • Returning None: converted to error text explaining that the handler must return a dict containing "content".
  • Returning a non-dict (e.g., string, number, list): converted to text content and marked isError=True.
  • Returning a dict but without "content": converted to error text and lists the actual keys.
  • Returning unsupported content types: that content block is skipped and a warning is logged.
These fallbacks prevent the model from seeing empty successful results, but business code should still always return the standard structure.

Tool Return Values

Tool handlers return an MCP CallToolResult (a dict in Python, converted by the SDK). Text content is the most common:
return {
  content: [{ type: 'text', text: 'done' }],
};
You can also return structured JSON strings, which help the model understand and continue processing:
return {
  content: [{
    type: 'text',
    text: JSON.stringify({
      orderId: 'O-1001',
      status: 'shipped',
      eta: '2026-05-20',
    }),
  }],
};
Common content blocks:
TypeShapeDescription
Text{ type: 'text', text }Most common; suitable for natural language or JSON strings
Image{ type: 'image', data, mimeType }data is base64
Audio{ type: 'audio', data, mimeType }data is base64 (TypeScript)
Resource link{ type: 'resource_link', uri, name?, description?, mimeType? }Returns a referenceable resource; Python degrades it to text, concatenating name / uri / description for the model
Embedded resource{ type: 'resource', resource }Returns text or binary resource content; Python converts embedded text resources to TextContent
The Python edition has two result differences worth noting:
  • The handler-returned dict's top-level _meta is not propagated to CallToolResult.
  • When the handler indicates an error, use the Python field name "is_error": True, not the MCP/TypeScript-style isError. The SDK maps it to the MCP result internally.

Common Pitfalls

  • When writing permission configuration for custom tools, use the full mcp__server__tool name.
  • In TypeScript, tool()'s third parameter takes a Zod raw shape—do not pass z.object(...).
  • All fields in a Python simple-dict schema are required; use TypedDict + NotRequired or a full JSON Schema for optional fields.
  • For enums, numeric ranges, nested objects, or string pattern/format constraints, use a full JSON Schema dict in Python.
  • Python handlers must be async def and return a dict containing a "content" list.
  • Tool descriptions should state "when to use it, what it does, what it returns"—never just query or helper.
  • readOnlyHint is tool metadata and a scheduling hint, not a permission switch. Whether execution is allowed is still determined by permission configuration.
  • Avoid putting a huge all-purpose business entry point into one universal tool. A tool should complete one clear class of action.

Continue Reading

  • Tools Reference: built-in tool list, tool creation APIs, CallToolResult, built-in tool I/O types.
  • MCP Integration: in-process, stdio, SSE, HTTP, OAuth, and other MCP server integration methods.
  • Permissions: permission modes, tool allowlists, permission callbacks, permission rule updates.
  • Subagent Guide: Let different agents use different tool sets.