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
2 changes: 1 addition & 1 deletion ctx-mcp-bridge/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@context-engine-bridge/context-engine-mcp-bridge",
"version": "0.0.13",
"version": "0.0.14",
"description": "Context Engine MCP bridge (http/stdio proxy combining indexer + memory servers)",
"bin": {
"ctxce": "bin/ctxce.js",
Expand Down
57 changes: 53 additions & 4 deletions ctx-mcp-bridge/src/mcpServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -502,10 +502,23 @@ async function createBridgeServer(options) {
}

if (forceRecreate) {
try {
debugLog("[ctxce] Reinitializing remote MCP clients after session error.");
} catch {
// ignore logging failures
debugLog("[ctxce] Reinitializing remote MCP clients after session error.");

if (indexerClient) {
try {
await indexerClient.close();
} catch {
// ignore close errors
}
indexerClient = null;
}
if (memoryClient) {
try {
await memoryClient.close();
} catch {
// ignore close errors
}
memoryClient = null;
}
}

Expand Down Expand Up @@ -830,11 +843,28 @@ export async function runHttpMcpServer(options) {
return;
}

const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
let body = "";
let bodyLimitExceeded = false;
req.on("data", (chunk) => {
if (bodyLimitExceeded) return;
body += chunk;
if (body.length > MAX_BODY_SIZE) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

body.length is a character count after decoding chunks to a JS string, not the raw byte size on the wire; a request can exceed 10MB in bytes while staying under this limit. If the intent is a hard 10MB cap, tracking the incoming byte count is more reliable.

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎

bodyLimitExceeded = true;
req.destroy();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

req.destroy() typically destroys the underlying socket, so the client may never receive the intended 413 JSON response (it can look like a connection reset instead). Consider stopping further reads without destroying the socket until after the response is sent.

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎

res.statusCode = 413;
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
jsonrpc: "2.0",
error: { code: -32000, message: "Request body too large" },
id: null,
}),
);
}
});
req.on("end", async () => {
if (bodyLimitExceeded) return;
let parsed;
try {
parsed = body ? JSON.parse(body) : {};
Expand Down Expand Up @@ -902,6 +932,25 @@ export async function runHttpMcpServer(options) {
httpServer.listen(port, '127.0.0.1', () => {
debugLog(`[ctxce] HTTP MCP bridge listening on 127.0.0.1:${port}`);
});

let shuttingDown = false;
const shutdown = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
debugLog(`[ctxce] Received ${signal}; closing HTTP server (waiting for in-flight requests).`);
httpServer.close(() => {
debugLog("[ctxce] HTTP server closed.");
process.exit(0);
});
const SHUTDOWN_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes for long MCP calls
setTimeout(() => {
debugLog("[ctxce] Forcing exit after shutdown timeout.");
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS).unref();
};

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
}

function loadConfig(startDir) {
Expand Down
95 changes: 82 additions & 13 deletions ctx-mcp-bridge/src/oauthHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,97 @@ const pendingCodes = new Map();
// Maps client_id to client info
const registeredClients = new Map();

// ============================================================================
// Storage Limits and Cleanup Configuration
// ============================================================================

const MAX_TOKEN_STORE_SIZE = 10000;
const MAX_PENDING_CODES_SIZE = 1000;
const MAX_REGISTERED_CLIENTS_SIZE = 1000;
const TOKEN_EXPIRY_MS = 86400000; // 24 hours
const CODE_EXPIRY_MS = 600000; // 10 minutes
const CLIENT_EXPIRY_MS = 7 * 86400000; // 7 days
const CLEANUP_INTERVAL_MS = 300000; // 5 minutes

// Cleanup interval reference (for cleanup on shutdown if needed)
let cleanupIntervalId = null;

// ============================================================================
// OAuth Utilities
// ============================================================================

/**
* Clean up expired tokens from tokenStore
* Called periodically to prevent unbounded memory growth
*/
function cleanupExpiredTokens() {
const now = Date.now();
const expiryMs = 86400000; // 24 hours
for (const [token, data] of tokenStore.entries()) {
if (now - data.createdAt > expiryMs) {
if (now - data.createdAt > TOKEN_EXPIRY_MS) {
tokenStore.delete(token);
}
}
}

function cleanupExpiredCodes() {
const now = Date.now();
for (const [code, data] of pendingCodes.entries()) {
if (now - data.createdAt > CODE_EXPIRY_MS) {
pendingCodes.delete(code);
}
}
}

function cleanupExpiredClients() {
const now = Date.now();
for (const [clientId, data] of registeredClients.entries()) {
if (now - data.createdAt > CLIENT_EXPIRY_MS) {
registeredClients.delete(clientId);
}
}
}

function enforceStorageLimits() {
if (tokenStore.size > MAX_TOKEN_STORE_SIZE) {
const entries = [...tokenStore.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
const toRemove = entries.slice(0, tokenStore.size - MAX_TOKEN_STORE_SIZE);
for (const [key] of toRemove) {
tokenStore.delete(key);
}
}
if (pendingCodes.size > MAX_PENDING_CODES_SIZE) {
const entries = [...pendingCodes.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
const toRemove = entries.slice(0, pendingCodes.size - MAX_PENDING_CODES_SIZE);
for (const [key] of toRemove) {
pendingCodes.delete(key);
}
}
if (registeredClients.size > MAX_REGISTERED_CLIENTS_SIZE) {
const entries = [...registeredClients.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
const toRemove = entries.slice(0, registeredClients.size - MAX_REGISTERED_CLIENTS_SIZE);
for (const [key] of toRemove) {
registeredClients.delete(key);
}
}
}

function runPeriodicCleanup() {
cleanupExpiredTokens();
cleanupExpiredCodes();
cleanupExpiredClients();
enforceStorageLimits();
}

export function startCleanupInterval() {
if (!cleanupIntervalId) {
cleanupIntervalId = setInterval(runPeriodicCleanup, CLEANUP_INTERVAL_MS);
cleanupIntervalId.unref?.();
}
}

export function stopCleanupInterval() {
if (cleanupIntervalId) {
clearInterval(cleanupIntervalId);
cleanupIntervalId = null;
}
}

function generateToken() {
return randomBytes(32).toString("hex");
}
Expand Down Expand Up @@ -545,20 +618,14 @@ export function handleOAuthToken(req, res) {
});
}

/**
* Validate Bearer token and return session info
* @param {string} token - Bearer token
* @returns {{sessionId: string, backendUrl: string} | null}
*/
export function validateBearerToken(token) {
const tokenData = tokenStore.get(token);
if (!tokenData) {
return null;
}

// Check token age (24 hour expiry)
const tokenAge = Date.now() - tokenData.createdAt;
if (tokenAge > 86400000) {
if (tokenAge > TOKEN_EXPIRY_MS) {
tokenStore.delete(token);
return null;
}
Expand All @@ -583,3 +650,5 @@ export function isOAuthEndpoint(pathname) {
pathname === "/oauth/token"
);
}

startCleanupInterval();
5 changes: 5 additions & 0 deletions vscode-extension/context-engine-uploader/auth_utils.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const process = require('process');

const _skippedAuthCombos = new Set();
const _SKIPPED_AUTH_MAX_SIZE = 100;

function getFetch(deps) {
if (deps && typeof deps.fetchGlobal === 'function') {
Expand Down Expand Up @@ -110,6 +111,10 @@ async function ensureAuthIfRequired(endpoint, deps) {
'Skip for now',
);
if (choice !== 'Sign In') {
if (_skippedAuthCombos.size >= _SKIPPED_AUTH_MAX_SIZE) {
const first = _skippedAuthCombos.values().next().value;
if (first) _skippedAuthCombos.delete(first);
}
_skippedAuthCombos.add(skipKey);
return;
}
Expand Down
4 changes: 4 additions & 0 deletions vscode-extension/context-engine-uploader/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,10 @@ async function writeCtxConfig() {
}
}
function deactivate() {
if (pendingProfileRestartTimer) {
clearTimeout(pendingProfileRestartTimer);
pendingProfileRestartTimer = undefined;
}
disposeIndexedWatcher();
return Promise.all([stopProcesses()]);
}
Expand Down
5 changes: 4 additions & 1 deletion vscode-extension/context-engine-uploader/logs_terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ function createLogsTerminalManager(deps) {
function dispose() {
try {
logTailActive = false;
logsTerminal = undefined;
if (logsTerminal) {
logsTerminal.dispose();
logsTerminal = undefined;
}
} catch (_) {
// ignore
}
Expand Down
2 changes: 1 addition & 1 deletion vscode-extension/context-engine-uploader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "context-engine-uploader",
"displayName": "Context Engine Uploader",
"description": "Runs the Context-Engine remote upload client with a force sync on startup followed by watch mode. Requires Python with pip install requests urllib3 charset_normalizer.",
"version": "0.1.39",
"version": "0.1.40",
"publisher": "context-engine",
"engines": {
"vscode": "^1.85.0"
Expand Down
29 changes: 29 additions & 0 deletions vscode-extension/context-engine-uploader/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,28 @@ function findConfigFile(bases, filename) {
const _authStatusCache = new Map();
const _AUTH_STATUS_TTL_MS = 30_000;
const _AUTH_STATUS_TIMEOUT_MS = 750;
const _AUTH_STATUS_MAX_ENTRIES = 100;

const _endpointReachableCache = new Map();
const _ENDPOINT_REACHABLE_TTL_MS = 15_000;
const _ENDPOINT_REACHABLE_TIMEOUT_MS = 750;
const _ENDPOINT_REACHABLE_MAX_ENTRIES = 100;

function pruneCache(cache, ttlMs, maxEntries) {
const now = Date.now();
for (const [key, entry] of cache.entries()) {
if (!entry.ts || (now - entry.ts) > ttlMs) {
cache.delete(key);
}
}
if (cache.size > maxEntries) {
const entries = [...cache.entries()].sort((a, b) => (a[1].ts || 0) - (b[1].ts || 0));
const toRemove = entries.slice(0, cache.size - maxEntries);
for (const [key] of toRemove) {
cache.delete(key);
}
}
}

async function probeEndpointReachable(endpoint) {
const base = (endpoint || '').trim().replace(/\/+$/, '');
Expand Down Expand Up @@ -613,6 +631,8 @@ function register(context, deps) {
profilesProvider.refresh();
statusProvider.refresh();
actionsProvider.refresh();
pruneCache(_authStatusCache, _AUTH_STATUS_TTL_MS, _AUTH_STATUS_MAX_ENTRIES);
pruneCache(_endpointReachableCache, _ENDPOINT_REACHABLE_TTL_MS, _ENDPOINT_REACHABLE_MAX_ENTRIES);
}, 2000);
}
};
Expand All @@ -621,6 +641,15 @@ function register(context, deps) {
statusTree.onDidChangeVisibility(bumpTimer, null, context.subscriptions);
actionsTree.onDidChangeVisibility(bumpTimer, null, context.subscriptions);

context.subscriptions.push({
dispose: () => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = undefined;
}
}
});

bumpTimer();

return {
Expand Down
Loading