Skip to content

feat(mcp-server-base): negotiate client capabilities from the handshake - #342

Merged
dsklyar merged 2 commits into
mainfrom
danielsklyar/zel-8128-client-capability-negotiation
Aug 4, 2026
Merged

dsklyar merged 2 commits into
mainfrom
danielsklyar/zel-8128-client-capability-negotiation

Conversation

@dsklyar

@dsklyar dsklyar commented Aug 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Derives what the connected host can do, once per connection, and exposes it to tool handlers without threading a server object through every call signature.

  • deriveClientCapabilities reduces the initialize handshake to the set we can act on, and whatIsTheClient maps clientInfo.name to an McpHostClient
  • The result lands in an AsyncLocalStorage session, read with getMcpSession() and hasCapability()
  • requestElicitation asks the host for a form and returns undefined when it cannot show one, rather than letting the SDK's own capability check throw and fail the tool call
  • TRANSCEND_MCP_ASSUME_CAPABILITIES can force a capability on for local debugging, warned about loudly at startup

Only elicitation and MCP Apps are detected, being the only capabilities a tool can act on differently. Sampling and roots are excluded deliberately: roots is inert for API-backed servers, our hosts do not implement sampling, and both are deprecated under SEP-2577 in the 2026-07-28 spec.

Nothing changes on the wire. Handshakes stay byte-identical and no tool behaves differently yet. This is the foundation; per-capability variants and ui:// view serving arrive in ZEL-8130, which is the first real consumer.

Ticket: ZEL-8128. Second of eleven PRs splitting the MCP Apps branch. Sits on #341 in the stack, which it does not otherwise depend on.

Note for review

MCP_UI_EXTENSION_ID and MCP_APP_MIME_TYPE live in capabilities/types.ts rather than beside the view-serving code, because the handshake is what consumes them: deriving a report needs the extension id before anything renders. The view module will re-export them when it lands, so callers that think of them as part of the view surface still find them there.

Where this sits in the stack

Eleven layers, each targeting the one below it, registered as a GitHub stack — so this diff is only its own commits, and merging a lower layer rebases and retargets everything above it automatically.

  1. fix(design-tokens): resolve tokens.css from source inside the monorepo #341 — design-tokens: resolve tokens.css from source inside the monorepo
  2. feat(mcp-server-base): negotiate client capabilities from the handshake #342 — negotiate client capabilities from the handshake ← this PR
  3. feat(mcp-server-base): attribute stdio sessions to the detected host #343 — attribute stdio sessions to the detected host
  4. feat(mcp): serve ui:// views and per-capability tool variants #344 — serve ui:// resources and per-capability tool variants
  5. feat(mcp-server-base): add a browser-only ./ui subpath with useMcpApp #345 — browser-only ./ui subpath with useMcpApp
  6. feat(mcp-server-base): publish the Tailwind view theme #346 — publish the Tailwind view theme
  7. feat(mcp): build MCP App views with React and Vite #347 — build views into one self-contained document with Vite
  8. fix(mcp): restore the Inspector's missing app sandbox document #348 — restore the Inspector's missing app sandbox document
  9. feat(dev): add mcp-server-examples with the reference view and form flows #349 — dev/mcp-server-examples, the first consumer of all of it
  10. feat(mcp): add pnpm mcp:inspect with per-request dev view HTML #350 — pnpm mcp:inspect with per-request dev view HTML
  11. feat(mcp): scaffold a tool, an app, or a form with pnpm mcp:new #351 — pnpm mcp:new-view scaffolder

Depends on nothing below it: its position is ordering alone, so it can be read and approved without the layers underneath.

Test plan

  • format, lint, typecheck, and the full test run are green; mcp-server-base goes from 29 to 33 test files
  • New unit tests cover capability derivation, host detection, the assume-capabilities override, and the session context, including that getMcpSession() is undefined and hasCapability() is false outside a request so a directly-called handler takes its baseline path
  • pnpm run check:changeset passes

Made with Cursor

@linear-code

linear-code Bot commented Aug 2, 2026

Copy link
Copy Markdown

ZEL-8128

@pkg-pr-new

pkg-pr-new Bot commented Aug 2, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@transcend-io/airgap.js-types

pnpm add https://pkg.pr.new/@transcend-io/airgap.js-types@342
yarn add https://pkg.pr.new/@transcend-io/airgap.js-types@342.tgz

@transcend-io/cli

pnpm add https://pkg.pr.new/@transcend-io/cli@342
yarn add https://pkg.pr.new/@transcend-io/cli@342.tgz

@transcend-io/design-tokens

pnpm add https://pkg.pr.new/@transcend-io/design-tokens@342
yarn add https://pkg.pr.new/@transcend-io/design-tokens@342.tgz

@transcend-io/internationalization

pnpm add https://pkg.pr.new/@transcend-io/internationalization@342
yarn add https://pkg.pr.new/@transcend-io/internationalization@342.tgz

@transcend-io/privacy-types

pnpm add https://pkg.pr.new/@transcend-io/privacy-types@342
yarn add https://pkg.pr.new/@transcend-io/privacy-types@342.tgz

@transcend-io/sdk

pnpm add https://pkg.pr.new/@transcend-io/sdk@342
yarn add https://pkg.pr.new/@transcend-io/sdk@342.tgz

@transcend-io/type-utils

pnpm add https://pkg.pr.new/@transcend-io/type-utils@342
yarn add https://pkg.pr.new/@transcend-io/type-utils@342.tgz

@transcend-io/utils

pnpm add https://pkg.pr.new/@transcend-io/utils@342
yarn add https://pkg.pr.new/@transcend-io/utils@342.tgz

@transcend-io/mcp

pnpm add https://pkg.pr.new/@transcend-io/mcp@342
yarn add https://pkg.pr.new/@transcend-io/mcp@342.tgz

@transcend-io/mcp-server-admin

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-admin@342
yarn add https://pkg.pr.new/@transcend-io/mcp-server-admin@342.tgz

@transcend-io/mcp-server-assessment

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-assessment@342
yarn add https://pkg.pr.new/@transcend-io/mcp-server-assessment@342.tgz

@transcend-io/mcp-server-base

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-base@342
yarn add https://pkg.pr.new/@transcend-io/mcp-server-base@342.tgz

@transcend-io/mcp-server-consent

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-consent@342
yarn add https://pkg.pr.new/@transcend-io/mcp-server-consent@342.tgz

@transcend-io/mcp-server-discovery

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-discovery@342
yarn add https://pkg.pr.new/@transcend-io/mcp-server-discovery@342.tgz

@transcend-io/mcp-server-docs

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-docs@342
yarn add https://pkg.pr.new/@transcend-io/mcp-server-docs@342.tgz

@transcend-io/mcp-server-dsr

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-dsr@342
yarn add https://pkg.pr.new/@transcend-io/mcp-server-dsr@342.tgz

@transcend-io/mcp-server-inventory

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-inventory@342
yarn add https://pkg.pr.new/@transcend-io/mcp-server-inventory@342.tgz

@transcend-io/mcp-server-preferences

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-preferences@342
yarn add https://pkg.pr.new/@transcend-io/mcp-server-preferences@342.tgz

@transcend-io/mcp-server-workflows

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-workflows@342
yarn add https://pkg.pr.new/@transcend-io/mcp-server-workflows@342.tgz

commit: 8d5ed3d

@dawson-turechek-transcend dawson-turechek-transcend left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Love it!

* overlap, iteration order cannot change the answer, and
* `client-detection.test.ts` enforces it.
*/
export const HOST_PATTERNS: Readonly<Record<DetectableHost, readonly RegExp[]>> = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do you think we should add copilot?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Cursor added windsurf, zed and I think copilot. I just wasn't able to verify if the patterns were valid. And also got lazy and didn't want to download and install the mcp there. But once we want to test for copilot I think we can just add it here.

* ticket and a removal condition. Keeping them in one table instead of inline
* conditionals means the set of active workarounds is greppable and auditable.
*/
export interface HostQuirks {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

❤️

* deprecated as of the 2026-07-28 spec under SEP-2577.
*/
export enum McpClientCapability {
/** Host renders server-requested forms via `elicitation/create` in `form` mode */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just curious: what other types of capabilities can be reported?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think there are 2 categories of capabilities client/server

I think the ones we care here a client ones and I think root/sampling is deprecated. Not sure about others tbh.

Client (host → server):

Capability What it means Who initiates later
elicitation Host can collect user input (form and/or url) Server → elicitation/create
sampling Host can run an LLM completion for the server Server → sampling/createMessage
roots Host can report workspace/filesystem roots Server → roots/list
tasks Host supports task-augmented client requests Shared task machinery
experimental Non-standard / experimental features Varies
extensions (SDK) Named extension bags — e.g. MCP Apps under io.modelcontextprotocol/ui Server serves ui://; host renders

Server (server → host):

Capability What it means
tools Exposes callable tools (tools/list, tools/call)
resources Exposes readable resources (resources/list, resources/read; optional subscribe / listChanged)
prompts Exposes prompt templates
logging Can emit structured log messages to the client
completions Argument autocompletion
tasks Task-augmented server requests
experimental Non-standard features
extensions Named extension bags on the server side too

const currentClient = (): ClientCapabilityReport => {
if (!cachedClient) {
const clientInfo = server.getClientVersion();
if (!clientInfo && !server.getClientCapabilities()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

client capabilites should get a variable like clientInfo

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Solid idea! I am going to take this feedback into a follow up ticket here and address it in another PR so I can merge the stack hehehe

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in #397

}
return cachedClient;
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we add the client to a higher scope so that we only have to call currentClient once?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ill add it for my follow up PR! But I think* that would simplify logic for these events.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in #397

* `requestedSchema` is restricted by the spec to a flat object of primitives —
* no nesting. {@link assertElicitFormSchema} enforces that at tool construction.
*/
export async function requestElicitation(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just curious: were you able to get this to work with any hosts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I got it working with mcp inspector!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sweeeet!

Base automatically changed from danielsklyar/zel-8131-design-tokens-source-condition to main August 4, 2026 20:24
@dsklyar
dsklyar force-pushed the danielsklyar/zel-8128-client-capability-negotiation branch from ca962ee to e607993 Compare August 4, 2026 20:24
…tiation

Keep capability session wrapping on tools/list and carry forward requireSombra _meta from main.
@dsklyar
dsklyar added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit c65d41e Aug 4, 2026
5 checks passed
@dsklyar
dsklyar deleted the danielsklyar/zel-8128-client-capability-negotiation branch August 4, 2026 22:38
dsklyar added a commit that referenced this pull request Aug 5, 2026
PRs #342-#345 squash-merged, so main carries their content under new SHAs while
this branch still carries the originals. Resolved by letting main win wherever it
had evolved and keeping only this PR's own additions.

Took main for the five files this PR never touched: build-server.ts and
tools/types.ts (requireSombra landed independently and main's build-server keeps
the ui metadata from #344), build-server.test.ts, use-mcp-app.ts (#345 extracted
tool-envelope.ts during review), and tsconfig.ui.base.json.

Kept this PR's additions in the four files it owns — the ui/theme.css export and
its attw exclusion, the tailwindcss and design-tokens optional peers, the entry's
doc comment, and the tsdown copy step. package.json was resolved by hand rather
than wholesale so main's 0.7.0 -> 0.10.0 bump survived. Regenerated the lockfile.

Dropped three things the merge would otherwise have resurrected. The changesets
for #342-#344 have already been released verbatim as 0.9.0 and 0.10.0, so keeping
them would publish the same notes and bump again. `css.d.ts` in tsconfig.base.json
and `lib: DOM` in mcp-server-base/tsconfig.json are both superseded: #345 scoped
the stylesheet declaration to tsconfig.ui.base.json on purpose, so that Node
packages keep rejecting CSS imports, and src/ui is excluded from the Node project.

Verified with range-diff that this PR's commit is unchanged, and that every file
it owns is byte-identical to before the merge.
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.

2 participants