Skip to content

Commit cc5c177

Browse files
fix: harden URL validation, body serialization, and Zod schema tests
- Validate VALIDKIT_API_URL has no path, query params, or fragments - Strip trailing slash from URL to prevent double-slash routing issues - Guard JSON.stringify(body) with clear error message - Add Zod min/max validation tests for bulk emails (empty + >1000) - Assert bulk request body content (not just endpoint URL) - 44 tests, 100% coverage
1 parent 2c78e46 commit cc5c177

2 files changed

Lines changed: 127 additions & 9 deletions

File tree

src/index.test.ts

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
33
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
4-
import { createServer } from './index.js';
4+
import { createServer, callApi } from './index.js';
55

66
// Mock fetch globally
77
const mockFetch = vi.fn();
@@ -276,13 +276,38 @@ describe('ValidKit MCP Server', () => {
276276
expect(parsed.results).toHaveLength(2);
277277
expect(parsed.summary.total).toBe(2);
278278

279-
// Verify correct endpoint (not /v1/verify/batch)
279+
// Verify correct endpoint and body
280280
expect(mockFetch).toHaveBeenCalledWith(
281281
'https://api.validkit.com/v1/verify/bulk',
282-
expect.anything()
282+
expect.objectContaining({
283+
body: JSON.stringify({ emails: ['a@gmail.com', 'b@fake.xyz'] }),
284+
})
283285
);
284286
});
285287

288+
it('rejects empty emails array via Zod schema', async () => {
289+
const { client } = await createTestClient();
290+
291+
const result = await client.callTool({
292+
name: 'validate_emails_bulk',
293+
arguments: { emails: [] },
294+
});
295+
296+
expect(result.isError).toBe(true);
297+
});
298+
299+
it('rejects >1000 emails via Zod schema', async () => {
300+
const { client } = await createTestClient();
301+
const tooMany = Array.from({ length: 1001 }, (_, i) => `u${i}@test.com`);
302+
303+
const result = await client.callTool({
304+
name: 'validate_emails_bulk',
305+
arguments: { emails: tooMany },
306+
});
307+
308+
expect(result.isError).toBe(true);
309+
});
310+
286311
it('handles API error on bulk', async () => {
287312
const { client } = await createTestClient();
288313
mockFetchResponse(401, { error: { message: 'Invalid API key' } });
@@ -507,6 +532,61 @@ describe('ValidKit MCP Server', () => {
507532
expect(getText(result)).toContain('must use https://');
508533
});
509534

535+
it('rejects URL with path', async () => {
536+
process.env.VALIDKIT_API_URL = 'https://api.validkit.com/v1';
537+
const { client } = await createTestClient();
538+
539+
const result = await client.callTool({
540+
name: 'validate_email',
541+
arguments: { email: 'test@gmail.com' },
542+
});
543+
544+
expect(result.isError).toBe(true);
545+
expect(getText(result)).toContain('origin URL without a path');
546+
});
547+
548+
it('rejects invalid URL', async () => {
549+
process.env.VALIDKIT_API_URL = 'https://not a valid url';
550+
const { client } = await createTestClient();
551+
552+
const result = await client.callTool({
553+
name: 'validate_email',
554+
arguments: { email: 'test@gmail.com' },
555+
});
556+
557+
expect(result.isError).toBe(true);
558+
expect(getText(result)).toContain('not a valid URL');
559+
});
560+
561+
it('rejects URL with query params', async () => {
562+
process.env.VALIDKIT_API_URL = 'https://api.validkit.com?foo=bar';
563+
const { client } = await createTestClient();
564+
565+
const result = await client.callTool({
566+
name: 'validate_email',
567+
arguments: { email: 'test@gmail.com' },
568+
});
569+
570+
expect(result.isError).toBe(true);
571+
expect(getText(result)).toContain('query parameters');
572+
});
573+
574+
it('strips trailing slash from URL', async () => {
575+
process.env.VALIDKIT_API_URL = 'https://api.validkit.com/';
576+
const { client } = await createTestClient();
577+
mockFetchResponse(200, { email: 'a@b.com', valid: true });
578+
579+
await client.callTool({
580+
name: 'validate_email',
581+
arguments: { email: 'a@b.com' },
582+
});
583+
584+
expect(mockFetch).toHaveBeenCalledWith(
585+
'https://api.validkit.com/v1/verify',
586+
expect.anything()
587+
);
588+
});
589+
510590
it('handles non-JSON API response gracefully', async () => {
511591
const { client } = await createTestClient();
512592
mockFetch.mockResolvedValueOnce({
@@ -624,5 +704,14 @@ describe('ValidKit MCP Server', () => {
624704
expect(getText(result)).toContain('30 seconds');
625705
expect(getText(result)).not.toContain('was aborted');
626706
});
707+
708+
it('throws clear error when body cannot be serialized', async () => {
709+
const circular: Record<string, unknown> = {};
710+
circular.self = circular;
711+
712+
await expect(callApi('/v1/verify', 'POST', circular)).rejects.toThrow(
713+
'Failed to serialize request body'
714+
);
715+
});
627716
});
628717
});

src/index.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,31 @@ export function formatCatchError(error: unknown, prefix: string): string {
1717
}
1818

1919
export function getApiBaseUrl(): string {
20-
const url = process.env.VALIDKIT_API_URL || 'https://api.validkit.com';
21-
if (!url.startsWith('https://')) {
20+
const raw = process.env.VALIDKIT_API_URL || 'https://api.validkit.com';
21+
if (!raw.startsWith('https://')) {
2222
throw new Error(
23-
`VALIDKIT_API_URL must use https:// (got "${url}")`
23+
`VALIDKIT_API_URL must use https:// (got "${raw}")`
2424
);
2525
}
26-
return url;
26+
let parsed: URL;
27+
try {
28+
parsed = new URL(raw);
29+
} catch {
30+
throw new Error(
31+
`VALIDKIT_API_URL is not a valid URL (got "${raw}")`
32+
);
33+
}
34+
if (parsed.pathname !== '/' && parsed.pathname !== '') {
35+
throw new Error(
36+
`VALIDKIT_API_URL must be an origin URL without a path (got "${raw}"). Use "https://api.validkit.com" not "https://api.validkit.com/v1"`
37+
);
38+
}
39+
if (parsed.search || parsed.hash) {
40+
throw new Error(
41+
`VALIDKIT_API_URL must not include query parameters or fragments (got "${raw}")`
42+
);
43+
}
44+
return raw.replace(/\/+$/, '');
2745
}
2846

2947
export function getApiKey(): string {
@@ -43,15 +61,26 @@ export async function callApi(
4361
): Promise<{ ok: boolean; status: number; data: unknown }> {
4462
const apiKey = getApiKey();
4563

64+
let serializedBody: string | undefined;
65+
if (body !== undefined) {
66+
try {
67+
serializedBody = JSON.stringify(body);
68+
} catch {
69+
throw new Error('Failed to serialize request body');
70+
}
71+
}
72+
4673
const response = await fetch(`${getApiBaseUrl()}${path}`, {
4774
method,
4875
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
4976
headers: {
5077
'X-API-Key': apiKey,
5178
'User-Agent': `validkit-mcp/${VERSION}`,
52-
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
79+
...(serializedBody !== undefined
80+
? { 'Content-Type': 'application/json' }
81+
: {}),
5382
},
54-
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
83+
...(serializedBody !== undefined ? { body: serializedBody } : {}),
5584
});
5685

5786
let data: unknown;

0 commit comments

Comments
 (0)