Skip to content

[security] Add MCP elicitation for secure preview token handling - #57

Open
mattpodwysocki wants to merge 24 commits into
mainfrom
add-preview-token-elicitation
Open

[security] Add MCP elicitation for secure preview token handling#57
mattpodwysocki wants to merge 24 commits into
mainfrom
add-preview-token-elicitation

Conversation

@mattpodwysocki

@mattpodwysocki mattpodwysocki commented Jan 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements MCP elicitation for secure token management in preview_style_tool and style_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 Handling

This branch sat for a while, so it's been rebased onto current main and hardened before merge (see details below for the full file list):

  • Rebased onto main - resolved conflicts against the path-traversal fix (encodeURIComponent around username/styleId), the shared styleIdSchema, the now-unconditional MCP-UI meta/CSP block, and _meta.viewUUID. style_comparison_tool elicitation (described throughout this PR) is now actually implemented to match the description below - it wasn't in the original commits.
  • Moved off raw fetch() - token listing/creation now go through the shared HttpPipeline (constructor-injected httpRequest), matching every other Mapbox-API-calling tool in this repo, and consolidated the duplicated listing/creation logic from both tools into shared functions in tokenElicitation.ts. This is a constructor signature change for direct consumers - see Breaking Changes.
  • Hosted MCP endpoint: creating tokens requires tokens:write on the caller's own access token. A literal Mapbox temporary token (tk.*) never has that scope, so isTemporaryServerToken() 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 Mapbox pk.*/sk.*/tk.* token), which also lacks tokens:write but 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_tool is disabled entirely on the hosted endpoint (unrelated to this PR).
  • New: real HTTP integration test (test/integration/elicitationOverHttp.test.ts) - every other elicitation test fakes tool['server'] directly; this one spins up an actual Streamable HTTP McpServer and drives it with a real Client that answers elicitation/create requests, covering the tk.* trim, the non-tk.*-shaped-token/scope-hint path, and a full auto-create success, for both tools. httpRequest is still mocked, so this stays fully offline.
    • Building it surfaced a separate, real finding: a first attempt used a fresh McpServer per HTTP request - the same "stateless" pattern hosted-mcp-server's own src/routes/mcp.ts uses (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 whichever Server instance processes the client's initialize request; a fresh server per request means the tools/call request 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.
  • Review feedback addressed (thanks @Valiunia): useCustomToken no 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, since getUserNameFromToken never checks a JWT's signature.
  • Critical: fixed a cross-session elicitation hijack - see the dedicated section below.
  • README/CHANGELOG updated to document the above.
  • All 651 tests pass (up from 529 - new coverage for the HttpPipeline refactor, the tk.* dialog-trimming behavior, the HTTP integration test, the review-feedback fixes, and the cross-session-hijack regression test), build and lint are clean. The manual client screenshots below predate this update and haven't been re-captured; see the note under Test Results.

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) does this.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's McpServer on every connection, which is exactly what CORE_TOOLS/getAllTools() being singletons implies, and what mcp-server's own scripts/dev-http-server.ts and hosted-mcp-server's request handling both do — durably overwrites this.server on every new connection. It ends up pointing at whichever session connected last, not whichever session is making the current call.

Before this PR, this.server was 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 (from RequestHandlerExtra, supplied fresh by the SDK on every tool invocation, correctly scoped to whichever session made that request) instead of this.server.server.elicitInput(). extra isn't available to BaseTool.execute() by default, so both tools now override run() to forward it down. This also removes the proactive getClientCapabilities() check (no per-call equivalent exists) in favor of attempting the elicitation request and catching an unsupported-client failure (new ElicitationUnavailableError), which conveniently also simplified the useCustomToken-vs-cache branching from the review-feedback fix above into one attempt-then-fallback path.

Regression test: test/security/cross-session-elicitation-hijack.test.ts spins up one shared PreviewStyleTool instance installed onto two real sessions over real Streamable HTTP (real Client/Server elicitation 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_tool and style_comparison_tool required users to provide an accessToken directly in the tool input, which created security and UX risks:

  • ⚠️ Risk of exposing powerful server token - Users might accidentally pass through their server token (sk.*) with write permissions
  • ⚠️ Over-privileged tokens - Users might manually provide tokens with more permissions than needed
  • ❌ No guided workflow for token selection
  • ❌ Users had to manually create and manage tokens
  • ❌ Token management friction

The Solution: Elicitation with Least Privilege

This PR implements MCP elicitation to enforce the principle of least privilege:

  • Server token stays secure - Your powerful secret token (sk.*) with write permissions NEVER appears in chat history or URLs
  • Minimal-scope public tokens - Preview URLs only contain read-only public tokens (pk.*) with scopes: styles:read, styles:tiles, fonts:read
  • Acceptable security trade-off - Even if preview URLs are shared, they only expose read-only access to styles, not your admin token

How It Works

When a user calls preview_style_tool or style_comparison_tool without providing an accessToken:

  1. Check for cached token - If a preview token was already created this session, use it
  2. Elicit token from user - Present a guided dialog with three options:
    • Provide existing token - User pastes a public token they already have
    • Create new preview token - System creates a new token with optional URL restrictions
    • Auto-create basic token - System auto-creates a simple preview token (recommended)
  3. Cache for session - Store token in memory to avoid re-prompting
  4. Generate preview/comparison URL - Use the minimal-scope public token in the URL

Key Security Properties:

  • Server token (sk.*) is only used server-side to create limited tokens via Mapbox API
  • Created tokens are always public (pk.*) with minimal read-only scopes
  • No risk of exposing write permissions (styles:write, tokens:write, etc.)

Testing

Test Case 1: First Time Preview (Elicitation Shown)

Steps:

  1. Start MCP server with valid Mapbox token
  2. Call preview_style_tool with only styleId (no accessToken)
  3. Observe elicitation dialog with three options
  4. Select "Auto-create a basic preview token for me"
  5. Verify preview URL is generated

Expected:

  • Elicitation dialog appears with token options
  • Dialog shows existing public tokens (if any)
  • After selection, preview URL is returned
  • Server token NEVER appears anywhere - only the minimal-scope public token in URL

Screenshot locations:

  • Elicitation dialog with options
Screenshot 2026-01-13 at 13 29 05
  • Preview URL result (only public token in URL)
Screenshot 2026-01-13 at 13 29 29
  • Token not in tool call log
Screenshot 2026-01-13 at 13 36 57

Test Case 2: Subsequent Preview (Cached Token)

Steps:

  1. After Test Case 1, call preview_style_tool again with different styleId
  2. No accessToken provided

Expected:

  • No elicitation dialog (token is cached)
Screenshot 2026-01-13 at 13 29 29
  • Preview URL generated immediately
Screenshot 2026-01-13 at 13 31 56
  • Same minimal-scope token used from cache

Test Case 3: Force New Token Selection

Steps:

  1. Call preview_style_tool with useCustomToken: true
  2. This time select "I have a token to provide"
  3. Paste a valid public token

Expected:

  • Elicitation dialog appears despite cache
Screenshot 2026-01-13 at 13 39 44
  • Token field accepts input
  • New token replaces cached token
Screenshot 2026-01-13 at 13 39 53

Test Case 4: Create Token with URL Restrictions

Steps:

  1. Call preview_style_tool with new styleId, useCustomToken: true
  2. Select "Create a new preview token with custom settings"
  3. Enter token name: "Test Preview Token"
  4. Enter URL restrictions: "https://example.com/*,https://test.com/*"

Expected:

  • New token created via Mapbox API with minimal scopes
Screenshot 2026-01-13 at 13 43 07
  • Token includes URL restrictions for additional security
Screenshot 2026-01-13 at 13 43 07
  • Preview URL generated with new token
Screenshot 2026-01-13 at 13 42 38

Test Case 5: Backward Compatibility

Steps:

  1. Call preview_style_tool with both styleId AND accessToken

Expected:

  • No elicitation dialog (token provided directly)
Screenshot 2026-01-13 at 13 48 46
  • Works exactly like before this PR
  • Preview URL generated with provided token
Screenshot 2026-01-13 at 13 48 53

Test Case 6: Elicitation Cancellation

Steps:

  1. Call preview_style_tool without accessToken
  2. When elicitation dialog appears, click "Cancel" or "Decline"

Expected:

  • Tool returns error: "Token elicitation was cancelled or declined by user"
Screenshot 2026-01-13 at 13 52 25 - No preview URL generated - Graceful error handling

Test Case 7: Works in Cursor

  • Run Elicitation
Screenshot 2026-01-13 at 11 14 05 - Get result Screenshot 2026-01-13 at 11 09 45

Test Case 8: Works in VS Code

  • Run Elicitation
Screenshot 2026-01-13 at 13 56 12
  • Get result
Screenshot 2026-01-13 at 13 56 31

Test Case 9: Works in Claude Desktop without support for Elicitation

  • Run tool
Screenshot 2026-01-13 at 13 57 42
  • Ask to create a token for you
Screenshot 2026-01-13 at 13 58 34

MCP Inspector Testing

To test in MCP Inspector:

npm run inspect:build

Then:

  1. Connect to the server in MCP Inspector
  2. Find preview_style_tool or style_comparison_tool in tools list
  3. Call with: {"styleId": "cmi189f9600lj01sc7evj2vhs"} or {"before": "mapbox/streets-v12", "after": "mapbox/outdoors-v12"}
  4. Observe elicitation dialog in Inspector UI
  5. Test all three token options

Key Features

🔐 Security Improvements

  • Principle of least privilege - Only minimal-scope read-only tokens (pk.*) appear in URLs
  • Server token protection - Powerful server token (sk.*) with write permissions never exposed
  • Reduced blast radius - Even if preview URL is shared, only read-only access to styles is exposed
  • URL-restricted tokens - Users can create tokens that only work on specific domains
  • Minimal scopes - Auto-created tokens only get styles:read, styles:tiles, fonts:read

🎯 User Experience

  • Guided workflow - Three clear options for token management (provide, create, or auto-create)
  • Session caching - Token only requested once per session
  • Smart defaults - Auto-create option requires zero configuration
  • No repeated prompts - Session storage remembers choice
  • Backward compatible - Can still provide token directly via accessToken parameter
  • Force re-selection - useCustomToken: true forces new token dialog
  • Client capability checks - Gracefully falls back for clients without elicitation support

🛠️ Implementation Details

New Files:

  • src/utils/tokenElicitation.ts - Elicitation dialog, session-token storage, and shared HTTP helpers (listPublicPreviewTokens, createPreviewToken, isTemporaryServerToken) used by both tools
  • test/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 takes httpRequest via constructor (see Update below)
  • src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts - Made accessToken optional, added useCustomToken
  • src/tools/style-comparison-tool/StyleComparisonTool.ts - Integrated elicitation flow with capability checks; same constructor change
  • src/tools/style-comparison-tool/StyleComparisonTool.schema.ts - Made accessToken optional, added useCustomToken
  • src/tools/toolRegistry.ts, src/tools/index.ts - Updated to pass httpRequest into both tools' constructors
  • test/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 change
  • README.md, CHANGELOG.md - Updated documentation (security model, hosted-endpoint limitation)

Test Results

  • ✅ All 651 tests pass (this branch was rebased onto current main, reworked to use the shared HttpPipeline instead of raw fetch, gained a real HTTP integration test, and fixed a cross-session elicitation hijack found during review; see Update and Critical Fix)
  • ✅ Build succeeds with no type errors, lint clean
  • ✅ Backward compatibility maintained
  • ✅ Manual client testing (MCP Inspector, Cursor, VS Code, Claude Desktop) below is from the original implementation and has been re-run since the rebase/refactor — behavior for the "provide" and "backward compatible" paths is unchanged.

Breaking Changes

None for MCP tool callers - This is fully backward compatible over the wire:

  • Existing code providing accessToken works exactly as before
  • New behavior only activates when accessToken is omitted
  • Clients without elicitation support work via capability checks

One for direct package consumers: PreviewStyleTool and StyleComparisonTool now both need httpRequest in their constructor (new PreviewStyleTool({ httpRequest })) instead of a no-arg constructor, to route the new token-listing/creation calls through the shared HttpPipeline rather than a bare fetch. 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:

  • ✅ Server token (sk.*) with write permissions never appears in chat history or URLs
  • ✅ Only minimal-scope public tokens (pk.*) with read-only access can appear in URLs
  • ✅ Enforces principle of least privilege automatically

Acceptable Security Trade-off:

  • Public tokens (pk.*) with read-only scopes appear in preview URLs
  • This is acceptable because:
    • They only grant read access to styles/tiles (no write permissions)
    • URL restrictions can further limit where tokens work
    • Much safer than exposing the server token with admin permissions

- 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>
@mattpodwysocki
mattpodwysocki requested a review from a team as a code owner January 13, 2026 15:01
mattpodwysocki and others added 6 commits January 13, 2026 10:30
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>
mattpodwysocki and others added 4 commits January 13, 2026 11:47
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
jussi-sa
jussi-sa previously approved these changes Mar 25, 2026
…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.
@mattpodwysocki
mattpodwysocki requested a review from jussi-sa July 31, 2026 14:54
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.'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

what happens if the client does not support selection dialog? Is that a possible scenario?

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.

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 || '');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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.

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'].
Comment thread CHANGELOG.md Outdated
Comment on lines +8 to +10
### 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`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is this relevant anymore?

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.

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.

Comment thread CHANGELOG.md Outdated

### 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: I think instead of cached in memory per account for the session would be more accurate to say cached in memory per token ?

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.

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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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.

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).
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