Skip to content

fix: convert TypedDict input_schema to proper JSON Schema in SDK MCP tools#736

Merged
qing-ant merged 5 commits intomainfrom
fix/typeddict-json-schema-conversion
Mar 26, 2026
Merged

fix: convert TypedDict input_schema to proper JSON Schema in SDK MCP tools#736
qing-ant merged 5 commits intomainfrom
fix/typeddict-json-schema-conversion

Conversation

@qing-ant
Copy link
Copy Markdown
Contributor

Problem

When a TypedDict class is passed as input_schema to the @tool decorator, create_sdk_mcp_server produces an empty JSON Schema ({"type": "object", "properties": {}}), making all parameters invisible to the model.

Before:

class SearchParams(TypedDict):
    query: str
    max_results: int

@tool("search", "Search for items", SearchParams)
async def search(args): ...

Produces: {"type": "object", "properties": {}} — the model sees zero parameters.

After:

{
  "type": "object",
  "properties": {
    "query": {"type": "string"},
    "max_results": {"type": "integer"}
  },
  "required": ["max_results", "query"]
}

Changes

  • Add _python_type_to_json_schema helper that converts Python type annotations to JSON Schema, handling:
    • Basic types (str, int, float, bool)
    • PEP 604 unions (str | None) and typing.Union — single-type optionals unwrap, multi-type produces anyOf
    • Parameterized generics (list[str]{"type": "array", "items": {"type": "string"}})
    • Bare list and dict without type parameters
    • Nested TypedDict (recursive conversion)
  • Add _typeddict_to_json_schema that extracts field annotations via get_type_hints() and respects __required_keys__ / NotRequired for the required array
  • Use typing.is_typeddict (stdlib, Python 3.10+) for reliable TypedDict detection instead of a custom heuristic
  • Use typing_extensions.get_type_hints at module level (with fallback) to properly strip NotRequired wrappers
  • Refactor the existing inline dict-to-schema mapping to use the same _python_type_to_json_schema helper, eliminating duplicated type-mapping code
  • Cache computed tool schemas at server creation time instead of recomputing on every list_tools call

Testing

  • All 346 tests pass
  • mypy and ruff clean
  • Verified end-to-end with a live SDK instance: TypedDict tool parameters are correctly visible to the model and tool calls succeed with properly typed arguments

Fixes #169

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type conversion logic looks correct, but this adds non-trivial recursive type-mapping functions to a core module with no unit tests for the new _python_type_to_json_schema and _typeddict_to_json_schema helpers — worth a human look to decide on test coverage expectations.

Extended reasoning...

Overview

This PR modifies src/claude_agent_sdk/__init__.py to fix TypedDict handling in the @tool decorator. It adds two new helper functions (_python_type_to_json_schema and _typeddict_to_json_schema) that recursively convert Python type annotations to JSON Schema dicts, handling unions, parameterized generics, nested TypedDicts, and optional fields. It also refactors the existing inline type-mapping code to use the new helpers and caches the computed tool list at server creation time.

Security risks

No security risks identified. The new code is purely type-conversion logic that runs at server initialization time. There is no user input processing, network access, or file system interaction in the new code paths.

Level of scrutiny

This deserves moderate scrutiny. While the code logic appears correct, the new functions handle recursive type conversion with multiple branches (unions, generics, nested TypedDicts, bare types, fallbacks), which is the kind of logic that benefits from targeted unit tests. The existing test suite has no coverage for TypedDict-based tool schemas — there are integration tests for create_sdk_mcp_server but none that exercise TypedDict input schemas, union types, or nested structures. A human reviewer should decide whether the PR should include tests before merging.

Other factors

  • The include_extras=False usage on _get_type_hints strips NotRequired wrappers, which is correct because __required_keys__ is used separately for determining required fields.
  • The fallback to {"type": "string"} for unrecognized types preserves existing behavior but is worth a human confirming as the desired default.
  • The caching optimization (pre-computing tool schemas) is a clean improvement that avoids redundant work on every list_tools call.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix itself looks correct and well-structured, but the new _python_type_to_json_schema and _typeddict_to_json_schema helpers have no unit test coverage despite handling several non-trivial edge cases (unions, nested TypedDict, parameterized generics) — worth a human look to decide if tests should be added before merging.

Extended reasoning...

Overview

This PR modifies a single file (src/claude_agent_sdk/__init__.py) to fix TypedDict classes producing empty JSON Schemas when passed as input_schema to the @tool decorator. It adds two new helper functions (_python_type_to_json_schema and _typeddict_to_json_schema), refactors the existing inline type-mapping code to use the shared helper, and caches computed tool schemas at server creation time.

Security risks

No security concerns. This is purely schema generation logic — it converts Python type annotations into JSON Schema dicts. There is no user input handling, no auth, no network calls, and no file I/O involved.

Level of scrutiny

Moderate scrutiny is warranted. While the fix addresses a clear bug and the code is well-structured, the new helpers handle several non-trivial cases: Union types and PEP 604 unions, parameterized generics (list[str]), nested TypedDict recursion, and NotRequired field handling via __required_keys__. The fallback to {"type": "string"} for unknown types could silently produce incorrect schemas. These edge cases deserve test coverage to prevent regressions.

Other factors

  • No existing tests cover TypedDict schema conversion — tests/test_sdk_mcp_integration.py only tests simple dict schemas like {"input": str}.
  • The project requires Python 3.10+, so typing.is_typeddict is available in stdlib.
  • The typing_extensions.get_type_hints fallback is appropriate for handling NotRequired stripping across Python versions.
  • No CODEOWNERS file exists for this path.
  • The caching optimization (pre-computing schemas at creation time) is a nice improvement but changes the behavior slightly — schema is now fixed at server creation rather than being recomputed per list_tools call.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found, but this adds meaningful new type-introspection logic with recursive conversion, union handling, and design decisions (e.g., defaulting unknown types to string) that warrant a human review.

Extended reasoning...

Overview

This PR adds TypedDict-to-JSON-Schema conversion for SDK MCP tools. It introduces two new helpers (_python_type_to_json_schema and _typeddict_to_json_schema) that handle basic types, unions, parameterized generics, and nested TypedDicts. It also refactors the existing inline type mapping to use the new helper and caches computed schemas at server creation time. Two files are modified: src/claude_agent_sdk/__init__.py (~60 lines of new logic) and tests/test_sdk_mcp_integration.py (~290 lines of new tests).

Security risks

No security concerns. The code performs type introspection and schema generation — no user input handling, authentication, or data exposure risks.

Level of scrutiny

Moderate scrutiny is warranted. While the code is well-structured and thoroughly tested, Python type introspection (especially around Union, NotRequired, get_type_hints, and __required_keys__) has well-known edge cases. The design decision to default unknown types to {"type": "string"} silently rather than raising could mask bugs. A human should verify these choices are intentional and appropriate for the SDK's users.

Other factors

The test coverage is comprehensive, covering basic types, unions, optionals, generics, nested TypedDicts, NotRequired fields, backward compatibility, and caching stability. No CODEOWNERS file exists. No bugs were found by the automated bug hunting system. The caching optimization is straightforward and safe.

claude and others added 2 commits March 26, 2026 03:14
…tools

When a TypedDict class is passed as input_schema to the @tool decorator,
create_sdk_mcp_server produces an empty JSON Schema ({"type": "object",
"properties": {}}), making all parameters invisible to the model.

Extract field annotations from TypedDict classes using get_type_hints()
and convert them to proper JSON Schema with support for basic types,
Optional/Union, parameterized generics (list[str], dict), NotRequired
fields, and nested TypedDicts. Uses typing.is_typeddict for reliable
detection and caches tool schemas at server creation time.

Fixes #169
@qing-ant qing-ant force-pushed the fix/typeddict-json-schema-conversion branch from bcfc793 to 8a058ab Compare March 26, 2026 03:14
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — straightforward bug fix with good test coverage.

Extended reasoning...

Overview

This PR fixes TypedDict input_schema producing empty JSON Schema by adding two well-structured helper functions (_python_type_to_json_schema and _typeddict_to_json_schema), refactoring existing inline type-mapping to use them, and caching tool schemas at creation time. Changes are limited to src/claude_agent_sdk/__init__.py and tests/test_sdk_mcp_integration.py.

Security risks

None. The changes are purely type-conversion logic with no auth, crypto, permissions, or external I/O. No user input is executed or interpolated unsafely.

Level of scrutiny

Moderate scrutiny is appropriate. The logic is straightforward type mapping (Python types → JSON Schema dicts) with well-defined behavior for each branch. The fallback to {"type": "string"} for unknown types matches the prior behavior. The use of typing_extensions.get_type_hints with include_extras=False correctly strips NotRequired wrappers. No CODEOWNERS file exists.

Other factors

The PR includes ~290 lines of new tests covering basic types, unions, parameterized generics, TypedDict with NotRequired fields, nested TypedDicts, empty TypedDicts, and full integration tests through create_sdk_mcp_server. Existing tests for dict schemas and JSON Schema passthrough are preserved. The caching optimization is a clean improvement that avoids recomputing schemas on every list_tools call. No bugs were found by the automated bug hunting system.

@qing-ant
Copy link
Copy Markdown
Contributor Author

E2E Test Results

Test script:

"""E2E test for PR #736: TypedDict input_schema produces correct JSON Schema.

This test creates an MCP server with a tool that uses a TypedDict as input_schema,
then queries the SDK to verify the model can see and call the tool with proper parameters.
"""

import asyncio
import sys
from typing import TypedDict

import claude_agent_sdk
from claude_agent_sdk import ClaudeAgentOptions
from claude_agent_sdk.mcp import create_sdk_mcp_server, tool


class SearchParams(TypedDict):
    query: str
    max_results: int


@tool("search", "Search for items by query string. Returns matching results.", SearchParams)
async def search(args: dict) -> str:
    return f"Found 3 results for: {args['query']}, max: {args['max_results']}"


async def main() -> None:
    print(f"SDK version: {claude_agent_sdk.__version__}")
    print()

    mcp_server = create_sdk_mcp_server("test-server", [search])
    print("Created MCP server with TypedDict-based 'search' tool")
    print()

    tools_list = await mcp_server.list_tools()
    for t in tools_list:
        print(f"Tool: {t.name}")
        print(f"  Schema: {t.inputSchema}")
    print()

    print("Sending query with max_turns=2...")
    print()

    messages = []
    try:
        async for msg in claude_agent_sdk.query(
            prompt="Search for 'python tutorials' with max 5 results. You must use the search tool.",
            options=ClaudeAgentOptions(max_turns=2, mcp_servers=[mcp_server]),
        ):
            messages.append(msg)
    except Exception as exc:
        print(f"FAIL: query raised {type(exc).__name__}: {exc}", file=sys.stderr)
        sys.exit(1)

    print(f"Received {len(messages)} messages:")
    print()

    tool_called = False
    for i, msg in enumerate(messages):
        msg_type = type(msg).__name__
        content = getattr(msg, "content", None)

        if isinstance(content, list):
            for block in content:
                block_type = type(block).__name__
                if block_type == "ToolUseBlock":
                    tool_called = True
                    print(f"  [{i}] {msg_type} -> {block_type}: name={getattr(block, 'name', '?')}, input={getattr(block, 'input', '?')}")
                elif block_type == "ToolResultBlock":
                    print(f"  [{i}] {msg_type} -> {block_type}: content={getattr(block, 'content', '?')[:100]}")
                elif block_type == "TextBlock":
                    print(f"  [{i}] {msg_type} -> {block_type}: {getattr(block, 'text', '')[:100]}")
                else:
                    print(f"  [{i}] {msg_type} -> {block_type}: {str(block)[:100]}")
        else:
            content_str = str(content)[:200] if content else ""
            print(f"  [{i}] {msg_type}: {content_str}")

    print()
    if tool_called:
        print("PASS: TypedDict tool was called successfully with proper parameters.")
        print("The TypedDict input_schema was correctly converted to JSON Schema.")
    else:
        print("FAIL: The search tool was never called.", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    asyncio.run(main())

Output:

SDK version: 0.1.50

Created MCP server with TypedDict-based 'search' tool

Tool: search
  Schema: {'type': 'object', 'properties': {'query': {'type': 'string'}, 'max_results': {'type': 'integer'}}, 'required': ['max_results', 'query']}

Sending query with max_turns=2...

Received 7 messages:

  [0] SystemMessage: 
  [1] AssistantMessage -> TextBlock: I'll search for Python tutorials for you using the search tool.
  [2] AssistantMessage -> ToolUseBlock: name=search, input={'query': 'python tutorials', 'max_results': 5}
  [3] ToolResultMessage -> ToolResultBlock: content=Found 3 results for: python tutorials, max: 5
  [4] AssistantMessage -> TextBlock: I found 3 results for "python tutorials" with a maximum of 5 results requested. The search was successful!
  [5] RateLimitEvent: None
  [6] ResultMessage: None

PASS: TypedDict tool was called successfully with proper parameters.
The TypedDict input_schema was correctly converted to JSON Schema.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — straightforward bug fix with solid test coverage.

Extended reasoning...

Overview

This PR fixes TypedDict input_schema producing empty JSON Schema ({"type": "object", "properties": {}}) by adding two helper functions: _python_type_to_json_schema and _typeddict_to_json_schema. It also caches computed tool schemas at server creation time and refactors the existing inline type-mapping to use the shared helper.

Security risks

None. The changes are purely internal type-conversion logic with no auth, crypto, permissions, or external I/O involved.

Level of scrutiny

Medium scrutiny is appropriate. The logic is self-contained within two new helper functions and a refactor of existing schema-building code. The type-mapping logic (str→string, int→integer, Union handling, list/dict generics, nested TypedDict) is straightforward and well-tested. The use of typing.is_typeddict (stdlib 3.10+) and typing_extensions.get_type_hints with fallback are both standard patterns.

Other factors

The PR includes comprehensive test coverage: unit tests for both helpers covering basic types, unions, optionals, parameterized generics, nested TypedDicts, and empty TypedDicts; integration tests verifying TypedDict schemas flow through create_sdk_mcp_server; backward compatibility tests for dict schemas and JSON Schema passthrough; and a caching stability test. An E2E test was also run successfully by a reviewer. No CODEOWNERS file exists for the modified paths.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — clean fix for TypedDict schema conversion with thorough test coverage; the one flagged bug is pre-existing.

Extended reasoning...

Overview

This PR adds proper TypedDict-to-JSON-Schema conversion for MCP tool definitions. It introduces two helper functions (_python_type_to_json_schema and _typeddict_to_json_schema), refactors the existing inline type-mapping code to use the shared helper, and caches computed schemas at server creation time. Two files are touched: src/claude_agent_sdk/__init__.py (core logic) and tests/test_sdk_mcp_integration.py (tests).

Security risks

None. This is purely schema-generation code that converts Python type annotations to JSON Schema dicts. It does not touch authentication, authorization, cryptography, user input handling, or network boundaries.

Level of scrutiny

Moderate scrutiny is appropriate. The changes are medium in scope — new helper functions plus a refactor of existing schema-building logic — but the domain is straightforward (type mapping) and well-covered by tests. The PR adds ~290 lines of tests covering unit-level helpers, integration through create_sdk_mcp_server, and edge cases (empty TypedDict, nested TypedDict, NotRequired fields, unions, parameterized generics). An E2E test was also run successfully against a live SDK instance.

Other factors

The only bug found is pre-existing: the dict-schema heuristic checks for "type" and "properties" keys without verifying the values are JSON Schema types. This PR preserves the exact same heuristic that existed before — it did not introduce or worsen the issue. The caching optimization (pre-computing schemas once instead of on every list_tools call) is a sensible improvement. No CODEOWNERS file exists for these paths.

The existing check for whether a dict is already valid JSON Schema only
verifies the presence of 'type' and 'properties' keys, but doesn't
validate their values. A dict like {'type': str, 'properties': list}
(with Python type objects as parameter names) would incorrectly pass
the heuristic and be returned as-is, producing an invalid schema.

Add isinstance(tool_def.input_schema['type'], str) to the condition so
that dicts with non-string 'type' values correctly fall through to the
type-conversion path.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — clean bug fix with thorough test coverage.

Extended reasoning...

Overview

This PR fixes a real user-facing bug where TypedDict classes passed as input_schema to the @tool decorator produced empty JSON Schema, making tool parameters invisible to the model. It adds two helper functions (_python_type_to_json_schema and _typeddict_to_json_schema), refactors the existing inline type-mapping logic to use them, and caches computed schemas at server creation time. Changes are confined to src/claude_agent_sdk/__init__.py and tests/test_sdk_mcp_integration.py.

Security risks

None. This is purely about type annotation to JSON Schema conversion — no auth, crypto, permissions, network, or user-input handling changes.

Level of scrutiny

Moderate. While it touches __init__.py (a core module), the changes are self-contained: two new private helper functions and a refactored schema-building path. The logic is straightforward type-mapping with well-established patterns. The test suite is thorough — unit tests for both helpers, integration tests through create_sdk_mcp_server, and E2E verification with a live SDK instance.

Other factors

  • ~290 lines of new tests covering basic types, unions, optionals, parameterized generics, nested TypedDicts, NotRequired fields, empty TypedDicts, JSON Schema passthrough, and caching stability.
  • The pre-existing JSON Schema detection heuristic is actually improved in this PR by adding isinstance(tool_def.input_schema["type"], str), which I noted in a previous review.
  • No CODEOWNERS file exists for these paths.
  • The design decisions (unknown types default to string, sorted required keys, caching at creation time) are all reasonable and consistent with common practices.

@qing-ant qing-ant enabled auto-merge (squash) March 26, 2026 23:26
Copy link
Copy Markdown
Collaborator

@bogini bogini left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stamped 👋

@qing-ant qing-ant merged commit 3e2d44d into main Mar 26, 2026
10 checks passed
@qing-ant qing-ant deleted the fix/typeddict-json-schema-conversion branch March 26, 2026 23:26
qing-ant added a commit that referenced this pull request Mar 27, 2026
…upport (#761)

## Problem

Release workflow for v0.1.51 ([run
23664697098](https://github.com/anthropics/claude-agent-sdk-python/actions/runs/23664697098))
failed on Python 3.10:

```
FAILED tests/test_sdk_mcp_integration.py::TestTypedDictToJsonSchema::test_typeddict_with_optional_fields
AssertionError: assert ['name', 'timeout'] == ['name']
```

## Root cause

PEP 655 (`NotRequired`) landed in stdlib `typing` in Python 3.11. On
3.10, using stdlib `typing.TypedDict` with
`typing_extensions.NotRequired` silently ignores the marker —
`__required_keys__` includes all fields.

This affects `_typeddict_to_json_schema` (commit 3e2d44d / #736):
optional TypedDict fields in SDK MCP tool schemas were incorrectly
marked as required on 3.10.

## Fix

Version-gate the imports: on Python <3.11, import `TypedDict`,
`NotRequired`, `is_typeddict`, and `get_type_hints` from
`typing_extensions` so the backported PEP 655 semantics apply.

## Verified

- `pytest tests/` — 409 passed on Python 3.10.19
- `pytest tests/` — 409 passed on Python 3.13.12
- `ruff check` + `mypy src/` — clean

Unblocks v0.1.51 release.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TypedDict as input_schema in 'tool' does not show visible parameters to bot

3 participants