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
22 changes: 22 additions & 0 deletions src/tools/BaseTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,26 @@ export abstract class BaseTool<
this.server.server.sendLoggingMessage({ level, data });
}
}

/**
* Validates output data against the output schema.
* If validation fails, logs a warning and returns the raw data instead of throwing an error.
* This allows tools to continue functioning when API responses deviate from expected schemas.
*/
protected validateOutput<T>(
schema: ZodTypeAny,
rawData: unknown,
toolName: string
): T {
try {
return schema.parse(rawData) as T;
} catch (validationError) {
this.log(
'warning',
`${toolName}: Output schema validation failed - ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}`
);
// Graceful fallback to raw data
return rawData as T;
}
}
}
30 changes: 11 additions & 19 deletions src/tools/create-token-tool/CreateTokenTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,33 +90,25 @@ export class CreateTokenTool extends MapboxApiBasedTool<
return this.handleApiError(response, 'create token');
}

const data = await response.json();
const parseResult = CreateTokenOutputSchema.safeParse(data);
if (!parseResult.success) {
this.log(
'error',
`CreateTokenTool: Output schema validation failed\n${parseResult.error}`
);
return {
content: [
{
type: 'text',
text: `CreateTokenTool: Response does not conform to output schema:\n${parseResult.error}`
}
],
isError: true
};
}
const rawData = await response.json();

// Validate response against schema with graceful fallback
const data = this.validateOutput<Record<string, unknown>>(
CreateTokenOutputSchema,
rawData,
'CreateTokenTool'
);

this.log('info', `CreateTokenTool: Successfully created token`);

return {
content: [
{
type: 'text',
text: JSON.stringify(parseResult.data, null, 2)
text: JSON.stringify(data, null, 2)
}
],
structuredContent: parseResult.data,
structuredContent: data,
isError: false
};
}
Expand Down
29 changes: 10 additions & 19 deletions src/tools/list-styles-tool/ListStylesTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,27 +68,18 @@ export class ListStylesTool extends MapboxApiBasedTool<
return this.handleApiError(response, 'list styles');
}

const data = await response.json();
// Validate the API response (which is an array)
const parseResult = StylesArraySchema.safeParse(data);
if (!parseResult.success) {
this.log(
'error',
`ListStylesTool: Output schema validation failed\n${parseResult.error}`
);
return {
content: [
{
type: 'text' as const,
text: `ListStylesTool: Response does not conform to output schema:\n${parseResult.error}`
}
],
isError: true
};
}
const rawData = await response.json();

// Validate the API response (which is an array) with graceful fallback
const validatedData = this.validateOutput(
StylesArraySchema,
rawData,
'ListStylesTool'
);

this.log('info', `ListStylesTool: Successfully listed styles`);

const wrappedData = { styles: parseResult.data };
const wrappedData = { styles: validatedData };
return {
content: [
{
Expand Down
26 changes: 8 additions & 18 deletions src/tools/list-tokens-tool/ListTokensTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,24 +130,14 @@ export class ListTokensTool extends MapboxApiBasedTool<
? data
: (data as { tokens?: unknown[] }).tokens || [];

// Validate tokens array against TokenObjectSchema
const parseResult = TokenObjectSchema.array().safeParse(tokens);
if (!parseResult.success) {
this.log(
'error',
`ListTokensTool: Token array schema validation failed\n${parseResult.error}`
);
return {
isError: true,
content: [
{
type: 'text',
text: `ListTokensTool: Response does not conform to token array schema:\n${parseResult.error}`
}
]
};
}
allTokens.push(...parseResult.data);
// Validate tokens array against TokenObjectSchema with graceful fallback
const validatedTokens = this.validateOutput<unknown[]>(
TokenObjectSchema.array(),
tokens,
'ListTokensTool'
);

allTokens.push(...validatedTokens);
this.log(
'info',
`ListTokensTool: Retrieved ${tokens.length} tokens on page ${pageCount}`
Expand Down
40 changes: 40 additions & 0 deletions test/tools/create-token-tool/CreateTokenTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,5 +362,45 @@ describe('CreateTokenTool', () => {
}
}
});

it('handles schema validation failures gracefully and logs warning', async () => {
// API response that doesn't match schema (missing required fields)
const invalidMockResponse = {
token: 'pk.test',
note: 'Test token',
// Missing required fields like 'id', 'created', 'modified', etc.
unexpectedField: 'some value'
};

const { httpRequest } = setupHttpRequest({
ok: true,
json: async () => invalidMockResponse
} as Response);

const tool = createTokenTool(httpRequest);
const logSpy = vi.spyOn(tool as any, 'log');

const result = await tool.run({
note: 'Test token',
scopes: ['styles:read']
});

// Should not error - graceful fallback to raw data
expect(result.isError).toBe(false);
expect(result.content[0]).toHaveProperty('type', 'text');

// Should log a warning about validation failure
expect(logSpy).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'CreateTokenTool: Output schema validation failed'
)
);

// Should return the raw data despite validation failure
const responseData = JSON.parse((result.content[0] as TextContent).text);
expect(responseData).toEqual(invalidMockResponse);
expect(responseData).toHaveProperty('unexpectedField');
});
});
});
44 changes: 44 additions & 0 deletions test/tools/list-styles-tool/ListStylesTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,4 +267,48 @@ describe('ListStylesTool', () => {

assertHeadersSent(mockHttpRequest);
});

it('handles schema validation failures gracefully and logs warning', async () => {
// API response that doesn't match schema (missing required fields)
const invalidMockStyles = [
{
id: 'style1',
name: 'Test Style',
// Missing required fields like 'owner', 'created', 'modified', etc.
customField: 'unexpected data'
}
];

const { httpRequest, mockHttpRequest } = setupHttpRequest({
ok: true,
json: async () => invalidMockStyles
});

const tool = new ListStylesTool({ httpRequest });
const logSpy = vi.spyOn(tool as any, 'log');

const result = await tool.run({});

// Should not error - graceful fallback to raw data
expect(result.isError).toBe(false);
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');

// Should log a warning about validation failure
expect(logSpy).toHaveBeenCalledWith(
'warning',
expect.stringContaining('ListStylesTool: Output schema validation failed')
);

// Should return the raw data despite validation failure
const content = result.content[0];
if (content.type === 'text') {
const parsedResponse = JSON.parse(content.text);
expect(parsedResponse).toHaveProperty('styles');
expect(parsedResponse.styles).toEqual(invalidMockStyles);
expect(parsedResponse.styles[0]).toHaveProperty('customField');
}

assertHeadersSent(mockHttpRequest);
});
});
41 changes: 41 additions & 0 deletions test/tools/list-tokens-tool/ListTokensTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,5 +564,46 @@ describe('ListTokensTool', () => {
expect(responseData.tokens).toHaveLength(1);
expect(responseData.count).toBe(1);
});

it('handles schema validation failures gracefully and logs warning', async () => {
// API response with tokens that don't match schema (missing required fields)
const invalidMockTokens = [
{
id: 'cktest123',
note: 'Test token',
// Missing required fields like 'usage', 'client', 'scopes', etc.
unexpectedField: 'some value'
}
];

const { httpRequest, mockHttpRequest } = setupHttpRequest();
mockHttpRequest.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
json: async () => invalidMockTokens
} as Response);

const tool = createListTokensTool(httpRequest);
const logSpy = vi.spyOn(tool as any, 'log');

const result = await tool.run({});

// Should not error - graceful fallback to raw data
expect(result.isError).toBe(false);
expect(result.content[0]).toHaveProperty('type', 'text');

// Should log a warning about validation failure
expect(logSpy).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'ListTokensTool: Output schema validation failed'
)
);

// Should return the raw data despite validation failure
const responseData = JSON.parse((result.content[0] as TextContent).text);
expect(responseData.tokens).toEqual(invalidMockTokens);
expect(responseData.tokens[0]).toHaveProperty('unexpectedField');
});
});
});
Loading