Skip to content

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

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

[security] Add MCP elicitation for secure preview token handling#57
mattpodwysocki wants to merge 18 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.
  • README/CHANGELOG updated to document the above.
  • All 644 tests pass (up from 529 - new coverage for the HttpPipeline refactor, the tk.* dialog-trimming behavior, and the HTTP integration 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.

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 644 tests pass (this branch was rebased onto current main, reworked to use the shared HttpPipeline instead of raw fetch, and gained a real HTTP integration test; see Update)
  • ✅ 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.
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