Repository navigation
feat(mcp-server-base): negotiate client capabilities from the handshake - #342
Conversation
@transcend-io/airgap.js-types
@transcend-io/cli
@transcend-io/design-tokens
@transcend-io/internationalization
@transcend-io/privacy-types
@transcend-io/sdk
@transcend-io/type-utils
@transcend-io/utils
@transcend-io/mcp
@transcend-io/mcp-server-admin
@transcend-io/mcp-server-assessment
@transcend-io/mcp-server-base
@transcend-io/mcp-server-consent
@transcend-io/mcp-server-discovery
@transcend-io/mcp-server-docs
@transcend-io/mcp-server-dsr
@transcend-io/mcp-server-inventory
@transcend-io/mcp-server-preferences
@transcend-io/mcp-server-workflows
commit: |
a120ddd to
859fe38
Compare
859fe38 to
ca962ee
Compare
| * overlap, iteration order cannot change the answer, and | ||
| * `client-detection.test.ts` enforces it. | ||
| */ | ||
| export const HOST_PATTERNS: Readonly<Record<DetectableHost, readonly RegExp[]>> = { |
There was a problem hiding this comment.
Do you think we should add copilot?
There was a problem hiding this comment.
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 { |
| * 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 */ |
There was a problem hiding this comment.
Just curious: what other types of capabilities can be reported?
There was a problem hiding this comment.
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()) { |
There was a problem hiding this comment.
client capabilites should get a variable like clientInfo
There was a problem hiding this comment.
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
| } | ||
| return cachedClient; | ||
| }; | ||
|
|
There was a problem hiding this comment.
Should we add the client to a higher scope so that we only have to call currentClient once?
There was a problem hiding this comment.
Ill add it for my follow up PR! But I think* that would simplify logic for these events.
| * `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( |
There was a problem hiding this comment.
Just curious: were you able to get this to work with any hosts?
There was a problem hiding this comment.
I got it working with mcp inspector!
ca962ee to
e607993
Compare
…tiation Keep capability session wrapping on tools/list and carry forward requireSombra _meta from main.
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.
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.
deriveClientCapabilitiesreduces theinitializehandshake to the set we can act on, andwhatIsTheClientmapsclientInfo.nameto anMcpHostClientAsyncLocalStoragesession, read withgetMcpSession()andhasCapability()requestElicitationasks the host for a form and returnsundefinedwhen it cannot show one, rather than letting the SDK's own capability check throw and fail the tool callTRANSCEND_MCP_ASSUME_CAPABILITIEScan force a capability on for local debugging, warned about loudly at startupOnly 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_IDandMCP_APP_MIME_TYPElive incapabilities/types.tsrather 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.
tokens.cssfrom source inside the monorepoui://resources and per-capability tool variants./uisubpath withuseMcpAppdev/mcp-server-examples, the first consumer of all of itpnpm mcp:inspectwith per-request dev view HTMLpnpm mcp:new-viewscaffolderDepends 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 fulltestrun are green;mcp-server-basegoes from 29 to 33 test filesgetMcpSession()isundefinedandhasCapability()isfalseoutside a request so a directly-called handler takes its baseline pathpnpm run check:changesetpassesMade with Cursor