Skip to content

Commit c23231f

Browse files
Add CORS support and related tests (#55)
- Introduced CORS middleware in the server to handle cross-origin requests. - Created a new `cors.ts` file to define CORS options and allowed origins. - Added tests for CORS functionality, ensuring proper handling of allowed and disallowed origins. - Updated package dependencies to include `cors` and `express`.
1 parent 15cefe2 commit c23231f

6 files changed

Lines changed: 136 additions & 1 deletion

File tree

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
"dependencies": {
3030
"@modelcontextprotocol/sdk": "^1.12.3",
3131
"axios": "^1.15.0",
32+
"cors": "^2.8.5",
3233
"dotenv": "^16.5.0",
34+
"express": "^5.2.1",
3335
"node-html-markdown": "^1.3.0",
3436
"tsx": "^4.21.0"
3537
},
@@ -40,6 +42,7 @@
4042
"@semantic-release/git": "^10.0.1",
4143
"@semantic-release/github": "^12.0.6",
4244
"@semantic-release/npm": "^13.1.5",
45+
"@types/cors": "^2.8.17",
4346
"@types/express": "^5.0.1",
4447
"@types/jest": "^30.0.0",
4548
"@types/node": "^24.0.0",

src/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import 'dotenv/config';
2+
import cors from 'cors';
23
import express from 'express';
34
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5+
import { corsOptions } from './server/cors';
46
import { ScraperAPIHttpServer } from './server/sapi-http-server';
57
import { resolveToolsets } from './utils';
68

79
const app = express();
810

11+
app.use(cors(corsOptions));
912
app.use(express.json());
1013

1114
app.get('/mcp', (_req, res) => {

src/server/__tests__/cors.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import cors from 'cors';
2+
import express from 'express';
3+
import { corsOptions, isAllowedOrigin } from '../cors';
4+
5+
describe('CORS origin allowlist', () => {
6+
it('allows requests with no origin', () => {
7+
expect(isAllowedOrigin(undefined)).toBe(true);
8+
});
9+
10+
it('allows claude.ai and subdomains', () => {
11+
expect(isAllowedOrigin('https://claude.ai')).toBe(true);
12+
expect(isAllowedOrigin('https://www.claude.ai')).toBe(true);
13+
});
14+
15+
it('allows anthropic.com and subdomains', () => {
16+
expect(isAllowedOrigin('https://anthropic.com')).toBe(true);
17+
expect(isAllowedOrigin('https://api.anthropic.com')).toBe(true);
18+
});
19+
20+
it('allows local development origins', () => {
21+
expect(isAllowedOrigin('http://localhost:3000')).toBe(true);
22+
expect(isAllowedOrigin('http://127.0.0.1:6274')).toBe(true);
23+
});
24+
25+
it('rejects unknown origins', () => {
26+
expect(isAllowedOrigin('https://evil.com')).toBe(false);
27+
expect(isAllowedOrigin('https://claude.ai.evil.com')).toBe(false);
28+
});
29+
});
30+
31+
describe('CORS middleware behavior', () => {
32+
const withTestServer = async (
33+
run: (port: number) => Promise<void>
34+
): Promise<void> => {
35+
const app = express();
36+
app.use(cors(corsOptions));
37+
app.get('/mcp', (_req, res) => {
38+
res.status(200).send('ok');
39+
});
40+
41+
const server = app.listen(0);
42+
const port = (server.address() as { port: number }).port;
43+
44+
try {
45+
await run(port);
46+
} finally {
47+
await new Promise<void>((resolve, reject) => {
48+
server.close((error) => {
49+
if (error) {
50+
reject(error);
51+
return;
52+
}
53+
54+
resolve();
55+
});
56+
});
57+
}
58+
};
59+
60+
it('does not return 500 for disallowed origins', async () => {
61+
await withTestServer(async (port) => {
62+
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
63+
headers: { Origin: 'https://evil.com' },
64+
});
65+
66+
expect(response.status).toBe(200);
67+
expect(response.headers.get('access-control-allow-origin')).toBeNull();
68+
});
69+
});
70+
71+
it('returns CORS headers for allowed origins', async () => {
72+
await withTestServer(async (port) => {
73+
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
74+
headers: { Origin: 'https://claude.ai' },
75+
});
76+
77+
expect(response.status).toBe(200);
78+
expect(response.headers.get('access-control-allow-origin')).toBe(
79+
'https://claude.ai'
80+
);
81+
});
82+
});
83+
});

src/server/cors.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import cors from 'cors';
2+
3+
const ALLOWED_ORIGIN_PATTERNS = [
4+
/^https:\/\/([a-z0-9-]+\.)*claude\.ai$/,
5+
/^https:\/\/([a-z0-9-]+\.)*anthropic\.com$/,
6+
/^http:\/\/localhost(:\d+)?$/,
7+
/^http:\/\/127\.0\.0\.1(:\d+)?$/,
8+
];
9+
10+
export const isAllowedOrigin = (origin: string | undefined): boolean => {
11+
if (!origin) {
12+
return true;
13+
}
14+
15+
return ALLOWED_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin));
16+
};
17+
18+
export const corsOptions: cors.CorsOptions = {
19+
origin: (origin, callback) => {
20+
if (isAllowedOrigin(origin)) {
21+
callback(null, true);
22+
return;
23+
}
24+
25+
callback(null, false);
26+
},
27+
methods: ['GET', 'POST', 'OPTIONS'],
28+
allowedHeaders: ['Content-Type', 'Authorization', 'Mcp-Session-Id'],
29+
exposedHeaders: ['Mcp-Session-Id'],
30+
maxAge: 86400,
31+
};

src/utils/__tests__/progress.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ describe('ProgressNotifier', () => {
8787
},
8888
});
8989

90-
if (timeout) clearTimeout(timeout);
90+
if (timeout) {
91+
clearTimeout(timeout);
92+
}
9193
jest.useRealTimers();
9294
});
9395

0 commit comments

Comments
 (0)