Skip to content

feat(serve): serve POST /worker/resources/list and /worker/resources/read [TOO-1936] - #922

Open
EricGustin wants to merge 3 commits into
ericgustin/too-1935-registry-through-boot-pathfrom
ericgustin/too-1936-worker-resource-endpoints
Open

feat(serve): serve POST /worker/resources/list and /worker/resources/read [TOO-1936]#922
EricGustin wants to merge 3 commits into
ericgustin/too-1935-registry-through-boot-pathfrom
ericgustin/too-1936-worker-resource-endpoints

Conversation

@EricGustin

@EricGustin EricGustin commented Aug 25, 2026

Copy link
Copy Markdown
Member

Summary

The two resource endpoints, POST /worker/resources/list and POST /worker/resources/read, registered in the default component set. One rule governs both: the path suffix names the operation, the request body is that operation's params object, and the response is its result object, all spelled the way they go on the wire so neither end has to translate anything.

Part 6 of 6. Stacked on #921. This is the whole protocol addition.

Resolves: https://linear.app/arcadedev/issue/TOO-1936

Design decisions

Both are POST

Both requests carry params: an optional cursor on list, a required uri on read. And RequestData carries only path, method and body, with path coming from request.url.path, which Starlette has already stripped of the query string. A worker component genuinely cannot read a query parameter. A first-page list sends an empty body, which the router already coerces to {}.

response_model_exclude_none=True gets passed through

FastAPI defaults it to False, so without it a resource with no annotations goes out carrying "annotations": null, "size": null, "icons": null. Those keys should be absent.

An unknown URI is 404 with code -32002

Which overlaps awkwardly with reading a 404 as "this worker has no resource endpoints at all", so the tolerance rule is per-endpoint: 404 and 405 mean an empty catalog on the list endpoint only. On read, a 404 is a typed not-found. That leaves one ambiguous case, an old worker 404ing a read, and it is harmless, because the caller does not get the resource either way.

A malformed body is 400, not 500

The body was parsed and RequestData built above the block that maps errors, so a body that is not a JSON object escaped as an unhandled 500. RequestData.body_json is dict[str, Any] | None, so [], false, 0, "" and truncated JSON all took that path. A caller sending nonsense was told the worker had broken, which matters because the engine reads a 500 as a worker fault worth retrying and a 400 as its own bug. The parse moved inside the mapped block.

This one is wider than the rest of the PR and worth a reviewer's attention: _wrap_handler is shared, so /worker/tools and /worker/tools/invoke move from 500 to 400 on a malformed body too. That behavior is pre-existing and identical on main. It is fixed here because this PR is what adds the ValidationError to 400 mapping in this file, and that mapping was dead for the most likely error. A literal null body is left reading as empty params, since the router already maps an absent body to {}.

The HTTP mapping lives in the FastAPI binding, which keeps core framework-agnostic. Components raise typed errors and the binding turns them into an error object carried by a status code.

Test plan

Exercised against a real uvicorn worker with a real toolkit installed, nothing in front of it:

$ curl -sS -X POST localhost:8931/worker/resources/list -H "Authorization: Bearer $TOKEN" -d "{}"
{"resources":[{"name":"draft_review","uri":"ui://demokit/3.2.1/draft-review.html","mimeType":"text/html;profile=example"}]}

$ curl -sS -X POST localhost:8931/worker/resources/read -H "Authorization: Bearer $TOKEN" -d "{\"uri\":\"ui://demokit/3.2.1/draft-review.html\"}"
{"contents":[{"uri":"ui://demokit/3.2.1/draft-review.html","mimeType":"text/html;profile=example","text":"<!DOCTYPE html>..."}]}

# unknown URI
404 {"code":-32002,"message":"Resource not found: ui://demokit/3.2.1/nope.html"}

# no Authorization header
401

The mime type is whatever the toolkit declared. These libraries carry it verbatim and never inspect it.

  • 19 contract tests asserting the wire bytes instead of the Python objects, including one checking that a parameterized mime type survives byte-identically. That one reads the raw response body, since a stray space after the semicolon survives JSON parsing and would still stop a host rendering.
  • uv run pytest libs/tests: 3807 passed, 1 skipped
  • uv run mypy . clean in each of the four libraries
  • ruff check and ruff format --check clean on every touched file

Risk note

Adds two routes under /worker/*, which this repo's PR template lists as sensitive. Both sit behind the same validate_engine_request dependency as the existing endpoints, and no auth path changes. A worker with no declared resources answers list with an empty array.

The malformed-body change above touches _wrap_handler, which every worker endpoint goes through. The blast radius is the status code a caller sees for a body that was already failing: 500 becomes 400 on /worker/tools and /worker/tools/invoke as well. No well-formed request changes behavior, and there is a test pinning each status.

Reviewer note

The default-components test pinned the three existing routes by name and now pins five.

Author checklist

Before moving this PR from Draft to Ready for Review:

  • Linked to a Linear ticket or GitHub issue (above)
  • I understand every change in the diff
  • Runs locally, exercised through the end-user path (not just unit tests)
  • make check and make test are green locally; CI is expected to pass
  • I've pulled the branch fresh and reviewed my own diff top-to-bottom
  • I'd merge it myself if a teammate said LGTM right now

Note

Medium Risk
New authenticated /worker/* routes plus shared request-wrapper behavior that changes status codes for malformed bodies on all worker endpoints; well-formed traffic should behave as before.

Overview
Adds POST /worker/resources/list and POST /worker/resources/read to the default worker component set, wiring them to list_resources / read_resource on the catalog with params/result shapes from arcade_core.resource_schema. Responses use response_model_exclude_none=True so optional resource fields stay off the wire instead of appearing as null.

The FastAPI _wrap_handler layer now maps typed failures to structured JSON errors: unknown URI → 404 with code -32002, bad cursor or Pydantic params → 400 with -32602, and malformed JSON bodies → 400 instead of an unhandled 500. That parsing/error mapping is shared with existing routes (/worker/tools, /worker/tools/invoke), so nonsense bodies on those paths also move from 500 to 400.

arcade-serve bumps to 3.6.0; arcade-mcp-server bumps to 1.28.1 and requires arcade-serve>=3.6.0. Contract tests assert wire bytes (mime types, omitted optional fields, auth, pagination).

Reviewed by Cursor Bugbot for commit 5f4a2bb. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
libs/arcade-serve/arcade_serve/core/base.py 96.73% <ø> (ø)
libs/arcade-serve/arcade_serve/core/components.py 100.00% <100.00%> (ø)
libs/arcade-serve/arcade_serve/fastapi/worker.py 88.40% <100.00%> (+3.50%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@EricGustin
EricGustin force-pushed the ericgustin/too-1935-registry-through-boot-path branch from 35dc47d to 9de50ed Compare August 26, 2026 06:56
@EricGustin
EricGustin force-pushed the ericgustin/too-1936-worker-resource-endpoints branch from 0acfcad to d64200b Compare August 26, 2026 06:56
@EricGustin
EricGustin force-pushed the ericgustin/too-1935-registry-through-boot-path branch from 9de50ed to 682dd87 Compare August 26, 2026 18:20
@EricGustin
EricGustin force-pushed the ericgustin/too-1936-worker-resource-endpoints branch from d64200b to f41cd44 Compare August 26, 2026 18:21
@EricGustin
EricGustin force-pushed the ericgustin/too-1935-registry-through-boot-path branch from 682dd87 to eb459e3 Compare August 26, 2026 20:33
@EricGustin
EricGustin force-pushed the ericgustin/too-1936-worker-resource-endpoints branch from f41cd44 to 1db7d9e Compare August 26, 2026 20:33
@EricGustin
EricGustin marked this pull request as ready for review August 26, 2026 21:23
@greptile-apps

greptile-apps Bot commented Aug 26, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds authenticated worker endpoints for listing and reading resources, registers them by default, and maps resource and request-validation failures onto protocol error responses.

  • Adds POST /worker/resources/list and POST /worker/resources/read with wire-format serialization.
  • Adds resource-specific not-found, invalid-cursor, and malformed-parameter mappings.
  • Adds endpoint contract, authentication, pagination, and serialization coverage.
  • Updates arcade-serve and arcade-mcp-server versions and dependency constraints.

Confidence Score: 4/5

The PR is not yet safe to merge because invalid UTF-8 request bodies still produce the worker-fault response this change is intended to eliminate.

The malformed-body fix catches JSONDecodeError and ValidationError, but json.loads can raise UnicodeDecodeError while decoding raw request bytes, leaving a reachable HTTP 500 path.

Files Needing Attention: libs/arcade-serve/arcade_serve/fastapi/worker.py

Important Files Changed

Filename Overview
libs/arcade-serve/arcade_serve/fastapi/worker.py Adds protocol error mappings, but malformed request bytes with invalid UTF-8 still bypass the intended HTTP 400 handling.
libs/arcade-serve/arcade_serve/core/components.py Adds typed list/read resource components and omits absent optional fields from responses.
libs/arcade-serve/arcade_serve/core/base.py Registers both resource components in the default worker component set.
libs/tests/worker/test_worker_resources_endpoints.py Covers resource endpoint contracts and common malformed JSON shapes, but not invalid byte encodings.
libs/arcade-serve/pyproject.toml Bumps arcade-serve for the new worker protocol functionality.
libs/arcade-mcp-server/pyproject.toml Bumps arcade-mcp-server and raises its minimum arcade-serve dependency.

Reviews (3): Last reviewed commit: "feat(serve): serve POST /worker/resource..." | Re-trigger Greptile

Comment thread libs/arcade-serve/arcade_serve/core/components.py
@EricGustin
EricGustin force-pushed the ericgustin/too-1935-registry-through-boot-path branch from eb459e3 to b4d3d91 Compare August 26, 2026 22:08
@EricGustin
EricGustin force-pushed the ericgustin/too-1936-worker-resource-endpoints branch from 1db7d9e to e0c6c4e Compare August 26, 2026 22:08
@EricGustin
EricGustin force-pushed the ericgustin/too-1935-registry-through-boot-path branch from b4d3d91 to 504cadf Compare August 27, 2026 00:15
@EricGustin
EricGustin force-pushed the ericgustin/too-1936-worker-resource-endpoints branch from e0c6c4e to 43a1d6f Compare August 27, 2026 00:15
method=request.method,
body_json=body_json,
)
except (json.JSONDecodeError, ValidationError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Invalid encoding escapes mapping

When a request body contains invalid UTF-8 bytes, json.loads raises UnicodeDecodeError, which this handler does not catch, causing an HTTP 500 instead of the intended HTTP 400 invalid-params response.

Suggested change
except (json.JSONDecodeError, ValidationError):
except (json.JSONDecodeError, UnicodeDecodeError, ValidationError):

@EricGustin
EricGustin force-pushed the ericgustin/too-1935-registry-through-boot-path branch from 504cadf to 0d952d2 Compare August 27, 2026 16:41
@EricGustin
EricGustin force-pushed the ericgustin/too-1936-worker-resource-endpoints branch from 43a1d6f to 457e3f6 Compare August 27, 2026 16:41

@greptile-apps greptile-apps 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.

Your trial has ended. Reactivate Greptile to resume code reviews.

…read [TOO-1936]

The two endpoint components, registered in the default component set. These are
the entire new protocol for this project, and they are thin by design: a
serialization shim over the registry, with no caching and no logic.

The path suffix names the operation, the request body is that operation's params
object, and the response is its result object, all spelled as they go on the
wire so neither end has to translate.

Both are POST because both requests carry params: an optional cursor on the
list, a required uri on the read. RequestData carries only path, method and
body, and its path comes from request.url.path, which Starlette strips of the
query string, so a component cannot read a query parameter at all. A first-page
list sends an empty body, which the router already coerces to {}.

Responses pass response_model_exclude_none through to FastAPI. Its default
emits an explicit null for every unset optional field, so a resource with no
annotations would go out as annotations, size and icons all null, which the
format does not do.

Errors are error objects carried by an HTTP status. An unknown URI is 404 with
code -32002. That overlaps with treating a 404 as "this worker serves no
resource endpoints", so the tolerance rule is endpoint-specific: 404 and 405
mean an empty catalog on the list endpoint only. The mapping lives in the
FastAPI binding rather than in core, which stays framework-agnostic.

The default-components test pinned the three existing routes by name and now
pins five.
@EricGustin
EricGustin force-pushed the ericgustin/too-1935-registry-through-boot-path branch from 0d952d2 to 97f6382 Compare August 27, 2026 17:32
@EricGustin
EricGustin force-pushed the ericgustin/too-1936-worker-resource-endpoints branch from 457e3f6 to 67a78e1 Compare August 27, 2026 17:32

@greptile-apps greptile-apps 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.

Your trial has ended. Reactivate Greptile to resume code reviews.

Follows the same reconnection one commit down the stack. Conflicts are all the
shape where this side already carries the base's changes plus its own; taking
this side keeps both intents and leaves the tree unchanged.

@greptile-apps greptile-apps 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.

Your trial has ended. Reactivate Greptile to resume code reviews.

@greptile-apps greptile-apps 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.

Your trial has ended. Reactivate Greptile to resume code reviews.

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.

1 participant