Skip to content

Validate task tool arguments against their declared types#4355

Closed
Michael-WhiteCapData wants to merge 1 commit into
PrefectHQ:mainfrom
Michael-WhiteCapData:fix/task-tool-validates-pydantic-args
Closed

Validate task tool arguments against their declared types#4355
Michael-WhiteCapData wants to merge 1 commit into
PrefectHQ:mainfrom
Michael-WhiteCapData:fix/task-tool-validates-pydantic-args

Conversation

@Michael-WhiteCapData

Copy link
Copy Markdown

Fixes #4349.

Problem

A tool parameter typed as a Pydantic model is validated into a model instance on a synchronous call, but arrives as a raw dict when the same tool runs as a task. Accessing a model attribute (e.g. items[0].value) therefore raises AttributeError under task=True only.

Cause

FunctionTool.register_with_docket registers the raw function, and Docket binds arguments by signature without coercing their values. The synchronous path validates arguments through FunctionTool.runTypeAdapter.validate_python; the task path skips that step, so model-typed parameters stay dicts.

Fix

Register a thin wrapper that:

  • keeps the raw function's signature via functools.wraps, so Docket still resolves all dependencies — FastMCP's (Context, Progress) and Docket-native (Retry, Timeout, ConcurrencyLimit); and
  • coerces the client-facing arguments through cached TypeAdapters before invoking the function, mirroring the synchronous path. Injected dependency parameters are excluded from coercion.

Task tools are always async (sync functions are rejected at registration), so the wrapper preserves the coroutine / async-generator nature of the wrapped function and leaves Docket's execution path unchanged.

Tests

Added a regression test in tests/server/tasks/test_task_tools.py covering both the sync and task paths. The full tests/server/tasks/ and tests/tools/ suites pass (663 passed, 1 skipped); ruff check and ruff format are clean on the changed files.

FunctionTool.register_with_docket registered the raw function and let
Docket bind arguments by signature. Docket does not coerce argument
values, so a parameter typed as a Pydantic model reached the function as
a raw dict under a task call, while a synchronous call validates it
through FunctionTool.run -> TypeAdapter.validate_python. Accessing a
model attribute then raised AttributeError under task=True only.

Register a wrapper that keeps the raw function's signature (so Docket
still resolves FastMCP and Docket-native dependencies) and coerces the
client-facing arguments via cached TypeAdapters before invoking it,
mirroring the synchronous path. Injected dependency parameters are
excluded from coercion. Task tools are always async, so the wrapper
preserves the coroutine / async-generator nature of the function.

Fixes PrefectHQ#4349
@github-actions

Copy link
Copy Markdown
Contributor

This PR has been automatically closed because you are not assigned to the issue it references.

Per CONTRIBUTING.md, an external PR must reference an issue that is assigned to its author. To proceed:

  1. Comment on the linked issue to ask a maintainer to assign it to you.
  2. Once a maintainer assigns you, the PR reopens automatically.

Maintainers: reopen this PR or remove the missing-issue-link label to bypass this check.

@github-actions github-actions Bot closed this Jun 23, 2026
@marvin-context-protocol marvin-context-protocol Bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. too-long Excessively verbose or unedited LLM output. Condense before triage. and removed missing-issue-link labels Jun 23, 2026
@marvin-context-protocol

Copy link
Copy Markdown
Contributor

Thanks for the report. This issue goes beyond what our contributor guidelines ask for — we just need a short problem description and an MRE. Please see our contributing guidelines and condense this issue. We'll triage it once it's trimmed down.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eaaf5f788e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +397 to +398
for name in inspect.signature(fn).parameters
if name not in exclude and name in hints

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip excluded parameters when building task adapters

Existing exclude_args support intentionally allows hidden parameters with non-serializable types (for example ServerSession) because the schema path removes them before creating Pydantic adapters. This new task registration iterates the original signature and builds adapters for every annotated non-DI parameter, so a task-enabled tool with exclude_args=["session"] and another typed client arg will construct a TypeAdapter for the hidden session parameter during server startup and fail instead of registering. The excluded names need to be carried here or the same pruned wrapper used by schema generation should be reused.

Useful? React with 👍 / 👎.


hints = get_type_hints(fn, include_extras=True)
adapters = {
name: get_cached_typeadapter(hints[name])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve Field constraints when validating task args

FastMCP supports constraints supplied as a Pydantic Field default (x: int = Field(ge=1)) and the synchronous path enforces them by validating the whole wrapper function. Here the adapter is built from get_type_hints, which returns only int for that parameter, so task calls validate/coerce the type but drop ge, aliases, and other Field metadata; for example, task=True with {"x": 0} runs while the normal call rejects it. Build from the function signature/field metadata rather than the bare hint.

Useful? React with 👍 / 👎.

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

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. too-long Excessively verbose or unedited LLM output. Condense before triage.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task tools receive Pydantic-model arguments as raw dicts

1 participant