[security] Add MCP elicitation for secure preview token handling - #57
[security] Add MCP elicitation for secure preview token handling#57mattpodwysocki wants to merge 24 commits into
Conversation
- Implement token elicitation to keep tokens out of chat history - Users can provide, create, or auto-create preview tokens - Add session-level token storage to avoid repeated prompts - Support URL-restricted tokens for enhanced security - Maintain backward compatibility with direct token provision - Update README with security best practices Security improvements: - Preview tokens no longer appear in chat history via elicitation - Users can create URL-restricted tokens inline - Token caching reduces friction while maintaining security Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Critical security fix for PreviewStyleTool: - Add `public: true` flag to token creation API request body - Validate that created tokens start with 'pk.' prefix - Prevent accidental creation of secret tokens (sk.*) which should never be exposed in browser URLs This ensures preview URLs always use public tokens that can be safely shared in preview URLs without security risk. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Root cause: The Mapbox Tokens API automatically determines token type (public vs secret) based on the SCOPES requested, not an explicit parameter. Problem: - We were requesting 'styles:download' which is a SECRET scope - This forced the API to create a secret token (sk.*) instead of public (pk.*) - Secret tokens cannot be safely exposed in browser URLs Solution: - Changed scopes to only public scopes: ['styles:read', 'styles:tiles', 'fonts:read'] - These are sufficient for preview URLs and guarantee public token creation - Removed the unsupported 'public: true' parameter - Updated comments to explain the scope selection rationale Testing: Verified in MCP Inspector that auto-create now produces pk.* tokens Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
According to the MCP specification, servers must verify that the client supports elicitation capability before attempting to use elicitInput(). Changes: - Added client capability check before calling elicitPreviewToken() - Returns clear error message if client doesn't support elicitation - Suggests providing accessToken parameter directly as fallback - Prevents "Method not found" errors when client lacks capability This fixes the issue where tools using elicitation would fail on clients that don't advertise elicitation support in their capabilities. Reference: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Added documentation to clarify that MCP elicitation support varies by client: - MCP Inspector has full support for secure token elicitation - Claude Desktop does not support elicitation yet, but Claude intelligently falls back to offering token creation via create_token_tool - Other clients should check their documentation for elicitation support Changes: - Added "Note on MCP Elicitation Support" in Quick Start section - Updated PreviewStyleTool description with client-specific behavior - Clarified that tokens appear in chat history when elicitation is unavailable - Added visual indicators (✅/⚠️ ) for support status This helps users understand expected behavior based on their MCP client. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Confirmed that Cursor and VS Code both have full MCP elicitation support. Updated README to accurately reflect support status: ✅ Full support: - MCP Inspector - Cursor - VS Code (with Copilot)⚠️ Not yet supported: - Claude Desktop (falls back to create_token_tool) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Created comprehensive bug report for Goose's MCP elicitation timing issue where forms display after timeout instead of during tool execution. Added: - docs/goose-elicitation-bug-report.md - Detailed bug report for Goose team with reproduction steps, expected vs actual behavior, technical details, and suggested fix - Updated README to document Goose's known elicitation bug with link to bug report in both Quick Start and PreviewStyleTool sections Bug Summary: Goose advertises elicitation capability but displays forms after tool execution completes/times out, preventing user input. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Updated bug report and README to reference the filed GitHub issue: aaif-goose/goose#6471 This allows users and developers to track the bug status directly with the Goose team. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Bug is now tracked on GitHub at aaif-goose/goose#6471 No need to maintain a duplicate markdown file in the repo. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Added comprehensive test coverage for the new elicitation features: Token Storage Tests (test/utils/tokenElicitation.test.ts): - Store and retrieve tokens by username - Return undefined for non-existent username - Overwrite existing tokens - Store tokens for multiple users independently - Clear specific username token - Clear all tokens - Handle edge cases (empty string, special characters) PreviewStyleTool Elicitation Tests: - Error when no accessToken and no server token - Backward compatibility when accessToken provided directly Test Results: All 527 tests pass (12 new tests added) These tests ensure the elicitation feature works correctly and maintains backward compatibility with existing usage patterns. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Tested preview_style_tool directly via MCP and confirmed that Claude Code does not advertise elicitation capability. The tool correctly returns the error message we designed for clients without elicitation support. Updated README to reflect: - Claude Code:⚠️ Not yet supported (provide accessToken directly) - Grouped with Claude Desktop in the "not yet supported" category This was confirmed by calling the tool through the registered MCP server and observing the capability check work as expected. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Made accessToken optional and added useCustomToken parameter - Integrated elicitation flow with capability checks - Added token creation/listing methods with minimal public scopes - Session caching via shared previewTokenStorage - Added 2 elicitation behavior tests - Updated README with security-focused documentation - All 529 tests pass
…citation # Conflicts: # src/tools/preview-style-tool/PreviewStyleTool.ts # src/tools/style-comparison-tool/StyleComparisonTool.ts # test/tools/style-comparison-tool/StyleComparisonTool.test.ts
…licitation - Reject token creation up front when the server's access token is a temporary tk.* token (used by the hosted MCP endpoint), instead of letting the Mapbox API round-trip fail. The elicitation dialog now omits "create"/"auto-create" in that case and only offers "provide an existing token". - Move token-listing/creation off raw fetch() onto the shared HttpPipeline (constructor-injected httpRequest), consistent with other Mapbox API tools. - Document the hosted-endpoint limitation in README and CHANGELOG.
… message The tk.* prefix check only catches a literal Mapbox temporary token supplied directly (e.g. MAPBOX_ACCESS_TOKEN=tk...). It does not detect the hosted MCP endpoint's lack of tokens:write: that deployment passes through its own access token, which isn't tk.*-shaped, so the guard never fires there. Corrected the doc comments, README, and CHANGELOG, which previously stated this as a general fact about the hosted endpoint's token shape. Since the guard can't see that case, createPreviewToken() now appends a scope/permission hint to whatever error the Tokens API returns on a 401/403 (or a message containing "scope"/"permission"), steering back to "provide an existing token" instead of leaving the caller to interpret a bare API error.
…ocol Every existing elicitation test fakes tool['server'] directly and never proves the SDK's own capability negotiation and request/response plumbing works end to end. This spins up a real Streamable HTTP MCP server (session-scoped, one McpServer/transport pair per Mcp-Session-Id) and drives it with a real Client that answers elicitation/create requests, modeled on hosted-mcp-server's own request handling (bearer token from Authorization attached to the raw request as .auth). Covers, fully offline (httpRequest mocked, no real network calls): - tk.* server token: dialog trims to ["provide"], Tokens API never called - non-tk.*-shaped token (the hosted-endpoint case): dialog offers all three choices, auto-create fails against a mocked 403 and the error includes the scope/permission hint - non-tk.*-shaped token: auto-create succeeds end to end - style_comparison_tool gets the same tk.* trimming as preview_style_tool Building this surfaced a real, separate finding worth a follow-up: a first attempt used a fresh McpServer per HTTP request (the "stateless" pattern both mcp-server's scripts/dev-http-server.ts and hosted-mcp-server's src/routes/mcp.ts use), and every elicitation call failed with "client does not support elicitation" regardless of what the client declared. Server#getClientCapabilities() is only ever set on whichever Server instance processes the client's initialize request; a fresh Server per request means the tools/call request's instance never saw that handshake. Documented in the harness's doc comment; not otherwise addressed here since it isn't this PR's tool code.
buildTransport() called mcpServer.connect(transport) without awaiting it, then returned the transport for immediate use by the very next handleRequest() call. Locally the connect() promise happened to settle before that mattered; under CI's different scheduling, the first request (the client's initialize) sometimes raced ahead of the server's own wiring, and the request never got a response — observed as the style_comparison_tool test timing out after 60s with "MCP error -32001: Request timed out" while the other three tests in the same file passed. Made buildTransport async and awaited it at the call site. Ran the suite 8x locally with no failures after the fix (it never reproduced locally to begin with, consistent with a narrow scheduling-dependent race rather than a logic bug in the tools themselves).
Both tools' fallback error (shown when the client lacks the elicitation capability) named Claude Desktop and Claude Code as example clients that support MCP elicitation — exactly backwards. Per the README's own support matrix, those two are the ones that *don't* support it; only MCP Inspector, Cursor, and VS Code do. Confirmed live in Claude Desktop, where the model was relaying this text almost verbatim while correctly working around it by asking the user for a pk. token directly.
| .default(false) | ||
| .describe( | ||
| 'Mapbox public access token (required, must start with pk.* and have styles:read permission). Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs. Please use an existing public token or get one from list_tokens_tool or create one with create_token_tool with styles:read permission.' | ||
| 'Force token selection dialog even if a preview token is already stored for this session. Useful when you want to use a different token.' |
There was a problem hiding this comment.
what happens if the client does not support selection dialog? Is that a possible scenario?
There was a problem hiding this comment.
Good catch — this was actually a real bug: if the client can't show the selection dialog, useCustomToken: true was forcing the same elicitation flow anyway, which then fails the capability check and returns an error — even when a perfectly good cached token already existed. Fixed in 848963c: useCustomToken now only bypasses the cache when the client actually supports elicitation; otherwise it silently falls back to the cached token (or the existing no-elicitation error if nothing is cached).
| // No token provided - use elicitation flow | ||
| let userName: string; | ||
| try { | ||
| userName = getUserNameFromToken(serverAccessToken || ''); |
There was a problem hiding this comment.
This looks dodgy - can we trust userName? maybe token signature is not valid, would getUserNameFromToken verify that? I doubt that it can, unless it's a network call
There was a problem hiding this comment.
Fair concern — getUserNameFromToken only base64-decodes the JWT payload, no signature verification. The username itself isn't a security boundary here (Mapbox's API re-authenticates via the token value regardless of what username we put in the request path), but I realized previewTokenStorage — new in this PR — was keying its in-memory cache off that unverified claim, which is a real (if narrow) risk for anyone running this behind a gateway that doesn't pre-verify bearers the way hosted-mcp-server does. Fixed in 848963c: the cache is now keyed by a sha256 hash of the actual token string (cacheKeyFor) instead of the decoded username, so two different presented tokens can never collide in the cache regardless of what they claim to be.
… trust - useCustomToken now only forces the elicitation dialog when the client actually supports it. A client without elicitation support can't act on the flag anyway, so it silently falls back to a cached token instead of returning an avoidable "client does not support elicitation" error when a perfectly good cached token exists. (Valiunia: "what happens if the client does not support selection dialog?") - previewTokenStorage is now keyed by a sha256 hash of the server's own access token (cacheKeyFor) instead of the username decoded out of it. getUserNameFromToken never verifies a JWT's signature, so two different presented tokens could decode to the same username without this process ever confirming that independently - not exploitable via Mapbox's API itself (which re-authenticates via the token value regardless of the path segment we build), but a real, avoidable risk for this package's own in-memory cache when it's run behind a gateway that doesn't pre-verify bearers the way hosted-mcp-server's does. (Valiunia: "can we trust userName... unless it's a network call") Added regression tests for both: a cache-hit test for the useCustomToken fallback on a capability-blind client, and cacheKeyFor unit tests proving two tokens with the same decoded username produce different cache keys.
BaseTool.installTo(server) does this.server = server — mutable state on the tool instance itself. CORE_TOOLS instantiates tools once as module-level singletons, and any embedder that reuses those singletons across concurrent sessions (installTo() called again on every new connection — the pattern mcp-server's own scripts/dev-http-server.ts uses, and that hosted-mcp-server's dynamic import() caching produces too) durably clobbers this.server on every new connection: it points at whichever session connected *last*, not whichever session is making the *current* call. Before this PR, this.server was only read for logging. PreviewStyleTool and StyleComparisonTool's elicitation flow was the first thing reading it for something session-sensitive: elicitPreviewToken(this.server.server, ...). Once a second session connects, every subsequent tool call on the shared instance sends its "paste your token" prompt to that other, uninvolved session instead — an unprompted-dialog-injection and credential-exfiltration primitive, not a hypothetical. Reported via Fable/Valentin. Fix: route elicitation through extra.sendRequest instead of this.server.server.elicitInput(). extra is supplied fresh per call by the SDK, correctly scoped to whichever session actually made the current request, and can't be clobbered by another session's installTo() call. This also drops the proactive getClientCapabilities() check (not available per-call) in favor of attempting the request and catching an unsupported-client failure (ElicitationUnavailableError), which folds the earlier useCustomToken-vs-cache branching into a single attempt-then-fallback path. - tokenElicitation.ts: elicitPreviewToken() now takes extra.sendRequest and manually builds the elicitation/create request (mirroring what Server#elicitInput() does internally), validated against ElicitResultSchema. Added ElicitationUnavailableError to distinguish "client can't be asked" from "user declined." - PreviewStyleTool.ts / StyleComparisonTool.ts: added a run() override to forward extra down to execute() (BaseTool.run() only forwards the access token), removed all this.server-based capability/elicitation logic. - test/security/cross-session-elicitation-hijack.test.ts: new regression test — one shared PreviewStyleTool instance installed onto two real sessions (real Streamable HTTP, real Client/Server elicit round trip); confirms session A's own elicitation request reaches session A's client even though session B connected afterward and durably owns this.server. Failed against the pre-fix code exactly as expected (0 calls to session A's handler, 1 to session B's) before the fix, passes after. - Updated existing elicitation tests to mock extra.sendRequest instead of faking tool['server'].
…citation # Conflicts: # CHANGELOG.md
| ### Security | ||
|
|
||
| - **Cross-session elicitation hijack in `preview_style_tool` / `style_comparison_tool`** (#57): `BaseTool.installTo()` stashes the connecting session's `Server` on `this.server` — mutable state shared by the tool instance. Any deployment that reuses singleton tool instances across concurrent sessions (installing the same instance onto a new session's server on every connection) could have a tool call from one session send its "paste your token" elicitation prompt to whichever _other_ session most recently connected, with that session's response returned as the original caller's result. Fixed by routing elicitation through the per-call `extra.sendRequest` (correctly scoped to the session that made the current request) instead of the shared `this.server`. |
There was a problem hiding this comment.
is this relevant anymore?
There was a problem hiding this comment.
Agreed, and I think you're pointing at something real beyond just staleness: this bug never shipped in any released version — it was found and fixed entirely within this unreleased PR. Calling it out as a standalone "### Security" entry (the format we'd normally use for "upgrade because a shipped version had a vulnerability") could misleadingly suggest a release was affected. Folded it into the elicitation feature bullet instead in c2f49b5 — no separate Security section.
|
|
||
| ### New Features | ||
|
|
||
| - **Secure token elicitation for `preview_style_tool` / `style_comparison_tool`** (#57): `accessToken` is now optional on both tools. When omitted, the tool uses MCP elicitation to ask for a public token instead — either pasting an existing `pk.*` token, creating a new one with optional URL restrictions, or auto-creating a minimally-scoped one (`styles:read`, `styles:tiles`, `fonts:read`). This keeps your server's `sk.*`/`pk.*` access token out of chat history and preview URLs. Falls back to requiring `accessToken` directly on clients without elicitation support (Claude Desktop, Claude Code). A chosen/created token is cached in memory per account for the session; pass `useCustomToken: true` to force re-selection. |
There was a problem hiding this comment.
Nit: I think instead of cached in memory per account for the session would be more accurate to say cached in memory per token ?
There was a problem hiding this comment.
You're right, that's more accurate — the cache key is a sha256 of the literal server access token (cacheKeyFor), not a resolved account identity, so "per token" describes it correctly and "per account" doesn't. Fixed in c2f49b5.
| @@ -8,6 +9,23 @@ import { | |||
| PreviewStyleInput | |||
| } from './PreviewStyleTool.input.schema.js'; | |||
| import { getUserNameFromToken } from '../../utils/jwtUtils.js'; | |||
There was a problem hiding this comment.
can we name getUserNameFromToken to getUntrustedUserNameFromToken or something? I fear if we can shoot ourselves in the foot in future by making assumptions and it slipping past peer review
There was a problem hiding this comment.
Agreed on the risk, but getUserNameFromToken is used in 12 files across the repo (jwtUtils.ts itself plus 10 other tools and their tests) — most of them untouched by this PR. Renaming it here would significantly widen this diff for something orthogonal to the elicitation work, and this PR is already large and waiting on approval. Strengthened the doc comment on the function in c2f49b5 to explicitly call out that it doesn't verify the signature and shouldn't be used for authorization decisions or sensitive cache keys (with a pointer to cacheKeyFor as the pattern to use instead when that matters). Happy to open a fast-follow PR for the actual rename across all call sites if you'd still like it done — let me know.
- CHANGELOG: fold the cross-session-hijack fix into the elicitation feature bullet instead of a standalone "### Security" entry, and fix "cached in memory per account" to "per token" (the cache key is a hash of the literal server access token, not a resolved account identity). Neither the bug nor the fix ever shipped in a released version, so a disclosure-style Security section overstated it. (Valiunia) - jwtUtils.ts: strengthened getUserNameFromToken's doc comment to explicitly call out that it doesn't verify the token signature and shouldn't be used for authorization decisions or sensitive cache keys. The function is used in 12 files across the repo, so a full rename (as suggested) is being proposed as a fast-follow rather than done here. (Valiunia)
Feedback relayed via Slack from a separate Claude review session (via Valentin), verified against the actual SDK/code before acting: 1. sendRequest was called with no timeout option, so it used the SDK's DEFAULT_REQUEST_TIMEOUT_MSEC (60s) implicitly. Calling preview_style_tool with no accessToken and never answering the elicitation held that call (and its session's live connection) pending for the full duration; a flood of these ties up resources for as long as the timeout, and silently changes if the SDK's default ever changes. Now passes an explicit `timeout` matching the current default, decoupling our behavior from the SDK's constant. 2. minLength/maxLength on the elicitation dialog's requestedSchema are hints for the client's own form UI, not a security boundary — nothing enforced them server-side. A client (malicious, or just not honoring the hints) could return an arbitrarily large token/ tokenNote/urlRestrictions value, which then sat in previewTokenStorage indefinitely. Added maxLength to the schema hint and, more importantly, server-side validation of whatever comes back before it's accepted or cached. 3. (Flagged by the reviewer as "probably not an issue, but just in case"): previewTokenStorage's cache key is sha256(caller's bearer token), so minting N distinct cache entries requires N distinct bearer values. Confirmed low-risk: in stdio mode the value is one fixed MAPBOX_ACCESS_TOKEN; on the hosted deployment, hosted-mcp-server's bearerAuth middleware verifies the token before it becomes extra.authInfo.token, so an attacker would need N real, verified credentials — impractical at DoS scale (confirmed by reading hosted-mcp-server's bearerAuth.ts directly). No code change needed for this specific vector, but added a bounded LRU cap (1000 entries) to previewTokenStorage regardless, since "nothing ever evicts" is the root cause underlying all three findings and is worth fixing as basic hygiene independent of exploitability — a long-lived multi-tenant deployment accumulating real distinct users over time hits the same unbounded-growth problem with zero adversarial intent involved. Added regression tests for the timeout option, the three size-limit rejections, and LRU eviction (including that reads protect an entry from eviction).
Summary
Implements MCP elicitation for secure token management in
preview_style_toolandstyle_comparison_tool, following the principle of least privilege. Elicitation ensures that only minimal-scope public tokens (pk.) appear in preview URLs, while your powerful server token (sk.) stays secure.Update: Rebase onto
main, HttpPipeline, and Hosted Endpoint HandlingThis branch sat for a while, so it's been rebased onto current
mainand hardened before merge (see details below for the full file list):main- resolved conflicts against the path-traversal fix (encodeURIComponentaround username/styleId), the sharedstyleIdSchema, the now-unconditional MCP-UImeta/CSP block, and_meta.viewUUID.style_comparison_toolelicitation (described throughout this PR) is now actually implemented to match the description below - it wasn't in the original commits.fetch()- token listing/creation now go through the sharedHttpPipeline(constructor-injectedhttpRequest), matching every other Mapbox-API-calling tool in this repo, and consolidated the duplicated listing/creation logic from both tools into shared functions intokenElicitation.ts. This is a constructor signature change for direct consumers - see Breaking Changes.tokens:writeon the caller's own access token. A literal Mapbox temporary token (tk.*) never has that scope, soisTemporaryServerToken()detects that shape up front and trims "create"/"auto-create" from the elicitation dialog rather than offering options that are guaranteed to fail. That check is necessarily narrow, though: the hosted MCP endpoint itself authenticates with its own access token (not a Mapboxpk.*/sk.*/tk.*token), which also lackstokens:writebut isn't detectable from its shape. For that case, "create"/"auto-create" still fail against the Tokens API, but the error now includes a scope/permission hint pointing back to "provide an existing token" instead of a bare API error.create_token_toolis disabled entirely on the hosted endpoint (unrelated to this PR).test/integration/elicitationOverHttp.test.ts) - every other elicitation test fakestool['server']directly; this one spins up an actual Streamable HTTPMcpServerand drives it with a realClientthat answerselicitation/createrequests, covering the tk.* trim, the non-tk.*-shaped-token/scope-hint path, and a full auto-create success, for both tools.httpRequestis still mocked, so this stays fully offline.McpServerper HTTP request - the same "stateless" pattern hosted-mcp-server's ownsrc/routes/mcp.tsuses (sessionIdGenerator: undefined, fresh server per POST) - and every elicitation call failed with "client does not support elicitation" regardless of what the client declared.Server#getClientCapabilities()is only ever populated on whicheverServerinstance processes the client'sinitializerequest; a fresh server per request means thetools/callrequest lands on an instance that never saw that handshake. If hosted-mcp-server's production deployment works this way, elicitation may not be functional there at all, independent of the tk.* issue above - that's hosted-mcp-server's architecture, not this PR's tool code, so it's just documented in the test's doc comment rather than addressed here.useCustomTokenno longer forces an avoidable error on clients without elicitation support when a valid cached token already exists (it now silently falls back to the cache);previewTokenStorage's cache key is now a sha256 hash of the actual server access token (cacheKeyFor) instead of the unverified username decoded out of it, sincegetUserNameFromTokennever checks a JWT's signature.Critical Fix: Cross-Session Elicitation Hijack
While reviewing this PR, a colleague ran an AI review pass (Fable) that surfaced a real vulnerability in how this PR's elicitation code uses
this.server, confirmed by tracing through the actual code and reproduced with a regression test:The bug:
BaseTool.installTo(server)doesthis.server = server— mutable state on the tool instance, not scoped to a single call.CORE_TOOLS(src/tools/toolRegistry.ts) instantiates tools once as module-level singletons. Any deployment that reuses those singleton instances across multiple concurrent sessions — installing the same tool instance onto a new session'sMcpServeron every connection, which is exactly whatCORE_TOOLS/getAllTools()being singletons implies, and what mcp-server's ownscripts/dev-http-server.tsand hosted-mcp-server's request handling both do — durably overwritesthis.serveron every new connection. It ends up pointing at whichever session connected last, not whichever session is making the current call.Before this PR,
this.serverwas only ever read for logging (harmless if stale).preview_style_tool/style_comparison_tool's elicitation flow was the first thing to read it for something session-sensitive:elicitPreviewToken(this.server.server, ...). Once a second session connects, every subsequent tool call on the shared instance — including one made over an entirely different, already-established session's own connection — sends its "paste your token" elicitation prompt to that other, uninvolved session instead. Whatever that session's client submits comes back as the original caller's tool result: an unprompted-dialog-injection + credential-exfiltration primitive, not a hypothetical.The fix: elicitation is now routed through the per-call
extra.sendRequest(fromRequestHandlerExtra, supplied fresh by the SDK on every tool invocation, correctly scoped to whichever session made that request) instead ofthis.server.server.elicitInput().extraisn't available toBaseTool.execute()by default, so both tools now overriderun()to forward it down. This also removes the proactivegetClientCapabilities()check (no per-call equivalent exists) in favor of attempting the elicitation request and catching an unsupported-client failure (newElicitationUnavailableError), which conveniently also simplified theuseCustomToken-vs-cache branching from the review-feedback fix above into one attempt-then-fallback path.Regression test:
test/security/cross-session-elicitation-hijack.test.tsspins up one sharedPreviewStyleToolinstance installed onto two real sessions over real Streamable HTTP (realClient/Serverelicitation round trip, no mocked transport). Session A connects, then session B connects (reusing the same tool instance), then session A calls the tool over its own already-established connection. Pre-fix this failed exactly as predicted (session A's elicitation handler: 0 calls; session B's: 1 call). Post-fix it passes.The Security Problem
Previously,
preview_style_toolandstyle_comparison_toolrequired users to provide anaccessTokendirectly in the tool input, which created security and UX risks:The Solution: Elicitation with Least Privilege
This PR implements MCP elicitation to enforce the principle of least privilege:
How It Works
When a user calls
preview_style_toolorstyle_comparison_toolwithout providing anaccessToken:Key Security Properties:
Testing
Test Case 1: First Time Preview (Elicitation Shown)
Steps:
preview_style_toolwith onlystyleId(noaccessToken)Expected:
Screenshot locations:
Test Case 2: Subsequent Preview (Cached Token)
Steps:
preview_style_toolagain with different styleIdaccessTokenprovidedExpected:
Test Case 3: Force New Token Selection
Steps:
preview_style_toolwithuseCustomToken: trueExpected:
Test Case 4: Create Token with URL Restrictions
Steps:
preview_style_toolwith new styleId,useCustomToken: trueExpected:
Test Case 5: Backward Compatibility
Steps:
preview_style_toolwith bothstyleIdANDaccessTokenExpected:
Test Case 6: Elicitation Cancellation
Steps:
preview_style_toolwithoutaccessTokenExpected:
Test Case 7: Works in Cursor
Test Case 8: Works in VS Code
Test Case 9: Works in Claude Desktop without support for Elicitation
MCP Inspector Testing
To test in MCP Inspector:
Then:
preview_style_toolorstyle_comparison_toolin tools list{"styleId": "cmi189f9600lj01sc7evj2vhs"}or{"before": "mapbox/streets-v12", "after": "mapbox/outdoors-v12"}Key Features
🔐 Security Improvements
styles:read,styles:tiles,fonts:read🎯 User Experience
accessTokenparameteruseCustomToken: trueforces new token dialog🛠️ Implementation Details
New Files:
src/utils/tokenElicitation.ts- Elicitation dialog, session-token storage, and shared HTTP helpers (listPublicPreviewTokens,createPreviewToken,isTemporaryServerToken) used by both toolstest/utils/tokenElicitation.test.ts- Unit tests for token storage, the shared HTTP helpers, and elicitation-dialog trimming (22 tests)Modified Files:
src/tools/preview-style-tool/PreviewStyleTool.ts- Integrated elicitation flow with capability checks; now takeshttpRequestvia constructor (see Update below)src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts- MadeaccessTokenoptional, addeduseCustomTokensrc/tools/style-comparison-tool/StyleComparisonTool.ts- Integrated elicitation flow with capability checks; same constructor changesrc/tools/style-comparison-tool/StyleComparisonTool.schema.ts- MadeaccessTokenoptional, addeduseCustomTokensrc/tools/toolRegistry.ts,src/tools/index.ts- Updated to passhttpRequestinto both tools' constructorstest/tools/preview-style-tool/PreviewStyleTool.test.ts- Added elicitation behavior tests, including the tk.* dialog-trimming test (3 tests)test/tools/style-comparison-tool/StyleComparisonTool.test.ts- Added elicitation behavior tests, including the tk.* dialog-trimming test (3 tests)test/security/path-traversal.test.ts- Updated for the constructor signature changeREADME.md,CHANGELOG.md- Updated documentation (security model, hosted-endpoint limitation)Test Results
main, reworked to use the sharedHttpPipelineinstead of rawfetch, gained a real HTTP integration test, and fixed a cross-session elicitation hijack found during review; see Update and Critical Fix)Breaking Changes
None for MCP tool callers - This is fully backward compatible over the wire:
accessTokenworks exactly as beforeaccessTokenis omittedOne for direct package consumers:
PreviewStyleToolandStyleComparisonToolnow both needhttpRequestin their constructor (new PreviewStyleTool({ httpRequest })) instead of a no-arg constructor, to route the new token-listing/creation calls through the sharedHttpPipelinerather than a barefetch. The package's own pre-configured exports (import { previewStyle, styleComparison } from '@mapbox/mcp-devkit-server/tools') are updated accordingly and need no changes from consumers; only code that constructs these two classes directly is affected.Security Model
What We Protect:
Acceptable Security Trade-off: