kiku-agent 1.0.0 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -122
- package/package.json +60 -61
- package/server/agent/agentCore.js +892 -782
- package/server/agent/appPaths.js +21 -0
- package/server/agent/audioPlayer.js +739 -673
- package/server/agent/cli.js +1414 -1111
- package/server/agent/commandRegistry.js +74 -58
- package/server/agent/commands.js +1694 -788
- package/server/agent/config.js +264 -90
- package/server/agent/contextCompactor.js +79 -0
- package/server/agent/doctor.js +69 -0
- package/server/agent/eventBus.js +107 -103
- package/server/agent/gpuMetrics.js +189 -0
- package/server/agent/llmClient.js +369 -200
- package/server/agent/mcpClient.js +364 -0
- package/server/agent/memory.js +670 -566
- package/server/agent/modelManager.js +77 -0
- package/server/agent/research.js +202 -0
- package/server/agent/session.js +244 -239
- package/server/agent/subagent.js +116 -0
- package/server/agent/theme.js +125 -124
- package/server/agent/toolRouter.js +201 -0
- package/server/agent/tools.js +1679 -1528
- package/server/agent/ui.js +760 -469
- package/server/agent/youtubeAuth.js +144 -65
- package/server/mcp/claude-desktop-config.example.json +1 -1
- package/server/mcp/data/discography.js +36 -36
- package/server/mcp/dataAdapter.js +102 -0
- package/server/mcp/examples/claude-code.mcp.example.json +19 -0
- package/server/mcp/examples/claude-desktop.example.json +11 -0
- package/server/mcp/examples/cline.example.json +13 -0
- package/server/mcp/examples/cursor.example.json +14 -0
- package/server/mcp/examples/gemini-cli.example.json +17 -0
- package/server/mcp/examples/opencode.example.json +21 -0
- package/server/mcp/examples/vscode.example.json +19 -0
- package/server/mcp/examples/windsurf.example.json +14 -0
- package/server/mcp/handler.js +85 -159
- package/server/mcp/security.js +140 -138
- package/server/mcp/server.js +129 -100
- package/server/mcp/stdio.js +4 -2
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://opencode.ai/config.json",
|
|
3
|
+
"mcp": {
|
|
4
|
+
"kiku": {
|
|
5
|
+
"type": "local",
|
|
6
|
+
"command": ["npx", "-y", "-p", "kiku-agent", "kiku-mcp"],
|
|
7
|
+
"environment": {
|
|
8
|
+
"KIKU_MCP_ENABLE_WEB_TOOLS": "true"
|
|
9
|
+
},
|
|
10
|
+
"enabled": true
|
|
11
|
+
},
|
|
12
|
+
"kiku-remote": {
|
|
13
|
+
"type": "remote",
|
|
14
|
+
"url": "https://jujingyi-kikuuu.vercel.app/mcp",
|
|
15
|
+
"headers": {
|
|
16
|
+
"X-API-Key": "YOUR_API_KEY"
|
|
17
|
+
},
|
|
18
|
+
"enabled": true
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"servers": {
|
|
3
|
+
"kiku": {
|
|
4
|
+
"type": "stdio",
|
|
5
|
+
"command": "npx",
|
|
6
|
+
"args": ["-y", "-p", "kiku-agent", "kiku-mcp"],
|
|
7
|
+
"env": {
|
|
8
|
+
"KIKU_MCP_ENABLE_WEB_TOOLS": "true"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"kiku-remote": {
|
|
12
|
+
"type": "http",
|
|
13
|
+
"url": "https://jujingyi-kikuuu.vercel.app/mcp",
|
|
14
|
+
"headers": {
|
|
15
|
+
"X-API-Key": "YOUR_API_KEY"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
package/server/mcp/handler.js
CHANGED
|
@@ -1,159 +1,85 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
87
|
-
const sessionId = url.searchParams.get('sessionId');
|
|
88
|
-
if (!sessionId || !validateSessionId(sessionId)) {
|
|
89
|
-
return sendJson(res, 400, { error: 'Invalid or missing sessionId.' });
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const session = sessions.get(sessionId);
|
|
93
|
-
if (!session) {
|
|
94
|
-
return sendJson(res, 404, { error: 'Session not found or expired. Reconnect through /api/v1/mcp/sse.' });
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const rate = checkRateLimit(`mcp:${sessionId}`);
|
|
98
|
-
if (!rate.allowed) {
|
|
99
|
-
return sendJson(res, 429, { error: rate.error }, { 'Retry-After': '60' });
|
|
100
|
-
}
|
|
101
|
-
res.setHeader('X-RateLimit-Remaining', String(rate.remaining));
|
|
102
|
-
|
|
103
|
-
session.messageCount++;
|
|
104
|
-
session.lastSeen = Date.now();
|
|
105
|
-
if (session.messageCount > 500) {
|
|
106
|
-
await closeSession(sessionId);
|
|
107
|
-
return sendJson(res, 429, { error: 'Session message limit reached. Reconnect to create a new session.' });
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
await session.transport.handlePostMessage(req, res);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export async function handleMcpHttp(req, res) {
|
|
114
|
-
const security = securityMiddleware(req, res);
|
|
115
|
-
if (security.blocked || !validateRequestSize(req, res)) return;
|
|
116
|
-
|
|
117
|
-
if (req.method !== 'POST') {
|
|
118
|
-
res.setHeader('Allow', 'POST, OPTIONS');
|
|
119
|
-
return sendJson(res, 405, {
|
|
120
|
-
jsonrpc: '2.0',
|
|
121
|
-
error: { code: -32000, message: 'Method not allowed. Use POST for Streamable HTTP.' },
|
|
122
|
-
id: null,
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const rate = checkRateLimit(`mcp-http:${req.socket?.remoteAddress || 'unknown'}`);
|
|
127
|
-
if (!rate.allowed) return sendJson(res, 429, { error: rate.error }, { 'Retry-After': '60' });
|
|
128
|
-
res.setHeader('X-RateLimit-Remaining', String(rate.remaining));
|
|
129
|
-
|
|
130
|
-
const server = createMcpServer();
|
|
131
|
-
const transport = new StreamableHTTPServerTransport({
|
|
132
|
-
sessionIdGenerator: undefined,
|
|
133
|
-
enableJsonResponse: true,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
await server.connect(transport);
|
|
138
|
-
await transport.handleRequest(req, res);
|
|
139
|
-
} catch (error) {
|
|
140
|
-
console.error(`MCP Streamable HTTP: ${error.message}`);
|
|
141
|
-
sendJson(res, 500, {
|
|
142
|
-
jsonrpc: '2.0',
|
|
143
|
-
error: { code: -32603, message: 'Internal MCP server error.' },
|
|
144
|
-
id: null,
|
|
145
|
-
});
|
|
146
|
-
} finally {
|
|
147
|
-
await Promise.allSettled([transport.close(), server.close()]);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export function getMcpSessionStats() {
|
|
152
|
-
return { activeSessions: sessions.size, maxSessions: MAX_SESSIONS, ttlMs: SESSION_TTL_MS };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function isMcpRoute(pathname) {
|
|
156
|
-
return pathname === '/api/v1/mcp'
|
|
157
|
-
|| pathname === '/api/v1/mcp/sse'
|
|
158
|
-
|| pathname === '/api/v1/mcp/message';
|
|
159
|
-
}
|
|
1
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
2
|
+
import { createMcpServer } from './server.js';
|
|
3
|
+
import { checkRateLimit, securityMiddleware, MAX_REQUEST_BODY_SIZE } from './security.js';
|
|
4
|
+
|
|
5
|
+
function sendJson(res, statusCode, payload, headers = {}) {
|
|
6
|
+
if (res.headersSent) return;
|
|
7
|
+
res.statusCode = statusCode;
|
|
8
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
9
|
+
for (const [name, value] of Object.entries(headers)) res.setHeader(name, value);
|
|
10
|
+
res.end(JSON.stringify(payload));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function validateRequestSize(req, res) {
|
|
14
|
+
const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10);
|
|
15
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BODY_SIZE) {
|
|
16
|
+
sendJson(res, 413, { error: 'Request body too large.' }, { 'Retry-After': '60' });
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function handleMcpSse(req, res) {
|
|
23
|
+
sendJson(res, 410, { error: 'Legacy SSE transport removed. Use POST /mcp.' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function handleMcpMessage(req, res) {
|
|
27
|
+
sendJson(res, 410, { error: 'Legacy SSE transport removed. Use POST /mcp.' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function handleMcpHttp(req, res) {
|
|
31
|
+
const security = securityMiddleware(req, res);
|
|
32
|
+
if (security.blocked || !validateRequestSize(req, res)) return;
|
|
33
|
+
|
|
34
|
+
if (req.method === 'OPTIONS') {
|
|
35
|
+
res.statusCode = 204;
|
|
36
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
37
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
38
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, X-API-Key, Accept');
|
|
39
|
+
res.end();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (req.method !== 'POST') {
|
|
44
|
+
res.setHeader('Allow', 'GET, POST, OPTIONS');
|
|
45
|
+
return sendJson(res, 405, {
|
|
46
|
+
jsonrpc: '2.0',
|
|
47
|
+
error: { code: -32000, message: 'Method not allowed. Use POST for Streamable HTTP JSON-RPC, or GET /mcp for server discovery.' },
|
|
48
|
+
id: null,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const rate = checkRateLimit(`mcp-http:${req.socket?.remoteAddress || 'unknown'}`);
|
|
53
|
+
if (!rate.allowed) return sendJson(res, 429, { error: rate.error }, { 'Retry-After': '60' });
|
|
54
|
+
res.setHeader('X-RateLimit-Remaining', String(rate.remaining));
|
|
55
|
+
|
|
56
|
+
const server = createMcpServer();
|
|
57
|
+
const transport = new StreamableHTTPServerTransport({
|
|
58
|
+
sessionIdGenerator: undefined,
|
|
59
|
+
enableJsonResponse: true,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await server.connect(transport);
|
|
64
|
+
await transport.handleRequest(req, res);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error(`MCP Streamable HTTP: ${error.message}`);
|
|
67
|
+
sendJson(res, 500, {
|
|
68
|
+
jsonrpc: '2.0',
|
|
69
|
+
error: { code: -32603, message: 'Internal MCP server error.' },
|
|
70
|
+
id: null,
|
|
71
|
+
});
|
|
72
|
+
} finally {
|
|
73
|
+
await Promise.allSettled([transport.close(), server.close()]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getMcpSessionStats() {
|
|
78
|
+
return { mode: 'stateless', transport: 'streamable-http' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isMcpRoute(pathname) {
|
|
82
|
+
return pathname === '/mcp'
|
|
83
|
+
|| pathname === '/mcp/sse'
|
|
84
|
+
|| pathname === '/mcp/message';
|
|
85
|
+
}
|
package/server/mcp/security.js
CHANGED
|
@@ -1,138 +1,140 @@
|
|
|
1
|
-
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
const SECURITY_CONFIG = {
|
|
4
|
-
apiKeyRequired: !!process.env.MCP_API_KEY,
|
|
5
|
-
|
|
6
|
-
rateLimitWindowMs: 60_000,
|
|
7
|
-
rateLimitMaxRequests: 30,
|
|
8
|
-
|
|
9
|
-
maxConcurrentSessions: 100,
|
|
10
|
-
sessionTtlMs: 30 * 60_000,
|
|
11
|
-
maxMessagesPerSession: 500,
|
|
12
|
-
|
|
13
|
-
corsAllowedOrigins: (process.env.MCP_CORS_ORIGINS || '*').split(',').map(s => s.trim()),
|
|
14
|
-
maxRequestBodyBytes: 1_048_576,
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export const MAX_REQUEST_BODY_SIZE = SECURITY_CONFIG.maxRequestBodyBytes;
|
|
18
|
-
|
|
19
|
-
export function validateApiKey(req) {
|
|
20
|
-
if (!SECURITY_CONFIG.apiKeyRequired) return { valid: true };
|
|
21
|
-
|
|
22
|
-
let key = req.headers['x-api-key'];
|
|
23
|
-
if (!key) {
|
|
24
|
-
try {
|
|
25
|
-
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
26
|
-
key = url.searchParams.get('key') || url.searchParams.get('apiKey');
|
|
27
|
-
} catch {
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
if (!key) return { valid: false, error: 'Missing API key. Set X-API-Key header or ?key= query parameter.' };
|
|
31
|
-
const expected = Buffer.from(process.env.MCP_API_KEY || '');
|
|
32
|
-
const received = Buffer.from(String(key));
|
|
33
|
-
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
|
|
34
|
-
return { valid: false, error: 'Invalid API key.' };
|
|
35
|
-
}
|
|
36
|
-
return { valid: true };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const rateLimitStore = new Map();
|
|
40
|
-
|
|
41
|
-
export function checkRateLimit(sessionId) {
|
|
42
|
-
const now = Date.now();
|
|
43
|
-
const entry = rateLimitStore.get(sessionId);
|
|
44
|
-
|
|
45
|
-
if (!entry || now - entry.windowStart > SECURITY_CONFIG.rateLimitWindowMs) {
|
|
46
|
-
rateLimitStore.set(sessionId, { count: 1, windowStart: now });
|
|
47
|
-
return { allowed: true, remaining: SECURITY_CONFIG.rateLimitMaxRequests - 1 };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
entry.count++;
|
|
51
|
-
const remaining = SECURITY_CONFIG.rateLimitMaxRequests - entry.count;
|
|
52
|
-
if (remaining < 0) {
|
|
53
|
-
return { allowed: false, error: `Rate limit exceeded. Max ${SECURITY_CONFIG.rateLimitMaxRequests} requests per minute.` };
|
|
54
|
-
}
|
|
55
|
-
return { allowed: true, remaining };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const rateLimitCleanupTimer = setInterval(() => {
|
|
59
|
-
const now = Date.now();
|
|
60
|
-
for (const [id, entry] of rateLimitStore) {
|
|
61
|
-
if (now - entry.windowStart > SECURITY_CONFIG.rateLimitWindowMs * 2) {
|
|
62
|
-
rateLimitStore.delete(id);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}, 60_000);
|
|
66
|
-
rateLimitCleanupTimer.unref?.();
|
|
67
|
-
|
|
68
|
-
export function sanitizeInput(input) {
|
|
69
|
-
if (typeof input === 'string') {
|
|
70
|
-
return input
|
|
71
|
-
.replace(/[<>]/g, '')
|
|
72
|
-
.replace(/\x00/g, '')
|
|
73
|
-
.slice(0, 1000);
|
|
74
|
-
}
|
|
75
|
-
if (typeof input === 'object' && input !== null) {
|
|
76
|
-
const sanitized = {};
|
|
77
|
-
for (const [key, value] of Object.entries(input)) {
|
|
78
|
-
if (typeof key === 'string' && key.length <= 100) {
|
|
79
|
-
sanitized[key] = sanitizeInput(value);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return sanitized;
|
|
83
|
-
}
|
|
84
|
-
return input;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function validateSessionId(sessionId) {
|
|
88
|
-
if (!sessionId || typeof sessionId !== 'string') return false;
|
|
89
|
-
return /^[a-zA-Z0-9_-]{1,100}$/.test(sessionId);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function setCorsHeaders(res, origin) {
|
|
93
|
-
const allowed = SECURITY_CONFIG.corsAllowedOrigins;
|
|
94
|
-
const isAllowed = allowed.includes('*') || allowed.includes(origin);
|
|
95
|
-
|
|
96
|
-
if (isAllowed) {
|
|
97
|
-
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
98
|
-
} else {
|
|
99
|
-
res.setHeader('Access-Control-Allow-Origin', '');
|
|
100
|
-
}
|
|
101
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
102
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key');
|
|
103
|
-
res.setHeader('Access-Control-Max-Age', '86400');
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
res.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
res.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const SECURITY_CONFIG = {
|
|
4
|
+
apiKeyRequired: !!process.env.MCP_API_KEY,
|
|
5
|
+
|
|
6
|
+
rateLimitWindowMs: 60_000,
|
|
7
|
+
rateLimitMaxRequests: 30,
|
|
8
|
+
|
|
9
|
+
maxConcurrentSessions: 100,
|
|
10
|
+
sessionTtlMs: 30 * 60_000,
|
|
11
|
+
maxMessagesPerSession: 500,
|
|
12
|
+
|
|
13
|
+
corsAllowedOrigins: (process.env.MCP_CORS_ORIGINS || '*').split(',').map(s => s.trim()),
|
|
14
|
+
maxRequestBodyBytes: 1_048_576,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const MAX_REQUEST_BODY_SIZE = SECURITY_CONFIG.maxRequestBodyBytes;
|
|
18
|
+
|
|
19
|
+
export function validateApiKey(req) {
|
|
20
|
+
if (!SECURITY_CONFIG.apiKeyRequired) return { valid: true };
|
|
21
|
+
|
|
22
|
+
let key = req.headers['x-api-key'];
|
|
23
|
+
if (!key) {
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
26
|
+
key = url.searchParams.get('key') || url.searchParams.get('apiKey');
|
|
27
|
+
} catch {
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!key) return { valid: false, error: 'Missing API key. Set X-API-Key header or ?key= query parameter.' };
|
|
31
|
+
const expected = Buffer.from(process.env.MCP_API_KEY || '');
|
|
32
|
+
const received = Buffer.from(String(key));
|
|
33
|
+
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
|
|
34
|
+
return { valid: false, error: 'Invalid API key.' };
|
|
35
|
+
}
|
|
36
|
+
return { valid: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rateLimitStore = new Map();
|
|
40
|
+
|
|
41
|
+
export function checkRateLimit(sessionId) {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const entry = rateLimitStore.get(sessionId);
|
|
44
|
+
|
|
45
|
+
if (!entry || now - entry.windowStart > SECURITY_CONFIG.rateLimitWindowMs) {
|
|
46
|
+
rateLimitStore.set(sessionId, { count: 1, windowStart: now });
|
|
47
|
+
return { allowed: true, remaining: SECURITY_CONFIG.rateLimitMaxRequests - 1 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
entry.count++;
|
|
51
|
+
const remaining = SECURITY_CONFIG.rateLimitMaxRequests - entry.count;
|
|
52
|
+
if (remaining < 0) {
|
|
53
|
+
return { allowed: false, error: `Rate limit exceeded. Max ${SECURITY_CONFIG.rateLimitMaxRequests} requests per minute.` };
|
|
54
|
+
}
|
|
55
|
+
return { allowed: true, remaining };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const rateLimitCleanupTimer = setInterval(() => {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
for (const [id, entry] of rateLimitStore) {
|
|
61
|
+
if (now - entry.windowStart > SECURITY_CONFIG.rateLimitWindowMs * 2) {
|
|
62
|
+
rateLimitStore.delete(id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}, 60_000);
|
|
66
|
+
rateLimitCleanupTimer.unref?.();
|
|
67
|
+
|
|
68
|
+
export function sanitizeInput(input) {
|
|
69
|
+
if (typeof input === 'string') {
|
|
70
|
+
return input
|
|
71
|
+
.replace(/[<>]/g, '')
|
|
72
|
+
.replace(/\x00/g, '')
|
|
73
|
+
.slice(0, 1000);
|
|
74
|
+
}
|
|
75
|
+
if (typeof input === 'object' && input !== null) {
|
|
76
|
+
const sanitized = {};
|
|
77
|
+
for (const [key, value] of Object.entries(input)) {
|
|
78
|
+
if (typeof key === 'string' && key.length <= 100) {
|
|
79
|
+
sanitized[key] = sanitizeInput(value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return sanitized;
|
|
83
|
+
}
|
|
84
|
+
return input;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function validateSessionId(sessionId) {
|
|
88
|
+
if (!sessionId || typeof sessionId !== 'string') return false;
|
|
89
|
+
return /^[a-zA-Z0-9_-]{1,100}$/.test(sessionId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function setCorsHeaders(res, origin) {
|
|
93
|
+
const allowed = SECURITY_CONFIG.corsAllowedOrigins;
|
|
94
|
+
const isAllowed = allowed.includes('*') || allowed.includes(origin);
|
|
95
|
+
|
|
96
|
+
if (isAllowed) {
|
|
97
|
+
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
98
|
+
} else {
|
|
99
|
+
res.setHeader('Access-Control-Allow-Origin', '');
|
|
100
|
+
}
|
|
101
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
102
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, X-API-Key, Accept');
|
|
103
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
104
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
105
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
106
|
+
|
|
107
|
+
return isAllowed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function securityMiddleware(req, res) {
|
|
111
|
+
const origin = req.headers.origin || '';
|
|
112
|
+
const corsOk = setCorsHeaders(res, origin);
|
|
113
|
+
|
|
114
|
+
if (req.method === 'OPTIONS') {
|
|
115
|
+
res.statusCode = 204;
|
|
116
|
+
res.end();
|
|
117
|
+
return { blocked: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!corsOk) {
|
|
121
|
+
res.statusCode = 403;
|
|
122
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
123
|
+
res.end(JSON.stringify({ error: 'Origin not allowed by CORS policy.' }));
|
|
124
|
+
return { blocked: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const auth = validateApiKey(req);
|
|
128
|
+
if (!auth.valid) {
|
|
129
|
+
res.statusCode = 401;
|
|
130
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
131
|
+
res.end(JSON.stringify({ error: auth.error }));
|
|
132
|
+
return { blocked: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { blocked: false };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getSecurityConfig() {
|
|
139
|
+
return { ...SECURITY_CONFIG, apiKeyRequired: SECURITY_CONFIG.apiKeyRequired };
|
|
140
|
+
}
|