Skip to content

fix(server): keep an advertised resource title across a metadata-only update - #3119

Open
Ben-Home wants to merge 2 commits into
modelcontextprotocol:mainfrom
Ben-Home:fix/resource-title-metadata-only
Open

Ben-Home wants to merge 2 commits into
modelcontextprotocol:mainfrom
Ben-Home:fix/resource-title-metadata-only

Conversation

@Ben-Home

Copy link
Copy Markdown

Motivation and Context

Fixes #3103.

resources/list, resources/templates/list and template-supplied list entries all spread the registered metadata, and update({ metadata }) replaces that carrier wholesale. A metadata-only update therefore drops the title that registration, or an earlier update({ title }), had put there, even though the handle still holds it.

Measured on main before the change, with a resource registered as { title: 'Docs', mimeType: 'text/markdown' }:

after register               {"uri":"mem://docs","name":"docs","title":"Docs","mimeType":"text/markdown"}
after update({ metadata })   {"uri":"mem://docs","name":"docs","mimeType":"text/markdown"}
after update({ title })      {"uri":"mem://docs","name":"docs","mimeType":"text/markdown","title":"Renamed"}

Same for a template on resources/templates/list. Registration is fine because the config object doubles as the metadata carrier; the loss is specific to a metadata replacement that does not repeat the title.

Approach

Derive the field at read time instead of re-syncing it on every write path:

title: resource.metadata?.title ?? resource.title
  • metadata that carries a title keeps winning, so no caller sees a different value than today
  • the registered title is the fallback, so a metadata-only update can no longer erase it
  • the update path needs no precedence rule of its own, and both reads agree by construction

The same derivation is applied to resources/templates/list and to the template metadata spread in resources/list, where listed instances had the same gap.

This is deliberately narrower than the closed #3110, which synced the handle on metadata-only updates: that leaves registration-then-replace on the update path to fix each new writer, while deriving on read removes the class.

How to verify

packages/server/test/server/resourceTitle.test.ts (new) asserts, for both the resource and the template path: the title is advertised at registration, survives a metadata-only update, follows a later update({ title }), and still loses to a metadata title when metadata carries one.

With the source change stashed, 3 of the 4 tests fail; with it applied, all 4 pass. Full packages/server suite: 678 passed, 56 files. tsc -p packages/server/tsconfig.json --noEmit clean, prettier clean. Changeset added as a patch for @modelcontextprotocol/server.

Happy to adjust the precedence (metadata-wins vs handle-wins) if maintainers prefer the opposite, or to fold this into whatever shape #3005 intended.

… update

resources/list, resources/templates/list and template-supplied list entries spread the registered metadata, and update({ metadata }) replaces that carrier wholesale, so a metadata-only update dropped the title that registration or a later update({ title }) had put there. Both read paths now derive the field: metadata wins when it carries a title, the registered title is the fallback. Refs modelcontextprotocol#3103.
@Ben-Home
Ben-Home requested a review from a team as a code owner October 28, 2026 15:11
@changeset-bot

changeset-bot Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8d6ef03

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/server Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/core-internal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@3119

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@3119

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@3119

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@3119

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@3119

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@3119

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@3119

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@3119

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@3119

commit: 8d6ef03

@arhancanli

Copy link
Copy Markdown

Read against head a26b936 (static read, I did not run the suite): the read-time derivation matches the update path, since update({ title }) already writes the title into both registeredResource.title and metadata (mcp.ts:791-795), so the precedence in the new tests holds.

One sequence the four tests do not pin: register with { title: 'Docs' }, then update({ metadata: { title: 'X' } }) (advertised: X), then update({ metadata: {} }). resource.title is still 'Docs' because only update({ title }) moves it, so the third call makes the list advertise 'Docs' again rather than 'X' or nothing. That may be the intended fallback, but the PR text says the advertised title is independent of how it was last written, and here it depends on the history. A test that records whichever behaviour you want (or having the metadata branch also set registeredResource.title when it carries one) would settle it. The same applies to the template branch at line 832.

@claude claude Bot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Oct 29, 2026
A metadata-only update replaces the metadata carrier wholesale. When the
incoming metadata carried a title, the read-time fallback still pointed at
the registration title, so a later replacement that omitted the title
advertised the old value again and made the advertised title depend on
write order.

Both update paths now copy a metadata-carried title onto the registered
resource or template. Tests pin the sequence register -> metadata title ->
titleless metadata for both resources and templates.
@Ben-Home

Copy link
Copy Markdown
Author

Good catch, and you are right that the four tests did not pin it. I took the second option you offered: the metadata branch now writes a metadata-carried title through to the registered resource and template, so your sequence advertises X at the third call rather than Docs.

Why write-through rather than recording the resurrection as intended behaviour: tools and prompts each have a single title carrier (tools/list reads tool.title, prompts/list reads prompt.title), and resources are the only kind that also mirrors a title into metadata. Keeping the fallback would make resources the one place in the server where the advertised title depends on write order, and it would mean a caller who writes a metadata title once can never replace it with nothing, only with an older value. With the mirror, the sequence is order-independent: the last title written wins, which is what the PR text claims.

One thing I am deliberately not widening this PR to cover: update({ title?: string }) cannot clear a title, while update({ name: null }) and update({ scopeChallenge: null }) in the same type both can. So there is currently no way to remove a title at all, under either model. Changing it to title?: string | null on RegisteredResource and RegisteredResourceTemplate, following the null-clears convention already in those types, would close that, and it is a separate API decision from this fix.

Evidence for the change:

  • Your exact sequence is now two tests, one for resources and one for templates.
  • Both fail against the PR head without the source change (Docs and Thing advertised instead of X and Y), so they pin the behaviour instead of restating it.
  • pnpm --filter @modelcontextprotocol/server test reports 680 passed; typecheck, eslint and prettier are clean.
  • Pushed as 8d6ef03, and the changeset now describes the mirror as well as the read-time derivation.

@arhancanli

Copy link
Copy Markdown

Read 8d6ef03 (static read, I did not run the suite). The mirror closes the sequence I raised: the metadata branch now sets registeredResource.title / registeredResourceTemplate.title whenever the new metadata carries one, so the titleless replacement keeps X, and the two new tests pin it for both resources and templates. The changeset sentence also states the order-independence the PR text claims. Leaving title?: string | null out of this PR is reasonable, since it is a type change.

One case the new tests do not pin: a single call that passes both, update({ title: 'A', metadata: { title: 'B' } }). The metadata branch runs first and writes B to the handle and the carrier, then the title branch runs and overwrites both with A, so A wins. That is probably the right precedence (the explicit top-level field beats the nested one), but it is now an ordering inside the function rather than something a test records. A one-line test asserting A would stop a later reorder of those two if blocks from flipping it silently. Is A the outcome you intend?

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

v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server: registeredResource.update and registeredResourceTemplate.update desync title and metadata.title when updating metadata alone

3 participants