mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-31 08:56:48 +00:00
* 📡 fix: Publish App-Level MCP Tool Catalogs Without a Reserved Revision Shared MCP servers advertised no tools to agents, so every turn failed with "configured to use MCP tools, but none are available" (#14857). `replaceAppServerTools` returned false whenever a publication carried no `publicationRevision`, but only `refreshChangedTools` reserves one. Every other app-level publisher — the first-connect snapshot, reinitialization, on-demand catalog reads, the retained-catalog restore — was silently dropped. The agent path fails closed on that drop: the skipped write returns null, so reinitialize yields no tools and the turn 503s. Startup hid it. `connectAppServers()` defers the initial refresh and calls `refreshToolList()` itself, which does reserve, so a boot that reaches its MCP servers looks healthy. Only a lazily created app connection — the server not yet up when LibreChat boots, a dropped connection, a cold cache — takes the unreserved path. `ConnectionsRepository` now reserves before its own `tools/list`, matching the list_changed path; a failed reservation publishes unordered rather than failing the connection. Publishers with no pre-fetch reservation point have already fetched by the time they reach the cache, so they take the next revision at write time instead of being discarded. `mergeAppTools` still publishes at revision 0 and stays deferential to a live catalog. * 📡 fix: Bind App Catalog Ordering to the Fetch That Produced It Addresses review feedback on the previous commit: allocating a revision at publish time lets a slow `tools/list` of an old catalog outrank a newer one that reserved after it started, and it would let the retained-catalog restore — which republishes deliberately pre-mutation data — outrank a live catalog. Ordering now travels with the data. `fetchToolsSnapshot` reserves before its first page and returns the ticket on the snapshot, so every app-level publisher reads the revision belonging to the read it is publishing rather than one allocated at an unrelated moment. `fetchOrderedToolsSnapshot` carries the refresh's revision when it defers to one, since that is whose catalog it returns. With the reservation at the single point where app-level tools are read, no publisher can forget it, so `replaceAppServerTools` goes back to refusing an unordered write: a publication that lost its ticket fetched at an unknown time and cannot be ordered. A failed reservation is reported as `orderingUnavailable` rather than swallowed, which keeps the list_changed path retrying instead of publishing a catalog that would be silently dropped, and leaves inspection unaffected by a transient cache outage. `MCPServerInspector.getToolFunctions` becomes `getToolCatalog` and returns the revision with the tools, so there is no variant that quietly discards ordering. * 📡 fix: Retry an Empty App Catalog That Could Not Reserve Ordering Review follow-up. The no-tools-capability branch destructured the reservation result and dropped `orderingUnavailable`, publishing without a revision when the revision store was transiently unavailable. That write is rejected in silence, and unlike the snapshot branch this one returned without reaching `refreshToolList()`, so whatever the server last advertised stayed in place until the connection was recreated or the cache expired. Both branches now route an unreservable catalog through the same retry path. * 📡 fix: Serve Tools Whose Shared Catalog Write Could Not Be Ordered Review follow-up. Only the shared catalog write needs ordering; the tools themselves were just read from the server and are correct to serve. Discarding them because the write could not be ordered is what turns a cache failure into a server that appears to have no tools at all, which is the reported symptom. `updateMCPServerTools` now returns the tools it built when the publication has no reserved revision, instead of null. A superseded write still discards — there another replica holds something newer. Reinitialization also asks the connection to republish under backoff when its snapshot could not reserve ordering, so the shared catalog does not stay cold until something else triggers a refresh. * 📡 fix: Surface a Discarded App Catalog Instead of Debug-Logging It #14857 went a release without a diagnostic because the only trace of a dropped app-level catalog was a debug line no deployment runs. Operators saw agents fail every turn with nothing in the logs to explain it, and the reporter had to read the source to find the cause. A publication discarded because it cannot be addressed or ordered means this server's tools are unavailable to every agent that selected them, and serving an unpublished catalog means every request re-fetches it. Both are warnings now. A superseded write stays at debug: concurrent replicas produce it routinely and the winner already holds newer tools. Tests pin the level, so a later refactor cannot quietly make the failure silent again. * 🧪 test: Pin the Reinitialize Path's Catalog Ordering Reinitialization is the path an agent falls back to when the shared catalog is cold, so it is where #14857 surfaced as "configured to use MCP tools, but none are available". Nothing pinned that it forwards the ordering its snapshot was fetched with, nor that it asks the connection to republish a catalog it could not order. Both assertions fail against the pre-fix source.
671 lines
22 KiB
JavaScript
671 lines
22 KiB
JavaScript
/**
|
|
* MCP Tools Controller
|
|
* Handles MCP-specific tool endpoints, decoupled from regular LibreChat tools
|
|
*
|
|
* @import { MCPServerRegistry } from '@librechat/api'
|
|
* @import { MCPServerDocument } from 'librechat-data-provider'
|
|
*/
|
|
const { randomUUID } = require('crypto');
|
|
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
|
|
const {
|
|
checkAccess,
|
|
isUserSourced,
|
|
MCPConnection,
|
|
MCPErrorCodes,
|
|
splitMCPToolKey,
|
|
normalizeServerName,
|
|
findShadowedServerNames,
|
|
redactServerSecrets,
|
|
redactAllServerSecrets,
|
|
isMCPDomainNotAllowedError,
|
|
isMCPInspectionFailedError,
|
|
isMCPOAuthSecretReentryRequiredError,
|
|
} = require('@librechat/api');
|
|
const {
|
|
Constants,
|
|
Permissions,
|
|
ResourceType,
|
|
PermissionBits,
|
|
PermissionTypes,
|
|
MCP_USER_INPUT_FIELDS,
|
|
MCPServerUserInputSchema,
|
|
} = require('librechat-data-provider');
|
|
const {
|
|
resolveConfigServers,
|
|
resolveMcpConfigNames,
|
|
resolveAllMcpConfigs,
|
|
} = require('~/server/services/MCP');
|
|
const {
|
|
cacheMCPServerTools,
|
|
getMCPServerTools,
|
|
getMCPToolsCacheGeneration,
|
|
invalidateCachedTools,
|
|
} = require('~/server/services/Config');
|
|
const { getResourcePermissionsMap } = require('~/server/services/PermissionService');
|
|
const { hasCapability } = require('~/server/middleware/roles/capabilities');
|
|
const { getMCPManager, getMCPServersRegistry } = require('~/config');
|
|
const db = require('~/models');
|
|
|
|
/**
|
|
* Handles MCP-specific errors and sends appropriate HTTP responses.
|
|
* @param {Error} error - The error to handle
|
|
* @param {import('express').Response} res - Express response object
|
|
* @returns {import('express').Response | null} Response if handled, null if not an MCP error
|
|
*/
|
|
function handleMCPError(error, res) {
|
|
if (isMCPDomainNotAllowedError(error)) {
|
|
return res.status(error.statusCode).json({
|
|
error: error.code,
|
|
message: error.message,
|
|
});
|
|
}
|
|
|
|
if (isMCPInspectionFailedError(error)) {
|
|
return res.status(error.statusCode).json({
|
|
error: error.code,
|
|
message: error.message,
|
|
});
|
|
}
|
|
|
|
if (isMCPOAuthSecretReentryRequiredError(error)) {
|
|
return res.status(error.statusCode).json({
|
|
error: error.code,
|
|
message: error.message,
|
|
});
|
|
}
|
|
|
|
// Fallback for legacy string-based error handling (backwards compatibility)
|
|
if (error.message?.startsWith(MCPErrorCodes.DOMAIN_NOT_ALLOWED)) {
|
|
return res.status(403).json({
|
|
error: MCPErrorCodes.DOMAIN_NOT_ALLOWED,
|
|
message: error.message.replace(/^MCP_DOMAIN_NOT_ALLOWED\s*:\s*/i, ''),
|
|
});
|
|
}
|
|
|
|
if (error.message?.startsWith(MCPErrorCodes.INSPECTION_FAILED)) {
|
|
return res.status(400).json({
|
|
error: MCPErrorCodes.INSPECTION_FAILED,
|
|
message: error.message,
|
|
});
|
|
}
|
|
|
|
if (error.message?.startsWith(MCPErrorCodes.OAUTH_SECRET_REENTRY_REQUIRED)) {
|
|
return res.status(400).json({
|
|
error: MCPErrorCodes.OAUTH_SECRET_REENTRY_REQUIRED,
|
|
message: error.message,
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Disposes a stale local connection after its DB-backed config has changed. */
|
|
async function disconnectLocalMCPServer(userId, serverName) {
|
|
try {
|
|
await getMCPManager()?.disconnectUserConnection(userId, serverName);
|
|
} catch (error) {
|
|
logger.warn(
|
|
`[MCP Cache] Failed to disconnect the local connection for ${serverName} (user: ${userId}):`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
const POST_COMMIT_FENCE_RETRY_DELAYS_MS = [0, 50, 200];
|
|
|
|
/** Retries the shared fence after persistence; config-bound connections remain a durable
|
|
* fallback if Redis stays unavailable, so an old connection cannot serve the new config. */
|
|
async function fenceCommittedMCPMutation({ userId, serverName }) {
|
|
let lastError;
|
|
for (const delay of POST_COMMIT_FENCE_RETRY_DELAYS_MS) {
|
|
if (delay > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
try {
|
|
await invalidateCachedTools({ userId, serverName });
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
logger.warn(
|
|
`[MCP Cache] Failed to fence committed mutation for ${serverName} (user: ${userId}); retrying:`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
/**
|
|
* Republishes the pre-mutation catalog under the new fence when persistence
|
|
* fails. The retained connection will reacquire that generation on its next
|
|
* use; this snapshot keeps every replica authoritative in the meantime.
|
|
*/
|
|
async function restoreRetainedServerCatalog({ userId, serverName, serverConfig, serverTools }) {
|
|
if (serverTools == null) {
|
|
return;
|
|
}
|
|
try {
|
|
const publicationGeneration = await getMCPToolsCacheGeneration({ userId, serverName });
|
|
await cacheMCPServerTools({
|
|
userId,
|
|
serverName,
|
|
serverConfig,
|
|
serverTools,
|
|
publicationGeneration,
|
|
});
|
|
} catch (error) {
|
|
logger.error(
|
|
`[MCP Cache] Failed to restore the retained catalog for ${serverName} (user: ${userId}):`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all MCP tools available to the user.
|
|
*/
|
|
const getMCPTools = async (req, res) => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
if (!userId) {
|
|
logger.warn('[getMCPTools] User ID not found in request');
|
|
return res.status(401).json({ message: 'Unauthorized' });
|
|
}
|
|
|
|
const mcpConfig = await resolveAllMcpConfigs(userId, req.user);
|
|
/**
|
|
* A server whose normalized name is claimed by an earlier server produces
|
|
* IDENTICAL model-facing tool keys — selecting its tools would silently
|
|
* execute against the first server's config (alias resolution is
|
|
* first-wins). Fail closed: never publish a shadowed server's tools.
|
|
*/
|
|
const shadowedServers = findShadowedServerNames(Object.keys(mcpConfig));
|
|
for (const shadowedName of shadowedServers) {
|
|
logger.warn(
|
|
`[getMCPTools] Skipping MCP server "${shadowedName}": its normalized name collides with an earlier configured server, making tool keys ambiguous. Rename one server to expose both.`,
|
|
);
|
|
}
|
|
const configuredServers = Object.keys(mcpConfig).filter(
|
|
(serverName) => !shadowedServers.has(serverName),
|
|
);
|
|
|
|
if (!configuredServers.length) {
|
|
return res.status(200).json({ servers: {} });
|
|
}
|
|
|
|
const mcpManager = getMCPManager();
|
|
const mcpServers = {};
|
|
|
|
const serverToolsMap = new Map();
|
|
const serversWithoutTools = [];
|
|
const cacheResults = await Promise.all(
|
|
configuredServers.map(async (serverName) => {
|
|
try {
|
|
return {
|
|
serverName,
|
|
tools: await getMCPServerTools(userId, serverName, mcpConfig[serverName]),
|
|
};
|
|
} catch (error) {
|
|
logger.error(`[getMCPTools] Error fetching cached tools for ${serverName}:`, error);
|
|
return { serverName, tools: null };
|
|
}
|
|
}),
|
|
);
|
|
for (const { serverName, tools } of cacheResults) {
|
|
if (tools) {
|
|
serverToolsMap.set(serverName, tools);
|
|
continue;
|
|
}
|
|
|
|
let serverTools;
|
|
let publicationGeneration;
|
|
let publicationRevision;
|
|
try {
|
|
({
|
|
tools: serverTools,
|
|
publicationGeneration,
|
|
publicationRevision,
|
|
} = await mcpManager.getServerToolFunctionsSnapshot(
|
|
userId,
|
|
serverName,
|
|
mcpConfig[serverName],
|
|
));
|
|
} catch (error) {
|
|
logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
|
|
continue;
|
|
}
|
|
if (!serverTools) {
|
|
serversWithoutTools.push(serverName);
|
|
continue;
|
|
}
|
|
serverToolsMap.set(serverName, serverTools);
|
|
|
|
// Empty is an authoritative catalog too; re-cache it after TTL expiry to avoid polling.
|
|
cacheMCPServerTools({
|
|
userId,
|
|
serverName,
|
|
serverTools,
|
|
serverConfig: mcpConfig[serverName],
|
|
publicationGeneration,
|
|
publicationRevision,
|
|
}).catch((err) =>
|
|
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
|
|
);
|
|
}
|
|
if (serversWithoutTools.length > 0) {
|
|
logger.debug(
|
|
`[getMCPTools] No tools (${serversWithoutTools.length}): ${serversWithoutTools.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
// Process each configured server
|
|
for (const serverName of configuredServers) {
|
|
try {
|
|
const serverTools = serverToolsMap.get(serverName);
|
|
|
|
const serverConfig = mcpConfig[serverName];
|
|
|
|
const server = {
|
|
name: serverName,
|
|
icon: serverConfig?.iconPath || '',
|
|
authenticated: true,
|
|
authConfig: [],
|
|
tools: [],
|
|
};
|
|
|
|
// Set authentication config once for the server
|
|
if (serverConfig?.customUserVars) {
|
|
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
|
if (customVarKeys.length > 0) {
|
|
server.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
|
|
authField: key,
|
|
label: value.title || key,
|
|
description: value.description || '',
|
|
sensitive: value.sensitive,
|
|
}));
|
|
server.authenticated = false;
|
|
}
|
|
}
|
|
|
|
// Process tools efficiently - no need for convertMCPToolToPlugin
|
|
if (serverTools) {
|
|
for (const [toolKey, toolData] of Object.entries(serverTools)) {
|
|
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
|
|
continue;
|
|
}
|
|
|
|
const [toolName] = splitMCPToolKey(toolKey, [
|
|
serverName,
|
|
normalizeServerName(serverName),
|
|
]);
|
|
server.tools.push({
|
|
name: toolName,
|
|
pluginKey: toolKey,
|
|
description: toolData.function.description || '',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Only add server if it has tools or is configured
|
|
if (server.tools.length > 0 || serverConfig) {
|
|
mcpServers[serverName] = server;
|
|
}
|
|
} catch (error) {
|
|
logger.error(`[getMCPTools] Error loading tools for server ${serverName}:`, error);
|
|
}
|
|
}
|
|
|
|
res.status(200).json({ servers: mcpServers });
|
|
} catch (error) {
|
|
logger.error('[getMCPTools]', error);
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
};
|
|
/**
|
|
* Mirrors canAccessResource's capability bypass plus per-resource ACL EDIT check.
|
|
* `skipCapabilityWithoutDbIds` lets the list path skip the MANAGE_MCP_SERVERS probe
|
|
* when no DB-backed server is present; no list consumer reads the edit-gated fields
|
|
* the bypass would disclose. The detail route must not set it.
|
|
*/
|
|
async function computeCanEditByServer(req, serverConfigs, { skipCapabilityWithoutDbIds } = {}) {
|
|
const canEditByServer = new Map();
|
|
const dbIdsToCheck = [];
|
|
const dbIdToServerName = new Map();
|
|
for (const [name, config] of Object.entries(serverConfigs)) {
|
|
if (config.dbId) {
|
|
dbIdsToCheck.push(config.dbId);
|
|
dbIdToServerName.set(String(config.dbId), name);
|
|
continue;
|
|
}
|
|
canEditByServer.set(name, isUserSourced(config));
|
|
}
|
|
if (skipCapabilityWithoutDbIds === true && dbIdsToCheck.length === 0) {
|
|
return canEditByServer;
|
|
}
|
|
let bypass = false;
|
|
try {
|
|
bypass = await hasCapability(req.user, SystemCapabilities.MANAGE_MCP_SERVERS);
|
|
} catch (err) {
|
|
logger.warn(`[computeCanEditByServer] Capability bypass check failed: ${err.message}`);
|
|
}
|
|
if (bypass) {
|
|
for (const name of Object.keys(serverConfigs)) {
|
|
canEditByServer.set(name, true);
|
|
}
|
|
return canEditByServer;
|
|
}
|
|
if (dbIdsToCheck.length > 0) {
|
|
try {
|
|
const permsMap = await getResourcePermissionsMap({
|
|
userId: req.user.id,
|
|
role: req.user.role,
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceIds: dbIdsToCheck,
|
|
});
|
|
for (const [dbIdStr, name] of dbIdToServerName) {
|
|
const bits = permsMap.get(dbIdStr) ?? 0;
|
|
canEditByServer.set(name, (bits & PermissionBits.EDIT) !== 0);
|
|
}
|
|
} catch (err) {
|
|
logger.warn(
|
|
`[computeCanEditByServer] ACL lookup failed, defaulting to no edit: ${err.message}`,
|
|
);
|
|
for (const name of dbIdToServerName.values()) {
|
|
canEditByServer.set(name, false);
|
|
}
|
|
}
|
|
}
|
|
return canEditByServer;
|
|
}
|
|
|
|
/**
|
|
* Get all MCP servers with permissions
|
|
* @route GET /api/mcp/servers
|
|
*/
|
|
const getMCPServersList = async (req, res) => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ message: 'Unauthorized' });
|
|
}
|
|
|
|
const serverConfigs = await resolveAllMcpConfigs(userId, req.user);
|
|
const canEditByServer = await computeCanEditByServer(req, serverConfigs, {
|
|
skipCapabilityWithoutDbIds: true,
|
|
});
|
|
return res.json(redactAllServerSecrets(serverConfigs, { canEditByServer }));
|
|
} catch (error) {
|
|
logger.error('[getMCPServersList]', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Returns true when the request body's parsed config configures OBO. We block
|
|
* non-permission holders from creating or updating any DB-stored MCP server
|
|
* that mints per-user delegated tokens.
|
|
*/
|
|
function configHasObo(parsedConfig) {
|
|
return (
|
|
!!parsedConfig &&
|
|
typeof parsedConfig === 'object' &&
|
|
'obo' in parsedConfig &&
|
|
parsedConfig.obo != null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fields a user without `CONFIGURE_OBO` may modify on an OBO server (allowlist).
|
|
* Any field not on this list is locked: changes to it (add, modify, or remove)
|
|
* require the permission. Allowlisting is fail-closed — when upstream introduces
|
|
* a new MCP server config field, it lands in the locked set by default until
|
|
* explicitly opted in here. Anything that could redirect the OBO token flow
|
|
* (`url`, `proxy`, `headers`), change scopes (`obo`), or reroute auth (`oauth`,
|
|
* `apiKey`, `customUserVars`) MUST stay locked.
|
|
*/
|
|
const OBO_USER_EDITABLE_FIELDS = new Set(['title', 'description', 'iconPath']);
|
|
|
|
/**
|
|
* Returns true when any non-allowlisted user-input field differs between the
|
|
* existing server config and the new payload. Treats add, remove, and modify
|
|
* as changes (stable JSON compare, with absence on either side counting as a
|
|
* change unless both sides are absent). The comparison surface is
|
|
* `MCP_USER_INPUT_FIELDS` (schema-derived from `MCPServerUserInputSchema`),
|
|
* so new fields on the schema are picked up automatically and stay locked
|
|
* by default until added to the allowlist above.
|
|
*/
|
|
function violatesOboLockdown(existingConfig, newConfig) {
|
|
for (const field of MCP_USER_INPUT_FIELDS) {
|
|
if (OBO_USER_EDITABLE_FIELDS.has(field)) continue;
|
|
const existing = existingConfig?.[field];
|
|
const next = newConfig?.[field];
|
|
if (existing === undefined && next === undefined) continue;
|
|
if (JSON.stringify(existing) !== JSON.stringify(next)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function callerCanConfigureObo(req) {
|
|
return checkAccess({
|
|
req,
|
|
user: req.user,
|
|
permissionType: PermissionTypes.MCP_SERVERS,
|
|
permissions: [Permissions.CONFIGURE_OBO],
|
|
getRoleByName: db.getRoleByName,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create MCP server
|
|
* @route POST /api/mcp/servers
|
|
*/
|
|
const createMCPServerController = async (req, res) => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
const { config } = req.body;
|
|
|
|
const validation = MCPServerUserInputSchema.safeParse(config);
|
|
if (!validation.success) {
|
|
return res.status(400).json({
|
|
message: 'Invalid configuration',
|
|
errors: validation.error.errors,
|
|
});
|
|
}
|
|
if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) {
|
|
logger.warn(
|
|
`[createMCPServer] User ${userId} attempted to configure OBO without ${Permissions.CONFIGURE_OBO} permission`,
|
|
);
|
|
return res
|
|
.status(403)
|
|
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
|
|
}
|
|
/** Reserve both spellings: a generated slug must not collide with a raw
|
|
* config name OR the normalized form its tool keys actually carry
|
|
* (deduped — the spellings coincide for safe names). */
|
|
const configNames = await resolveMcpConfigNames(req);
|
|
const reservedServerNames = [
|
|
...new Set([...configNames, ...configNames.map(normalizeServerName)]),
|
|
];
|
|
const inspectionServerName = `temp_server_${randomUUID()}`;
|
|
let result;
|
|
try {
|
|
result = await getMCPServersRegistry().addServer(
|
|
inspectionServerName,
|
|
validation.data,
|
|
'DB',
|
|
userId,
|
|
reservedServerNames,
|
|
);
|
|
} finally {
|
|
MCPConnection.clearCooldown(inspectionServerName);
|
|
}
|
|
res.status(201).json({
|
|
serverName: result.serverName,
|
|
...redactServerSecrets(result.config, { canEdit: true }),
|
|
});
|
|
} catch (error) {
|
|
logger.error('[createMCPServer]', error);
|
|
const mcpErrorResponse = handleMCPError(error, res);
|
|
if (mcpErrorResponse) {
|
|
return mcpErrorResponse;
|
|
}
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get MCP server by ID
|
|
*/
|
|
const getMCPServerById = async (req, res) => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
const { serverName } = req.params;
|
|
if (!serverName) {
|
|
return res.status(400).json({ message: 'Server name is required' });
|
|
}
|
|
const configServers = await resolveConfigServers(req);
|
|
const parsedConfig = await getMCPServersRegistry().getServerConfig(
|
|
serverName,
|
|
userId,
|
|
configServers,
|
|
);
|
|
|
|
if (!parsedConfig) {
|
|
return res.status(404).json({ message: 'MCP server not found' });
|
|
}
|
|
|
|
const canEditMap = await computeCanEditByServer(req, { [serverName]: parsedConfig });
|
|
const canEdit = canEditMap.get(serverName) ?? false;
|
|
res.status(200).json(redactServerSecrets(parsedConfig, { canEdit }));
|
|
} catch (error) {
|
|
logger.error('[getMCPServerById]', error);
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Update MCP server
|
|
* @route PATCH /api/mcp/servers/:serverName
|
|
*/
|
|
const updateMCPServerController = async (req, res) => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
const { serverName } = req.params;
|
|
const { config } = req.body;
|
|
|
|
const validation = MCPServerUserInputSchema.safeParse(config);
|
|
if (!validation.success) {
|
|
return res.status(400).json({
|
|
message: 'Invalid configuration',
|
|
errors: validation.error.errors,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* On an existing OBO server, lock down every user-input field except the
|
|
* cosmetic allowlist (title, description, iconPath) for callers without
|
|
* CONFIGURE_OBO. This closes the OBO redirect vector — without it, a user
|
|
* with UPDATE could change `url` (or `proxy`/`headers`/`customUserVars`)
|
|
* to point OBO-minted tokens at an attacker-controlled endpoint. Adds,
|
|
* modifies, and removes are all caught.
|
|
*/
|
|
const existingConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
|
|
if (configHasObo(existingConfig) && !(await callerCanConfigureObo(req))) {
|
|
if (violatesOboLockdown(existingConfig, validation.data)) {
|
|
logger.warn(
|
|
`[updateMCPServer] User ${userId} attempted to modify a locked field on OBO server '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`,
|
|
);
|
|
return res
|
|
.status(403)
|
|
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
|
|
}
|
|
} else if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) {
|
|
// Adding OBO to a non-OBO server (or first-time configuration) still
|
|
// requires the permission, even if existing has no OBO.
|
|
logger.warn(
|
|
`[updateMCPServer] User ${userId} attempted to add OBO to '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`,
|
|
);
|
|
return res
|
|
.status(403)
|
|
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
|
|
}
|
|
|
|
const registry = getMCPServersRegistry();
|
|
const parsedConfig = await registry.inspectServerUpdate(
|
|
serverName,
|
|
validation.data,
|
|
'DB',
|
|
userId,
|
|
);
|
|
const retainedTools = await getMCPServerTools(userId, serverName, existingConfig);
|
|
await invalidateCachedTools({ userId, serverName });
|
|
try {
|
|
await registry.commitServerUpdate(serverName, parsedConfig, 'DB', userId);
|
|
} catch (error) {
|
|
await restoreRetainedServerCatalog({
|
|
userId,
|
|
serverName,
|
|
serverConfig: existingConfig,
|
|
serverTools: retainedTools,
|
|
});
|
|
throw error;
|
|
}
|
|
/** Fence connections another replica could have created from the old DB
|
|
* config between the pre-commit fence and the committed update. */
|
|
await fenceCommittedMCPMutation({ userId, serverName });
|
|
await disconnectLocalMCPServer(userId, serverName);
|
|
|
|
res.status(200).json(redactServerSecrets(parsedConfig, { canEdit: true }));
|
|
} catch (error) {
|
|
logger.error('[updateMCPServer]', error);
|
|
const mcpErrorResponse = handleMCPError(error, res);
|
|
if (mcpErrorResponse) {
|
|
return mcpErrorResponse;
|
|
}
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Delete MCP server
|
|
* @route DELETE /api/mcp/servers/:serverName
|
|
*/
|
|
const deleteMCPServerController = async (req, res) => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
const { serverName } = req.params;
|
|
const registry = getMCPServersRegistry();
|
|
const existingConfig = await registry.getServerConfig(serverName, userId);
|
|
const retainedTools = await getMCPServerTools(userId, serverName, existingConfig);
|
|
await invalidateCachedTools({ userId, serverName });
|
|
try {
|
|
await registry.removeServer(serverName, 'DB', userId);
|
|
} catch (error) {
|
|
await restoreRetainedServerCatalog({
|
|
userId,
|
|
serverName,
|
|
serverConfig: existingConfig,
|
|
serverTools: retainedTools,
|
|
});
|
|
throw error;
|
|
}
|
|
/** Fence connections another replica could have created before deletion committed. */
|
|
await fenceCommittedMCPMutation({ userId, serverName });
|
|
await disconnectLocalMCPServer(userId, serverName);
|
|
res.status(200).json({ message: 'MCP server deleted successfully' });
|
|
} catch (error) {
|
|
logger.error('[deleteMCPServer]', error);
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
getMCPTools,
|
|
getMCPServersList,
|
|
createMCPServerController,
|
|
getMCPServerById,
|
|
updateMCPServerController,
|
|
deleteMCPServerController,
|
|
};
|