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 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. For the complete permissions reference, see Permissions.
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.
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.
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:
- Decorate an
async def handler with @tool().
- Register one or more tools to an in-process MCP server with
create_sdk_mcp_server().
- Attach via
mcp_servers in QoderAgentOptions, and control calls with permission settings.
First, here is a complete minimal example. The following sections then explain what each of the three steps can configure:
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())
This step defines the tool itself: its name, description, input parameters, execution logic, and metadata.
@tool() is a decorator. It has 4 arguments:
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().
The tool handler must be an async function and typically receives an args dict:
@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:
@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"}]}
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.
input_schema = {
"query": str,
"max_results": int,
"include_archived": bool,
}
You can use typing.Annotated to attach descriptions to fields:
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:
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:
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.
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.
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.
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.
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().
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.
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.
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:
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:
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)
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.
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.
tools controls the tools visible to this session. allowed_tools and disallowed_tools control permission rules. Custom MCP tools must use full names.
# 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.
Method 2: permission_mode
permission_mode sets the default permission behavior for the whole session with a single line.
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 |
can_use_tool runs before a tool call. You can allow or deny based on the tool name, arguments, and approval context.
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.
When using custom tools in a subagent, also use the full tool name:
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"],
),
},
)
If you already use the hooks system, use PreToolUse to intercept or audit tool calls in one place.
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".
Tool handlers have three error paths.
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.
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.
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.
@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.
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.
A tool handler returns a dict, which the SDK converts into MCP’s CallToolResult. 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.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.
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.
Continue Reading
- MCP Integration: in-process, stdio, SSE, HTTP, OAuth, and other MCP server integration methods.
- Permissions:
permission_mode, allowed_tools, can_use_tool, hooks, and permission rule updates.
- Hooks:
PreToolUse, PostToolUse, PermissionRequest, and other lifecycle extensions.
- Subagent Guide: Let different agents use different tool sets.