Skip to content

Commit 32cf42a

Browse files
committed
feat(extension/bridge): sync all UX improvements from private repo
Extension changes (v0.1.52): - commands.js: Add configureCloudEndpoint command for SaaS onboarding - dashboard.js: Full sync with private repo improvements - onboarding.js: Add mode selection for SaaS/local - process_manager.js: Add auth error notification parsing from bridge stderr - sidebar.js: Full sync with private repo improvements Bridge changes (v0.0.18): - mcpServer.js: Add formatAuthRejectionError, emitAuthErrorNotification for better auth UX - oauthHandler.js: Add hasAnyAuthEntries helper, PKCE validation comments This makes the public repo the source of truth for extension/bridge builds. All changes are additive UX improvements - no org dependencies.
1 parent 7d01386 commit 32cf42a

9 files changed

Lines changed: 862 additions & 52 deletions

File tree

ctx-mcp-bridge/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@context-engine-bridge/context-engine-mcp-bridge",
3-
"version": "0.0.17",
3+
"version": "0.0.18",
44
"description": "Context Engine MCP bridge (http/stdio proxy combining indexer + memory servers)",
55
"bin": {
66
"ctxce": "bin/ctxce.js",

ctx-mcp-bridge/src/mcpServer.js

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,36 @@ function isAuthRejectionError(error) {
175175
}
176176
}
177177

178+
/**
179+
* Format an auth rejection error with actionable information for users.
180+
* Includes the backend URL and a hint to sign in via VS Code command palette.
181+
*/
182+
function formatAuthRejectionError(originalError, backendUrl) {
183+
const originalMsg =
184+
(originalError && typeof originalError.message === "string" && originalError.message) ||
185+
(typeof originalError === "string" ? originalError : String(originalError || "Unknown auth error"));
186+
187+
const serverInfo = backendUrl ? ` (server: ${backendUrl})` : "";
188+
const hint = "Run 'Context Engine: Sign In' from the VS Code command palette to authenticate.";
189+
190+
return `Authentication failed${serverInfo}: ${originalMsg}. ${hint}`;
191+
}
192+
193+
/**
194+
* Emit a special log line that the VS Code extension can detect to show a notification toast.
195+
* Format: [ctxce:auth-error] JSON payload
196+
*/
197+
function emitAuthErrorNotification(backendUrl, originalError) {
198+
const payload = {
199+
type: "auth_rejection",
200+
backend: backendUrl || "unknown",
201+
message: String(originalError?.message || originalError || "Authentication failed"),
202+
hint: "Run 'Context Engine: Sign In' from the VS Code command palette",
203+
};
204+
// This special prefix allows the VS Code extension to detect auth errors in stderr
205+
debugLog(`[ctxce:auth-error] ${JSON.stringify(payload)}`);
206+
}
207+
178208
function getBridgeRetryAttempts() {
179209
try {
180210
const raw = process.env.CTXCE_TOOL_RETRY_ATTEMPTS;
@@ -520,8 +550,21 @@ async function createBridgeServer(options) {
520550
sessionId = resolveSessionId();
521551
}
522552

553+
// Only fall back to deterministic session if auth is not configured
554+
// If auth backend is configured but no session found, log warning instead of creating deterministic session
523555
if (!sessionId) {
524-
sessionId = `ctxce-${Buffer.from(workspace).toString("hex").slice(0, 24)}`;
556+
if (authBackendUrl) {
557+
// Auth is configured but no valid session - don't use deterministic fallback
558+
debugLog(`[ctxce] WARNING: Auth backend configured (${authBackendUrl}) but no valid session found.`);
559+
debugLog("[ctxce] To authenticate, run 'Context Engine: Sign In' from the VS Code command palette, or run `ctxce auth login` from the terminal.");
560+
debugLog("[ctxce] Continuing with deterministic session for backward compatibility, but this may fail if backend requires auth.");
561+
// Emit notification for VS Code extension
562+
emitAuthErrorNotification(authBackendUrl, { message: "No valid session found - authentication required" });
563+
sessionId = `ctxce-${Buffer.from(workspace).toString("hex").slice(0, 24)}`;
564+
} else {
565+
// No auth configured - use deterministic session for local-only operation
566+
sessionId = `ctxce-${Buffer.from(workspace).toString("hex").slice(0, 24)}`;
567+
}
525568
}
526569

527570
// Best-effort: inform the indexer of default collection and session.
@@ -531,6 +574,17 @@ async function createBridgeServer(options) {
531574
defaultsPayload.collection = defaultCollection;
532575
}
533576

577+
// Include org context from auth entry if available (for org-scoped collection isolation)
578+
try {
579+
const authEntry = backendHint ? loadAuthEntry(backendHint) : null;
580+
if (authEntry && authEntry.org_id) {
581+
defaultsPayload.org_id = authEntry.org_id;
582+
defaultsPayload.org_slug = authEntry.org_slug;
583+
}
584+
} catch {
585+
// ignore auth entry lookup failures
586+
}
587+
534588
const repoName = detectRepoName(workspace, config);
535589

536590
try {
@@ -782,10 +836,13 @@ async function createBridgeServer(options) {
782836

783837
// Backend auth rejection (mcp_auth.py ValidationError) - expire local auth
784838
if (isAuthRejectionError(err)) {
839+
const serverUrl = backendHint || uploadServiceUrl || "unknown server";
785840
debugLog(
786-
"[ctxce] tools/call: backend auth rejection; marking local session as expired: " +
841+
`[ctxce] tools/call: backend auth rejection from ${serverUrl}; marking local session as expired: ` +
787842
String(err),
788843
);
844+
// Emit special notification for VS Code extension to detect and show toast
845+
emitAuthErrorNotification(serverUrl, err);
789846
if (backendHint) {
790847
try {
791848
const entry = loadAuthEntry(backendHint);
@@ -796,6 +853,13 @@ async function createBridgeServer(options) {
796853
// ignore failures
797854
}
798855
}
856+
// Enhance error with actionable message before throwing
857+
if (!isTransientToolError(err) || attempt === maxAttempts - 1) {
858+
const enhancedMessage = formatAuthRejectionError(err, serverUrl);
859+
const enhancedError = new Error(enhancedMessage);
860+
enhancedError.cause = err;
861+
throw enhancedError;
862+
}
799863
}
800864

801865
if (!isTransientToolError(err) || attempt === maxAttempts - 1) {
@@ -908,10 +972,42 @@ export async function runHttpMcpServer(options) {
908972
// Check Bearer token for MCP endpoint (accept /mcp and /mcp/ for compatibility)
909973
if (parsedUrl.pathname === "/mcp" || parsedUrl.pathname === "/mcp/") {
910974
const authHeader = req.headers["authorization"] || "";
911-
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
912-
913-
// TODO: Validate token and inject session
914-
// For now, allow unauthenticated (backward compatible)
975+
const bearerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
976+
977+
// ----------------------------------------------------------------
978+
// AUTHENTICATION DESIGN: Permissive by default for backward compatibility
979+
// ----------------------------------------------------------------
980+
// The condition `bearerToken && hasTokenStore()` is INTENTIONALLY permissive:
981+
//
982+
// 1. PRE-EXISTING USERS (v3.0.0, local/dev mode):
983+
// - No OAuth flow occurs → tokenStore remains empty
984+
// - hasTokenStore() returns false → auth check skipped entirely
985+
// - Requests proceed without authentication (local dev experience)
986+
//
987+
// 2. SAAS PLATFORM USERS (multi-tenant):
988+
// - User completes OAuth flow → token stored in tokenStore
989+
// - hasTokenStore() returns true → bearer token validation required
990+
// - Invalid/missing tokens are rejected with 401
991+
//
992+
// WHY NOT `hasTokenStore() && !bearerToken` (require token when store exists)?
993+
// - Mixed environments: Some clients may be local (no auth) while others
994+
// are authenticated. Requiring auth globally after first login would
995+
// break local dev workflows in hybrid setups.
996+
// - The current design: "validate if provided, but don't require"
997+
//
998+
// SECURITY NOTE: If strict authentication is required for all clients once
999+
// any user authenticates, add an environment flag like CTXCE_REQUIRE_AUTH=1
1000+
// and check it here to enforce bearer tokens regardless of token store state.
1001+
// ----------------------------------------------------------------
1002+
if (bearerToken && oauthHandler.hasTokenStore()) {
1003+
const sessionId = oauthHandler.lookupToken(bearerToken);
1004+
if (!sessionId) {
1005+
// Token provided but invalid - reject
1006+
res.writeHead(401, { "Content-Type": "application/json" });
1007+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Invalid or expired bearer token" }, id: null }));
1008+
return;
1009+
}
1010+
}
9151011

9161012
if (req.method !== "POST") {
9171013
res.statusCode = 405;

ctx-mcp-bridge/src/oauthHandler.js

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,7 @@ export function handleOAuthStoreSession(req, res) {
534534
export function handleOAuthToken(req, res) {
535535
let body = "";
536536
req.on("data", (chunk) => { body += chunk; });
537-
req.on("end", () => {
537+
req.on("end", async () => {
538538
try {
539539
const data = new URLSearchParams(body);
540540
const code = data.get("code");
@@ -585,8 +585,24 @@ export function handleOAuthToken(req, res) {
585585
return;
586586
}
587587

588-
// TODO: Validate PKCE code_verifier against code_challenge
589-
// For now, skip validation (local bridge, trusted)
588+
// TODO: PKCE validation - disabled for now, no clients implement it yet
589+
// if (pendingData.codeChallenge && pendingData.codeChallengeMethod === "S256") {
590+
// const codeVerifier = data.get("code_verifier");
591+
// if (!codeVerifier) {
592+
// pendingCodes.delete(code);
593+
// res.statusCode = 400;
594+
// res.end(JSON.stringify({ error: "invalid_grant", error_description: "code_verifier required for PKCE" }));
595+
// return;
596+
// }
597+
// const crypto = await import("node:crypto");
598+
// const expectedChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
599+
// if (expectedChallenge !== pendingData.codeChallenge) {
600+
// pendingCodes.delete(code);
601+
// res.statusCode = 400;
602+
// res.end(JSON.stringify({ error: "invalid_grant", error_description: "code_verifier validation failed" }));
603+
// return;
604+
// }
605+
// }
590606

591607
// Clean up expired tokens periodically to prevent unbounded growth
592608
cleanupExpiredTokens();
@@ -651,4 +667,31 @@ export function isOAuthEndpoint(pathname) {
651667
);
652668
}
653669

670+
/**
671+
* Check if the token store has any entries (indicates auth is active)
672+
* @returns {boolean}
673+
*/
674+
export function hasTokenStore() {
675+
return tokenStore.size > 0;
676+
}
677+
678+
/**
679+
* Look up a bearer token and return the associated session ID
680+
* @param {string} token - Bearer token to validate
681+
* @returns {string|null} - Session ID if valid, null otherwise
682+
*/
683+
export function lookupToken(token) {
684+
const entry = tokenStore.get(token);
685+
if (!entry) return null;
686+
687+
// Check expiration
688+
const tokenAge = Date.now() - entry.createdAt;
689+
if (tokenAge > TOKEN_EXPIRY_MS) {
690+
tokenStore.delete(token);
691+
return null;
692+
}
693+
694+
return entry.sessionId || null;
695+
}
696+
654697
startCleanupInterval();

vscode-extension/context-engine-uploader/commands.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,41 @@ function registerExtensionCommands(deps) {
316316
}
317317
}));
318318

319+
// Remote endpoint configuration for onboarding
320+
disposables.push(vscode.commands.registerCommand('contextEngineUploader.configureCloudEndpoint', async () => {
321+
try {
322+
const endpoint = await vscode.window.showInputBox({
323+
prompt: 'Enter your Context Engine server URL (your own server or SaaS)',
324+
placeHolder: 'https://your-server.example.com or https://ce.context-engine.ai',
325+
value: '',
326+
ignoreFocusOut: true,
327+
validateInput: (value) => {
328+
if (!value.trim()) {
329+
return 'Endpoint URL is required';
330+
}
331+
try {
332+
const url = new URL(value);
333+
if (!url.protocol.startsWith('http')) {
334+
return 'URL must use http:// or https://';
335+
}
336+
return null;
337+
} catch (_) {
338+
return 'Please enter a valid URL (e.g., https://your-server.example.com)';
339+
}
340+
}
341+
});
342+
if (!endpoint) {
343+
// User cancelled - reset to mode selection
344+
await vscode.workspace.getConfiguration('contextEngineUploader').update('onboardingMode', '', vscode.ConfigurationTarget.Global);
345+
return;
346+
}
347+
await vscode.workspace.getConfiguration('contextEngineUploader').update('endpoint', endpoint.trim(), vscode.ConfigurationTarget.Global);
348+
vscode.window.showInformationMessage(`Endpoint set to ${endpoint}. You can now configure your workspace.`);
349+
} catch (error) {
350+
handleCatch(error, 'Endpoint configuration failed');
351+
}
352+
}));
353+
319354
return disposables;
320355
}
321356

0 commit comments

Comments
 (0)