Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

### Fixed

- **Tracing**: Access tokens are no longer included in exported spans. Mapbox APIs take the access token as a URL query parameter, and OpenTelemetry's HTTP/undici auto-instrumentation records the full request URL on client spans (`url.full`, `url.query`), so operators who configured `OTEL_EXPORTER_OTLP_ENDPOINT` had tokens copied verbatim into their telemetry backend. The trace exporter is now wrapped in a `RedactingSpanExporter` that strips the token signature from all string span attributes before export. Redaction keeps the token prefix and account name — `pk.eyJ1...xyz.signature` becomes `pk.your-account.redacted` — so spans still distinguish public from secret tokens and show which account a request billed to, without carrying a usable credential. Values that do not parse as a Mapbox token fall back to `access_token=***`.

- **map_matching_tool**: When the Map Matching API can't match a trace (e.g. `code: "NoMatch"` for distant/unmatchable coordinates), the tool now returns a clear `isError` text result instead of crashing with `MCP error -32602: Output validation error` — the API omits `tracepoints`/`matchings` in this case, which previously violated the tool's output schema and was returned as `structuredContent` anyway, triggering the MCP SDK's output validation. The same schema-violating `structuredContent` could also be returned for a `code: "Ok"` response that otherwise failed schema validation (e.g. a `confidence` out of range); that fallback now also returns a graceful `isError` result instead of the raw invalid payload. `tracepoints` and `matchings` are also now `.optional()` in the output schema as a defensive measure. (AGI-1021)

## 0.12.7 - 2026-07-20
Expand Down
15 changes: 10 additions & 5 deletions docs/tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ This MCP server uses **stdio transport exclusively** for communication. Console

### Security & Performance

- **Sensitive Data Protection**: Input parameters logged by size only, not content
- **Sensitive Data Protection**: Input parameters logged by size only, not content; access token signatures are stripped from span attributes before export
- **Minimal Overhead**: <1% CPU impact, ~10MB memory for trace buffers
- **Configurable Sampling**: Support for production trace volume management
- **Graceful Fallback**: No impact on functionality when tracing is disabled
Expand Down Expand Up @@ -137,13 +137,11 @@ The server supports **any OTLP-compatible observability backend**. Configuration
### Production Cloud Providers

- **AWS X-Ray**: AWS-native distributed tracing

- Endpoint: AWS Distro for OpenTelemetry Collector
- Auth: IAM credentials
- [Setup Guide](https://aws-otel.github.io/docs/getting-started/collector)

- **Azure Monitor**: Azure Application Insights

- Endpoint: `https://<region>.livediagnostics.monitor.azure.com`
- Auth: Connection string or AAD token
- [Setup Guide](https://learn.microsoft.com/en-us/azure/azure-monitor/app/opentelemetry-enable)
Expand All @@ -156,13 +154,11 @@ The server supports **any OTLP-compatible observability backend**. Configuration
### Production SaaS Observability Platforms

- **Datadog**: Full-stack observability platform

- Endpoint: `https://api.datadoghq.com/api/v2/traces` or local agent
- Auth: API key
- [Setup Guide](https://docs.datadoghq.com/tracing/trace_collection/opentelemetry/)

- **New Relic**: Application performance monitoring

- Endpoint: `https://otlp.nr-data.net:4318` (US) or `https://otlp.eu01.nr-data.net:4318` (EU)
- Auth: License key
- [Setup Guide](https://docs.newrelic.com/docs/more-integrations/open-source-telemetry-integrations/opentelemetry/opentelemetry-setup/)
Expand Down Expand Up @@ -352,6 +348,15 @@ getNodeAutoInstrumentations({
### Data Privacy

- **Input sanitization**: Only input/output sizes are logged, not content
- **Access tokens**: Mapbox APIs take the access token as a URL query parameter, and HTTP
auto-instrumentation records request URLs on client spans (`url.full`, `url.query`). Every
span passes through a redacting exporter that strips the token signature from all string
attributes before anything is sent to your trace backend, so usable credentials are not
copied into your telemetry backend. The token prefix and account name are kept, so a span
shows `access_token=pk.your-account.redacted` — enough to tell a public token from a secret
one and see which account a request billed to, without the part that authenticates it.
Tokens that do not parse as `<prefix>.<payload>.<signature>` with a `pk`/`sk`/`tk` prefix
and an account name in the payload are replaced wholesale with `access_token=***`
- **JWT validation**: Basic format validation only, no secret verification
- **Error messages**: Error details are logged but sensitive data is protected

Expand Down
3 changes: 2 additions & 1 deletion src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import type { z } from 'zod';
import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js';
import type { HttpRequest } from '../../utils/types.js';
import { redactToken } from '../../utils/redactToken.js';
import { SearchAndGeocodeInputSchema } from './SearchAndGeocodeTool.input.schema.js';
import {
SearchBoxResponseSchema,
Expand Down Expand Up @@ -174,7 +175,7 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool<

this.log(
'info',
`SearchAndGeocodeTool: Fetching from URL: ${url.toString().replace(accessToken, '[REDACTED]')}`
`SearchAndGeocodeTool: Fetching from URL: ${redactToken(url.toString())}`
);

const response = await this.httpRequest(url.toString());
Expand Down
6 changes: 6 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export {
// Export types
export type { HttpRequest, TracedRequestInit } from './types.js';

// Export tracing helpers for consumers running their own OpenTelemetry setup.
// Wrap your span exporter in RedactingSpanExporter to keep Mapbox access tokens,
// which travel as a URL query parameter, out of exported span attributes.
export { RedactingSpanExporter } from './redactingSpanExporter.js';
export { redactToken } from './redactToken.js';

// Export version utilities
export { getVersionInfo } from './versionUtils.js';
export type { VersionInfo } from './versionUtils.js';
63 changes: 63 additions & 0 deletions src/utils/redactToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

/** Token prefixes Mapbox uses: public, secret, and temporary. */
const TOKEN_PREFIXES = new Set(['pk', 'sk', 'tk']);

/**
* Character allowlist for an account name lifted out of a token payload, matching the
* username validation in StyleComparisonTool. This bounds what a token payload can put
* into a span attribute or log line; it is not a statement of Mapbox's account naming
* rules. A name outside it is not partially disclosed — it falls back to `***`.
*/
Comment on lines +8 to +12

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.

Suggested change
* Character allowlist for an account name lifted out of a token payload, matching the
* username validation in StyleComparisonTool. This bounds what a token payload can put
* into a span attribute or log line; it is not a statement of Mapbox's account naming
* rules. A name outside it is not partially disclosed it falls back to `***`.
*/
/**
* Character allowlist for an account name lifted out of a token payload. This
* bounds what a token payload can put into a span attribute or log line the
* value comes from decoding untrusted JWT payload data, so the allowlist is a
* safety gate against log/span injection and oversized values, not a
* statement of Mapbox's actual account naming rules. A name outside it is not
* partially disclosed it falls back to `***`.
*/

Comment on lines +7 to +12

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 is stale in this repo — StyleComparisonTool doesn't exist in mcp-server (only in mcp-devkit-server, where this comment originated). Suggested wording that keeps the substance without the dangling cross-repo reference:

Suggested change
/**
* Character allowlist for an account name lifted out of a token payload, matching the
* username validation in StyleComparisonTool. This bounds what a token payload can put
* into a span attribute or log line; it is not a statement of Mapbox's account naming
* rules. A name outside it is not partially disclosed it falls back to `***`.
*/
/**
* Character allowlist for an account name lifted out of a token payload. This
* bounds what a token payload can put into a span attribute or log line the
* value comes from decoding untrusted JWT payload data, so the allowlist is a
* safety gate against log/span injection and oversized values, not a
* statement of Mapbox's actual account naming rules. A name outside it is not
* partially disclosed it falls back to `***`.
*/

const ACCOUNT_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;

/**
* Replace a token value with a placeholder that keeps the parts safe to publish.
*
* Mapbox tokens are JWTs (`<prefix>.<payload>.<signature>`) whose payload carries
* the account name under `u`, so `pk.eyJ1IjoiZXhhbXBsZSJ9.signature` becomes
* `pk.example.redacted`. Keeping the prefix and account name makes traces and logs
* readable — you can still tell a secret token from a public one, and whose account
* a request billed to — while the signature, which is the part that authenticates,
* never leaves the process.
*
* Anything that does not parse cleanly as such a token falls back to `***`, so an
* unrecognized shape is never partially disclosed on the assumption it was harmless.
*/
function maskTokenValue(token: string): string {
const parts = token.split('.');
if (parts.length !== 3) {
return '***';
}

const [prefix, payload] = parts;
if (!TOKEN_PREFIXES.has(prefix)) {
return '***';
}

try {
const decoded = JSON.parse(
Buffer.from(payload, 'base64').toString('utf-8')
) as { u?: unknown };

if (
typeof decoded.u !== 'string' ||
!ACCOUNT_NAME_PATTERN.test(decoded.u)
) {
return '***';
}

return `${prefix}.${decoded.u}.redacted`;
} catch {
return '***';
}
}

/** Remove access_token query parameter values from strings before logging or returning to callers. */
export function redactToken(s: string): string {
return s.replace(
/access_token=([^&\s#"']+)/g,
(_match, token: string) => `access_token=${maskTokenValue(token)}`
);
}
119 changes: 119 additions & 0 deletions src/utils/redactingSpanExporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base';
import type { ExportResult } from '@opentelemetry/core';
import type { Attributes } from '@opentelemetry/api';
import { redactToken } from './redactToken.js';

/**
* Rewrites every string attribute value through `redactToken`, returning the
* original object when nothing changed so unaffected spans pass through as-is.
*/
function redactAttributes(attributes: Attributes): Attributes {
let redacted: Attributes | undefined;

for (const [key, value] of Object.entries(attributes)) {
if (typeof value !== 'string') {
continue;
}
const clean = redactToken(value);
if (clean !== value) {
redacted ??= { ...attributes };
redacted[key] = clean;
}
}

return redacted ?? attributes;
}

/**
* Collect every property name reachable on a span, including accessors defined
* on its prototype chain. Copying by key list rather than a hardcoded field list
* keeps this working across OpenTelemetry SDK versions, which have moved fields
* (e.g. `parentSpanId` to `parentSpanContext`) between releases.
*/
function collectKeys(span: ReadableSpan): Set<string> {
const keys = new Set<string>();

for (
let current: object | null = span;
current && current !== Object.prototype;
current = Object.getPrototypeOf(current)
) {
for (const key of Object.getOwnPropertyNames(current)) {
if (key !== 'constructor') {
keys.add(key);
}
}
}

return keys;
}

/**
* Return a copy of `span` with redacted attributes, or the span itself when no
* attribute needed redaction. The copy is a plain object rather than a mutation
* of the original, so the SDK's own span state is left untouched.
*/
function redactSpan(span: ReadableSpan): ReadableSpan {
const attributes = redactAttributes(span.attributes);
const events = span.events.map((event) =>
event.attributes
? { ...event, attributes: redactAttributes(event.attributes) }
: event
);

const attributesChanged = attributes !== span.attributes;
const eventsChanged = events.some(
(event, index) => event !== span.events[index]
);

if (!attributesChanged && !eventsChanged) {
return span;
}

const copy: Record<string, unknown> = {};
for (const key of collectKeys(span)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic property copy across unknown SDK span shapes
const value = (span as any)[key];
copy[key] = typeof value === 'function' ? value.bind(span) : value;
}
copy.attributes = attributes;
copy.events = events;

return copy as unknown as ReadableSpan;
}

/**
* Wraps a SpanExporter and strips access token signatures from span attributes
* before they leave the process, leaving behind the prefix and account name
* (`access_token=pk.some-account.redacted`).
*
* Auto-instrumentation of `fetch`/`undici` records the full request URL on client
* spans (`url.full`, `url.query`), and Mapbox APIs take the access token as a
* query parameter. Without this wrapper, an operator who configures an OTLP
* endpoint gets those tokens copied verbatim into their telemetry backend.
*
* Redaction happens at the exporter rather than in an instrumentation
* `requestHook` so it covers every attribute on every span, including attribute
* names introduced by future semantic-convention or instrumentation changes.
*/
export class RedactingSpanExporter implements SpanExporter {
constructor(private readonly delegate: SpanExporter) {}

export(
spans: ReadableSpan[],
resultCallback: (result: ExportResult) => void
): void {
this.delegate.export(spans.map(redactSpan), resultCallback);
}

shutdown(): Promise<void> {
return this.delegate.shutdown();
}

forceFlush(): Promise<void> {
return this.delegate.forceFlush?.() ?? Promise.resolve();
}
}
5 changes: 4 additions & 1 deletion src/utils/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
DiagLogLevel
} from '@opentelemetry/api';
import { getVersionInfo } from './versionUtils.js';
import { RedactingSpanExporter } from './redactingSpanExporter.js';
import { ATTR_SERVICE_INSTANCE_ID } from '@opentelemetry/semantic-conventions/incubating';
import { type HttpRequest } from './types.js';

Expand Down Expand Up @@ -207,7 +208,9 @@ export async function initializeTracing(): Promise<void> {
// Create SDK instance
sdk = new NodeSDK({
resource,
traceExporter: exporters[0],
// Wrap the exporter so access tokens recorded in HTTP client span
// attributes (url.full, url.query) never reach the trace backend
traceExporter: new RedactingSpanExporter(exporters[0]),
instrumentations: [
getNodeAutoInstrumentations({
// Disable instrumentations that might be too noisy
Expand Down
92 changes: 51 additions & 41 deletions src/utils/versionUtils-cjs.cjs
Original file line number Diff line number Diff line change
@@ -1,48 +1,58 @@
"use strict";
'use strict';

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 file shouldn't be included

// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
var __importDefault =
(this && this.__importDefault) ||
function (mod) {
return mod && mod.__esModule ? mod : { default: mod };
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.getVersionInfo = getVersionInfo;
const node_fs_1 = require("node:fs");
const node_path_1 = __importDefault(require("node:path"));
const node_fs_1 = require('node:fs');
const node_path_1 = __importDefault(require('node:path'));
function getVersionInfo() {
const name = 'Mapbox MCP server';
const name = 'Mapbox MCP server';
try {
const dirname = __dirname;
// Try to read from version.json first (for build artifacts)
const versionJsonPath = node_path_1.default.resolve(
dirname,
'..',
'version.json'
);
try {
const dirname = __dirname;
// Try to read from version.json first (for build artifacts)
const versionJsonPath = node_path_1.default.resolve(dirname, '..', 'version.json');
try {
const versionData = (0, node_fs_1.readFileSync)(versionJsonPath, 'utf-8');
const info = JSON.parse(versionData);
info['name'] = name;
return info;
}
catch {
// Fall back to package.json
const packageJsonPath = node_path_1.default.resolve(dirname, '..', '..', '..', 'package.json');
const packageData = (0, node_fs_1.readFileSync)(packageJsonPath, 'utf-8');
const packageInfo = JSON.parse(packageData);
return {
name: name,
version: packageInfo.version || '0.0.0',
sha: 'unknown',
tag: 'unknown',
branch: 'unknown'
};
}
}
catch (error) {
console.warn(`Failed to read version info: ${error}`);
return {
name: name,
version: '0.0.0',
sha: 'unknown',
tag: 'unknown',
branch: 'unknown'
};
const versionData = (0, node_fs_1.readFileSync)(versionJsonPath, 'utf-8');
const info = JSON.parse(versionData);
info['name'] = name;
return info;
} catch {
// Fall back to package.json
const packageJsonPath = node_path_1.default.resolve(
dirname,
'..',
'..',
'..',
'package.json'
);
const packageData = (0, node_fs_1.readFileSync)(packageJsonPath, 'utf-8');
const packageInfo = JSON.parse(packageData);
return {
name: name,
version: packageInfo.version || '0.0.0',
sha: 'unknown',
tag: 'unknown',
branch: 'unknown'
};
}
} catch (error) {
console.warn(`Failed to read version info: ${error}`);
return {
name: name,
version: '0.0.0',
sha: 'unknown',
tag: 'unknown',
branch: 'unknown'
};
}
}
//# sourceMappingURL=versionUtils-cjs.cjs.map
//# sourceMappingURL=versionUtils-cjs.cjs.map
Loading
Loading