Skip to content

Commit 299e9dd

Browse files
ykpdangclaude
andcommitted
Merge upstream/main: action-dispatch tool consolidation
Upstream 00d9d1f collapses ~177 per-operation tools into 28 action-dispatch tools. Two conflicts, both from this fork's divergence rather than anything upstream broke. server.py: kept get_authentik_oauth_mcp() and _public_base_url(), and routed the Authentik factory through upstream's new _configured(). That helper is why this merge is worth taking promptly — it puts the middleware stack and tool registration in one place, so a transport can no longer be left a middleware behind. A hand-copied add_middleware line missed in the previous upstream merge is exactly what took the web connector offline (427c8b0); after this, that class of bug is structurally impossible. tools/workspaces.py: superseded by upstream's tools/workspace.py. Ported get_active/set_active there as actions, adopting the local conventions — guards return "Error: ..." strings rather than raising, the absent-parameter check runs before the identity guard so the one refusal a caller can act on is not masked, and the description is generated from ACTIONS. Dropped the LEGACY aliases for the standalone get_active_workspace / set_active_workspace spellings. The retired-name table is upstream's frozen record of its own pre-consolidation surface, and those two names were never in it — nor released to any user, since production still runs a pre-consolidation image. Adding them would mean carrying an edit to a pinned upstream literal forever. Two exemptions added to their dispatch matrix: get_active reads the resolved request context and never calls the SDK; set_active refuses before the SDK unless the request carries a verified OIDC identity, which that harness has no request context to supply — it is covered against a real identity in tests/tools/test_workspace_active.py. Also updated the server instructions to the backticked `workspace get_active` reference form their test_references.py enforces, and the two README counts. 1155 tests pass; the merged server boots against the live Authentik and advertises 28 tools with all four workspace actions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 parents 498f7ea + 00d9d1f commit 299e9dd

110 files changed

Lines changed: 10205 additions & 7677 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,19 @@ ruff check plane_mcp/
4545

4646
### Server Factories (`server.py`)
4747

48-
Three factory functions (`get_oauth_mcp`, `get_header_mcp`, `get_stdio_mcp`) each create a `FastMCP` instance, register all tools, and configure the appropriate auth provider. OAuth/HTTP modes use Redis for token storage (falls back to in-memory).
48+
Three factory functions (`get_oauth_mcp`, `get_header_mcp`, `get_stdio_mcp`) each create a `FastMCP` instance and configure the appropriate auth provider, then hand it to `_configured()` for the middleware stack and tools — one place, so a transport cannot be left a middleware behind. OAuth/HTTP modes use Redis for token storage (falls back to in-memory).
49+
50+
### Middleware (`middleware.py`)
51+
52+
Ordered as registered; the earlier one wraps the later:
53+
54+
| Middleware | Does |
55+
|---|---|
56+
| `PlaneLoggingMiddleware` | structured logging, plus the tool name |
57+
| `CoerceArguments` | repairs arguments a client encoded as strings, before validation (`coercion.py`) |
58+
| `ValidateActionArguments` | refuses arguments the chosen action does not accept, from the `ACTIONS` declaration |
59+
60+
Coercion runs before validation so an argument is judged by the value it repairs to. `ValidateActionArguments` closes a gap a per-tool schema cannot: every action's parameters share one schema, so an argument meant for another action validated cleanly and was then dropped, and the call answered a different question than the one asked. Only arguments carrying a value are judged, and retired names are exempt — they arrive with no `action` and under their own parameter spelling.
4961

5062
### Client Context (`client.py`)
5163

@@ -58,22 +70,44 @@ Three factory functions (`get_oauth_mcp`, `get_header_mcp`, `get_stdio_mcp`) eac
5870

5971
### Tools (`tools/`)
6072

61-
29 tool modules organized by Plane domain (projects, work_items, cycles, modules, releases, etc.), totaling 160+ tools. Each module exports a `register_*_tools(mcp: FastMCP)` function called from `tools/__init__.py`.
73+
One action-dispatch tool per Plane resource: **28 tools, 183 actions, ~57k chars advertised**. `tools/__init__.py` re-exports `register_tools`, so `server.py` and `__main__.py` see a single entry point.
6274

63-
**Tool pattern:**
64-
```python
65-
def register_*_tools(mcp: FastMCP) -> None:
66-
@mcp.tool()
67-
def tool_name(param: str, optional_param: str | None = None) -> SomePlaneModel:
68-
"""Docstring with Args and Returns sections."""
69-
client, workspace_slug = get_plane_client_context()
70-
return client.endpoint.operation(workspace_slug=workspace_slug, ...)
71-
```
75+
One module per resource, each exporting `NAME`, `ACTIONS`, `LEGACY` and `register(mcp)`. `ACTIONS` is the single source of truth: the tool description and its `ToolAnnotations` are generated from it, and the conformance suite asserts they agree with the function signature. See `tools/README.md` for the full convention.
76+
77+
`tools/` contains resource modules plus `registry.py` (the `RESOURCES` tuple and alias tables) and `legacy.py` (retired-name resolution). Shared helpers live in `plane_mcp/toolkit/`, not here — see below.
78+
79+
Where a resource exists at both project and workspace scope, it resolves that once in a local `_scope_of` rather than through a shared abstraction: the two resources that need it need different shapes (`workitem_type` is a two-way split, `workitem_property` three-way plus a method-name suffix).
80+
81+
`RESOURCES` is an explicit tuple, not a directory scan. Its order is the advertised order and therefore a wire-format guarantee: tool definitions head a client's prompt cache, so reordering invalidates live conversations. Append; never re-sort. `test_resource_order_is_pinned` holds it to a literal list.
82+
83+
**Retired names.** Before consolidation this server exposed 177 tools, one per operation. 169 of those names still resolve, via a `Transform` mapping each to its `(tool, action)` pair with `action` hidden, and keeping the parameter spelling they shipped with (`work_item_id`, not `workitem_id`). The transforms implement `list_tools`/`get_tool` only — execution keeps the full schema, so tool results are unchanged, and nothing is advertised so the listing is unaffected. Seven encoded their action in a parameter and are declared in `LEGACY_UNMAPPED` with a replacement. `tests/tools/_retired_names.py` is the frozen record of all 177.
7284

7385
Tools return Pydantic models from `plane-sdk` and use Python 3.10+ union syntax (`str | None`).
7486

87+
### Toolkit (`toolkit/`)
88+
89+
Shared building blocks for the tool surface, split by *when* they act:
90+
91+
| Module | Acts at | Provides |
92+
|---|---|---|
93+
| `spec.py` | declaration | `Action`, `build_description`, `build_annotations` |
94+
| `runtime.py` | call | `missing`, `needs`, `require`, `one_of`, `opt`, `coerce_list`, `page_params`, `as_params`, `ids_of` |
95+
| `paging.py` | response | `envelope`, `dump_results`, `pql_failure`, `workitem_page` |
96+
| `governance.py` | policy | `workspace_owns_resource`, `GOVERNED_BY`, `workspace_owns`, `migration_in_progress`, `plan_gated` |
97+
| `transforms.py` | listing | `StripOutputSchemas` |
98+
99+
Governance has two questions, and both matter. `workspace_owns_resource` reads the workspace flag that governs a resource — used *before* a write, to pick the scope. `workspace_owns` reads the refusal — used *after*, because the flag is cached and the lockout outlives it being toggled off. There is no single flag: work item types carry their own (`is_work_item_types_enabled`, public `work_item_types`), while states, labels, workflows, templates and automations share `workspace_governance_status` (public `states_owned_by_workspace`). `GOVERNED_BY` maps resource to flag so a newly governed resource is one row.
100+
101+
Names are re-exported from `plane_mcp/toolkit/__init__.py`, so a resource module needs one import: `from plane_mcp.toolkit import Action, build_description, missing, opt`.
102+
103+
These sit outside `tools/` deliberately. They were previously `_`-prefixed modules inside the resource package, where the underscore was the module-discovery filter rather than a privacy marker — which made helper filenames load-bearing and made the most widely imported module in the package look private. Nothing here knows which catalogue is calling it.
104+
105+
Anything that encodes the catalogue's history — `LegacyNames`, the `RESOURCES` tuple — belongs under `tools/`, not here.
106+
75107
### Testing
76108

109+
`tests/tools/` covers the surface with no network and no credentials: surface-wide invariants, plus every action of every resource executed against `SpyClient`, a stand-in that binds each call against the genuine SDK signature and type-checks its arguments.
110+
77111
Integration tests in `tests/test_integration.py` use `FastMCP.Client` with `StreamableHttpTransport`. Tests run against a live Plane instance — configure via `.env.test` (copy to `.env.test.local` with real values).
78112

79113
## Key Environment Variables

0 commit comments

Comments
 (0)