> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qoder.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tools

Tools are capabilities the model can call while executing a task. The Python edition of 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 through `@tool()` and `create_sdk_mcp_server()` as Python functions and exposed to the model via an in-process MCP server.

This guide focuses on defining custom tools. For more MCP server integration methods, see [MCP Integration](/en/cli/sdk/python/mcp). For the complete permissions reference, see [Permissions](/en/cli/sdk/python/permissions).

<div id="built-in-tools" />

<div id="builtintools" />

## Built-in Tools

When using built-in tools, you do not implement the tools yourself. You only control which tools the current session sees, which tools are pre-authorized, and which tools are denied through `QoderAgentOptions`.

```python theme={null}
import asyncio

from qoder_agent_sdk import QoderAgentOptions, qodercli_auth, query


async def main():
    options = QoderAgentOptions(
        auth=qodercli_auth(),
        cwd="/path/to/project",
        tools=["Read", "Grep", "Glob"],
        allowed_tools=["Read", "Grep", "Glob"],
    )

    async for message in query(
        prompt=(
            "Read this repository and summarize risks in the authentication "
            "module. Do not modify files."
        ),
        options=options,
    ):
        print(message)


asyncio.run(main())
```

Common built-in tools include `Read`, `Edit`, `Write`, `Bash`, `Glob`, `Grep`, `WebFetch`, `WebSearch`, and `Agent`. Tool names are determined by the underlying Qoder CLI; in permission configuration, use the names the CLI exposes to the model — for example `Read`, `Write`, `Bash`. For the complete built-in tool list, see [Tools Reference - Built-in Tool List](/en/cli/sdk/python/references#built-in-tool-list).

<div id="custom-tools" />

<div id="customtools" />

## Custom Tools

Define a custom tool when you want the model to call your own business capability — for example, querying orders, searching an internal knowledge base, calling an approval system, or accessing a read-only database.

Python custom tools usually involve three steps:

1. Decorate an `async def` handler with `@tool()`.
2. Register one or more tools to an in-process MCP server with `create_sdk_mcp_server()`.
3. Attach via `mcp_servers` in `QoderAgentOptions`, and control calls with permission settings.

<div id="custom-tool-integration-steps" />

<div id="customtoolintegrationsteps" />

## Custom Tool Integration Steps

First, here is a complete minimal example. The following sections then explain what each of the three steps can configure:

```python theme={null}
import asyncio
import json

from mcp.types import ToolAnnotations

from qoder_agent_sdk import (
    QoderAgentOptions,
    create_sdk_mcp_server,
    qodercli_auth,
    query,
    tool,
)


orders = {
    "O-1001": {"order_id": "O-1001", "status": "shipped", "eta": "2026-05-20"},
}


@tool(
    "lookup_order",
    "Look up an order by order ID and return its status as JSON.",
    {"order_id": str},
    annotations=ToolAnnotations(readOnlyHint=True),
)
async def lookup_order(args):
    order_id = args["order_id"]
    order = orders.get(order_id)

    if order is None:
        return {
            "is_error": True,
            "content": [{"type": "text", "text": f"Order not found: {order_id}"}],
        }

    return {"content": [{"type": "text", "text": json.dumps(order)}]}


order_tools = create_sdk_mcp_server(
    name="orders",
    tools=[lookup_order],
)


async def main():
    options = QoderAgentOptions(
        auth=qodercli_auth(),
        mcp_servers={"orders": order_tools},
        allowed_tools=["mcp__orders__lookup_order"],
    )

    async for message in query(
        prompt="Check the status of order O-1001 and summarize it in one sentence.",
        options=options,
    ):
        print(message)


asyncio.run(main())
```

<div id="step-1-create-a-tool-with-tool" />

<div id="step1createatoolwithtool" />

### Step 1: Create a Tool with `@tool()`

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

<div id="tool-arguments" />

<div id="toolarguments" />

#### `@tool()` Arguments

`@tool()` is a decorator. It has 4 arguments:

```python theme={null}
def tool(
    name: str,
    description: str,
    input_schema: type | dict[str, Any],
    annotations: ToolAnnotations | None = None,
): ...
```

| Argument       | Type                      | Required | Meaning                                                                                     |
| -------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------- |
| `name`         | `str`                     | Yes      | Unique tool identifier within the current MCP server                                        |
| `description`  | `str`                     | Yes      | Tool description for the model; explain when to use it, what it does, and what it returns   |
| `input_schema` | `type \| dict[str, Any]`  | Yes      | Defines tool input parameters; supports simple dict, `TypedDict`, and full JSON Schema dict |
| `annotations`  | `ToolAnnotations \| None` | No       | MCP tool annotations such as `readOnlyHint`, `destructiveHint`, `openWorldHint`             |

For the complete API signature and the returned `SdkMcpTool` type, see [Tools Reference - `tool()`](/en/cli/sdk/python/references#tool).

The tool handler must be an async function and typically receives an `args` dict:

```python theme={null}
@tool("search_docs", "Search internal product documentation.", {"query": str})
async def search_docs(args):
    return {"content": [{"type": "text", "text": f"Searching: {args['query']}"}]}
```

If the handler declares a second positional parameter, the SDK passes a `ToolInvocationContext`. Its `signal` is an `asyncio.Event` that is set when the CLI cancels the current tool call — useful for long tasks to bail out cooperatively:

```python theme={null}
@tool("watch", "Watch a counter until max.", {"max": int})
async def watch(args, extra):
    for i in range(args["max"]):
        if extra.signal.is_set():
            return {"content": [{"type": "text", "text": f"aborted at {i}"}]}
        await asyncio.sleep(0.01)

    return {"content": [{"type": "text", "text": "done"}]}
```

<div id="configure-input-parameters" />

<div id="configureinputparameters" />

#### Configure Input Parameters

The Python edition's `input_schema` supports three forms. They are all normalized to MCP's JSON Schema.

**Form 1: simple dict**

Suitable for a few simple parameters. Dict keys are parameter names and values are Python types; with this form, all keys are required.

```python theme={null}
input_schema = {
    "query": str,
    "max_results": int,
    "include_archived": bool,
}
```

You can use `typing.Annotated` to attach descriptions to fields:

```python theme={null}
from typing import Annotated


input_schema = {
    "query": Annotated[str, "Search keywords"],
    "max_results": Annotated[int, "Maximum number of snippets to return"],
}
```

Common types are translated to JSON Schema:

| Python form           | JSON Schema meaning                |
| --------------------- | ---------------------------------- |
| `str`                 | `{"type": "string"}`               |
| `int`                 | `{"type": "integer"}`              |
| `float`               | `{"type": "number"}`               |
| `bool`                | `{"type": "boolean"}`              |
| `list[str]`           | String array                       |
| `dict`                | Object                             |
| `Annotated[T, "..."]` | Adds `description` to `T`'s schema |

**Form 2: `TypedDict`**

Suitable when there are many fields, optional fields are needed, or you want to reuse type definitions. Use `NotRequired` for optional fields:

```python theme={null}
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})"}]}
```

Python 3.11+ can import `NotRequired` directly from `typing`; Python 3.10 needs to import it from `typing_extensions`.

**Form 3: full JSON Schema dict**

Use a full JSON Schema when you need enums, numeric ranges, string format constraints, or nested objects:

```python theme={null}
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"],
}
```

About optional parameters: with the simple-dict form, all fields are required. To express optional fields, prefer `TypedDict + NotRequired` or the `required` list of a full JSON Schema.

<div id="configure-tool-metadata" />

<div id="configuretoolmetadata" />

#### Configure Tool Metadata

`annotations` uses `mcp.types.ToolAnnotations`. The SDK puts it on the MCP tool definition; the CLI can use it for scheduling, permissions, or status display.

```python theme={null}
from mcp.types import ToolAnnotations


@tool(
    "search_docs",
    "Search internal product documentation.",
    {"query": str},
    annotations=ToolAnnotations(
        title="Search docs",
        readOnlyHint=True,
        destructiveHint=False,
        openWorldHint=False,
    ),
)
async def search_docs(args):
    return {"content": [{"type": "text", "text": args["query"]}]}
```

Common fields:

| Field                | Type   | Meaning                                                                                                                 |
| -------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------- |
| `title`              | `str`  | Human-readable title for the tool                                                                                       |
| `readOnlyHint`       | `bool` | Marks the tool as read-only and not modifying any state                                                                 |
| `destructiveHint`    | `bool` | Marks that the tool may modify or delete data                                                                           |
| `openWorldHint`      | `bool` | Marks that the tool accesses external systems or networks                                                               |
| `maxResultSizeChars` | `int`  | The Python SDK passes this to the CLI via `_meta["anthropic/maxResultSizeChars"]` to relax the tool result length limit |

Note: annotations do not replace permission configuration. Whether a tool call is allowed is still determined by `tools`, `allowed_tools`, `disallowed_tools`, `permission_mode`, `can_use_tool`, and hooks. The annotations field names echoed in `get_mcp_status()` / MCP status may also be the CLI-projected `readOnly`, `destructive`, `openWorld`, rather than the original MCP `*Hint` names.

<div id="step-2-register-with-an-mcp-server" />

<div id="step2registerwithanmcpserver" />

### Step 2: Register with an MCP Server

`create_sdk_mcp_server()` registers one or more tools as an in-process MCP server. The server name becomes part of the full tool name, so keep it short and stable.

```python theme={null}
kb_tools = create_sdk_mcp_server(
    name="kb",
    version="1.0.0",
    tools=[search_docs],
)
```

| Field     | How to set it                 | Description                                                   |
| --------- | ----------------------------- | ------------------------------------------------------------- |
| `name`    | For example `kb`, `orders`    | Server name; forms full tool names like `mcp__{name}__{tool}` |
| `version` | For example `"1.0.0"`         | Informational version, defaults to `"1.0.0"`                  |
| `tools`   | `[search_docs, lookup_order]` | Tools registered to this server                               |

The return value is `McpSdkServerConfig`, which can be passed directly to `QoderAgentOptions.mcp_servers`.

For the complete return type, see [Tools Reference - `create_sdk_mcp_server()`](/en/cli/sdk/python/references#create_sdk_mcp_server).

`create_sdk_mcp_server()` performs synchronous validation:

* The server name must be a non-empty string.
* Tool names must be non-empty strings.
* Tool descriptions must be non-empty strings.
* The same server cannot have duplicate tool names.

<div id="step-3-attach-to-query" />

<div id="step3attachtoquery" />

### Step 3: Attach to `query()`

After you put the server in `options.mcp_servers`, the CLI discovers its tools and calls back into your handler through the SDK when the model needs them.

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

async for message in query(
    prompt="Search docs for the refund policy and summarize it.",
    options=options,
):
    print(message)
```

The full custom tool name format is:

```text theme={null}
mcp__{serverName}__{toolName}
```

For example, if the server name is `orders` and the tool name is `lookup_order`, the full name is `mcp__orders__lookup_order`. Use this full name in `allowed_tools`, `disallowed_tools`, `can_use_tool`, hook matchers, and subagent `tools` configuration.

`QoderSDKClient` multi-turn sessions use the same `mcp_servers` configuration:

```python theme={null}
from qoder_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)
```

<div id="controlling-tool-permissions" />

<div id="controllingtoolpermissions" />

## 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 makes a dynamic decision before each tool call.

<div id="permission-control-overview" />

<div id="permissioncontroloverview" />

### Permission Control Overview

| Method                               | Purpose                                                  | Granularity | Use case                                                              |
| ------------------------------------ | -------------------------------------------------------- | ----------- | --------------------------------------------------------------------- |
| `tools`                              | Limits the visible tool set for this session             | Session     | Narrow the tools the model can see at the source                      |
| `allowed_tools` / `disallowed_tools` | Pre-authorizes or denies specific tools                  | Tool        | You know exactly which tools to allow or deny                         |
| `permission_mode`                    | Sets the default permission policy for the whole session | Global      | Quickly switch plan mode, accept edits, or skip permissions           |
| `can_use_tool`                       | Runs custom logic before each call                       | Call        | Decide dynamically based on argument content                          |
| `hooks["PreToolUse"]`                | Intercepts tool calls through the hooks lifecycle        | Call        | You already use the hooks system and want shared auditing or blocking |

These methods can be combined. A common pattern is to use `tools` to narrow the visible set, `allowed_tools` / `disallowed_tools` for static rules, and `can_use_tool` for argument-level decisions.

<div id="method-1-tools-allowed_tools-disallowed_tools" />

<div id="method1toolsallowed_toolsdisallowed_tools" />

### Method 1: `tools`, `allowed_tools`, `disallowed_tools`

`tools` controls the tools visible to this session. `allowed_tools` and `disallowed_tools` control permission rules. Custom MCP tools must use full names.

```python theme={null}
# Only expose read/search tools to this session.
QoderAgentOptions(
    tools=["Read", "Glob", "Grep"],
    allowed_tools=["Read", "Glob", "Grep"],
)

# Explicitly deny high-risk tools.
QoderAgentOptions(
    disallowed_tools=["Bash", "Write", "Edit"],
)

# Use full names for custom MCP tools.
QoderAgentOptions(
    mcp_servers={"orders": order_tools},
    allowed_tools=["mcp__orders__lookup_order"],
)

# Disable all tools. The model can only answer from its context.
QoderAgentOptions(tools=[])
```

When the same tool matches both allow and deny rules, the deny rule takes precedence.

<div id="method-2-permission_mode" />

<div id="method2permission_mode" />

### Method 2: `permission_mode`

`permission_mode` sets the default permission behavior for the whole session with a single line.

```python theme={null}
QoderAgentOptions(
    permission_mode="acceptEdits",
)
```

| Mode                  | Effect                                                                                                    |
| --------------------- | --------------------------------------------------------------------------------------------------------- |
| `"default"`           | Standard permission behavior; sensitive operations are handled by rules or runtime policy                 |
| `"acceptEdits"`       | Automatically accepts file edit operations; other sensitive operations still follow the permission policy |
| `"bypassPermissions"` | Skips permission checks; must also set `allow_dangerously_skip_permissions=True`                          |
| `"yolo"`              | Compatibility alias for `"bypassPermissions"`; must also set `allow_dangerously_skip_permissions=True`    |
| `"plan"`              | Plan mode, suitable for asking the model to produce a plan first                                          |
| `"dontAsk"`           | Does not ask interactively; operations that are not pre-authorized or allowed by rules are denied         |
| `"auto"`              | Runtime capability decides allow or deny automatically                                                    |

<div id="method-3-can_use_tool" />

<div id="method3can_use_tool" />

### Method 3: `can_use_tool`

`can_use_tool` runs before a tool call. You can allow or deny based on the tool name, arguments, and approval context.

```python theme={null}
from typing import Any

from qoder_agent_sdk import (
    PermissionResultAllow,
    PermissionResultDeny,
    ToolPermissionContext,
)


async def can_use_tool(
    tool_name: str,
    input_data: dict[str, Any],
    context: ToolPermissionContext,
):
    if tool_name != "mcp__orders__lookup_order":
        return PermissionResultDeny(
            message="Only order lookup is allowed in this workflow.",
        )

    return PermissionResultAllow(updated_input=input_data)


options = QoderAgentOptions(
    auth=qodercli_auth(),
    mcp_servers={"orders": order_tools},
    allowed_tools=["mcp__orders__lookup_order"],
    can_use_tool=can_use_tool,
)
```

Common return values:

| Return                                                   | Effect                                                             |
| -------------------------------------------------------- | ------------------------------------------------------------------ |
| `PermissionResultAllow()`                                | Allows execution with the original arguments                       |
| `PermissionResultAllow(updated_input={...})`             | Allows execution and replaces tool arguments                       |
| `PermissionResultDeny(message="reason")`                 | Denies execution; the model can see the reason and try another way |
| `PermissionResultDeny(message="reason", interrupt=True)` | Denies and interrupts the current agent loop                       |

Common fields on `ToolPermissionContext` include `tool_use_id`, `agent_id`, `signal`, `title`, `display_name`, `description`, `suggestions`, `blocked_path`, and `decision_reason`. For a more complete permission strategy, see [Permissions](/en/cli/sdk/python/permissions).

When using custom tools in a subagent, also use the full tool name:

```python theme={null}
from qoder_agent_sdk import AgentDefinition


options = QoderAgentOptions(
    auth=qodercli_auth(),
    mcp_servers={"orders": order_tools},
    allowed_tools=["Agent"],
    agents={
        "order-support": AgentDefinition(
            description="Handles order lookup and explains order status.",
            prompt="Use order tools to answer order status questions clearly.",
            tools=["mcp__orders__lookup_order"],
        ),
    },
)
```

<div id="method-4-hookspretooluse" />

<div id="method4hookspretooluse" />

### Method 4: `hooks["PreToolUse"]`

If you already use the hooks system, use `PreToolUse` to intercept or audit tool calls in one place.

```python theme={null}
from qoder_agent_sdk import HookMatcher


async def block_dangerous_bash(inp, tool_use_id, context):
    command = inp.get("tool_input", {}).get("command", "")
    if "rm -rf" in command:
        return {
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "deny",
                "permissionDecisionReason": "rm -rf is not allowed",
            }
        }

    return {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "allow",
        }
    }


options = QoderAgentOptions(
    allowed_tools=["Bash"],
    hooks={
        "PreToolUse": [
            HookMatcher(matcher="Bash", hooks=[block_dangerous_bash]),
        ],
    },
)
```

`PreToolUse`'s `permissionDecision` can be `"allow"`, `"deny"`, `"ask"`, or `"defer"`.

<div id="how-the-sdk-handles-tool-errors" />

<div id="howthesdkhandlestoolerrors" />

## How the SDK Handles Tool Errors

Tool handlers have three error paths.

<div id="business-failure-return-is_error-true" />

<div id="businessfailurereturnis_errortrue" />

### Business Failure: Return `is_error: True`

For expected business failures, return `is_error: True`. The SDK converts the result into an MCP `CallToolResult` and passes it to the CLI. The model can see the failure content and may retry or choose another path.

```python theme={null}
return {
    "is_error": True,
    "content": [
        {
            "type": "text",
            "text": json.dumps(
                {
                    "error": "VALIDATION_ERROR",
                    "message": "Only SELECT statements are allowed.",
                }
            ),
        }
    ],
}
```

Good cases for `is_error: True`:

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

<div id="unexpected-exception-handler-throws" />

<div id="unexpectedexceptionhandlerthrows" />

### Unexpected Exception: Handler Throws

If the handler raises an exception, the MCP layer converts it into an error result; the agent loop does not crash because of an ordinary tool exception. However, the model usually only sees the exception message — its format and content are less controllable than explicitly returning `is_error: True`.

```python theme={null}
@tool("fetch_user", "Fetch a user by ID.", {"user_id": str})
async def fetch_user(args):
    response = await user_service.fetch(args["user_id"])
    if not response.ok:
        raise RuntimeError("User service failed")

    return {"content": [{"type": "text", "text": await response.text()}]}
```

Recommendation: use `is_error: True` for predictable business failures, and raise only for truly unexpected exceptions.

<div id="malformed-returns-sdk-wraps-as-errors" />

<div id="malformedreturnssdkwrapsaserrors" />

### Malformed Returns: SDK Wraps as Errors

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 an empty success result, but documentation and business code should still always return the standard structure.

<div id="tool-return-values" />

<div id="toolreturnvalues" />

## Tool Return Values

A tool handler returns a dict, which the SDK converts into MCP's `CallToolResult`. Text content is the most common:

```python theme={null}
return {
    "content": [{"type": "text", "text": "done"}],
}
```

You can also return structured JSON strings, which help the model understand and continue processing:

```python theme={null}
return {
    "content": [
        {
            "type": "text",
            "text": json.dumps(
                {
                    "order_id": "O-1001",
                    "status": "shipped",
                    "eta": "2026-05-20",
                }
            ),
        }
    ],
}
```

Common content blocks:

| Type                   | Shape                                                                          | Python SDK behavior                                                          |
| ---------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| Text                   | `{"type": "text", "text": "..."}`                                              | Converted to `TextContent`                                                   |
| Image                  | `{"type": "image", "data": "...", "mimeType": "image/png"}`                    | Converted to `ImageContent`; `data` is base64                                |
| Resource link          | `{"type": "resource_link", "uri": "...", "name": "...", "description": "..."}` | Degrades to text, concatenating `name` / `uri` / `description` for the model |
| Embedded text resource | `{"type": "resource", "resource": {"text": "..."}}`                            | Converted 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.

<div id="common-pitfalls" />

<div id="commonpitfalls" />

## Common Pitfalls

* When writing permission configuration for custom tools, use the full `mcp__server__tool` name.
* All fields in a Python simple-dict schema are required; use `TypedDict + NotRequired` or a full JSON Schema for optional fields.
* Use a full JSON Schema dict when you need enums, numeric ranges, nested objects, or string pattern/format.
* Handlers must be `async def` and return a dict with a `"content"` list.
* Tool descriptions should explain "when to use it, what it does, what it returns" — avoid vague descriptions like `query` or `helper`.
* `readOnlyHint` is tool metadata and a scheduling/permission 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.

<div id="continue-reading" />

<div id="continuereading" />

## Continue Reading

* [MCP Integration](/en/cli/sdk/python/mcp): in-process, stdio, SSE, HTTP, OAuth, and other MCP server integration methods.
* [Permissions](/en/cli/sdk/python/permissions): `permission_mode`, `allowed_tools`, `can_use_tool`, hooks, and permission rule updates.
* [Hooks](/en/cli/sdk/python/hooks): `PreToolUse`, `PostToolUse`, `PermissionRequest`, and other lifecycle extensions.
* [Subagent Guide](/en/cli/sdk/python/agents): Let different agents use different tool sets.
