Skip to content

put_artifact cannot store JSON-object content: string arg is coerced to dict before validation #409

Description

@tildesrc

Summary

put_artifact rejects any content that is a JSON object string. The value is coerced
from a string into a Python dict somewhere in the MCP argument layer before Pydantic
validates it, so the str | None-typed content field then rejects it as the wrong type:

Error executing tool put_artifact: 1 validation error for put_artifactArguments
content
  Input should be a valid string [type=string_type,
  input_value={'started_at': '2026-09-0... {}, 'completed': False}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/string_type

Note input_type=dict — by the time validation runs the argument is no longer the string
that was sent. A string-typed field can therefore never accept JSON-object-shaped text.

The handler in src/panopticon/taskservice/mcp.py is itself correct:

async def put_artifact(
    task_id: str, name: str, content: str | None = None, content_base64: str | None = None
) -> str:
    if content is not None and content_base64 is None:
        data = content.encode()

content.encode() is exactly right for a str. The problem is upstream — the caller's
string is parsed into a dict before it ever reaches this signature.

Reproduction

put_artifact(
  task_id = "<any task id>",
  name    = ".babysit-ci-state.json",
  content = '{"started_at": "2026-09-04T17:31:00Z", "completed": false}'
)

Fails every time; any JSON object literal reproduces it.

What was ruled out

  • Not name-dependent. The .json extension is irrelevant — the coercion is on content.
  • Not a whitespace or parsing edge. Retried with a leading newline and with a trailing
    newline wrapping the object; identical error both times.
  • Not a general content failure. The same tool accepted a large multi-line markdown
    string in the same session moments earlier. Only object-shaped content trips it.

Untested but likely affected by the same coercion: JSON array content ([...]). A JSON
scalar (bare quoted string or number) would probably survive, coercing to str/int rather
than dict.

Impact

The babysit-ci skill specifies a cross-turn state artifact named .babysit-ci-state.json
with a documented schema (started_at, head_sha, watch_bash_id, retries, completed).
That artifact is JSON by design, so the skill's own prescribed state-passing mechanism cannot
be used as written — the agent hits this error on the first write and again on every update.
Any skill or workflow that persists structured state through put_artifact hits the same wall.

Encountered while running the github-issue workflow; CI-watch state had to go to a local
/tmp file instead, which does not survive the container and is not readable via the task's
MCP resource URI.

Workarounds

  • Wrap the JSON in a markdown fence so the payload is not object-shaped at the top level, and
    strip the fence on read.
  • Use the REST endpoint (PUT /tasks/{id}/artifacts/{name} with raw bytes), which bypasses
    the MCP argument layer entirely.
  • Base64 the JSON and send it as content_base64 — though that field is meant for binaries.

All three are worse than just writing the string.

Suggested fix

Stop parsing content before validation, or accept str | dict | list and re-serialize
non-string input to JSON text before storing. The first is preferable: artifact content
should round-trip byte-for-byte, and a permissive type would silently reformat whatever the
caller sent (key order, indentation, False vs false).

Worth checking whether the same coercion affects other string-typed MCP tool params here —
anything that might legitimately carry JSON-shaped text.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions