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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,8 @@ jobs:
- name: Compile typescript
run: npm run build

- name: Typecheck src and tests
run: npm run typecheck

- name: Run tests
run: npm test
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
### Maintenance

- **Removed build output committed under `src/` (#297).** `src/utils/versionUtils-cjs.cjs`, `.d.cts` and their source maps were compiled output from a `tsc` run that wrote next to the sources, committed back in #96. Only `versionUtils-cjs.cts` is source: tshy swaps it in for `versionUtils.ts` in the CommonJS build. `.gitignore` now keeps `.js`, `.cjs`, `.mjs`, `.d.*ts` and `.map` files out of `src/` and `test/`.
- **Tests are typechecked in CI (#298).** `tsconfig.test.json` covers `src/` and `test/`, but nothing ran it, so test type errors piled up: about 200 on main, plus about 250 more from SDK v2 typing `structuredContent` as an open object (#296). They're all fixed, and `npm run typecheck` now runs in CI before the tests. `tsconfig.test.json` also gets `noEmit`, since running it without that writes `.js` files next to the sources (see #297).
- **New helpers** in `test/utils/resultTestUtils.ts`: `structuredOf<ToolOutput>(result)` reads structured content as the tool's own output type, so a test that checks a field the schema doesn't have fails to compile. `textOf` and `resourceTextOf` read text content and throw on the wrong content type.
- **Tests that were wrong:** the tuple check in `input-schema-validation.test.ts` never looked inside arrays under zod 4 (`ZodArray._def.type` is now the string `'array'`), so a tuple nested in an array would have passed. It uses zod 4's accessors now and covers that case. `structured-content.test.ts` imported a file that never existed. `cleanResponseData.test.ts` used an old directions input shape and now builds it with `DirectionsInputSchema.parse`. A `|| Infinity` fallback in a SimplifyTool test meant one comparison could never fail.

## 0.16.1-dev - 2026-10-16

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"inspect:build": "npm run build && npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" node dist/esm/index.js",
"inspect:dev": "npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" npx -y tsx src/index.ts",
"dev:http": "npx -y tsx scripts/dev-http-server.ts",
"typecheck": "tsc -p tsconfig.test.json",
"lint": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" \"./examples/**/*.{ts,tsx}\"",
"lint:fix": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" \"./examples/**/*.{ts,tsx}\" --fix",
"postinstall": "patch-package || (cd ../../.. && node ./node_modules/patch-package/index.js --patch-dir ./node_modules/@mapbox/mcp-server/patches) || true",
Expand Down
35 changes: 25 additions & 10 deletions test/prompts/BasePrompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,23 @@ import type {
PromptMessage
} from '@modelcontextprotocol/server';

// Makes validateArguments public so tests can call it directly.
abstract class TestableBasePrompt extends BasePrompt {
override validateArguments(args: Record<string, string>): void {
super.validateArguments(args);
}
}

// The text of a prompt message. Throws if the content isn't text.
function messageText(message: PromptMessage): string {
if (message.content.type !== 'text') {
throw new Error(`message content is ${message.content.type}, not text`);
}
return message.content.text;
}

// Create a concrete test implementation of BasePrompt
class TestPrompt extends BasePrompt {
class TestPrompt extends TestableBasePrompt {
readonly name = 'test-prompt';
readonly description = 'A test prompt for unit testing';
readonly arguments: PromptArgument[] = [
Expand Down Expand Up @@ -173,7 +188,7 @@ describe('BasePrompt', () => {
expect(message.role).toBeDefined();
expect(message.content).toBeDefined();
expect(message.content.type).toBe('text');
expect(typeof message.content.text).toBe('string');
expect(typeof messageText(message)).toBe('string');
});
});

Expand All @@ -194,10 +209,10 @@ describe('BasePrompt', () => {
};

const messages = prompt.getMessages(args);
const messageText = messages[0].content.text;
const text = messageText(messages[0]);

expect(messageText).toContain('test_required');
expect(messageText).toContain('test_optional');
expect(text).toContain('test_required');
expect(text).toContain('test_optional');
});

test('handles missing optional arguments gracefully', () => {
Expand All @@ -206,16 +221,16 @@ describe('BasePrompt', () => {
};

const messages = prompt.getMessages(args);
const messageText = messages[0].content.text;
const text = messageText(messages[0]);

expect(messageText).toContain('test_required');
expect(messageText).toContain('not provided');
expect(text).toContain('test_required');
expect(text).toContain('not provided');
});
});

describe('edge cases', () => {
test('validates empty args object when no arguments are required', () => {
class NoArgsPrompt extends BasePrompt {
class NoArgsPrompt extends TestableBasePrompt {
readonly name = 'no-args';
readonly description = 'No arguments needed';
readonly arguments: PromptArgument[] = [];
Expand All @@ -239,7 +254,7 @@ describe('BasePrompt', () => {
});

test('validates all required arguments', () => {
class MultiRequiredPrompt extends BasePrompt {
class MultiRequiredPrompt extends TestableBasePrompt {
readonly name = 'multi-required';
readonly description = 'Multiple required arguments';
readonly arguments: PromptArgument[] = [
Expand Down
5 changes: 3 additions & 2 deletions test/resources/category-list/CategoryListResource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
assertHeadersSent
} from '../../utils/httpPipelineUtils.js';
import { CategoryListResource } from '../../../src/resources/category-list/CategoryListResource.js';
import { resourceTextOf } from '../../utils/resultTestUtils.js';

describe('CategoryListResource', () => {
afterEach(() => {
Expand Down Expand Up @@ -111,7 +112,7 @@ describe('CategoryListResource', () => {
expect(result.contents[0].uri).toBe('mapbox://categories');
expect(result.contents[0].mimeType).toBe('application/json');

const parsed = JSON.parse(result.contents[0].text!);
const parsed = JSON.parse(resourceTextOf(result));
expect(parsed.listItems).toEqual(['restaurant', 'hotel']);
expect(parsed.version).toBe('25:test');
expect(parsed.attribution).toBe('Test Attribution');
Expand All @@ -131,7 +132,7 @@ describe('CategoryListResource', () => {
);

expect(result.contents).toHaveLength(1);
const parsed = JSON.parse(result.contents[0].text!);
const parsed = JSON.parse(resourceTextOf(result));
expect(parsed.listItems).toEqual([]);
});

Expand Down
7 changes: 4 additions & 3 deletions test/resources/compute/ComputeResource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { describe, it, expect } from 'vitest';
import { ComputeResource } from '../../../src/resources/compute/ComputeResource.js';
import { buildComputeRef } from '../../../src/utils/computeRef.js';
import { resourceTextOf } from '../../utils/resultTestUtils.js';

describe('ComputeResource', () => {
it('resolves a union compute ref via the real resources/read path with zero prior server-side state', async () => {
Expand Down Expand Up @@ -47,7 +48,7 @@ describe('ComputeResource', () => {
const result = await resource.read(ref);

expect(result.contents[0].mimeType).toBe('application/json');
const payload = JSON.parse(result.contents[0].text as string);
const payload = JSON.parse(resourceTextOf(result));
expect(payload.layers).toHaveLength(6);
expect(payload.summary).toBe('Union of 2 polygons');
});
Expand Down Expand Up @@ -90,7 +91,7 @@ describe('ComputeResource', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const asAccountB = { http: { authInfo: { token: 'irrelevant' } } } as any;
const result = await resource.read(ref, asAccountB);
const payload = JSON.parse(result.contents[0].text as string);
const payload = JSON.parse(resourceTextOf(result));
expect(payload.summary).toBe('Intersection of two polygons');
});

Expand All @@ -99,6 +100,6 @@ describe('ComputeResource', () => {
const result = await resource.read('mapbox://compute/union?data=!!!');

expect(result.contents[0].mimeType).toBe('text/plain');
expect(result.contents[0].text).toContain('malformed or unsupported');
expect(resourceTextOf(result)).toContain('malformed or unsupported');
});
});
5 changes: 3 additions & 2 deletions test/resources/inline-payload/InlinePayloadResource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { describe, it, expect } from 'vitest';
import { InlinePayloadResource } from '../../../src/resources/inline-payload/InlinePayloadResource.js';
import { buildInlinePayloadRef } from '../../../src/utils/inlinePayloadRef.js';
import { resourceTextOf } from '../../utils/resultTestUtils.js';

describe('InlinePayloadResource', () => {
it("resolves render_map_tool's self-describing merged ref via the real resources/read path with zero prior server-side state", async () => {
Expand Down Expand Up @@ -33,7 +34,7 @@ describe('InlinePayloadResource', () => {
const result = await resource.read(ref);

expect(result.contents[0].mimeType).toBe('application/json');
const payload = JSON.parse(result.contents[0].text as string);
const payload = JSON.parse(resourceTextOf(result));
expect(payload.summary).toBe('Test trip');
expect(payload.layers).toHaveLength(1);
expect(payload.markers).toHaveLength(1);
Expand All @@ -44,6 +45,6 @@ describe('InlinePayloadResource', () => {
const result = await resource.read('mapbox://inline/payload?data=!!!');

expect(result.contents[0].mimeType).toBe('text/plain');
expect(result.contents[0].text).toContain('malformed');
expect(resourceTextOf(result)).toContain('malformed');
});
});
39 changes: 24 additions & 15 deletions test/resources/temporary/TemporaryDataResource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ import {
getOwnerKeyFromToken,
getUserNameFromToken
} from '../../../src/utils/jwtUtils.js';
import type { ReadResourceResult } from '@modelcontextprotocol/server';
import { resourceTextOf } from '../../utils/resultTestUtils.js';

// The blob of a resource read's first contents entry, or undefined when
// it's text.
function blobOf(result: ReadResourceResult): string | undefined {
const entry = result.contents[0];
return 'blob' in entry ? entry.blob : undefined;
}

// Build a Mapbox-style 3-part JWT whose payload carries the username (`u`).
function tokenFor(username: string): string {
Expand Down Expand Up @@ -82,7 +91,7 @@ describe('TemporaryDataResource — AGI-890 cross-account access control', () =>
seedTextResource(uri, 'accountA', { route: 'A-secret-geometry' });

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

expect(text).toContain('A-secret-geometry');
});
Expand All @@ -92,7 +101,7 @@ describe('TemporaryDataResource — AGI-890 cross-account access control', () =>
seedTextResource(uri, 'accountA', { route: 'A-secret-geometry' });

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

expect(text).toBe(NOT_FOUND);
expect(text).not.toContain('A-secret-geometry');
Expand All @@ -117,16 +126,16 @@ describe('TemporaryDataResource — AGI-890 cross-account access control', () =>
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);
expect(resourceTextOf(crossAccount)).toBe(resourceTextOf(missing));
expect(resourceTextOf(crossAccount)).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);
expect(resourceTextOf(result)).toBe(NOT_FOUND);
});

it('fails closed when the resource has no owner recorded', async () => {
Expand All @@ -139,7 +148,7 @@ describe('TemporaryDataResource — AGI-890 cross-account access control', () =>
});

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

it('falls back to the env token so stdio/single-user reads still work', async () => {
Expand All @@ -150,7 +159,7 @@ describe('TemporaryDataResource — AGI-890 cross-account access control', () =>

// 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');
expect(resourceTextOf(result)).toContain('local-data');
});

it('returns image blobs to the owner and not-found to others', async () => {
Expand All @@ -165,12 +174,12 @@ describe('TemporaryDataResource — AGI-890 cross-account access control', () =>
});

const owner = await resource.read(uri, extraFor(tokenFor('accountA')));
expect(owner.contents[0].blob).toBe('BASE64IMAGEDATA');
expect(blobOf(owner)).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);
expect(blobOf(other)).toBeUndefined();
expect(resourceTextOf(other)).toBe(NOT_FOUND);
});

it('resolves a storeMapPayload ref via the real resources/read path (regression)', async () => {
Expand All @@ -185,7 +194,7 @@ describe('TemporaryDataResource — AGI-890 cross-account access control', () =>
);

const result = await resource.read(ref, extraFor(tokenFor('accountA')));
const parsed = JSON.parse(result.contents[0].text as string);
const parsed = JSON.parse(resourceTextOf(result));

expect(parsed.summary).toBe('Test route');
});
Expand All @@ -210,8 +219,8 @@ describe('TemporaryDataResource — AGI-890 cross-account access control', () =>

const result = await resource.read(uri, extraFor(forged));

expect(result.contents[0].text).toBe(NOT_FOUND);
expect(result.contents[0].text).not.toContain('A-secret-geometry');
expect(resourceTextOf(result)).toBe(NOT_FOUND);
expect(resourceTextOf(result)).not.toContain('A-secret-geometry');
});

it('does NOT return an image blob to a token forging the owner’s username', async () => {
Expand All @@ -230,8 +239,8 @@ describe('TemporaryDataResource — AGI-890 cross-account access control', () =>
extraFor(forgedTokenFor('victim_user'))
);

expect(result.contents[0].blob).toBeUndefined();
expect(result.contents[0].text).toBe(NOT_FOUND);
expect(blobOf(result)).toBeUndefined();
expect(resourceTextOf(result)).toBe(NOT_FOUND);
});

it('does NOT resolve a map-payload ref for a token forging the owner’s username', async () => {
Expand Down
16 changes: 9 additions & 7 deletions test/resources/ui-apps/MapAppUIResource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const SK_TOKEN = 'sk.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature';
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { MapAppUIResource } from '../../../src/resources/ui-apps/MapAppUIResource.js';
import { __resetMapboxPublicTokenCache } from '../../../src/utils/mapboxPublicToken.js';
import { resourceTextOf } from '../../utils/resultTestUtils.js';
import type { HttpRequest } from '../../../src/utils/types.js';

const fakeTokenList = [
{
Expand Down Expand Up @@ -39,7 +41,8 @@ describe('MapAppUIResource', () => {
});

it('serves HTML at ui://mapbox/map-app/index.html with the mcp-app mime type', async () => {
const httpRequest = vi.fn(async (url: string) => {
const httpRequest = vi.fn<HttpRequest>(async (input) => {
const url = String(input);
if (url.includes('tokens/v2/'))
return makeOkJson(fakeTokenList) as Response;
throw new Error(`Unexpected URL: ${url}`);
Expand All @@ -57,11 +60,10 @@ describe('MapAppUIResource', () => {
const entry = result.contents[0];
expect(entry.mimeType).toBe('text/html;profile=mcp-app');
expect(entry.uri).toBe('ui://mapbox/map-app/index.html');
expect(typeof entry.text).toBe('string');
expect(entry.text as string).toContain(
'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token'
);
expect(entry.text as string).toContain('mapbox-gl.js');
const text = resourceTextOf(result);
expect(typeof text).toBe('string');
expect(text).toContain('pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token');
expect(text).toContain('mapbox-gl.js');

const meta = (entry as { _meta?: unknown })._meta as
| { ui?: { csp?: Record<string, string[]> } }
Expand All @@ -86,7 +88,7 @@ describe('MapAppUIResource', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);

expect(result.contents[0].text as string).toContain(
expect(resourceTextOf(result)).toContain(
'No Mapbox public token available'
);
});
Expand Down
Loading
Loading