context-mcp-server 1.0.1
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 +464 -0
- package/codegraph/__init__.py +0 -0
- package/codegraph/__main__.py +24 -0
- package/codegraph/__pycache__/__init__.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/__main__.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/cache.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/config.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/report.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/scanner.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/server.cpython-313.pyc +0 -0
- package/codegraph/cache.py +137 -0
- package/codegraph/config.py +31 -0
- package/codegraph/extractors/__init__.py +0 -0
- package/codegraph/extractors/__pycache__/__init__.cpython-313.pyc +0 -0
- package/codegraph/extractors/__pycache__/ast_extractor.cpython-313.pyc +0 -0
- package/codegraph/extractors/__pycache__/audio_extractor.cpython-313.pyc +0 -0
- package/codegraph/extractors/__pycache__/doc_extractor.cpython-313.pyc +0 -0
- package/codegraph/extractors/__pycache__/image_extractor.cpython-313.pyc +0 -0
- package/codegraph/extractors/ast_extractor.py +222 -0
- package/codegraph/extractors/audio_extractor.py +8 -0
- package/codegraph/extractors/doc_extractor.py +34 -0
- package/codegraph/extractors/image_extractor.py +26 -0
- package/codegraph/graph/__init__.py +0 -0
- package/codegraph/graph/__pycache__/__init__.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/builder.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/clustering.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/query.cpython-313.pyc +0 -0
- package/codegraph/graph/builder.py +145 -0
- package/codegraph/graph/clustering.py +40 -0
- package/codegraph/graph/query.py +283 -0
- package/codegraph/report.py +115 -0
- package/codegraph/scanner.py +92 -0
- package/codegraph/server.py +514 -0
- package/package.json +62 -0
- package/src/cli.js +1010 -0
- package/src/config.js +89 -0
- package/src/db.js +786 -0
- package/src/guard.js +20 -0
- package/src/hooks/autoContext.js +17 -0
- package/src/hooks/autoLink.js +7 -0
- package/src/http.js +765 -0
- package/src/index.js +47 -0
- package/src/search.js +50 -0
- package/src/server.js +80 -0
- package/src/summarizer.js +124 -0
- package/src/templates/AGENTS.md +76 -0
- package/src/templates/CLAUDE.md +94 -0
- package/src/templates/GEMINI.md +76 -0
- package/src/templates/cursor-rules.mdc +41 -0
- package/src/templates/windsurf-rules.md +35 -0
- package/src/tools/codegraph.js +215 -0
- package/src/tools/context.js +188 -0
- package/src/tools/discussion.js +123 -0
- package/src/tools/errorCheck.js +65 -0
- package/src/tools/fileTools.js +185 -0
- package/src/tools/gitTools.js +259 -0
- package/src/tools/search.js +55 -0
- package/src/vector.js +153 -0
package/src/http.js
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* context-mcp HTTP server — Streamable HTTP transport (OAuth 2.0)
|
|
4
|
+
*
|
|
5
|
+
* Enables web-based AI clients (ChatGPT, Claude.ai, etc.) to connect
|
|
6
|
+
* to context-mcp over HTTP.
|
|
7
|
+
*
|
|
8
|
+
* Auth: OAuth 2.0 Client Credentials flow
|
|
9
|
+
* 1. Client sends POST /oauth/token with client_id & client_secret
|
|
10
|
+
* 2. Server returns an access_token
|
|
11
|
+
* 3. Client uses Authorization: Bearer <token> on /mcp requests
|
|
12
|
+
*
|
|
13
|
+
* Setup:
|
|
14
|
+
* 1. Run: ctx online
|
|
15
|
+
* 2. Config auto-generated in ~/.context-mcp/contextconfig.json
|
|
16
|
+
* 3. Add http://localhost:3100 as MCP connector in Claude.ai / ChatGPT
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createServer as createHTTPServer } from 'node:http';
|
|
20
|
+
import { randomUUID, createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
24
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
25
|
+
import { getConfig, getConfigPath } from './config.js';
|
|
26
|
+
import { createServer } from './server.js';
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const ROOT = join(__dirname, '..');
|
|
30
|
+
|
|
31
|
+
// ── CLI flags ─────────────────────────────────────────────────────────────────
|
|
32
|
+
// --port <number> HTTP port (default: 3100)
|
|
33
|
+
// --host <string> Bind host (default: localhost)
|
|
34
|
+
// --access-git Enable git tools
|
|
35
|
+
// --data-dir <path> Override ~/.context-mcp storage directory
|
|
36
|
+
// --help Show usage
|
|
37
|
+
|
|
38
|
+
const _args = process.argv.slice(2);
|
|
39
|
+
|
|
40
|
+
if (_args.includes('--help') || _args.includes('-h')) {
|
|
41
|
+
console.log(`
|
|
42
|
+
context-mcp-http — Persistent AI memory MCP server (HTTP/OAuth transport)
|
|
43
|
+
|
|
44
|
+
Usage:
|
|
45
|
+
context-mcp-http [options]
|
|
46
|
+
npx context-mcp-server@latest [options]
|
|
47
|
+
|
|
48
|
+
Options:
|
|
49
|
+
--port <number> HTTP listen port (default: 3100)
|
|
50
|
+
--host <string> Bind address (default: localhost)
|
|
51
|
+
--access-git Enable git tools for connected clients
|
|
52
|
+
--data-dir <path> Override storage directory (default: ~/.context-mcp)
|
|
53
|
+
Also settable via env: CONTEXT_MCP_DIR=<path>
|
|
54
|
+
--help, -h Show this help
|
|
55
|
+
|
|
56
|
+
Platform setup (HTTP — for Claude.ai, ChatGPT, web clients):
|
|
57
|
+
1. Start the server: ctx online
|
|
58
|
+
(or directly: context-mcp-http --port 3100)
|
|
59
|
+
2. Add http://localhost:3100 as a remote MCP connector in your AI client.
|
|
60
|
+
Use the CLIENT_ID and CLIENT_SECRET from ~/.context-mcp/contextconfig.json
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
context-mcp-http
|
|
64
|
+
context-mcp-http --port 4000 --access-git
|
|
65
|
+
context-mcp-http --host 0.0.0.0 --port 3100
|
|
66
|
+
context-mcp-http --data-dir /my/project/.ctx
|
|
67
|
+
`);
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _getFlag(flag, defaultVal) {
|
|
72
|
+
const idx = _args.indexOf(flag);
|
|
73
|
+
if (idx !== -1 && _args[idx + 1] && !_args[idx + 1].startsWith('--')) return _args[idx + 1];
|
|
74
|
+
return defaultVal;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (_args.includes('--access-git')) process.env.CONTEXT_MCP_ACCESS_GIT = 'true';
|
|
78
|
+
const _dataDirIdx = _args.indexOf('--data-dir');
|
|
79
|
+
if (_dataDirIdx !== -1 && _args[_dataDirIdx + 1]) process.env.CONTEXT_MCP_DIR = _args[_dataDirIdx + 1];
|
|
80
|
+
|
|
81
|
+
// ── Load Config ──────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const config = getConfig();
|
|
84
|
+
|
|
85
|
+
// ── Config ───────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const PORT = Number(_getFlag('--port', null)) || config.port || 3100;
|
|
88
|
+
const HOST = _getFlag('--host', null) || config.host || 'localhost';
|
|
89
|
+
const CLIENT_ID = config.client_id || 'context-mcp';
|
|
90
|
+
const CLIENT_SECRET = config.client_secret || '';
|
|
91
|
+
|
|
92
|
+
// ── Validate credentials ────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
if (!CLIENT_ID || !CLIENT_SECRET) {
|
|
95
|
+
console.error(`
|
|
96
|
+
\x1b[31m✗ ERROR: CLIENT_ID and CLIENT_SECRET are required.\x1b[0m
|
|
97
|
+
|
|
98
|
+
These are managed automatically via contextconfig.json
|
|
99
|
+
Location: ${getConfigPath()}
|
|
100
|
+
|
|
101
|
+
The server auto-generates credentials on first run.
|
|
102
|
+
Check the config file for your CLIENT_ID and CLIENT_SECRET.
|
|
103
|
+
`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── OAuth 2.0 token store ────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
const activeTokens = new Map(); // token -> { expiresAt }
|
|
110
|
+
const authCodes = new Map(); // code -> { code_challenge, redirect_uri, expiresAt }
|
|
111
|
+
const TOKEN_TTL = 3600 * 1000; // 1 hour
|
|
112
|
+
const CODE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
113
|
+
|
|
114
|
+
function issueToken() {
|
|
115
|
+
return issueJWT({ sub: CLIENT_ID, scope: 'mcp' }, CLIENT_SECRET, TOKEN_TTL / 1000);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isValidToken(token) {
|
|
119
|
+
// Try JWT validation first (stateless)
|
|
120
|
+
if (token.includes('.')) {
|
|
121
|
+
return verifyJWT(token, CLIENT_SECRET) !== null;
|
|
122
|
+
}
|
|
123
|
+
// Fallback: opaque UUID in map
|
|
124
|
+
const entry = activeTokens.get(token);
|
|
125
|
+
if (!entry) return false;
|
|
126
|
+
if (Date.now() > entry.expiresAt) { activeTokens.delete(token); return false; }
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Clean expired tokens & codes every 10 minutes
|
|
131
|
+
setInterval(() => {
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
for (const [token, { expiresAt }] of activeTokens) {
|
|
134
|
+
if (now > expiresAt) activeTokens.delete(token);
|
|
135
|
+
}
|
|
136
|
+
for (const [code, { expiresAt }] of authCodes) {
|
|
137
|
+
if (now > expiresAt) authCodes.delete(code);
|
|
138
|
+
}
|
|
139
|
+
}, 600_000);
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
// ── HMAC request signing ─────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
const HMAC_WINDOW_MS = 30_000;
|
|
146
|
+
|
|
147
|
+
function makeHmacSignature(secret, timestampMs, body) {
|
|
148
|
+
return createHmac('sha256', secret)
|
|
149
|
+
.update(`${timestampMs}:${body}`)
|
|
150
|
+
.digest('hex');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function verifyHmac(secret, req, body) {
|
|
154
|
+
const ts = req.headers['x-timestamp'];
|
|
155
|
+
const sig = req.headers['x-signature'];
|
|
156
|
+
if (!ts || !sig) return false;
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
if (Math.abs(now - Number(ts)) > HMAC_WINDOW_MS) return false;
|
|
159
|
+
const expected = makeHmacSignature(secret, ts, body);
|
|
160
|
+
try {
|
|
161
|
+
return timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'));
|
|
162
|
+
} catch { return false; }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── JWT access token validation ──────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
function decodeJWT(token) {
|
|
168
|
+
try {
|
|
169
|
+
const [, payload] = token.split('.');
|
|
170
|
+
return JSON.parse(Buffer.from(payload, 'base64url').toString());
|
|
171
|
+
} catch { return null; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function verifyJWT(token, secret) {
|
|
175
|
+
const parts = token.split('.');
|
|
176
|
+
if (parts.length !== 3) return null;
|
|
177
|
+
const sig = createHmac('sha256', secret).update(`${parts[0]}.${parts[1]}`).digest('base64url');
|
|
178
|
+
try {
|
|
179
|
+
if (!timingSafeEqual(Buffer.from(sig), Buffer.from(parts[2]))) return null;
|
|
180
|
+
} catch { return null; }
|
|
181
|
+
const payload = decodeJWT(token);
|
|
182
|
+
if (!payload) return null;
|
|
183
|
+
if (payload.exp && Date.now() / 1000 > payload.exp) return null;
|
|
184
|
+
return payload;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function issueJWT(payload, secret, ttlSeconds = 3600) {
|
|
188
|
+
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
189
|
+
const body = Buffer.from(JSON.stringify({ ...payload, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + ttlSeconds })).toString('base64url');
|
|
190
|
+
const sig = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
|
|
191
|
+
return `${header}.${body}.${sig}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── MCP session management ───────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
const sessions = new Map();
|
|
197
|
+
|
|
198
|
+
async function createMCPSession() {
|
|
199
|
+
const id = randomUUID();
|
|
200
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => id });
|
|
201
|
+
const server = createServer({
|
|
202
|
+
enableFileTools: true,
|
|
203
|
+
enableGitTools: process.env.CONTEXT_MCP_ACCESS_GIT === 'true' || config.access_git === true,
|
|
204
|
+
});
|
|
205
|
+
await server.connect(transport);
|
|
206
|
+
sessions.set(id, { transport, server });
|
|
207
|
+
transport.onclose = () => sessions.delete(id);
|
|
208
|
+
return { transport, server };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
const ALLOWED_ORIGINS = [
|
|
214
|
+
'https://claude.ai',
|
|
215
|
+
`http://localhost:${PORT}`,
|
|
216
|
+
`http://127.0.0.1:${PORT}`,
|
|
217
|
+
...(config.allowed_origins || []),
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
function corsHeaders(reqOrigin) {
|
|
221
|
+
const origin = ALLOWED_ORIGINS.includes(reqOrigin) ? reqOrigin : ALLOWED_ORIGINS[0];
|
|
222
|
+
return {
|
|
223
|
+
'Access-Control-Allow-Origin': origin,
|
|
224
|
+
'Vary': 'Origin',
|
|
225
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
226
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Session-Id, Accept',
|
|
227
|
+
'Access-Control-Expose-Headers': 'Mcp-Session-Id',
|
|
228
|
+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function sendJSON(res, statusCode, data, reqOrigin) {
|
|
233
|
+
res.writeHead(statusCode, { ...corsHeaders(reqOrigin), 'Content-Type': 'application/json' });
|
|
234
|
+
res.end(JSON.stringify(data));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Simple in-memory rate limiter: ip -> { count, resetAt }
|
|
238
|
+
const _rateLimits = new Map();
|
|
239
|
+
function checkRate(ip, limit, windowMs) {
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
let e = _rateLimits.get(ip) ?? { count: 0, resetAt: now + windowMs };
|
|
242
|
+
if (now > e.resetAt) e = { count: 0, resetAt: now + windowMs };
|
|
243
|
+
e.count++;
|
|
244
|
+
_rateLimits.set(ip, e);
|
|
245
|
+
return e.count <= limit;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
249
|
+
|
|
250
|
+
async function readBody(req) {
|
|
251
|
+
const chunks = [];
|
|
252
|
+
let totalBytes = 0;
|
|
253
|
+
for await (const chunk of req) {
|
|
254
|
+
totalBytes += chunk.length;
|
|
255
|
+
if (totalBytes > MAX_BODY_BYTES) {
|
|
256
|
+
req.destroy();
|
|
257
|
+
throw new Error('Request body too large (max 10 MB)');
|
|
258
|
+
}
|
|
259
|
+
chunks.push(chunk);
|
|
260
|
+
}
|
|
261
|
+
return Buffer.concat(chunks).toString();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Request handler ──────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
async function handleRequest(req, res) {
|
|
267
|
+
const url = new URL(req.url, `http://${HOST}:${PORT}`);
|
|
268
|
+
|
|
269
|
+
const reqOrigin = req.headers['origin'] || '';
|
|
270
|
+
const clientIp = req.socket?.remoteAddress || 'unknown';
|
|
271
|
+
|
|
272
|
+
// CORS preflight
|
|
273
|
+
if (req.method === 'OPTIONS') {
|
|
274
|
+
res.writeHead(204, corsHeaders(reqOrigin));
|
|
275
|
+
res.end();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Health check (public)
|
|
280
|
+
if (url.pathname === '/health' && req.method === 'GET') {
|
|
281
|
+
sendJSON(res, 200, { status: 'ok', sessions: sessions.size }, reqOrigin);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── OAuth 2.0 Authorization endpoint ──
|
|
286
|
+
if (url.pathname === '/authorize' && req.method === 'GET') {
|
|
287
|
+
const response_type = url.searchParams.get('response_type');
|
|
288
|
+
const client_id = url.searchParams.get('client_id');
|
|
289
|
+
const redirect_uri = url.searchParams.get('redirect_uri');
|
|
290
|
+
const state = url.searchParams.get('state');
|
|
291
|
+
const code_challenge = url.searchParams.get('code_challenge');
|
|
292
|
+
|
|
293
|
+
if (response_type !== 'code') {
|
|
294
|
+
sendJSON(res, 400, { error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' }, reqOrigin);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (client_id !== CLIENT_ID) {
|
|
299
|
+
sendJSON(res, 401, { error: 'invalid_client' }, reqOrigin);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!redirect_uri) {
|
|
304
|
+
sendJSON(res, 400, { error: 'invalid_request', error_description: 'redirect_uri is required' }, reqOrigin);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Validate redirect_uri against whitelist to prevent open redirect attacks
|
|
309
|
+
const allowedRedirectUris = config.allowed_redirect_uris ?? ['https://claude.ai'];
|
|
310
|
+
if (!allowedRedirectUris.some(u => redirect_uri.startsWith(u))) {
|
|
311
|
+
sendJSON(res, 400, { error: 'invalid_request', error_description: 'redirect_uri not allowed' }, reqOrigin);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Generate an authorization code
|
|
316
|
+
const code = randomUUID();
|
|
317
|
+
authCodes.set(code, {
|
|
318
|
+
code_challenge,
|
|
319
|
+
redirect_uri,
|
|
320
|
+
expiresAt: Date.now() + CODE_TTL
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Auto-redirect back to the client (e.g. Claude.ai)
|
|
324
|
+
const redirectUrl = new URL(redirect_uri);
|
|
325
|
+
redirectUrl.searchParams.set('code', code);
|
|
326
|
+
if (state) redirectUrl.searchParams.set('state', state);
|
|
327
|
+
|
|
328
|
+
res.writeHead(302, { Location: redirectUrl.toString() });
|
|
329
|
+
res.end();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── OAuth 2.0 token endpoint ──
|
|
334
|
+
if ((url.pathname === '/oauth/token' || url.pathname === '/token') && req.method === 'POST') {
|
|
335
|
+
// Rate limit: 10 requests per minute per IP
|
|
336
|
+
if (!checkRate(clientIp, 10, 60_000)) {
|
|
337
|
+
sendJSON(res, 429, { error: 'rate_limit_exceeded', error_description: 'Too many token requests' }, reqOrigin);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const bodyStr = await readBody(req);
|
|
342
|
+
let params;
|
|
343
|
+
|
|
344
|
+
const ct = req.headers['content-type'] || '';
|
|
345
|
+
|
|
346
|
+
if (ct.includes('application/json')) {
|
|
347
|
+
try { params = JSON.parse(bodyStr); } catch { params = {}; }
|
|
348
|
+
} else {
|
|
349
|
+
params = Object.fromEntries(new URLSearchParams(bodyStr));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Also check Basic Auth in headers just in case Claude is using it
|
|
353
|
+
let clientId = params.client_id;
|
|
354
|
+
let clientSecret = params.client_secret;
|
|
355
|
+
const authHeader = req.headers['authorization'];
|
|
356
|
+
if (authHeader && authHeader.startsWith('Basic ')) {
|
|
357
|
+
const b64 = authHeader.slice(6);
|
|
358
|
+
const [u, p] = Buffer.from(b64, 'base64').toString().split(':');
|
|
359
|
+
clientId = clientId || u;
|
|
360
|
+
clientSecret = clientSecret || p;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const { grant_type, code, code_verifier } = params;
|
|
364
|
+
|
|
365
|
+
if (clientId !== CLIENT_ID || clientSecret !== CLIENT_SECRET) {
|
|
366
|
+
sendJSON(res, 401, { error: 'invalid_client', error_description: 'Invalid client_id or client_secret' }, reqOrigin);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (grant_type === 'authorization_code') {
|
|
371
|
+
const codeEntry = authCodes.get(code);
|
|
372
|
+
if (!codeEntry || Date.now() > codeEntry.expiresAt) {
|
|
373
|
+
sendJSON(res, 400, { error: 'invalid_grant', error_description: 'Invalid or expired authorization code' }, reqOrigin);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (codeEntry.code_challenge && code_verifier) {
|
|
378
|
+
const hash = createHash('sha256').update(code_verifier).digest('base64')
|
|
379
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
380
|
+
if (hash !== codeEntry.code_challenge) {
|
|
381
|
+
sendJSON(res, 400, { error: 'invalid_grant', error_description: 'PKCE verification failed' }, reqOrigin);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
authCodes.delete(code);
|
|
387
|
+
} else if (grant_type !== 'client_credentials') {
|
|
388
|
+
sendJSON(res, 400, { error: 'unsupported_grant_type', error_description: 'Unsupported grant type' }, reqOrigin);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const token = issueToken();
|
|
393
|
+
sendJSON(res, 200, {
|
|
394
|
+
access_token: token,
|
|
395
|
+
token_type: 'Bearer',
|
|
396
|
+
expires_in: TOKEN_TTL / 1000,
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── OAuth discovery (well-known) ──
|
|
402
|
+
if (url.pathname === '/.well-known/oauth-authorization-server' && req.method === 'GET') {
|
|
403
|
+
const base = `${req.headers['x-forwarded-proto'] || 'https'}://${req.headers.host || `${HOST}:${PORT}`}`;
|
|
404
|
+
sendJSON(res, 200, {
|
|
405
|
+
issuer: base,
|
|
406
|
+
authorization_endpoint: `${base}/authorize`,
|
|
407
|
+
token_endpoint: `${base}/oauth/token`,
|
|
408
|
+
grant_types_supported: ['client_credentials', 'authorization_code'],
|
|
409
|
+
response_types_supported: ['code'],
|
|
410
|
+
code_challenge_methods_supported: ['S256'],
|
|
411
|
+
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
|
|
412
|
+
}, reqOrigin);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// User-friendly HTML guide for the root route (GET only)
|
|
417
|
+
if (url.pathname === '/' && req.method === 'GET') {
|
|
418
|
+
const html = `
|
|
419
|
+
<!DOCTYPE html>
|
|
420
|
+
<html lang="en">
|
|
421
|
+
<head>
|
|
422
|
+
<meta charset="UTF-8">
|
|
423
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
424
|
+
<title>context-mcp | Secure Transport</title>
|
|
425
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
426
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
427
|
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&family=JetBrains+Mono&display=swap" rel="stylesheet">
|
|
428
|
+
<style>
|
|
429
|
+
:root {
|
|
430
|
+
--bg: #05070a;
|
|
431
|
+
--card: rgba(15, 18, 25, 0.8);
|
|
432
|
+
--brand: #00f2ff;
|
|
433
|
+
--accent: #bc13fe;
|
|
434
|
+
--text: #e2e8f0;
|
|
435
|
+
--muted: #64748b;
|
|
436
|
+
--success: #10b981;
|
|
437
|
+
--glass: rgba(255, 255, 255, 0.03);
|
|
438
|
+
--border: rgba(255, 255, 255, 0.1);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
* { box-sizing: border-box; }
|
|
442
|
+
body {
|
|
443
|
+
font-family: 'Outfit', sans-serif;
|
|
444
|
+
background: var(--bg);
|
|
445
|
+
background-image:
|
|
446
|
+
radial-gradient(circle at 20% 30%, rgba(0, 242, 255, 0.05) 0%, transparent 40%),
|
|
447
|
+
radial-gradient(circle at 80% 70%, rgba(188, 19, 254, 0.05) 0%, transparent 40%);
|
|
448
|
+
color: var(--text);
|
|
449
|
+
margin: 0;
|
|
450
|
+
display: flex;
|
|
451
|
+
align-items: center;
|
|
452
|
+
justify-content: center;
|
|
453
|
+
min-height: 100vh;
|
|
454
|
+
line-height: 1.6;
|
|
455
|
+
overflow-x: hidden;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.container {
|
|
459
|
+
width: 100%;
|
|
460
|
+
max-width: 680px;
|
|
461
|
+
padding: 40px;
|
|
462
|
+
position: relative;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.glass-card {
|
|
466
|
+
background: var(--card);
|
|
467
|
+
backdrop-filter: blur(12px);
|
|
468
|
+
-webkit-backdrop-filter: blur(12px);
|
|
469
|
+
border: 1px solid var(--border);
|
|
470
|
+
border-radius: 24px;
|
|
471
|
+
padding: 48px;
|
|
472
|
+
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
|
473
|
+
position: relative;
|
|
474
|
+
z-index: 1;
|
|
475
|
+
overflow: hidden;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.glass-card::before {
|
|
479
|
+
content: '';
|
|
480
|
+
position: absolute;
|
|
481
|
+
top: 0; left: 0; right: 0; height: 2px;
|
|
482
|
+
background: linear-gradient(90deg, transparent, var(--brand), var(--accent), transparent);
|
|
483
|
+
opacity: 0.5;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.logo-area {
|
|
487
|
+
display: flex;
|
|
488
|
+
align-items: center;
|
|
489
|
+
gap: 16px;
|
|
490
|
+
margin-bottom: 32px;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.status-pulse {
|
|
494
|
+
width: 12px;
|
|
495
|
+
height: 12px;
|
|
496
|
+
background: var(--success);
|
|
497
|
+
border-radius: 50%;
|
|
498
|
+
position: relative;
|
|
499
|
+
box-shadow: 0 0 15px var(--success);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
.status-pulse::after {
|
|
503
|
+
content: '';
|
|
504
|
+
position: absolute;
|
|
505
|
+
top: -4px; left: -4px; right: -4px; bottom: -4px;
|
|
506
|
+
border: 2px solid var(--success);
|
|
507
|
+
border-radius: 50%;
|
|
508
|
+
animation: pulse 2s infinite;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
@keyframes pulse {
|
|
512
|
+
0% { transform: scale(1); opacity: 0.8; }
|
|
513
|
+
100% { transform: scale(2); opacity: 0; }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
h1 {
|
|
517
|
+
font-size: 32px;
|
|
518
|
+
font-weight: 600;
|
|
519
|
+
margin: 0;
|
|
520
|
+
background: linear-gradient(135deg, #fff 0%, #94a3b8 100%);
|
|
521
|
+
-webkit-background-clip: text;
|
|
522
|
+
-webkit-text-fill-color: transparent;
|
|
523
|
+
letter-spacing: -0.02em;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
p { color: var(--muted); font-size: 17px; margin-bottom: 32px; }
|
|
527
|
+
|
|
528
|
+
.setup-grid {
|
|
529
|
+
display: grid;
|
|
530
|
+
gap: 20px;
|
|
531
|
+
margin-top: 40px;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.step {
|
|
535
|
+
background: var(--glass);
|
|
536
|
+
border: 1px solid var(--border);
|
|
537
|
+
border-radius: 16px;
|
|
538
|
+
padding: 20px;
|
|
539
|
+
transition: all 0.3s ease;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
.step:hover {
|
|
543
|
+
border-color: rgba(0, 242, 255, 0.3);
|
|
544
|
+
transform: translateY(-2px);
|
|
545
|
+
background: rgba(255, 255, 255, 0.05);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
.step-num {
|
|
549
|
+
display: inline-block;
|
|
550
|
+
width: 24px;
|
|
551
|
+
height: 24px;
|
|
552
|
+
background: var(--brand);
|
|
553
|
+
color: var(--bg);
|
|
554
|
+
border-radius: 6px;
|
|
555
|
+
text-align: center;
|
|
556
|
+
font-size: 14px;
|
|
557
|
+
font-weight: 600;
|
|
558
|
+
line-height: 24px;
|
|
559
|
+
margin-bottom: 12px;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
.step-title {
|
|
563
|
+
font-weight: 600;
|
|
564
|
+
color: var(--text);
|
|
565
|
+
margin-bottom: 8px;
|
|
566
|
+
display: block;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
.step-content {
|
|
570
|
+
font-size: 14px;
|
|
571
|
+
color: var(--muted);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
code {
|
|
575
|
+
font-family: 'JetBrains Mono', monospace;
|
|
576
|
+
background: rgba(0, 0, 0, 0.3);
|
|
577
|
+
color: var(--brand);
|
|
578
|
+
padding: 4px 8px;
|
|
579
|
+
border-radius: 6px;
|
|
580
|
+
font-size: 13px;
|
|
581
|
+
border: 1px solid rgba(0, 242, 255, 0.1);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.badge {
|
|
585
|
+
display: inline-flex;
|
|
586
|
+
align-items: center;
|
|
587
|
+
background: rgba(16, 185, 129, 0.1);
|
|
588
|
+
color: var(--success);
|
|
589
|
+
padding: 4px 12px;
|
|
590
|
+
border-radius: 20px;
|
|
591
|
+
font-size: 12px;
|
|
592
|
+
font-weight: 600;
|
|
593
|
+
margin-left: auto;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
footer {
|
|
597
|
+
margin-top: 32px;
|
|
598
|
+
text-align: center;
|
|
599
|
+
font-size: 13px;
|
|
600
|
+
color: var(--muted);
|
|
601
|
+
}
|
|
602
|
+
</style>
|
|
603
|
+
</head>
|
|
604
|
+
<body>
|
|
605
|
+
<div class="container">
|
|
606
|
+
<div class="glass-card">
|
|
607
|
+
<div class="logo-area">
|
|
608
|
+
<div class="status-pulse"></div>
|
|
609
|
+
<h1>context-mcp</h1>
|
|
610
|
+
<div class="badge">HTTP LOCAL</div>
|
|
611
|
+
</div>
|
|
612
|
+
|
|
613
|
+
<p>Your AI memory server is running and ready for connections from Claude.ai or ChatGPT.</p>
|
|
614
|
+
|
|
615
|
+
<div class="setup-grid">
|
|
616
|
+
<div class="step">
|
|
617
|
+
<span class="step-num">1</span>
|
|
618
|
+
<span class="step-title">Add MCP Connector</span>
|
|
619
|
+
<span class="step-content">Go to your AI client → Settings → Integrations → <b>Add MCP Connector</b>.</span>
|
|
620
|
+
</div>
|
|
621
|
+
|
|
622
|
+
<div class="step">
|
|
623
|
+
<span class="step-num">2</span>
|
|
624
|
+
<span class="step-title">Server URL</span>
|
|
625
|
+
<span class="step-content">Enter: <code>${req.headers['x-forwarded-proto'] || req.socket?.encrypted ? 'https' : 'http'}://${req.headers.host}</code></span>
|
|
626
|
+
</div>
|
|
627
|
+
|
|
628
|
+
<div class="step">
|
|
629
|
+
<span class="step-num">3</span>
|
|
630
|
+
<span class="step-title">Credentials</span>
|
|
631
|
+
<span class="step-content">
|
|
632
|
+
Client ID & Secret: see <code>~/.context-mcp/contextconfig.json</code>
|
|
633
|
+
</span>
|
|
634
|
+
</div>
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
<footer>
|
|
638
|
+
© ${new Date().getFullYear()} context-mcp • Premium AI Memory
|
|
639
|
+
</footer>
|
|
640
|
+
</div>
|
|
641
|
+
</body>
|
|
642
|
+
</html>
|
|
643
|
+
`;
|
|
644
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
645
|
+
res.end(html);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Allow both /mcp and / to handle MCP requests (but not GET / — that serves the HTML guide)
|
|
650
|
+
if (url.pathname !== '/mcp' && !(url.pathname === '/' && req.method !== 'GET')) {
|
|
651
|
+
sendJSON(res, 404, { error: `Not found: ${url.pathname}` }, reqOrigin);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ── Auth: validate Bearer token ──
|
|
656
|
+
const auth = req.headers['authorization'];
|
|
657
|
+
const token = auth?.startsWith('Bearer ') ? auth.slice(7) : null;
|
|
658
|
+
if (!token || !isValidToken(token)) {
|
|
659
|
+
sendJSON(res, 401, { error: 'invalid_token', error_description: 'Missing or expired token. POST /oauth/token first.' }, reqOrigin);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const sessionId = req.headers['mcp-session-id'] || null;
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
if (req.method === 'POST') {
|
|
667
|
+
const bodyStr = await readBody(req);
|
|
668
|
+
let body;
|
|
669
|
+
try { body = JSON.parse(bodyStr); } catch { body = null; }
|
|
670
|
+
|
|
671
|
+
if (body && isInitializeRequest(body)) {
|
|
672
|
+
const { transport } = await createMCPSession();
|
|
673
|
+
await transport.handleRequest(req, res, body);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
678
|
+
sendJSON(res, 400, { error: 'Missing or invalid Mcp-Session-Id.' }, reqOrigin);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const { transport } = sessions.get(sessionId);
|
|
682
|
+
await transport.handleRequest(req, res, body);
|
|
683
|
+
|
|
684
|
+
} else if (req.method === 'GET') {
|
|
685
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
686
|
+
sendJSON(res, 400, { error: 'Missing or invalid Mcp-Session-Id' }, reqOrigin);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const { transport } = sessions.get(sessionId);
|
|
690
|
+
await transport.handleRequest(req, res);
|
|
691
|
+
|
|
692
|
+
} else if (req.method === 'DELETE') {
|
|
693
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
694
|
+
const { transport } = sessions.get(sessionId);
|
|
695
|
+
await transport.close();
|
|
696
|
+
sessions.delete(sessionId);
|
|
697
|
+
}
|
|
698
|
+
sendJSON(res, 200, { closed: true }, reqOrigin);
|
|
699
|
+
|
|
700
|
+
} else {
|
|
701
|
+
sendJSON(res, 405, { error: 'Method not allowed' }, reqOrigin);
|
|
702
|
+
}
|
|
703
|
+
} catch (err) {
|
|
704
|
+
console.error('MCP HTTP error:', err.message);
|
|
705
|
+
if (!res.headersSent) {
|
|
706
|
+
sendJSON(res, 500, { error: err.message }, reqOrigin);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ── Start server ─────────────────────────────────────────────────────────────
|
|
712
|
+
|
|
713
|
+
async function start() {
|
|
714
|
+
const server = createHTTPServer(handleRequest);
|
|
715
|
+
|
|
716
|
+
server.listen(PORT, async () => {
|
|
717
|
+
const LOGO = `
|
|
718
|
+
\x1b[96m ██████╗ ██████╗ ███╗ ██╗████████╗███████╗██╗ ██╗████████╗
|
|
719
|
+
██╔════╝██╔═══██╗████╗ ██║╚══██╔══╝██╔════╝╚██╗██╔╝╚══██╔══╝
|
|
720
|
+
██║ ██║ ██║██╔██╗ ██║ ██║ █████╗ ╚███╔╝ ██║
|
|
721
|
+
██║ ██║ ██║██║╚██╗██║ ██║ ██╔══╝ ██╔██╗ ██║
|
|
722
|
+
╚██████╗╚██████╔╝██║ ╚████║ ██║ ███████╗██╔╝ ██╗ ██║
|
|
723
|
+
╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝\x1b[0m`;
|
|
724
|
+
|
|
725
|
+
console.log(LOGO);
|
|
726
|
+
console.log(`
|
|
727
|
+
\x1b[90m┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\x1b[0m
|
|
728
|
+
\x1b[90m┃\x1b[0m \x1b[1m\x1b[95mcontext-mcp Web AI Server\x1b[0m \x1b[90m┃\x1b[0m
|
|
729
|
+
\x1b[90m┃\x1b[0m \x1b[90m┃\x1b[0m
|
|
730
|
+
\x1b[90m┃\x1b[0m \x1b[2mEndpoint:\x1b[0m \x1b[96m${'http://' + HOST + ':' + PORT}\x1b[0m \x1b[90m┃\x1b[0m
|
|
731
|
+
\x1b[90m┃\x1b[0m \x1b[2mConfig:\x1b[0m \x1b[2m${getConfigPath().padEnd(36)}\x1b[0m\x1b[90m┃\x1b[0m
|
|
732
|
+
\x1b[90m┃\x1b[0m \x1b[2mMCP path:\x1b[0m \x1b[94mPOST /mcp\x1b[0m \x1b[90m┃\x1b[0m
|
|
733
|
+
\x1b[90m┃\x1b[0m \x1b[2mOAuth:\x1b[0m \x1b[94mPOST /oauth/token\x1b[0m \x1b[90m┃\x1b[0m
|
|
734
|
+
\x1b[90m┃\x1b[0m \x1b[2mHealth:\x1b[0m \x1b[94mGET /health\x1b[0m \x1b[90m┃\x1b[0m
|
|
735
|
+
\x1b[90m┃\x1b[0m \x1b[90m┃\x1b[0m
|
|
736
|
+
\x1b[90m┃\x1b[0m \x1b[2mAuth:\x1b[0m 🔒 Client Credentials + OAuth 2.0 \x1b[90m┃\x1b[0m
|
|
737
|
+
\x1b[90m┃\x1b[0m \x1b[90m┃\x1b[0m
|
|
738
|
+
\x1b[90m┃\x1b[0m \x1b[2mClient ID:\x1b[0m \x1b[92m${CLIENT_ID.padEnd(34)}\x1b[0m\x1b[90m┃\x1b[0m
|
|
739
|
+
\x1b[90m┃\x1b[0m \x1b[2mSecret:\x1b[0m \x1b[2m${CLIENT_SECRET.slice(0, 8)}... (see config)\x1b[0m\x1b[90m┃\x1b[0m
|
|
740
|
+
\x1b[90m┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\x1b[0m
|
|
741
|
+
`);
|
|
742
|
+
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// Graceful shutdown
|
|
746
|
+
const shutdown = async (signal) => {
|
|
747
|
+
console.log(`\n[http] Received ${signal}, shutting down...`);
|
|
748
|
+
|
|
749
|
+
server.close(() => {
|
|
750
|
+
console.log('[http] Server closed');
|
|
751
|
+
process.exit(0);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// Force shutdown after 10s
|
|
755
|
+
setTimeout(() => {
|
|
756
|
+
console.error('[http] Forced shutdown');
|
|
757
|
+
process.exit(1);
|
|
758
|
+
}, 10000);
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
762
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
start();
|