Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Unreleased

### Changed

- **Temporary resources** (`mapbox://temp/...`) are now scoped to the account that created them: a read by a different account returns the standard not-found response. Token resolution mirrors the tools (request auth, then the env token for stdio/single-user), so local reads are unaffected. Adds regression tests.

## 0.12.2-dev - 2026-06-10

### Security
Expand Down
55 changes: 44 additions & 11 deletions src/resources/temporary/TemporaryDataResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@
// Licensed under the MIT License.

import { BaseResource } from '../BaseResource.js';
import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type {
ReadResourceResult,
ServerNotification,
ServerRequest
} from '@modelcontextprotocol/sdk/types.js';
import { temporaryResourceManager } from '../../utils/temporaryResourceManager.js';
import { getUserNameFromToken } from '../../utils/jwtUtils.js';

/**
* Resource for temporary data storage (large tool responses).
Expand All @@ -16,19 +22,46 @@ export class TemporaryDataResource extends BaseResource {
'Temporary storage for large tool responses. Data expires after TTL.';
readonly mimeType = 'application/json';

async read(uri: string): Promise<ReadResourceResult> {
async read(
uri: string,
extra?: RequestHandlerExtra<ServerRequest, ServerNotification>
): Promise<ReadResourceResult> {
const notFound: ReadResourceResult = {
contents: [
{
uri: uri,
mimeType: 'text/plain',
text: 'Resource not found or expired. Temporary resources have a 30-minute TTL.'
}
]
};

const resource = temporaryResourceManager.get(uri);

if (!resource) {
return {
contents: [
{
uri: uri,
mimeType: 'text/plain',
text: 'Resource not found or expired. Temporary resources have a 30-minute TTL.'
}
]
};
return notFound;
}

// Enforce per-account ownership: only the account that created the resource
// may read it. Resolve the requester the same way tools resolve their token
// (request auth first, then the env token). A mismatch returns the SAME
// "not found" response as a missing resource, so a caller cannot probe
// whether someone else's resource exists.
//
// The env-token fallback is for stdio/single-user mode only. In hosted
// multi-tenant deployments MAPBOX_ACCESS_TOKEN is NOT set and every request
// carries its own bearer token via authInfo; the request token therefore
// always wins and the env fallback never applies cross-tenant. Do not set a
// shared MAPBOX_ACCESS_TOKEN in a multi-tenant deployment or all env-less
// reads would resolve to that one account.
const requesterToken =
(extra?.authInfo?.token as string | undefined) ??
process.env.MAPBOX_ACCESS_TOKEN;
const requester = requesterToken
? getUserNameFromToken(requesterToken)
: undefined;
if (!resource.owner || !requester || resource.owner !== requester) {
return notFound;
}

// For image data, return as blob
Expand Down
10 changes: 7 additions & 3 deletions src/tools/directions-tool/DirectionsTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from './DirectionsTool.output.schema.js';
import type { HttpRequest } from '../..//utils/types.js';
import { temporaryResourceManager } from '../../utils/temporaryResourceManager.js';
import { getUserNameFromToken } from '../../utils/jwtUtils.js';
import { isMcpUiEnabled } from '../../config/toolConfig.js';
import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js';
import { renderDirectionsAppHtml } from '../../resources/ui-apps/directionsAppHtml.js';
Expand Down Expand Up @@ -297,9 +298,12 @@ export class DirectionsTool extends MapboxApiBasedTool<
const resourceId = randomBytes(16).toString('hex');
const resourceUri = `mapbox://temp/directions-${resourceId}`;

temporaryResourceManager.create(resourceId, resourceUri, validatedData, {
toolName: this.name,
size: responseSize
temporaryResourceManager.create({
id: resourceId,
uri: resourceUri,
data: validatedData,
metadata: { toolName: this.name, size: responseSize },
owner: getUserNameFromToken(accessToken)
});

// Extract summary information
Expand Down
10 changes: 7 additions & 3 deletions src/tools/isochrone-tool/IsochroneTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type IsochroneResponse
} from './IsochroneTool.output.schema.js';
import { temporaryResourceManager } from '../../utils/temporaryResourceManager.js';
import { getUserNameFromToken } from '../../utils/jwtUtils.js';

export class IsochroneTool extends MapboxApiBasedTool<
typeof IsochroneInputSchema,
Expand Down Expand Up @@ -161,9 +162,12 @@ export class IsochroneTool extends MapboxApiBasedTool<
const resourceId = randomBytes(16).toString('hex');
const resourceUri = `mapbox://temp/isochrone-${resourceId}`;

temporaryResourceManager.create(resourceId, resourceUri, data, {
toolName: this.name,
size: responseSize
temporaryResourceManager.create({
id: resourceId,
uri: resourceUri,
data,
metadata: { toolName: this.name, size: responseSize },
owner: getUserNameFromToken(accessToken)
});

const contourCount =
Expand Down
17 changes: 9 additions & 8 deletions src/tools/static-map-image-tool/StaticMapImageTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { StaticMapImageInputSchema } from './StaticMapImageTool.input.schema.js'
import type { OverlaySchema } from './StaticMapImageTool.input.schema.js';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { temporaryResourceManager } from '../../utils/temporaryResourceManager.js';
import { getUserNameFromToken } from '../../utils/jwtUtils.js';

// Images larger than this threshold are stored as temporary resources instead
// of being inlined as base64, to avoid exceeding Claude Desktop's 1MB tool
Expand Down Expand Up @@ -134,14 +135,14 @@ export class StaticMapImageTool extends MapboxApiBasedTool<
const resourceId = randomBytes(16).toString('hex');
const resourceUri = `mapbox://temp/static-map-${resourceId}`;
const base64Data = Buffer.from(buffer).toString('base64');
temporaryResourceManager.create(
resourceId,
resourceUri,
base64Data,
{ toolName: this.name, size: buffer.byteLength },
undefined,
mimeType
);
temporaryResourceManager.create({
id: resourceId,
uri: resourceUri,
data: base64Data,
metadata: { toolName: this.name, size: buffer.byteLength },
mimeType,
owner: getUserNameFromToken(accessToken)
});
content.push({
type: 'text',
text: `⚠️ Image (${Math.round(buffer.byteLength / 1024)}KB) stored as temporary resource.\nResource URI: ${resourceUri}\nTTL: 30 minutes`
Expand Down
26 changes: 18 additions & 8 deletions src/utils/temporaryResourceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export interface TemporaryResource {
byteSize: number;
created: number;
ttl: number;
/**
* Account (Mapbox username) that created this resource. Reads are restricted
* to the same account so one account cannot read another account's temporary
* data. Undefined means no owner could be determined, in which case reads
* fail closed.
*/
owner?: string;
metadata?: {
toolName?: string;
size?: number;
Expand All @@ -39,14 +46,16 @@ export class TemporaryResourceManager {
* Create a temporary resource. Evicts oldest entries if the byte cap would
* be exceeded.
*/
create(
id: string,
uri: string,
data: unknown,
metadata?: TemporaryResource['metadata'],
ttl?: number,
mimeType?: string
): TemporaryResource {
create(params: {
id: string;
uri: string;
data: unknown;
owner?: string;
metadata?: TemporaryResource['metadata'];
ttl?: number;
mimeType?: string;
}): TemporaryResource {
const { id, uri, data, owner, metadata, ttl, mimeType } = params;
const dataStr = typeof data === 'string' ? data : JSON.stringify(data);
const byteSize = Buffer.byteLength(dataStr, 'utf8');

Expand All @@ -69,6 +78,7 @@ export class TemporaryResourceManager {
byteSize,
created: Date.now(),
ttl: ttl ?? this.defaultTTL,
owner,
metadata
};

Expand Down
149 changes: 149 additions & 0 deletions test/resources/temporary/TemporaryDataResource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TemporaryDataResource } from '../../../src/resources/temporary/TemporaryDataResource.js';
import { temporaryResourceManager } from '../../../src/utils/temporaryResourceManager.js';

// Build a Mapbox-style 3-part JWT whose payload carries the username (`u`).
function tokenFor(username: string): string {
const payload = Buffer.from(JSON.stringify({ u: username })).toString(
'base64'
);
return `pk.${payload}.sig`;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extraFor(token?: string): any {
return token ? { authInfo: { token } } : {};
}

const NOT_FOUND =
'Resource not found or expired. Temporary resources have a 30-minute TTL.';

describe('TemporaryDataResource — AGI-890 cross-account access control', () => {
let resource: TemporaryDataResource;
let savedEnvToken: string | undefined;

beforeEach(() => {
temporaryResourceManager.clear();
resource = new TemporaryDataResource();
savedEnvToken = process.env.MAPBOX_ACCESS_TOKEN;
delete process.env.MAPBOX_ACCESS_TOKEN;
});

afterEach(() => {
temporaryResourceManager.clear();
if (savedEnvToken !== undefined) {
process.env.MAPBOX_ACCESS_TOKEN = savedEnvToken;
} else {
delete process.env.MAPBOX_ACCESS_TOKEN;
}
});

function seedTextResource(uri: string, owner: string, data: unknown) {
temporaryResourceManager.create({
id: 'id',
uri,
data,
metadata: { toolName: 'directions_tool' },
owner
});
}

it('lets the creating account read its own resource', async () => {
const uri = 'mapbox://temp/directions-aaa';
seedTextResource(uri, 'accountA', { route: 'A-secret-geometry' });

const result = await resource.read(uri, extraFor(tokenFor('accountA')));
const text = result.contents[0].text as string;

expect(text).toContain('A-secret-geometry');
});

it('does NOT return another account’s resource body (regression)', async () => {
const uri = 'mapbox://temp/directions-bbb';
seedTextResource(uri, 'accountA', { route: 'A-secret-geometry' });

const result = await resource.read(uri, extraFor(tokenFor('accountB')));
const text = result.contents[0].text as string;

expect(text).toBe(NOT_FOUND);
expect(text).not.toContain('A-secret-geometry');
});

it('returns an identical response for "not yours" and "does not exist" (no existence oracle)', async () => {
const ownedUri = 'mapbox://temp/directions-ccc';
seedTextResource(ownedUri, 'accountA', { route: 'A-secret-geometry' });

const crossAccount = await resource.read(
ownedUri,
extraFor(tokenFor('accountB'))
);
const missing = await resource.read(
'mapbox://temp/directions-does-not-exist',
extraFor(tokenFor('accountB'))
);

// The only field that differs is the echoed-back request URI (caller's own
// input, not an existence signal). The mimeType and message are identical,
// so a caller cannot distinguish "not yours" from "does not exist".
expect(crossAccount.contents[0].mimeType).toBe(
missing.contents[0].mimeType
);
expect(crossAccount.contents[0].text).toBe(missing.contents[0].text);
expect(crossAccount.contents[0].text).toBe(NOT_FOUND);
});

it('fails closed when the reader has no token', async () => {
const uri = 'mapbox://temp/directions-ddd';
seedTextResource(uri, 'accountA', { route: 'A-secret-geometry' });

const result = await resource.read(uri, extraFor(undefined));
expect(result.contents[0].text).toBe(NOT_FOUND);
});

it('fails closed when the resource has no owner recorded', async () => {
const uri = 'mapbox://temp/directions-eee';
// No owner -> owner undefined
temporaryResourceManager.create({
id: uri,
uri,
data: { route: 'legacy' }
});

const result = await resource.read(uri, extraFor(tokenFor('accountA')));
expect(result.contents[0].text).toBe(NOT_FOUND);
});

it('falls back to the env token so stdio/single-user reads still work', async () => {
const envToken = tokenFor('localuser');
process.env.MAPBOX_ACCESS_TOKEN = envToken;
const uri = 'mapbox://temp/directions-fff';
seedTextResource(uri, 'localuser', { route: 'local-data' });

// No authInfo on the request (stdio) -> requester resolved from env token.
const result = await resource.read(uri, extraFor(undefined));
expect(result.contents[0].text as string).toContain('local-data');
});

it('returns image blobs to the owner and not-found to others', async () => {
const uri = 'mapbox://temp/static-map-ggg';
temporaryResourceManager.create({
id: 'imgid',
uri,
data: 'BASE64IMAGEDATA',
metadata: { toolName: 'static_map_image_tool' },
mimeType: 'image/png',
owner: 'accountA'
});

const owner = await resource.read(uri, extraFor(tokenFor('accountA')));
expect(owner.contents[0].blob).toBe('BASE64IMAGEDATA');
expect(owner.contents[0].mimeType).toBe('image/png');

const other = await resource.read(uri, extraFor(tokenFor('accountB')));
expect(other.contents[0].blob).toBeUndefined();
expect(other.contents[0].text).toBe(NOT_FOUND);
});
});
Loading
Loading