Skip to content

Read an MCP tool answer that arrived as a resource link - #638

Open
kevin9327 wants to merge 2 commits into
CopilotKit:mainfrom
kevin9327:read-mcp-resource-link-uri
Open

kevin9327 wants to merge 2 commits into
CopilotKit:mainfrom
kevin9327:read-mcp-resource-link-uri

Conversation

@kevin9327

@kevin9327 kevin9327 commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

A tool that points at a file or a page often returns a resource_link: a URI, a name, and a sentence of what it is, rather than the contents themselves. That part was named [resource_link] and the URI was dropped, so the model was told a link arrived and never shown where it went. A search that answered with pages produced no page it could open.

The URI, name and description are now read, each on its own labelled line. The URI leads, whole, and the title or name and the description are bounded at 400 characters, so a link's own metadata cannot push its pointer past the result cap: truncation may lose what a resource was called, never where it is. The spec's title is shown over name when a server gives one. A part that already carried text is unchanged. A link that names nothing is still named [resource_link], rather than dropped.

Where it runs

OpenBot is deployed as several server processes behind a load balancer, serving a whole company.
Consecutive requests from the same person reach different processes, and the process that answered a
WebSocket upgrade is rarely the one that answers the next call on that conversation.

State that outlives a single request therefore has to be shared, or the change works on one machine
and stops working the moment there are two, without saying so. That failure is worse than not
shipping the feature: it passes review, passes CI, passes a local demo, and only surfaces as a Bot
that forgets, a question nobody can answer, or a boundary that never fires.

Answer these even when the answer is "none":

  • New state that outlives a request? None.
  • What happens on the second replica? The same. Each call reads the payload it just received.
  • Anything serialised? None.
  • Anything fanned out to a browser? None.
  • New listener, port, or schedule? None.

Boundary and audit

  • Every acting call still goes through the gateway: resolve, decide, audit, then act. Untouched. This is how a permitted call's result is turned into text.
  • New refusals and new failures each write a row. None added.
  • Nothing new is trusted from the client that the server can resolve itself. The payload is still the vendor's.

Changelog

  • A line in CHANGELOG.md under Unreleased.

Proof

On unmodified main, with the new tests present:

(fail) reads a resource_link's name, uri and description, as a server points at a file
  Received: "[resource_link]"
(fail) a resource_link with only a uri is still that uri, not an empty name
  Received: "[resource_link]"
(fail) joins a resource_link beside a text part
  Received: "matching pages:\n[resource_link]"
(fail) a resource_link's long name cannot push its uri past the result cap
  the pointer was the second line, after a name as long as the cap

After the fix: bun test tests/mcp-result.test.ts in server → 24 pass, 0 fail. Neighbouring mcp-protocol and mcp-listing suites with it: 38 pass, 0 fail. bunx tsc --noEmit in server exits 0. Biome format and lint clean on the changed files.

AI-assisted (Grok)

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

kvnloo commented Sep 23, 2026

Copy link
Copy Markdown

One boundary I'd pin here: for a resource_link, the URI is the identity; name/description are metadata.

Right now the new representation is:

[item.name, item.uri, item.description]

and the existing 20k result cap is applied afterwards.

That means a long enough name can still push the URI past MAX_RESULT_CHARS, so the final model-visible result again contains a resource link with no usable pointer — the exact failure this PR is fixing.

A small regression should expose it:

name = "x".repeat(MAX_RESULT_CHARS), uri = "https://example.com/source"

and assert that the returned text still contains the URI.

I'd preserve the contract as:

truncation may discard descriptive resource metadata, but not the pointer that identifies the resource.

Putting a labeled/bounded URI representation first (then bounded name/description) would also make the three fields unambiguous to the model rather than relying on line position.

AI-use note: I used an AI assistant to trace the new resource-link formatting through the existing result-size cap and draft this review; I verified the current PR head before posting.

@davidmckayv

Copy link
Copy Markdown
Contributor

Approved to merge, but it now conflicts with main in CHANGELOG.md after #636 added entries under Unreleased. Rebase onto main, keeping both sets of entries, and it will be merged once CI is green. Optional: show the spec's title field when present, falling back to name.

@kevin9327
kevin9327 force-pushed the read-mcp-resource-link-uri branch from 84eb6da to 5e3d261 Compare September 25, 2026 23:02
@kevin9327

Copy link
Copy Markdown
Contributor Author

Rebased onto main; both sets of Unreleased entries are kept, this one above #636's.

@kvnloo Taken as stated. The link is now shown with the URI first and whole, and the title or name and the description cut at 400 characters, so a link's own metadata cannot spend the result cap its pointer has to fit in. Each line is labelled (uri:, title:/name:, description:), so the model tells the fields apart by name rather than by position. The regression you described is in the suite: name = "x".repeat(MAX_RESULT_CHARS), uri = "https://example.com/source"; the text starts with uri: https://example.com/source and is not truncated. It fails on the previous reader, where the pointer was the second line after a name as long as the cap.

@davidmckayv title is shown over name when a server sends one.

bun test tests/mcp-result.test.ts 24 pass, 0 fail; with the mcp-protocol and mcp-listing suites 38 pass; tsc --noEmit and Biome clean. The PR description is updated to match.

Review on the first cut: the link was shown as name, URI, description and
the result cap was applied afterwards, so a name long enough could push
the URI past MAX_RESULT_CHARS and the model would again hold a link with
nowhere to go, the failure this reader exists to remove. The URI is the
link's identity and the rest is metadata, so the URI now leads, whole,
and the title or name and the description are cut at 400 characters.
Each line is labelled, so the fields are told apart by name rather than
by position. The spec's title is shown over the name when a server gives
one. A regression with a name as long as the cap asserts the pointer
stays readable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

3 participants