Files
openchamber/packages/web/server/lib/opencode/routes.js
T
Dave Otero d73edc672e Improve MCP settings auth flow, remote config support, and diagnostics UX (#953)
* feat: improve MCP settings auth workflow

* fix: complete MCP settings auth flow

* fix: harden MCP settings auth flow

* fix: add MCP settings refresh control

* fix: stabilize MCP authorization and status handling

* fix: clarify MCP advanced remote options toggle

* fix: improve MCP import and diagnostics

* feat: improve MCP settings panel visual hierarchy and UX

* fix: expose MCP auth actions in connected state

* fix: remove MCP import snippet helper text

* fix: address MCP review feedback

* fix: correct MCP page transport layout after rebase
2026-04-21 20:46:51 +03:00

299 lines
10 KiB
JavaScript

import { createProjectIdFromPath } from '../projects/project-id.js';
export const registerOpenCodeRoutes = (app, dependencies) => {
const {
crypto,
clientReloadDelayMs,
getOpenCodeResolutionSnapshot,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
persistSettings,
sanitizeProjects,
validateDirectoryPath,
resolveProjectDirectory,
getProviderSources,
removeProviderConfig,
refreshOpenCodeAfterConfigChange,
} = dependencies;
let authLibrary = null;
const pendingMcpAuthContextByState = new Map();
const PENDING_MCP_AUTH_TTL_MS = 30 * 60 * 1000;
const getAuthLibrary = async () => {
if (!authLibrary) {
authLibrary = await import('./auth.js');
}
return authLibrary;
};
const normalizePendingString = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed || null;
};
const pruneExpiredPendingMcpAuthContexts = () => {
const now = Date.now();
for (const [state, entry] of pendingMcpAuthContextByState.entries()) {
if (!entry || typeof entry.expiresAt !== 'number' || entry.expiresAt <= now) {
pendingMcpAuthContextByState.delete(state);
}
}
};
app.get('/api/config/settings', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
res.json(formatSettingsResponse(settings));
} catch (error) {
console.error('Failed to read settings:', error);
res.status(500).json({ error: 'Failed to read settings' });
}
});
app.get('/api/config/opencode-resolution', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
const resolution = await getOpenCodeResolutionSnapshot(settings);
res.json(resolution);
} catch (error) {
console.error('Failed to resolve OpenCode binary:', error);
res.status(500).json({ error: 'Failed to resolve OpenCode binary' });
}
});
app.put('/api/config/settings', async (req, res) => {
console.log('[API:PUT /api/config/settings] Received request');
try {
const updated = await persistSettings(req.body ?? {});
console.log(`[API:PUT /api/config/settings] Success, returning ${updated.projects?.length || 0} projects`);
res.json(updated);
} catch (error) {
console.error('[API:PUT /api/config/settings] Failed to save settings:', error);
console.error('[API:PUT /api/config/settings] Error stack:', error.stack);
res.status(500).json({ error: 'Failed to save settings' });
}
});
app.post('/api/mcp/auth/pending', async (req, res) => {
try {
pruneExpiredPendingMcpAuthContexts();
const state = normalizePendingString(req.body?.state);
if (!state) {
return res.json({ success: true, context: null });
}
const name = normalizePendingString(req.body?.name);
if (!name) {
return res.status(400).json({ error: 'MCP server name is required' });
}
const entry = {
name,
directory: normalizePendingString(req.body?.directory),
expiresAt: Date.now() + PENDING_MCP_AUTH_TTL_MS,
};
pendingMcpAuthContextByState.set(state, entry);
return res.json({
success: true,
context: {
name: entry.name,
directory: entry.directory,
},
});
} catch (error) {
console.error('Failed to store pending MCP auth context:', error);
return res.status(500).json({ error: error.message || 'Failed to store pending MCP auth context' });
}
});
app.get('/api/mcp/auth/pending', async (req, res) => {
try {
pruneExpiredPendingMcpAuthContexts();
const state = normalizePendingString(Array.isArray(req.query?.state) ? req.query.state[0] : req.query?.state);
if (!state) {
return res.json(null);
}
const pendingMcpAuthContext = pendingMcpAuthContextByState.get(state) ?? null;
if (!pendingMcpAuthContext) {
return res.status(404).json({ error: 'No pending MCP auth context' });
}
return res.json(pendingMcpAuthContext);
} catch (error) {
console.error('Failed to read pending MCP auth context:', error);
return res.status(500).json({ error: error.message || 'Failed to read pending MCP auth context' });
}
});
app.delete('/api/mcp/auth/pending', async (req, res) => {
try {
const state = normalizePendingString(Array.isArray(req.query?.state) ? req.query.state[0] : req.query?.state);
if (!state) {
return res.json({ success: true });
}
pendingMcpAuthContextByState.delete(state);
return res.json({ success: true });
} catch (error) {
console.error('Failed to clear pending MCP auth context:', error);
return res.status(500).json({ error: error.message || 'Failed to clear pending MCP auth context' });
}
});
app.get('/api/provider/:providerId/source', async (req, res) => {
try {
const { providerId } = req.params;
if (!providerId) {
return res.status(400).json({ error: 'Provider ID is required' });
}
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requestedDirectory = headerDirectory || queryDirectory || null;
let directory = null;
const resolved = await resolveProjectDirectory(req);
if (resolved.directory) {
directory = resolved.directory;
} else if (requestedDirectory) {
return res.status(400).json({ error: resolved.error });
}
const sources = getProviderSources(providerId, directory);
const { getProviderAuth } = await getAuthLibrary();
const auth = getProviderAuth(providerId);
sources.sources.auth.exists = Boolean(auth);
return res.json({
providerId,
sources: sources.sources,
});
} catch (error) {
console.error('Failed to get provider sources:', error);
return res.status(500).json({ error: error.message || 'Failed to get provider sources' });
}
});
app.delete('/api/provider/:providerId/auth', async (req, res) => {
try {
const { providerId } = req.params;
if (!providerId) {
return res.status(400).json({ error: 'Provider ID is required' });
}
const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth';
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requestedDirectory = headerDirectory || queryDirectory || null;
let directory = null;
if (scope === 'project' || requestedDirectory) {
const resolved = await resolveProjectDirectory(req);
if (!resolved.directory) {
return res.status(400).json({ error: resolved.error });
}
directory = resolved.directory;
} else {
const resolved = await resolveProjectDirectory(req);
if (resolved.directory) {
directory = resolved.directory;
}
}
let removed = false;
if (scope === 'auth') {
const { removeProviderAuth } = await getAuthLibrary();
removed = removeProviderAuth(providerId);
} else if (scope === 'user' || scope === 'project' || scope === 'custom') {
removed = removeProviderConfig(providerId, directory, scope);
} else if (scope === 'all') {
const { removeProviderAuth } = await getAuthLibrary();
const authRemoved = removeProviderAuth(providerId);
const userRemoved = removeProviderConfig(providerId, directory, 'user');
const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false;
const customRemoved = removeProviderConfig(providerId, directory, 'custom');
removed = authRemoved || userRemoved || projectRemoved || customRemoved;
} else {
return res.status(400).json({ error: 'Invalid scope' });
}
if (removed) {
await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`);
}
return res.json({
success: true,
removed,
requiresReload: removed,
message: removed ? 'Provider disconnected successfully' : 'Provider was not connected',
reloadDelayMs: removed ? clientReloadDelayMs : undefined,
});
} catch (error) {
console.error('Failed to disconnect provider:', error);
return res.status(500).json({ error: error.message || 'Failed to disconnect provider' });
}
});
app.post('/api/opencode/directory', async (req, res) => {
try {
const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
if (!requestedPath) {
return res.status(400).json({ error: 'Path is required' });
}
const validated = await validateDirectoryPath(requestedPath);
if (!validated.ok) {
return res.status(400).json({ error: validated.error });
}
const resolvedPath = validated.directory;
const currentSettings = await readSettingsFromDisk();
const existingProjects = sanitizeProjects(currentSettings.projects) || [];
const existing = existingProjects.find((project) => project.path === resolvedPath) || null;
const nextProjects = existing
? existingProjects
: [
...existingProjects,
{
id: createProjectIdFromPath(resolvedPath),
path: resolvedPath,
addedAt: Date.now(),
lastOpenedAt: Date.now(),
},
];
const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
const updated = await persistSettings({
projects: nextProjects,
activeProjectId,
lastDirectory: resolvedPath,
});
return res.json({
success: true,
restarted: false,
path: resolvedPath,
settings: updated,
});
} catch (error) {
console.error('Failed to update OpenCode working directory:', error);
return res.status(500).json({ error: error.message || 'Failed to update working directory' });
}
});
};