* 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
299 lines
10 KiB
JavaScript
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' });
|
|
}
|
|
});
|
|
};
|