fix(mcplocal): make the paginated-result contract usable by any MCP client #111

Merged
michal merged 1 commits from fix/paginator-drilldown-contract into main 2026-08-14 22:28:01 +00:00
Owner

UniFi tools were unusable from non-Claude agents (opencode, LibreChat).

Root cause. A tool result over 2000 chars is swapped for a table of contents and re-read via _resultId/_section — but those params were never declared on the tool's inputSchema, and unifi-network and my-grafana ship additionalProperties: false. For any client that validates arguments the drill-down call is illegal, so the data is unreachable. The stub also said "Use section parameter", naming a param that does not exist; sending it fell through to the upstream, re-paginated and minted a fresh _resultId — an unbounded loop.

Why UniFi specifically. It is the one server whose results always trip the threshold: get_devices 11,718 chars, get_clients 136,696 across 19 pages with a 92-char page-1. Grafana and Gitea return compact results and never paginate.

Changes

  • content-pipeline gains onToolsList, declaring the two params on every tool it can paginate (gate tools excluded — intercepted before the pipeline). additionalProperties stays false: a listed property is already legal under it, so the upstream keeps its typo protection.
  • createDefaultPlugin wired only the gate's onToolsList; the pipeline's was silently dropped. Both chain now.
  • _resultId without _section re-shows the ToC instead of forwarding an unknown arg upstream.
  • Nested MCP envelopes collapsed — a server fronting another MCP server returns the payload two or three times over. A layer is peeled only when it carries nothing the inner value lacks, so it stays lossless.
  • deploy/mcplocal.service shipped a dead MCPLOCAL_MCPD_URL (10.0.0.194:3100); every working machine had a hand-written drop-in. Points at the k8s ingress now.

Verification — live against the sre project with a real McpToken:

  • get_devices 11,718 -> 5,210 chars, no longer paginates at all
  • get_clients 136,696 -> 62,358 across 8 pages instead of 19
  • drill-down with _resultId+_section returns real device data; _resultId alone re-lists pages

777 unit tests pass. 12 new tests in plugin-content-pipeline-drilldown.test.ts (4 fail without the src changes) plus 3 smoke tests in smoke/tool-drilldown.test.ts that fail against the deployed build and pass against the patched one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JNXFvxanvM6uiFcb4Mp3xU

UniFi tools were unusable from non-Claude agents (opencode, LibreChat). **Root cause.** A tool result over 2000 chars is swapped for a table of contents and re-read via `_resultId`/`_section` — but those params were never declared on the tool's `inputSchema`, and `unifi-network` and `my-grafana` ship `additionalProperties: false`. For any client that validates arguments the drill-down call is illegal, so the data is unreachable. The stub also said "Use section parameter", naming a param that does not exist; sending it fell through to the upstream, re-paginated and minted a fresh `_resultId` — an unbounded loop. **Why UniFi specifically.** It is the one server whose results always trip the threshold: `get_devices` 11,718 chars, `get_clients` 136,696 across 19 pages with a 92-char page-1. Grafana and Gitea return compact results and never paginate. **Changes** - `content-pipeline` gains `onToolsList`, declaring the two params on every tool it can paginate (gate tools excluded — intercepted before the pipeline). `additionalProperties` stays `false`: a listed property is already legal under it, so the upstream keeps its typo protection. - `createDefaultPlugin` wired only the gate's `onToolsList`; the pipeline's was silently dropped. Both chain now. - `_resultId` without `_section` re-shows the ToC instead of forwarding an unknown arg upstream. - Nested MCP envelopes collapsed — a server fronting another MCP server returns the payload two or three times over. A layer is peeled only when it carries nothing the inner value lacks, so it stays lossless. - `deploy/mcplocal.service` shipped a dead `MCPLOCAL_MCPD_URL` (10.0.0.194:3100); every working machine had a hand-written drop-in. Points at the k8s ingress now. **Verification** — live against the `sre` project with a real McpToken: - `get_devices` 11,718 -> 5,210 chars, no longer paginates at all - `get_clients` 136,696 -> 62,358 across 8 pages instead of 19 - drill-down with `_resultId`+`_section` returns real device data; `_resultId` alone re-lists pages 777 unit tests pass. 12 new tests in `plugin-content-pipeline-drilldown.test.ts` (4 fail without the src changes) plus 3 smoke tests in `smoke/tool-drilldown.test.ts` that fail against the deployed build and pass against the patched one. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JNXFvxanvM6uiFcb4Mp3xU
michal added 1 commit 2026-08-14 22:27:54 +00:00
fix(mcplocal): make the paginated-result contract usable by any MCP client
Some checks failed
CI/CD / lint (pull_request) Successful in 1m13s
CI/CD / test (pull_request) Successful in 1m25s
CI/CD / typecheck (pull_request) Successful in 3m2s
CI/CD / smoke (pull_request) Failing after 2m5s
CI/CD / build (pull_request) Successful in 5m1s
CI/CD / publish (pull_request) Has been skipped
03350856ea
UniFi tools were unusable from non-Claude agents. A tool result over 2000
chars is replaced with a table of contents and re-read by calling the tool
again with _resultId/_section, but those params were never declared on the
tool's inputSchema — and unifi-network and my-grafana ship
`additionalProperties: false`, so for a client that validates arguments the
drill-down call was illegal and the data unreachable. The stub's own wording
made it worse: "Use section parameter" names a param that does not exist;
sending it fell through to the upstream, re-paginated, and minted a fresh
_resultId. An unbounded loop.

UniFi took the blame because it is the one server whose results always trip
the threshold: get_devices was 11,718 chars, get_clients 136,696 across 19
pages with a 92-char page-1. Grafana and Gitea return compact results.

- content-pipeline gains onToolsList, declaring _resultId/_section on every
  tool it can paginate (gate tools excluded — they are intercepted before the
  pipeline runs). additionalProperties stays false: a property listed in
  `properties` is already legal under it, so declaring is enough and the
  upstream keeps its typo protection.
- createDefaultPlugin wired only the gate's onToolsList, so the pipeline's
  would have been dropped on the floor. Both now chain, gate first.
- _resultId without _section re-shows the table of contents instead of
  forwarding an unknown argument to a strict upstream.
- The stub names the tool, the live _resultId and a real section id.
- Nested MCP envelopes are collapsed. A server fronting another MCP server
  returns the inner result wrapped in its own content/structuredContent pair,
  so the payload arrives two or three times over. A layer is peeled only when
  it carries nothing the inner value lacks, which keeps it lossless. Live
  against the sre project: get_devices 11,718 -> 5,210 chars and no longer
  paginates at all; get_clients 136,696 -> 62,358 across 8 pages instead of 19.
- deploy/mcplocal.service shipped MCPLOCAL_MCPD_URL=http://10.0.0.194:3100,
  which is dead — every working machine had a hand-written drop-in. Points at
  the k8s ingress now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNXFvxanvM6uiFcb4Mp3xU
michal merged commit 35d506df77 into main 2026-08-14 22:28:01 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: michal/mcpctl#111