mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
* 🔗 fix: Normalize MCP Tool Keys at Every Producer, Resolve Raw Names via Aliases Tool keys had two spellings that could diverge for any server whose name contains characters outside [a-zA-Z0-9_.-]: the tool cache (and registry inspector) built keys with the RAW server name, while runtime instances are named with normalizeServerName(serverName). Three code comments already asserted "tool keys embed the normalized server name" - no producer honored it. For a special-character server that meant: - definitions-only mode shipped raw def names the model echoed back, but the executor's tool map held the normalized instance name, so every call failed with "Tool not found"; - per-tool tool_options (defer_loading / allowed_callers / run_in_background / describe_intent) were persisted under raw keys that never matched the definition names the option passes resolve against, so builder settings were silently inert; - tool-key parsing against normalized candidate lists silently fell back to last-delimiter splitting, which mis-parses delimiter-bearing tool names. The reconciliation is one contract enforced in three moves: 1. PRODUCERS NORMALIZE. The tool cache (packages/api/src/mcp/tools.ts) and the registry inspector build keys with the normalized server name, matching the instance names MCP.js has always assigned. The builder's tool ids, agent.tools entries, tool_options keys, and definition names all flow from these keys, so every model-facing name now agrees. The cache STORE stays keyed by the raw config name. 2. CONFIG LOOKUPS RESOLVE ALIASES. New shared helpers in data-provider (buildServerNameAliases, normalizeMCPToolKey) map a parsed normalized name back to the raw config name that the registry, config maps, tool cache, and plugin-auth rows are keyed by. Applied in the definitions loader closure, handleTools grouping, createMCPTool's parsing fallback, getUserMCPAuthMap, and the MCP tools endpoint - matching both spellings so legacy raw keys keep resolving. 3. LEGACY DATA HEALS AT ONE BOUNDARY. initializeAgent rewrites raw-keyed agent.tools entries and tool_options keys to the normalized form (normalizeAgentToolKeys) before anything consumes them, so agents persisted under the old convention load their tools AND have all four per-tool options honored. Placeholder and server-pin tokens stay raw - they are config-identity references, not model-facing names. Servers whose names are already in the safe character set (the common case) produce byte-identical keys before and after; the fast path allocates nothing. Stale Redis-cached raw keys self-heal via the existing reconnect-on-missing path within one cache cycle. * 🧯 fix: Deterministic Alias Collisions + Raw Names in Definition Metadata Two review findings on the normalization contract: - Two configured server names that normalize to the same segment (e.g. 'Sales Force' and 'Sales:Force' -> 'Sales_Force') produce inherently ambiguous tool keys; the alias map silently resolved last-wins, so a tool selected from one server could execute against the other's config. buildServerNameAliases now resolves collisions to the FIRST configured name deterministically, and resolveMCPServerContext warns once per colliding pair per process so the operator can rename one server. A collision-resistant identifier would change every existing tool key, so detection + stable routing is the right treatment here; startup-time config validation can follow separately. - The definitions loader resolved parsed (normalized) server names to raw only inside the ToolService closure, while the definition metadata (serverName -> mcpRawServerName) kept the normalized value. Server instructions are keyed by raw config names, so a special-character server's instructions were silently omitted in definitions-only mode. loadToolDefinitions now takes rawServerNames, resolves the boundary against both spellings, and stores the RAW name in definition metadata - consistent with the instance path. * 🧯 fix: Heal Stale Caches, Skill Allowed-Tools, and Builder Selectors Three review findings on the normalization rollout, all in the transition class: - Stale cache entries (P1): the definitions-only loader treats the per-server tool map as authoritative and never reconnects on a per-key miss, so a pre-change raw-keyed Redis entry would make a special-character server's tools vanish for up to the cache TTL. getMCPServerTools now heals legacy raw-keyed entries to the normalized format at read time (keys and function names), covering every consumer with no coordinated invalidation; safe names return the map untouched. - Skill allowed-tools: a skill declaring a raw MCP key in allowed-tools bypassed the initialize-boundary heal (the union runs after it) and would neither dedupe against healed agent tools nor match the normalized tool map. The primes' allowedTools now pass through the same normalizeAgentToolKeys heal before unioning. - Builder selectors: matchesMcpServer and useVisibleTools parsed tool ids against raw server names only, so an attached special-character server rendered as an unselected orphan card. Both now accept the normalized spelling and resolve it back to the raw map key, keeping legacy raw ids working. * 🧯 fix: Fail Closed on Normalized Server-Name Collisions Escalation of the collision finding: a deterministic first-wins alias plus a warning still let the tools listing publish BOTH colliding servers, so a tool selected under the shadowed second server would silently execute against the first server's configuration (their model-facing keys are identical, so routing cannot ever distinguish them). - findShadowedServerNames identifies later-configured names whose normalized form an earlier different name claimed. - getMCPTools excludes shadowed servers from the published listing entirely (with a warn naming the collision), so their tools are never selectable - nothing ambiguous can be picked. - Server creation reserves both spellings: a generated slug may not collide with a raw config name OR the normalized form its tool keys would carry. Collision-resistant model-facing IDs remain out of scope: changing normalizeServerName's output would rewrite every existing tool key (agent documents, caches, instance names) for ALL servers to handle a misconfiguration that is now blocked from exposure instead. * ✅ fix: Dedupe Reserved Server-Name Spellings at Creation The reservation list appended normalized forms unconditionally, which duplicated every safe name (raw === normalized) and broke the route-level contract test pinning the exact list. Dedupe via a Set so safe names contribute one entry, while special-character names still reserve both spellings; adds the special-character reservation case. * 🧯 fix: Never Heal a Shadowed Server's Keys; Align Authorization Tie-Break Persisted references were the remaining collision vector: an agent or skill saved with the shadowed later server's raw key was HEALED into the shared normalized key, authorized through a last-wins map, and routed first-wins - authorized as one server, executed as another. - normalizeAgentToolKeys now refuses to rewrite keys of shadowed servers (findShadowedServerNames): rewriting would produce exactly the first server's key. Left raw, the key cannot match the normalized-keyed tool map and the tool fails visibly - broken beats misrouted. Covers agent.tools, tool_options, and skill allowed-tools through the shared heal. - filterAuthorizedTools (agents/v1.js) builds its normalized-to-raw map via the shared buildServerNameAliases instead of a last-wins Map constructor, so authorization resolves a colliding key to the SAME first server execution routes to. * 🧯 fix: Direct Identity Wins Over Aliases; Heal Client Forms and Degraded Contexts Four review findings on the normalization edges: - Alias hijack (P1): a user-DB server named exactly like an operator server's normalized form ('foo' vs YAML 'foo!') had its tools rerouted to the operator server by unconditional alias resolution. Resolution is now DIRECT-FIRST everywhere: the parsed name is tried as-is, and only when nothing resolves is it treated as a normalized spelling (definitions loader, handleTools grouping, createMCPTool fallback). buildServerNameAliases seats identity entries before derived ones so a literal name owns its slot regardless of config order, findShadowedServerNames and the collision warning derive from the same construction, and getUserMCPAuthMap fetches auth under both spellings so either owner finds its rows. - Builder double-match: a normalized name containing the delimiter ('foo mcp bar' -> 'foo_mcp_bar') also suffix-matched a server named 'bar', selecting both cards and making removal strip the wrong tool. matchesMcpServer now resolves the token ONCE against the full configured list (longest boundary, both spellings) when the caller supplies it; selection and removal share the resolution. - Builder legacy ids: an agent saved with raw-keyed ids showed its tools unchecked while the runtime heal kept them active, and selection updates never replaced the legacy entries. McpSection maps legacy raw ids to their current normalized ids when deriving and rewriting this server's selection. - Degraded context: a transient ensureConfigServers failure returned an entirely empty context, leaving normalized keys unresolvable for the request. resolveMCPServerContext now keeps the name lists (they derive from the config snapshot alone) and degrades only the lazy-init configs. * 🧯 fix: Collision Detection Sees Accessible Servers; Shadowed Refs Fail Closed End to End Round follow-ups on the collision design, all in the DB-server-visibility class: - The legacy-key heal detected collisions against operator-config names only, so healing could still produce a key that direct-first resolution routes to an invisible user-DB server. initializeAgent gains an optional getAccessibleMcpServerNames dep (wired through ToolService for controllers that mock it, directly elsewhere), consulted ONLY when a configured name needs normalization - zero cost for safe-name deployments. The heal then sees the full accessible set and skips shadowed servers' keys. - Wildcard and legacy raw tokens bypassed catalog filtering, letting a shadowed server's instances join a run under the same normalized names as the winner's. filterAuthorizedTools rejects tools of shadowed servers at authorization (its merged map sees DB + config), and handleTools skips them at execution. - The builder migrated only tool selection, not tool_options: legacy raw option keys showed disabled while the runtime honored them, and toggles could not clear them. McpSection now migrates option keys to the current normalized ids (existing normalized entries win). - A transient ensureConfigServers failure degraded to an EMPTY server context, leaving normalized keys unresolvable for the request. resolveMCPServerContext keeps the name lists (derived from the config snapshot alone) and degrades only the lazy-init configs. * 🧯 fix: Complete the Collision Audit at Every Gate; Safer Heal Semantics Round follow-ups hardening the collision audit: - Execution guards now consult the FULL accessible set: the caller's heal threads its already-fetched names through loadTools, and handleTools fetches them itself when a configured name needs normalization (never for safe-name deployments) - so a cross-tier collision (user-DB 'foo' vs operator 'foo!') fails closed at eager execution instead of joining the run under one normalized name. - Healing is SKIPPED when the collision audit cannot complete (transient lookup failure, or no dep): un-healed raw keys still resolve through the direct-first candidates, so skipping is safe while rewriting against an incomplete audit is not. - The audit lookup is gated on the agent actually carrying delimiter-bearing keys (tools, tool_options, or skill allowed-tools), so non-MCP agents never pay a registry round-trip even on specially named deployments. - normalizeAgentToolKeys gives the CURRENT (normalized) entry precedence when both spellings carry options, matching the builder's migration semantics instead of letting insertion order decide. - The builder's toCurrentToolId resolves entries boundary-exactly against every configured server (longest match, both spellings), so a raw suffix shared with a LONGER server name can no longer reassign that server's selection or options while another dialog is open. * 🧯 fix: Shared Collision Audit for Definitions Loading; Fail Closed on Audit Failure Round follow-ups closing the remaining audit gaps: - The definitions-only loader now consumes the same collision audit as eager loading: shadowed servers' entries (wildcards included) are dropped before definitions are emitted, so the default execution path can never resolve a shadowed server's normalized function name to another server. The audit names thread from initializeAgent's heal; the loader self-fetches only when a configured name needs normalization. - resolveCollisionAuditNames centralizes the audit-resolution policy (threaded set > self-fetch when needed > incomplete on failure), and BOTH loaders now fail closed under an incomplete audit: any normalization-sensitive reference (its own name needs normalizing, or it equals the normalized form of a configured special-character name) is skipped with a warning instead of being audited against operator names alone. isNormalizationSensitiveName lives in packages/api as a pure helper so test mocks use the real predicate. - normalizeAgentToolKeys collapses duplicate ids after healing (order-preserving): a document carrying both spellings converges on one key, never two instances with the same function name. * 🧯 fix: Thread the Audit Everywhere; Identity-Aware Alias Fallback Round follow-ups on audit plumbing: - The OpenAI-compatible and Responses tool loaders now forward the already-resolved accessibleMcpServerNames instead of discarding it, so the definitions loader neither repeats the registry lookup nor fails closed on a transient second lookup after the first succeeded. - The skill-only path threads its audit: when the baseline agent has no MCP keys but a primed skill's allowed-tools fetched the complete set, that set (not the operator-only list) reaches the loader, so the collision remains visible and the shadowed reference stays rejected end to end. - OAuth discovery iterates the collision-FILTERED tool list, so a request can no longer emit an OAuth prompt, wait out the connection timeout, and reconnect a server whose definitions were deliberately rejected. - The definitions loader's alias fallback is identity-aware: when the parsed name IS a known accessible server, a null tool fetch means temporarily unavailable (OAuth pending, missing user variables, disconnected) and no longer reroutes to the raw alias - previously the aliased operator server's definitions could be emitted under the unavailable DB server's names. * 🧯 fix: Legacy-Key Definition Lookup; Retain Audit for Deferred Execution - createMCPTool resolves tool definitions by BOTH spellings: the key as persisted plus the canonical normalized key built from the resolved server name. Assistants and direct tool calls persisted before the rollout bypass the agent-boundary heal and arrive with raw keys, while availableTools is now indexed canonically - previously every such call missed the index, burned a reconnect, and returned the unavailable stub permanently via the negative cache. - The initialized agent retains accessibleMcpServerNames (the COMPLETE collision audit this initialization resolved), buildAgentToolContext copies it into every per-agent tool context, and loadToolsForExecution threads it into the eager loader as bare options. Deferred/event-driven execution therefore reuses the snapshot instead of repeating the merged registry read - a transient failure there could fail-closed a tool the same turn already advertised from the successful first audit. - MCP.spec.js keeps @librechat/api pure helpers REAL (requireActual spread) so normalization paths are exercised rather than mirrored. * 🧯 fix: Parse Legacy Keys Against Both Server-Name Spellings createMCPTool's boundary candidates were normalized-only, so a legacy raw key whose server name contains the delimiter (foo_mcp_bar!) missed the suffix match and fell to the generic last-delimiter split - the canonical rebuild then produced a key that could never hit the index and the persisted call stubbed out. The candidate list now carries the RAW resolved name (and raw config names on the parse-only path) next to the normalized spellings. * 🧯 fix: Honest Audit Completeness; Shadowed-Server Form-Key Guard - resolveAllMcpConfigs tolerates ensureConfigServers failures, so the merged registry read can silently omit config-only servers while the audit still reported complete: true - a foo/foo! collision would go unseen and a persisted key could route to the wrong server. Both audit consumers now union the snapshot-derived raw config names back in (resolveCollisionAuditNames unions the caller's rawServerNames; the initializeAgent heal unions configRawServerNames), keeping the completeness label honest without an extra read: operator names come from the registry-independent config snapshot, user-DB names from the merged read that fails loudly into the existing incomplete path. - The client tool_options migration now mirrors the runtime heal's fail-closed rule for SHADOWED servers: when the dialog's server has lost its normalized slot to another catalog name, legacy raw keys stay raw instead of being rewritten onto the winning server's key, where a later save would apply the wrong server's per-tool settings. The dialog's own server joins the alias construction so a stale catalog map can't misread as a collision. * 🧯 fix: Heal Legacy Assistant MCP Tool Names on Save The assistants create/update controllers look tools up in the cached definitions by exact key, and the cache is now normalized-keyed - an assistant saved before the convention resubmits its raw-suffixed MCP name on every edit, so any save silently removed the tool. healMcpToolNames pre-heals the payload's tool list: a delimiter-bearing string that misses the cache resolves through the configured raw names (longest-suffix, boundary-exact) and rewrites to the normalized key only when that key actually exists in the cache. SHADOWED raw names stay raw and fail closed, mirroring the runtime heal; the config read happens only when a delimiter-bearing name actually misses, and read failures propagate (write path) rather than silently dropping tools. v2's update loop also stops re-reading the tool cache per iteration. * 🧯 fix: Full-Audit Shadow Set + Dedupe in the Assistant Key Heal - The assistant-save heal built its shadow set from operator config names alone, so a cross-tier collision (user-DB `foo` owning the normalized slot of operator `foo!`) looked unshadowed and the legacy key healed into the shared normalized name - which direct-first execution then binds to the DB server. The shadow set now comes from resolveCollisionAuditNames' full accessible audit, and an incomplete audit skips healing outright (every rewrite candidate is normalization-sensitive by construction, so raw-and-fail-closed is the only safe answer). - Healed string entries dedupe order-preserving: a payload carrying both spellings of the same tool collapses to one entry instead of expanding into duplicate function definitions the provider rejects.
1577 lines
53 KiB
JavaScript
1577 lines
53 KiB
JavaScript
const { z } = require('zod');
|
|
const fs = require('fs').promises;
|
|
const { nanoid } = require('nanoid');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const {
|
|
refreshS3Url,
|
|
splitMCPToolKey,
|
|
buildServerNameAliases,
|
|
findShadowedServerNames,
|
|
agentCreateSchema,
|
|
agentUpdateSchema,
|
|
refreshListAvatars,
|
|
collectEdgeAgentIds,
|
|
replaceEdgeSourceId,
|
|
mergeDeploymentSkillIds,
|
|
mergeAgentOcrConversion,
|
|
sanitizeModelParameters,
|
|
MAX_AVATAR_REFRESH_AGENTS,
|
|
collectToolResourceFileIds,
|
|
convertOcrToContextInPlace,
|
|
stripFileIdsFromToolResources,
|
|
} = require('@librechat/api');
|
|
const {
|
|
Time,
|
|
Tools,
|
|
CacheKeys,
|
|
Constants,
|
|
FileSources,
|
|
ResourceType,
|
|
AccessRoleIds,
|
|
PrincipalType,
|
|
EToolResources,
|
|
isActionTool,
|
|
PermissionBits,
|
|
actionDelimiter,
|
|
AgentCapabilities,
|
|
EModelEndpoint,
|
|
removeNullishValues,
|
|
} = require('librechat-data-provider');
|
|
const {
|
|
findPubliclyAccessibleResources,
|
|
getResourcePermissionsMap,
|
|
findAccessibleResources,
|
|
hasPublicPermission,
|
|
grantPermission,
|
|
} = require('~/server/services/PermissionService');
|
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
|
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
|
const { getFileStrategy } = require('~/server/utils/getFileStrategy');
|
|
const { filterFile } = require('~/server/services/Files/process');
|
|
const { getCachedTools } = require('~/server/services/Config');
|
|
const {
|
|
createMCPPermissionContext,
|
|
resolveConfigServers,
|
|
userCanUseMCPServers,
|
|
} = require('~/server/services/MCP');
|
|
const { attachOwnerContacts } = require('~/server/services/Agents/ownerContact');
|
|
const { getMCPServersRegistry } = require('~/config');
|
|
const { getLogStores } = require('~/cache');
|
|
const db = require('~/models');
|
|
|
|
const systemTools = {
|
|
[Tools.execute_code]: true,
|
|
[Tools.file_search]: true,
|
|
[Tools.web_search]: true,
|
|
[Tools.memory]: true,
|
|
};
|
|
|
|
const MAX_SEARCH_LEN = 100;
|
|
const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const getSafeModelParameters = (modelParameters) => {
|
|
const { useResponsesApi } = modelParameters ?? {};
|
|
return typeof useResponsesApi === 'boolean' ? { useResponsesApi } : {};
|
|
};
|
|
const hasEditBit = (permission) => (permission & PermissionBits.EDIT) === PermissionBits.EDIT;
|
|
|
|
const sanitizeViewerSkillScope = (agent, accessibleSkillSet) => {
|
|
const skillScopeEnabled = agent.skills_enabled === true;
|
|
delete agent.skills_enabled;
|
|
|
|
if (!skillScopeEnabled) {
|
|
delete agent.skills;
|
|
return agent;
|
|
}
|
|
|
|
const configuredSkills = Array.isArray(agent.skills) ? agent.skills : [];
|
|
if (configuredSkills.length === 0) {
|
|
// Empty allowlist means the viewer's full accessible catalog.
|
|
delete agent.skills;
|
|
agent.skills_enabled = true;
|
|
return agent;
|
|
}
|
|
|
|
const visibleSkills = configuredSkills
|
|
.map((skillId) => String(skillId))
|
|
.filter((skillId) => accessibleSkillSet.has(skillId));
|
|
|
|
if (visibleSkills.length === 0) {
|
|
delete agent.skills;
|
|
return agent;
|
|
}
|
|
|
|
agent.skills = visibleSkills;
|
|
agent.skills_enabled = true;
|
|
return agent;
|
|
};
|
|
|
|
/**
|
|
* Looks up each referenced agent id in Mongo, splits them into three
|
|
* buckets the caller needs for validation: ids that don't exist at all,
|
|
* ids the user lacks VIEW permission on, and ids that are fully
|
|
* accessible. Missing ids are intentionally NOT treated as unauthorized
|
|
* — for `edges`, a self-referential `from` can legitimately name the
|
|
* agent being created (no DB record yet); callers that should reject
|
|
* missing ids (like the subagent path) read the `missing` bucket
|
|
* instead.
|
|
* @param {Iterable<string>} agentIds
|
|
* @param {string} userId
|
|
* @param {string} userRole
|
|
* @returns {Promise<{ missing: string[], unauthorized: string[] }>}
|
|
*/
|
|
const classifyAgentReferences = async (agentIds, userId, userRole) => {
|
|
const ids = [...new Set(agentIds)];
|
|
if (ids.length === 0) return { missing: [], unauthorized: [] };
|
|
|
|
const agents = await db.getAgents({ id: { $in: ids } });
|
|
const foundIds = new Set(agents.map((a) => a.id));
|
|
const missing = ids.filter((id) => !foundIds.has(id));
|
|
|
|
if (agents.length === 0) return { missing, unauthorized: [] };
|
|
|
|
const permissionsMap = await getResourcePermissionsMap({
|
|
userId,
|
|
role: userRole,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceIds: agents.map((a) => a._id),
|
|
});
|
|
|
|
const unauthorized = agents
|
|
.filter((a) => {
|
|
const bits = permissionsMap.get(a._id.toString()) ?? 0;
|
|
return (bits & PermissionBits.VIEW) === 0;
|
|
})
|
|
.map((a) => a.id);
|
|
|
|
return { missing, unauthorized };
|
|
};
|
|
|
|
/**
|
|
* Validates that every agent referenced in `edges` exists and is viewable.
|
|
* The create path may allow its newly generated self id because that agent
|
|
* has not been inserted yet; all other missing references are invalid.
|
|
* @param {GraphEdge[]} edges
|
|
* @param {string} userId
|
|
* @param {string} userRole
|
|
* @param {Set<string>} [allowedMissingIds]
|
|
* @returns {Promise<{ missing: string[], unauthorized: string[] }>}
|
|
*/
|
|
const validateEdgeAgentReferences = async (
|
|
edges,
|
|
userId,
|
|
userRole,
|
|
allowedMissingIds = new Set(),
|
|
) => {
|
|
const { missing, unauthorized } = await classifyAgentReferences(
|
|
collectEdgeAgentIds(edges),
|
|
userId,
|
|
userRole,
|
|
);
|
|
return {
|
|
missing: missing.filter((id) => !allowedMissingIds.has(id)),
|
|
unauthorized,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Validates `subagents.agent_ids` more strictly than edges: both
|
|
* missing AND unauthorized ids are errors. `subagents.agent_ids`
|
|
* can't self-reference (subagents spawn *other* agents), so a
|
|
* missing id is always a typo or a reference to a deleted agent —
|
|
* `initializeClient` would silently drop it at runtime, leaving the
|
|
* persisted config out of sync with actual spawn targets (Codex P2).
|
|
* Returning the split lets the caller report each bucket with the
|
|
* appropriate status.
|
|
*/
|
|
const validateSubagentReferences = (subagents, userId, userRole) =>
|
|
classifyAgentReferences(subagents?.agent_ids ?? [], userId, userRole);
|
|
|
|
/**
|
|
* Returns true when the agents-endpoint `subagents` capability is
|
|
* enabled in this request's resolved app config. When disabled,
|
|
* `initializeClient` already strips the `subagents` block at runtime
|
|
* so persisted `agent_ids` are inert — gating the ACL check on this
|
|
* keeps stale references in legacy records from blocking unrelated
|
|
* edits after a capability-off rollback (Codex P2).
|
|
* @param {Express.Request} req
|
|
*/
|
|
const isSubagentsCapabilityEnabled = (req) => {
|
|
const capabilities = req.config?.endpoints?.[EModelEndpoint.agents]?.capabilities;
|
|
if (!Array.isArray(capabilities)) return false;
|
|
return capabilities.includes(AgentCapabilities.subagents);
|
|
};
|
|
|
|
/**
|
|
* Filters tools to only include those the user is authorized to use.
|
|
* MCP tools must match the exact format `{toolName}_mcp_{serverName}` (exactly 2 segments).
|
|
* Multi-delimiter keys are rejected to prevent authorization/execution mismatch.
|
|
* Non-MCP tools must appear in availableTools (global tool cache) or systemTools.
|
|
*
|
|
* When `existingTools` is provided and the MCP registry is unavailable (e.g. server restart),
|
|
* tools already present on the agent are preserved rather than stripped — they were validated
|
|
* when originally added, and we cannot re-verify them without the registry.
|
|
* @param {object} params
|
|
* @param {string[]} params.tools - Raw tool strings from the request
|
|
* @param {string} params.userId - Requesting user ID for MCP server access check
|
|
* @param {string} [params.role] - Requesting user's role for ACL principal resolution
|
|
* @param {object} [params.user] - Requesting user for MCP server use permission checks
|
|
* @param {{ canUseServers: (user?: object) => Promise<boolean> }} [params.mcpPermissionContext] - Request-scoped MCP permission context
|
|
* @param {Record<string, unknown>} params.availableTools - Global non-MCP tool cache
|
|
* @param {string[]} [params.existingTools] - Tools already persisted on the agent document
|
|
* @param {Record<string, unknown>} [params.configServers] - Config-source MCP servers resolved from appConfig overrides
|
|
* @returns {Promise<string[]>} Only the authorized subset of tools
|
|
*/
|
|
const filterAuthorizedTools = async ({
|
|
tools,
|
|
userId,
|
|
role,
|
|
user,
|
|
mcpPermissionContext,
|
|
availableTools,
|
|
existingTools,
|
|
configServers,
|
|
resolvedServerNames,
|
|
}) => {
|
|
const filteredTools = [];
|
|
let mcpServerConfigs;
|
|
/** normalized server name -> the raw key `mcpServerConfigs` is indexed by */
|
|
let configNamesByNormalized = new Map();
|
|
let shadowedServerNames = new Set();
|
|
let registryUnavailable = false;
|
|
const existingToolSet = existingTools?.length ? new Set(existingTools) : null;
|
|
const hasMCPTools = tools.some((tool) => tool?.includes(Constants.mcp_delimiter));
|
|
const canUseMCP = hasMCPTools
|
|
? await (mcpPermissionContext
|
|
? mcpPermissionContext.canUseServers(user)
|
|
: userCanUseMCPServers(user))
|
|
: true;
|
|
let loggedMCPDenied = false;
|
|
|
|
for (const tool of tools) {
|
|
const isActionToolName = typeof tool === 'string' && isActionTool(tool);
|
|
const isMCPTool = tool?.includes(Constants.mcp_delimiter) && !isActionToolName;
|
|
|
|
if (!isMCPTool) {
|
|
if (availableTools[tool] || systemTools[tool] || isActionToolName) {
|
|
filteredTools.push(tool);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!canUseMCP) {
|
|
if (!loggedMCPDenied) {
|
|
logger.warn(`[filterAuthorizedTools] User ${userId} lacks MCP server use permission`);
|
|
loggedMCPDenied = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (mcpServerConfigs === undefined) {
|
|
try {
|
|
mcpServerConfigs =
|
|
(role
|
|
? await getMCPServersRegistry().getAllServerConfigs(userId, configServers, role)
|
|
: await getMCPServersRegistry().getAllServerConfigs(userId, configServers)) ?? {};
|
|
} catch (e) {
|
|
logger.warn(
|
|
'[filterAuthorizedTools] MCP registry unavailable, filtering all MCP tools',
|
|
e.message,
|
|
);
|
|
mcpServerConfigs = {};
|
|
registryUnavailable = true;
|
|
}
|
|
/** Shared first-wins construction — authorization must resolve a
|
|
* colliding normalized key to the SAME server execution routes to,
|
|
* or a tool could be authorized against one server and executed
|
|
* against another. */
|
|
configNamesByNormalized = buildServerNameAliases(Object.keys(mcpServerConfigs));
|
|
shadowedServerNames = findShadowedServerNames(Object.keys(mcpServerConfigs));
|
|
}
|
|
|
|
/** Tool keys embed the normalized server name; the config is keyed by the raw name. */
|
|
const [, normalizedServerName] = splitMCPToolKey(
|
|
tool,
|
|
Array.from(configNamesByNormalized.keys()),
|
|
);
|
|
const serverName = configNamesByNormalized.get(normalizedServerName) ?? normalizedServerName;
|
|
if (!serverName) {
|
|
logger.warn(
|
|
`[filterAuthorizedTools] Rejected malformed MCP tool key "${tool}" for user ${userId}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (registryUnavailable && existingToolSet?.has(tool)) {
|
|
filteredTools.push(tool);
|
|
continue;
|
|
}
|
|
|
|
if (!Object.hasOwn(mcpServerConfigs, serverName)) {
|
|
logger.warn(
|
|
`[filterAuthorizedTools] Rejected MCP tool "${tool}" — server "${serverName}" not accessible to user ${userId}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
/** A shadowed server's tools (including its `mcp_all` wildcard) produce
|
|
* the SAME normalized function names as the winning server's — in-run
|
|
* dispatch could execute either. Fail closed at authorization; this map
|
|
* is the full accessible set, so DB-vs-config collisions are visible. */
|
|
if (shadowedServerNames.has(serverName)) {
|
|
logger.warn(
|
|
`[filterAuthorizedTools] Rejected MCP tool "${tool}" — server "${serverName}" is shadowed by a name collision; rename one server to use it`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
resolvedServerNames?.add(serverName);
|
|
filteredTools.push(tool);
|
|
}
|
|
|
|
return filteredTools;
|
|
};
|
|
|
|
/**
|
|
* Removes file IDs from tool resources unless they are already attached to the
|
|
* agent or owned by an allowed uploader.
|
|
* @param {object} params
|
|
* @param {object} params.tool_resources
|
|
* @param {string | object | Array<string | object>} params.ownerIds
|
|
* @param {object} [params.existingToolResources]
|
|
* @param {string} params.logPrefix
|
|
* @returns {Promise<number>} Count of removed file references.
|
|
*/
|
|
const pruneToolResourceFileIdsForAgent = async ({
|
|
tool_resources,
|
|
ownerIds,
|
|
existingToolResources,
|
|
logPrefix,
|
|
}) => {
|
|
const referencedFileIds = collectToolResourceFileIds(tool_resources);
|
|
if (referencedFileIds.length === 0) {
|
|
return 0;
|
|
}
|
|
const ownerIdSet = new Set(
|
|
(Array.isArray(ownerIds) ? ownerIds : [ownerIds])
|
|
.filter(Boolean)
|
|
.map((ownerId) => ownerId.toString()),
|
|
);
|
|
const existingFileIds = new Set(collectToolResourceFileIds(existingToolResources ?? {}));
|
|
|
|
try {
|
|
const files = await db.getFiles({ file_id: { $in: referencedFileIds } }, null, {
|
|
file_id: 1,
|
|
user: 1,
|
|
});
|
|
const allowedIds = new Set(
|
|
(files ?? [])
|
|
.filter((file) => {
|
|
if (!file.user) {
|
|
return false;
|
|
}
|
|
return existingFileIds.has(file.file_id) || ownerIdSet.has(file.user.toString());
|
|
})
|
|
.map((file) => file.file_id),
|
|
);
|
|
const disallowedIds = referencedFileIds.filter((id) => !allowedIds.has(id));
|
|
if (disallowedIds.length > 0) {
|
|
logger.warn(`${logPrefix} Pruning ${disallowedIds.length} invalid file reference(s)`);
|
|
return stripFileIdsFromToolResources(tool_resources, disallowedIds).removedCount;
|
|
}
|
|
return 0;
|
|
} catch (fileCheckError) {
|
|
logger.warn(`${logPrefix} File ownership check failed, pruning incoming file references`, {
|
|
error: fileCheckError?.message,
|
|
});
|
|
return stripFileIdsFromToolResources(tool_resources, referencedFileIds).removedCount;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Creates an Agent.
|
|
* @route POST /Agents
|
|
* @param {ServerRequest} req - The request object.
|
|
* @param {AgentCreateParams} req.body - The request body.
|
|
* @param {ServerResponse} res - The response object.
|
|
* @returns {Promise<Agent>} 201 - success response - application/json
|
|
*/
|
|
const createAgentHandler = async (req, res) => {
|
|
try {
|
|
const validatedData = agentCreateSchema.parse(req.body);
|
|
const { tools = [], ...agentData } = removeNullishValues(validatedData);
|
|
|
|
if (agentData.model_parameters && typeof agentData.model_parameters === 'object') {
|
|
agentData.model_parameters = removeNullishValues(
|
|
sanitizeModelParameters(agentData.model_parameters),
|
|
true,
|
|
);
|
|
}
|
|
|
|
const { id: userId, role: userRole } = req.user;
|
|
agentData.id = `agent_${nanoid()}`;
|
|
agentData.edges = replaceEdgeSourceId(agentData.edges, '', agentData.id);
|
|
|
|
if (agentData.tool_resources) {
|
|
await pruneToolResourceFileIdsForAgent({
|
|
tool_resources: agentData.tool_resources,
|
|
ownerIds: userId,
|
|
logPrefix: '[/Agents]',
|
|
});
|
|
}
|
|
|
|
if (agentData.edges?.length) {
|
|
const { missing, unauthorized } = await validateEdgeAgentReferences(
|
|
agentData.edges,
|
|
userId,
|
|
userRole,
|
|
new Set([agentData.id]),
|
|
);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'One or more agents referenced in edges do not exist',
|
|
agent_ids: missing,
|
|
});
|
|
}
|
|
if (unauthorized.length > 0) {
|
|
return res.status(403).json({
|
|
error: 'You do not have access to one or more agents referenced in edges',
|
|
agent_ids: unauthorized,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Only validate subagent ACL when the feature is actually enabled
|
|
* on BOTH the endpoint (capability flag in appConfig) AND the
|
|
* agent payload. Runtime (`initializeClient` + `run.ts`) checks
|
|
* `subagents?.enabled` as a truthy predicate — so `undefined` /
|
|
* `null` / missing `enabled` all disable the feature. The ACL
|
|
* check must match exactly: only enforce when `enabled === true`.
|
|
* Otherwise a payload that omits `enabled` (e.g. API clients, or
|
|
* legacy records that never set the field) could 403 here while
|
|
* runtime would happily no-op on the subagent tool. Disable-path
|
|
* is also untouched: toggling `enabled: false` always passes the
|
|
* gate, so a user who lost VIEW on a child can still save the
|
|
* disable edit.
|
|
*/
|
|
if (
|
|
isSubagentsCapabilityEnabled(req) &&
|
|
agentData.subagents?.enabled === true &&
|
|
agentData.subagents?.agent_ids?.length
|
|
) {
|
|
const { missing, unauthorized } = await validateSubagentReferences(
|
|
agentData.subagents,
|
|
userId,
|
|
userRole,
|
|
);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'One or more agents referenced in subagents do not exist',
|
|
agent_ids: missing,
|
|
});
|
|
}
|
|
if (unauthorized.length > 0) {
|
|
return res.status(403).json({
|
|
error: 'You do not have access to one or more agents referenced in subagents',
|
|
agent_ids: unauthorized,
|
|
});
|
|
}
|
|
}
|
|
|
|
agentData.author = userId;
|
|
agentData.tools = [];
|
|
|
|
const hasMCPTools = tools.some((t) => t?.includes(Constants.mcp_delimiter));
|
|
const [availableTools, configServers] = await Promise.all([
|
|
getCachedTools().then((t) => t ?? {}),
|
|
hasMCPTools ? resolveConfigServers(req) : Promise.resolve(undefined),
|
|
]);
|
|
const mcpPermissionContext = createMCPPermissionContext(req);
|
|
/** Resolved during authorization, so persistence indexes the real server rather
|
|
* than a suffix guess - see the note on `filterAuthorizedTools`. */
|
|
const resolvedServerNames = new Set();
|
|
agentData.tools = await filterAuthorizedTools({
|
|
tools,
|
|
userId,
|
|
role: req.user.role,
|
|
user: req.user,
|
|
mcpPermissionContext,
|
|
availableTools,
|
|
configServers,
|
|
resolvedServerNames,
|
|
});
|
|
if (hasMCPTools) {
|
|
agentData.mcpServerNames = Array.from(resolvedServerNames);
|
|
}
|
|
|
|
const agent = await db.createAgent(agentData);
|
|
|
|
try {
|
|
await Promise.all([
|
|
grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: userId,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
accessRoleId: AccessRoleIds.AGENT_OWNER,
|
|
grantedBy: userId,
|
|
}),
|
|
grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: userId,
|
|
resourceType: ResourceType.REMOTE_AGENT,
|
|
resourceId: agent._id,
|
|
accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
|
|
grantedBy: userId,
|
|
}),
|
|
]);
|
|
logger.debug(
|
|
`[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`,
|
|
);
|
|
} catch (permissionError) {
|
|
logger.error(
|
|
`[createAgent] Failed to grant owner permissions for agent ${agent.id}:`,
|
|
permissionError,
|
|
);
|
|
}
|
|
|
|
res.status(201).json(agent);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
logger.error('[/Agents] Validation error', error.errors);
|
|
return res.status(400).json({ error: 'Invalid request data', details: error.errors });
|
|
}
|
|
logger.error('[/Agents] Error creating agent', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Retrieves an Agent by ID.
|
|
* @route GET /Agents/:id
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.params - Request params
|
|
* @param {string} req.params.id - Agent identifier.
|
|
* @param {object} req.user - Authenticated user information
|
|
* @param {string} req.user.id - User ID
|
|
* @returns {Promise<Agent>} 200 - success response - application/json
|
|
* @returns {Error} 404 - Agent not found
|
|
*/
|
|
const getAgentHandler = async (req, res, expandProperties = false) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const author = req.user.id;
|
|
|
|
// Permissions are validated by middleware before calling this function.
|
|
// Load the agent with a `version` count but without the heavy `versions`
|
|
// array; version history is fetched lazily via GET /agents/:id/versions.
|
|
const agent = await db.getAgentWithVersionCount({ id });
|
|
|
|
if (!agent) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
|
|
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
|
|
try {
|
|
agent.avatar = {
|
|
...agent.avatar,
|
|
filepath: await refreshS3Url(agent.avatar),
|
|
};
|
|
} catch (e) {
|
|
logger.warn('[/Agents/:id] Failed to refresh S3 URL', e);
|
|
}
|
|
}
|
|
|
|
agent.author = agent.author.toString();
|
|
|
|
// Check if agent is public
|
|
const isPublic = await hasPublicPermission({
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
});
|
|
agent.isPublic = isPublic;
|
|
|
|
await attachOwnerContacts([agent]);
|
|
|
|
if (agent.author !== author) {
|
|
delete agent.author;
|
|
}
|
|
|
|
if (!expandProperties) {
|
|
// VIEW permission: Basic agent info only
|
|
const responseAgent = {
|
|
_id: agent._id,
|
|
id: agent.id,
|
|
name: agent.name,
|
|
description: agent.description,
|
|
conversation_starters: agent.conversation_starters,
|
|
avatar: agent.avatar,
|
|
author: agent.author,
|
|
provider: agent.provider,
|
|
model: agent.model,
|
|
model_parameters: getSafeModelParameters(agent.model_parameters),
|
|
isPublic: agent.isPublic,
|
|
version: agent.version,
|
|
// Safe metadata
|
|
createdAt: agent.createdAt,
|
|
updatedAt: agent.updatedAt,
|
|
};
|
|
|
|
if (agent.support_contact !== undefined) {
|
|
responseAgent.support_contact = agent.support_contact;
|
|
}
|
|
if (agent.owner_contact !== undefined) {
|
|
responseAgent.owner_contact = agent.owner_contact;
|
|
}
|
|
|
|
return res.status(200).json(responseAgent);
|
|
}
|
|
|
|
// EDIT permission: Full agent details including sensitive configuration
|
|
return res.status(200).json(agent);
|
|
} catch (error) {
|
|
logger.error('[/Agents/:id] Error retrieving agent', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Retrieves an agent's version history.
|
|
* Loaded lazily so the editor doesn't transfer large histories up front.
|
|
* @route GET /agents/:id/versions
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.params - Request params
|
|
* @param {string} req.params.id - Agent identifier.
|
|
* @returns {Promise<Agent[]>} 200 - The agent's version history - application/json
|
|
* @returns {Error} 404 - Agent not found
|
|
*/
|
|
const getAgentVersionsHandler = async (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const versions = await db.getAgentVersions({ id });
|
|
|
|
if (versions == null) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
|
|
return res.status(200).json(versions);
|
|
} catch (error) {
|
|
logger.error('[/Agents/:id/versions] Error retrieving agent versions', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Updates an Agent.
|
|
* @route PATCH /Agents/:id
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.params - Request params
|
|
* @param {string} req.params.id - Agent identifier.
|
|
* @param {AgentUpdateParams} req.body - The Agent update parameters.
|
|
* @returns {Promise<Agent>} 200 - success response - application/json
|
|
*/
|
|
const updateAgentHandler = async (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const validatedData = agentUpdateSchema.parse(req.body);
|
|
// Preserve explicit null for avatar to allow resetting the avatar
|
|
const { avatar: avatarField, _id, ...rest } = validatedData;
|
|
const updateData = removeNullishValues(rest);
|
|
|
|
if (updateData.model_parameters && typeof updateData.model_parameters === 'object') {
|
|
updateData.model_parameters = removeNullishValues(
|
|
sanitizeModelParameters(updateData.model_parameters),
|
|
true,
|
|
);
|
|
}
|
|
|
|
if (avatarField === null) {
|
|
updateData.avatar = avatarField;
|
|
}
|
|
|
|
if (updateData.edges !== undefined) {
|
|
updateData.edges = replaceEdgeSourceId(updateData.edges, '', id);
|
|
}
|
|
|
|
if (updateData.edges?.length) {
|
|
const { id: userId, role: userRole } = req.user;
|
|
const { missing, unauthorized } = await validateEdgeAgentReferences(
|
|
updateData.edges,
|
|
userId,
|
|
userRole,
|
|
);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'One or more agents referenced in edges do not exist',
|
|
agent_ids: missing,
|
|
});
|
|
}
|
|
if (unauthorized.length > 0) {
|
|
return res.status(403).json({
|
|
error: 'You do not have access to one or more agents referenced in edges',
|
|
agent_ids: unauthorized,
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Same guard as the create path: capability on the endpoint,
|
|
* AND `subagents.enabled === true` on the payload (runtime's
|
|
* truthy check treats `undefined` / `null` / `false` as
|
|
* disabled, so the ACL check must too). Missing or explicitly-
|
|
* disabled payloads always pass the gate — that preserves the
|
|
* "can always save a disable edit" behavior a user might need
|
|
* after losing VIEW on a referenced child. */
|
|
if (
|
|
isSubagentsCapabilityEnabled(req) &&
|
|
updateData.subagents?.enabled === true &&
|
|
updateData.subagents?.agent_ids?.length
|
|
) {
|
|
const { id: userId, role: userRole } = req.user;
|
|
const { missing, unauthorized } = await validateSubagentReferences(
|
|
updateData.subagents,
|
|
userId,
|
|
userRole,
|
|
);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'One or more agents referenced in subagents do not exist',
|
|
agent_ids: missing,
|
|
});
|
|
}
|
|
if (unauthorized.length > 0) {
|
|
return res.status(403).json({
|
|
error: 'You do not have access to one or more agents referenced in subagents',
|
|
agent_ids: unauthorized,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Convert OCR to context in incoming updateData
|
|
convertOcrToContextInPlace(updateData);
|
|
|
|
const existingAgent = await db.getAgent({ id });
|
|
|
|
if (!existingAgent) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
|
|
// Convert legacy OCR tool resource to context format in existing agent
|
|
const ocrConversion = mergeAgentOcrConversion(existingAgent, updateData);
|
|
if (ocrConversion.tool_resources) {
|
|
updateData.tool_resources = ocrConversion.tool_resources;
|
|
}
|
|
if (ocrConversion.tools) {
|
|
updateData.tools = ocrConversion.tools;
|
|
}
|
|
|
|
if (updateData.tool_resources) {
|
|
await pruneToolResourceFileIdsForAgent({
|
|
tool_resources: updateData.tool_resources,
|
|
ownerIds: req.user.id,
|
|
existingToolResources: existingAgent.tool_resources,
|
|
logPrefix: `[/Agents/:id] Agent ${id}`,
|
|
});
|
|
}
|
|
|
|
const isMCPTool = (t) =>
|
|
typeof t === 'string' && t.includes(Constants.mcp_delimiter) && !isActionTool(t);
|
|
const hasToolUpdate = updateData.tools !== undefined;
|
|
const editingOwnAgent = existingAgent.author?.toString() === req.user.id;
|
|
const existingTools = existingAgent.tools ?? [];
|
|
const effectiveTools = (hasToolUpdate ? updateData.tools : existingAgent.tools) ?? [];
|
|
const requestedMCPTools = effectiveTools.filter(isMCPTool);
|
|
const existingMCPTools = existingTools.filter(isMCPTool);
|
|
|
|
if (requestedMCPTools.length > 0 || (hasToolUpdate && existingMCPTools.length > 0)) {
|
|
const mcpPermissionContext = createMCPPermissionContext(req);
|
|
if (!(await mcpPermissionContext.canUseServers(req.user))) {
|
|
if (editingOwnAgent) {
|
|
updateData.tools = effectiveTools.filter((t) => !isMCPTool(t));
|
|
/** Every MCP tool just went away, so nothing should stay indexed. */
|
|
updateData.mcpServerNames = [];
|
|
} else if (hasToolUpdate) {
|
|
const existingMCPToolSet = new Set(existingMCPTools);
|
|
const nextTools = updateData.tools.filter(
|
|
(t) => !isMCPTool(t) || existingMCPToolSet.has(t),
|
|
);
|
|
const nextToolSet = new Set(nextTools);
|
|
for (const existingMCPTool of existingMCPTools) {
|
|
if (!nextToolSet.has(existingMCPTool)) {
|
|
nextTools.push(existingMCPTool);
|
|
}
|
|
}
|
|
updateData.tools = nextTools;
|
|
/** The agent's MCP tools are retained verbatim here, so carry its resolved
|
|
* names across too. Left unset when the agent has none stored, so
|
|
* `updateAgent` can still derive rather than being pinned to an empty
|
|
* index that would strip agent-scoped access. */
|
|
if (existingAgent.mcpServerNames?.length) {
|
|
updateData.mcpServerNames = existingAgent.mcpServerNames;
|
|
}
|
|
}
|
|
} else if (hasToolUpdate) {
|
|
const existingToolSet = new Set(existingTools);
|
|
const newMCPTools = requestedMCPTools.filter((t) => !existingToolSet.has(t));
|
|
/** Names resolved during authorization of the newly added tools. */
|
|
const resolvedServerNames = new Set();
|
|
|
|
if (newMCPTools.length > 0) {
|
|
const [availableTools, configServers] = await Promise.all([
|
|
getCachedTools().then((t) => t ?? {}),
|
|
resolveConfigServers(req),
|
|
]);
|
|
const approvedNew = await filterAuthorizedTools({
|
|
tools: newMCPTools,
|
|
userId: req.user.id,
|
|
role: req.user.role,
|
|
user: req.user,
|
|
mcpPermissionContext,
|
|
availableTools,
|
|
configServers,
|
|
resolvedServerNames,
|
|
});
|
|
const rejectedSet = new Set(newMCPTools.filter((t) => !approvedNew.includes(t)));
|
|
if (rejectedSet.size > 0) {
|
|
updateData.tools = updateData.tools.filter((t) => !rejectedSet.has(t));
|
|
}
|
|
}
|
|
|
|
/** Rebuild the index from the tools that survive this edit: carry a prior name
|
|
* forward only while some retained tool still resolves to it, so detaching every
|
|
* tool for a server revokes agent-scoped access to it. The agent's own persisted
|
|
* names are the candidate set, which needs neither a registry query nor a guess. */
|
|
const priorNames = existingAgent.mcpServerNames ?? [];
|
|
if (priorNames.length > 0) {
|
|
const priorNameSet = new Set(priorNames);
|
|
for (const tool of updateData.tools ?? []) {
|
|
if (typeof tool !== 'string' || !tool.includes(Constants.mcp_delimiter)) {
|
|
continue;
|
|
}
|
|
const [, retainedName] = splitMCPToolKey(tool, priorNames);
|
|
if (retainedName && priorNameSet.has(retainedName)) {
|
|
resolvedServerNames.add(retainedName);
|
|
}
|
|
}
|
|
}
|
|
/** Supplying `[]` would pin the index empty and suppress `updateAgent`'s
|
|
* derivation, so only assert it when the result is authoritative: either we
|
|
* resolved names, or no MCP tool survives and the index genuinely is empty. */
|
|
const retainsMCPTools = (updateData.tools ?? []).some(isMCPTool);
|
|
if (resolvedServerNames.size > 0 || !retainsMCPTools) {
|
|
updateData.mcpServerNames = Array.from(resolvedServerNames);
|
|
}
|
|
}
|
|
}
|
|
|
|
let updatedAgent =
|
|
Object.keys(updateData).length > 0
|
|
? await db.updateAgent({ id }, updateData, {
|
|
updatingUserId: req.user.id,
|
|
})
|
|
: existingAgent;
|
|
|
|
// Add version count to the response
|
|
updatedAgent.version = updatedAgent.versions ? updatedAgent.versions.length : 0;
|
|
|
|
if (updatedAgent.author) {
|
|
updatedAgent.author = updatedAgent.author.toString();
|
|
}
|
|
|
|
await attachOwnerContacts([updatedAgent]);
|
|
|
|
if (updatedAgent.author !== req.user.id) {
|
|
delete updatedAgent.author;
|
|
}
|
|
|
|
return res.json(updatedAgent);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
logger.error('[/Agents/:id] Validation error', error.errors);
|
|
return res.status(400).json({ error: 'Invalid request data', details: error.errors });
|
|
}
|
|
|
|
logger.error('[/Agents/:id] Error updating Agent', error);
|
|
|
|
if (error.statusCode === 409) {
|
|
return res.status(409).json({
|
|
error: error.message,
|
|
details: error.details,
|
|
});
|
|
}
|
|
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Duplicates an Agent based on the provided ID.
|
|
* @route POST /Agents/:id/duplicate
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.params - Request params
|
|
* @param {string} req.params.id - Agent identifier.
|
|
* @returns {Promise<Agent>} 201 - success response - application/json
|
|
*/
|
|
const duplicateAgentHandler = async (req, res) => {
|
|
const { id } = req.params;
|
|
const { id: userId, role: userRole } = req.user;
|
|
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
|
|
|
|
try {
|
|
const agent = await db.getAgent({ id });
|
|
if (!agent) {
|
|
return res.status(404).json({
|
|
error: 'Agent not found',
|
|
status: 'error',
|
|
});
|
|
}
|
|
|
|
const {
|
|
id: _id,
|
|
_id: __id,
|
|
author: _author,
|
|
createdAt: _createdAt,
|
|
updatedAt: _updatedAt,
|
|
tool_resources: _tool_resources = {},
|
|
versions: _versions,
|
|
__v: _v,
|
|
...cloneData
|
|
} = agent;
|
|
cloneData.name = `${agent.name} (${new Date().toLocaleString('en-US', {
|
|
dateStyle: 'short',
|
|
timeStyle: 'short',
|
|
hour12: false,
|
|
})})`;
|
|
|
|
if (_tool_resources?.[EToolResources.context]) {
|
|
cloneData.tool_resources = {
|
|
[EToolResources.context]: _tool_resources[EToolResources.context],
|
|
};
|
|
}
|
|
|
|
if (_tool_resources?.[EToolResources.ocr]) {
|
|
cloneData.tool_resources = {
|
|
/** Legacy conversion from `ocr` to `context` */
|
|
[EToolResources.context]: {
|
|
...(_tool_resources[EToolResources.context] ?? {}),
|
|
..._tool_resources[EToolResources.ocr],
|
|
},
|
|
};
|
|
}
|
|
|
|
const newAgentId = `agent_${nanoid()}`;
|
|
const newAgentData = Object.assign(cloneData, {
|
|
id: newAgentId,
|
|
author: userId,
|
|
});
|
|
newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, id, newAgentId);
|
|
newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, '', newAgentId);
|
|
|
|
if (newAgentData.edges?.length) {
|
|
const { missing, unauthorized } = await validateEdgeAgentReferences(
|
|
newAgentData.edges,
|
|
userId,
|
|
userRole,
|
|
new Set([newAgentId]),
|
|
);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'One or more agents referenced in edges do not exist',
|
|
agent_ids: missing,
|
|
});
|
|
}
|
|
if (unauthorized.length > 0) {
|
|
return res.status(403).json({
|
|
error: 'You do not have access to one or more agents referenced in edges',
|
|
agent_ids: unauthorized,
|
|
});
|
|
}
|
|
}
|
|
|
|
const newActionsList = [];
|
|
const originalActions = (await db.getActions({ agent_id: id }, true)) ?? [];
|
|
const promises = [];
|
|
|
|
/**
|
|
* Duplicates an action and returns the new action ID.
|
|
* @param {Action} action
|
|
* @returns {Promise<string>}
|
|
*/
|
|
const duplicateAction = async (action) => {
|
|
const newActionId = nanoid();
|
|
const { domain } = action.metadata;
|
|
const fullActionId = `${domain}${actionDelimiter}${newActionId}`;
|
|
|
|
// Sanitize sensitive metadata before persisting
|
|
const filteredMetadata = { ...(action.metadata || {}) };
|
|
for (const field of sensitiveFields) {
|
|
delete filteredMetadata[field];
|
|
}
|
|
|
|
const newAction = await db.updateAction(
|
|
{ action_id: newActionId, agent_id: newAgentId },
|
|
{
|
|
metadata: filteredMetadata,
|
|
agent_id: newAgentId,
|
|
user: userId,
|
|
},
|
|
);
|
|
|
|
newActionsList.push(newAction);
|
|
return fullActionId;
|
|
};
|
|
|
|
for (const action of originalActions) {
|
|
promises.push(
|
|
duplicateAction(action).catch((error) => {
|
|
logger.error('[/agents/:id/duplicate] Error duplicating Action:', error);
|
|
}),
|
|
);
|
|
}
|
|
|
|
const agentActions = await Promise.all(promises);
|
|
newAgentData.actions = agentActions;
|
|
|
|
if (newAgentData.tools?.length) {
|
|
const [availableTools, configServers] = await Promise.all([
|
|
getCachedTools().then((t) => t ?? {}),
|
|
resolveConfigServers(req),
|
|
]);
|
|
const mcpPermissionContext = createMCPPermissionContext(req);
|
|
/** The duplicate carries the source agent's `mcpServerNames`; replace it with what
|
|
* this user is actually authorized for, or the copy would grant the source's servers. */
|
|
const resolvedServerNames = new Set();
|
|
newAgentData.tools = await filterAuthorizedTools({
|
|
tools: newAgentData.tools,
|
|
userId,
|
|
role: req.user.role,
|
|
user: req.user,
|
|
mcpPermissionContext,
|
|
availableTools,
|
|
existingTools: newAgentData.tools,
|
|
configServers,
|
|
resolvedServerNames,
|
|
});
|
|
/** When the registry is unavailable, `filterAuthorizedTools` grandfathers the
|
|
* source's tools without resolving them, so carry forward the source names those
|
|
* retained tools still point at rather than blanking the index. */
|
|
const sourceNames = agent.mcpServerNames ?? [];
|
|
if (sourceNames.length > 0) {
|
|
const sourceNameSet = new Set(sourceNames);
|
|
for (const tool of newAgentData.tools ?? []) {
|
|
if (typeof tool !== 'string' || !tool.includes(Constants.mcp_delimiter)) {
|
|
continue;
|
|
}
|
|
const [, retainedName] = splitMCPToolKey(tool, sourceNames);
|
|
if (retainedName && sourceNameSet.has(retainedName)) {
|
|
resolvedServerNames.add(retainedName);
|
|
}
|
|
}
|
|
}
|
|
newAgentData.mcpServerNames = Array.from(resolvedServerNames);
|
|
}
|
|
|
|
if (newAgentData.tool_resources) {
|
|
await pruneToolResourceFileIdsForAgent({
|
|
tool_resources: newAgentData.tool_resources,
|
|
ownerIds: userId,
|
|
logPrefix: '[/Agents/:id/duplicate]',
|
|
});
|
|
}
|
|
|
|
const newAgent = await db.createAgent(newAgentData);
|
|
|
|
try {
|
|
await Promise.all([
|
|
grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: userId,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: newAgent._id,
|
|
accessRoleId: AccessRoleIds.AGENT_OWNER,
|
|
grantedBy: userId,
|
|
}),
|
|
grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: userId,
|
|
resourceType: ResourceType.REMOTE_AGENT,
|
|
resourceId: newAgent._id,
|
|
accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
|
|
grantedBy: userId,
|
|
}),
|
|
]);
|
|
logger.debug(
|
|
`[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`,
|
|
);
|
|
} catch (permissionError) {
|
|
logger.error(
|
|
`[duplicateAgent] Failed to grant owner permissions for duplicated agent ${newAgent.id}:`,
|
|
permissionError,
|
|
);
|
|
}
|
|
|
|
return res.status(201).json({
|
|
agent: newAgent,
|
|
actions: newActionsList,
|
|
});
|
|
} catch (error) {
|
|
logger.error('[/Agents/:id/duplicate] Error duplicating Agent:', error);
|
|
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Deletes an Agent based on the provided ID.
|
|
* @route DELETE /Agents/:id
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.params - Request params
|
|
* @param {string} req.params.id - Agent identifier.
|
|
* @returns {Promise<Agent>} 200 - success response - application/json
|
|
*/
|
|
const deleteAgentHandler = async (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const agent = await db.getAgent({ id });
|
|
if (!agent) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
await db.deleteAgent({ id });
|
|
return res.json({ message: 'Agent deleted' });
|
|
} catch (error) {
|
|
logger.error('[/Agents/:id] Error deleting Agent', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Lists agents using ACL-aware permissions (ownership + explicit shares).
|
|
* @route GET /Agents
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.query - Request query
|
|
* @param {string} [req.query.user] - The user ID of the agent's author.
|
|
* @returns {Promise<AgentListResponse>} 200 - success response - application/json
|
|
*/
|
|
const getListAgentsHandler = async (req, res) => {
|
|
try {
|
|
const userId = req.user.id;
|
|
const { category, search, limit = 100, cursor, promoted } = req.query;
|
|
let requiredPermission = req.query.requiredPermission;
|
|
if (typeof requiredPermission === 'string') {
|
|
requiredPermission = parseInt(requiredPermission, 10);
|
|
if (isNaN(requiredPermission)) {
|
|
requiredPermission = PermissionBits.VIEW;
|
|
}
|
|
} else if (typeof requiredPermission !== 'number') {
|
|
requiredPermission = PermissionBits.VIEW;
|
|
}
|
|
const canReturnSkillConfig = hasEditBit(requiredPermission);
|
|
// Base filter
|
|
const filter = {};
|
|
|
|
// Handle category filter - only apply if category is defined
|
|
if (category !== undefined && category.trim() !== '') {
|
|
filter.category = category;
|
|
}
|
|
|
|
// Handle promoted filter - only from query param
|
|
if (promoted === '1') {
|
|
filter.is_promoted = true;
|
|
} else if (promoted === '0') {
|
|
filter.is_promoted = { $ne: true };
|
|
}
|
|
|
|
// Handle search filter (escape regex and cap length)
|
|
if (search && search.trim() !== '') {
|
|
const safeSearch = escapeRegex(search.trim().slice(0, MAX_SEARCH_LEN));
|
|
const regex = new RegExp(safeSearch, 'i');
|
|
filter.$or = [{ name: regex }, { description: regex }];
|
|
}
|
|
|
|
// Get agent IDs the user has VIEW access to via ACL
|
|
const accessibleIds = await findAccessibleResources({
|
|
userId,
|
|
role: req.user.role,
|
|
resourceType: ResourceType.AGENT,
|
|
requiredPermissions: requiredPermission,
|
|
});
|
|
|
|
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
|
resourceType: ResourceType.AGENT,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
});
|
|
|
|
/**
|
|
* Refresh all S3 avatars for this user's accessible agent set (not only the current page)
|
|
* This addresses page-size limits preventing refresh of agents beyond the first page
|
|
*/
|
|
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
|
|
const refreshKey = `${userId}:agents_avatar_refresh`;
|
|
let cachedRefresh = await cache.get(refreshKey);
|
|
const isValidCachedRefresh =
|
|
cachedRefresh != null && typeof cachedRefresh === 'object' && cachedRefresh.urlCache != null;
|
|
if (!isValidCachedRefresh) {
|
|
try {
|
|
const fullList = await db.getListAgentsByAccess({
|
|
accessibleIds,
|
|
otherParams: {},
|
|
limit: MAX_AVATAR_REFRESH_AGENTS,
|
|
after: null,
|
|
});
|
|
const { urlCache } = await refreshListAvatars({
|
|
agents: fullList?.data ?? [],
|
|
userId,
|
|
refreshS3Url,
|
|
updateAgent: db.updateAgent,
|
|
});
|
|
cachedRefresh = { urlCache };
|
|
await cache.set(refreshKey, cachedRefresh, Time.THIRTY_MINUTES);
|
|
} catch (err) {
|
|
logger.error('[/Agents] Error refreshing avatars for full list: %o', err);
|
|
}
|
|
} else {
|
|
logger.debug('[/Agents] S3 avatar refresh already checked, skipping');
|
|
}
|
|
|
|
// Use the new ACL-aware function
|
|
const data = await db.getListAgentsByAccess({
|
|
accessibleIds,
|
|
otherParams: filter,
|
|
limit,
|
|
after: cursor,
|
|
includeSkillConfig: true,
|
|
});
|
|
|
|
const agents = data?.data ?? [];
|
|
if (!agents.length) {
|
|
return res.json(data);
|
|
}
|
|
|
|
let accessibleSkillSet = null;
|
|
if (!canReturnSkillConfig) {
|
|
const accessibleSkillIds = await findAccessibleResources({
|
|
userId,
|
|
role: req.user.role,
|
|
resourceType: ResourceType.SKILL,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
});
|
|
accessibleSkillSet = new Set(
|
|
mergeDeploymentSkillIds(accessibleSkillIds).map((oid) => oid.toString()),
|
|
);
|
|
}
|
|
|
|
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
|
|
const agentsWithContacts = await attachOwnerContacts(agents);
|
|
|
|
const urlCache = cachedRefresh?.urlCache;
|
|
data.data = agentsWithContacts.map((agent) => {
|
|
if (accessibleSkillSet) {
|
|
sanitizeViewerSkillScope(agent, accessibleSkillSet);
|
|
}
|
|
try {
|
|
if (agent?._id && publicSet.has(agent._id.toString())) {
|
|
agent.isPublic = true;
|
|
}
|
|
if (
|
|
urlCache &&
|
|
agent?.id &&
|
|
agent?.avatar?.source === FileSources.s3 &&
|
|
urlCache[agent.id]
|
|
) {
|
|
agent.avatar = { ...agent.avatar, filepath: urlCache[agent.id] };
|
|
}
|
|
} catch (e) {
|
|
// Silently ignore mapping errors
|
|
void e;
|
|
}
|
|
return agent;
|
|
});
|
|
|
|
return res.json(data);
|
|
} catch (error) {
|
|
logger.error('[/Agents] Error listing Agents: %o', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Uploads and updates an avatar for a specific agent.
|
|
* @route POST /:agent_id/avatar
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.params - Request params
|
|
* @param {string} req.params.agent_id - The ID of the agent.
|
|
* @param {Express.Multer.File} req.file - The avatar image file.
|
|
* @param {object} req.body - Request body
|
|
* @param {string} [req.body.avatar] - Optional avatar for the agent's avatar.
|
|
* @returns {Promise<void>} 200 - success response - application/json
|
|
*/
|
|
const uploadAgentAvatarHandler = async (req, res) => {
|
|
try {
|
|
const appConfig = req.config;
|
|
if (!req.file) {
|
|
return res.status(400).json({ message: 'No file uploaded' });
|
|
}
|
|
filterFile({ req, file: req.file, image: true, isAvatar: true });
|
|
const { agent_id } = req.params;
|
|
if (!agent_id) {
|
|
return res.status(400).json({ message: 'Agent ID is required' });
|
|
}
|
|
|
|
const existingAgent = await db.getAgent({ id: agent_id });
|
|
|
|
if (!existingAgent) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
|
|
const buffer = await fs.readFile(req.file.path);
|
|
const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
|
|
const resizedBuffer = await resizeAvatar({
|
|
userId: req.user.id,
|
|
input: buffer,
|
|
});
|
|
|
|
const { processAvatar } = getStrategyFunctions(fileStrategy);
|
|
const avatarUrl = await processAvatar({
|
|
buffer: resizedBuffer,
|
|
userId: req.user.id,
|
|
manual: 'false',
|
|
agentId: agent_id,
|
|
tenantId: req.user.tenantId,
|
|
});
|
|
|
|
const image = {
|
|
filepath: avatarUrl,
|
|
source: fileStrategy,
|
|
};
|
|
|
|
let _avatar = existingAgent.avatar;
|
|
|
|
if (_avatar && _avatar.source) {
|
|
const { deleteFile } = getStrategyFunctions(_avatar.source);
|
|
try {
|
|
await deleteFile(req, {
|
|
filepath: _avatar.filepath,
|
|
user: req.user.id,
|
|
tenantId: req.user.tenantId,
|
|
});
|
|
await db.deleteFileByFilter({ user: req.user.id, filepath: _avatar.filepath });
|
|
} catch (error) {
|
|
logger.error('[/:agent_id/avatar] Error deleting old avatar', error);
|
|
}
|
|
}
|
|
|
|
const data = {
|
|
avatar: {
|
|
filepath: image.filepath,
|
|
source: image.source,
|
|
},
|
|
};
|
|
|
|
const updatedAgent = await db.updateAgent({ id: agent_id }, data, {
|
|
updatingUserId: req.user.id,
|
|
});
|
|
await attachOwnerContacts([updatedAgent]);
|
|
|
|
try {
|
|
const avatarCache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
|
|
await avatarCache.delete(`${req.user.id}:agents_avatar_refresh`);
|
|
} catch (cacheErr) {
|
|
logger.error('[/:agent_id/avatar] Error invalidating avatar refresh cache', cacheErr);
|
|
}
|
|
|
|
res.status(201).json(updatedAgent);
|
|
} catch (error) {
|
|
const message = 'An error occurred while updating the Agent Avatar';
|
|
logger.error(
|
|
`[/:agent_id/avatar] ${message} (${req.params?.agent_id ?? 'unknown agent'})`,
|
|
error,
|
|
);
|
|
res.status(500).json({ message });
|
|
} finally {
|
|
try {
|
|
await fs.unlink(req.file.path);
|
|
logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
|
|
} catch {
|
|
logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Reverts an agent to a previous version from its version history.
|
|
* @route PATCH /agents/:id/revert
|
|
* @param {object} req - Express Request object
|
|
* @param {object} req.params - Request parameters
|
|
* @param {string} req.params.id - The ID of the agent to revert
|
|
* @param {object} req.body - Request body
|
|
* @param {number} req.body.version_index - The index of the version to revert to
|
|
* @param {object} req.user - Authenticated user information
|
|
* @param {string} req.user.id - User ID
|
|
* @param {string} req.user.role - User role
|
|
* @param {ServerResponse} res - Express Response object
|
|
* @returns {Promise<Agent>} 200 - The updated agent after reverting to the specified version
|
|
* @throws {Error} 400 - If version_index is missing
|
|
* @throws {Error} 403 - If user doesn't have permission to modify the agent
|
|
* @throws {Error} 404 - If agent not found
|
|
* @throws {Error} 500 - If there's an internal server error during the reversion process
|
|
*/
|
|
const revertAgentVersionHandler = async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { version_index } = req.body;
|
|
|
|
if (version_index === undefined) {
|
|
return res.status(400).json({ error: 'version_index is required' });
|
|
}
|
|
|
|
const existingAgent = await db.getAgent({ id });
|
|
|
|
if (!existingAgent) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
|
|
const revertVersion = existingAgent.versions?.[version_index];
|
|
const storedRevertEdges = Array.isArray(revertVersion?.edges) ? revertVersion.edges : [];
|
|
const revertEdges = replaceEdgeSourceId(storedRevertEdges, '', id);
|
|
const hasLegacyEdgeSource = storedRevertEdges.some((edge) =>
|
|
Array.isArray(edge.from) ? edge.from.includes('') : edge.from === '',
|
|
);
|
|
if (revertEdges.length > 0) {
|
|
const { missing, unauthorized } = await validateEdgeAgentReferences(
|
|
revertEdges,
|
|
req.user.id,
|
|
req.user.role,
|
|
);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'One or more agents referenced in edges do not exist',
|
|
agent_ids: missing,
|
|
});
|
|
}
|
|
if (unauthorized.length > 0) {
|
|
return res.status(403).json({
|
|
error: 'You do not have access to one or more agents referenced in edges',
|
|
agent_ids: unauthorized,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Permissions are enforced via route middleware (ACL EDIT)
|
|
|
|
let updatedAgent = await db.revertAgentVersion({ id }, version_index);
|
|
const revertUpdates = {};
|
|
if (
|
|
revertVersion &&
|
|
(hasLegacyEdgeSource || (!Array.isArray(revertVersion.edges) && updatedAgent.edges?.length))
|
|
) {
|
|
revertUpdates.edges = revertEdges;
|
|
}
|
|
|
|
if (updatedAgent.tools?.length) {
|
|
const [availableTools, configServers] = await Promise.all([
|
|
getCachedTools().then((t) => t ?? {}),
|
|
resolveConfigServers(req),
|
|
]);
|
|
const mcpPermissionContext = createMCPPermissionContext(req);
|
|
const filteredTools = await filterAuthorizedTools({
|
|
tools: updatedAgent.tools,
|
|
userId: req.user.id,
|
|
role: req.user.role,
|
|
user: req.user,
|
|
mcpPermissionContext,
|
|
availableTools,
|
|
existingTools: updatedAgent.tools,
|
|
configServers,
|
|
});
|
|
if (filteredTools.length !== updatedAgent.tools.length) {
|
|
revertUpdates.tools = filteredTools;
|
|
}
|
|
}
|
|
|
|
if (updatedAgent.tool_resources) {
|
|
const removedCount = await pruneToolResourceFileIdsForAgent({
|
|
tool_resources: updatedAgent.tool_resources,
|
|
ownerIds: req.user.id,
|
|
existingToolResources: updatedAgent.tool_resources,
|
|
logPrefix: '[/Agents/:id/revert]',
|
|
});
|
|
if (removedCount > 0) {
|
|
revertUpdates.tool_resources = updatedAgent.tool_resources;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(revertUpdates).length > 0) {
|
|
updatedAgent = await db.updateAgent({ id }, revertUpdates, { updatingUserId: req.user.id });
|
|
}
|
|
|
|
if (updatedAgent.author) {
|
|
updatedAgent.author = updatedAgent.author.toString();
|
|
}
|
|
|
|
await attachOwnerContacts([updatedAgent]);
|
|
|
|
if (updatedAgent.author !== req.user.id) {
|
|
delete updatedAgent.author;
|
|
}
|
|
|
|
return res.json(updatedAgent);
|
|
} catch (error) {
|
|
logger.error('[/agents/:id/revert] Error reverting Agent version', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
/**
|
|
* Get all agent categories with counts
|
|
*
|
|
* @param {Object} _req - Express request object (unused)
|
|
* @param {Object} res - Express response object
|
|
*/
|
|
const getAgentCategories = async (_req, res) => {
|
|
try {
|
|
const categories = await db.getCategoriesWithCounts();
|
|
const promotedCount = await db.countPromotedAgents();
|
|
const formattedCategories = categories.map((category) => ({
|
|
value: category.value,
|
|
label: category.label,
|
|
count: category.agentCount,
|
|
description: category.description,
|
|
}));
|
|
|
|
if (promotedCount > 0) {
|
|
formattedCategories.unshift({
|
|
value: 'promoted',
|
|
label: 'Promoted',
|
|
count: promotedCount,
|
|
description: 'Our recommended agents',
|
|
});
|
|
}
|
|
|
|
formattedCategories.push({
|
|
value: 'all',
|
|
label: 'All',
|
|
description: 'All available agents',
|
|
});
|
|
|
|
res.status(200).json(formattedCategories);
|
|
} catch (error) {
|
|
logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to fetch agent categories',
|
|
userMessage: 'Unable to load categories. Please refresh the page.',
|
|
suggestion: 'Try refreshing the page or check your network connection',
|
|
});
|
|
}
|
|
};
|
|
module.exports = {
|
|
createAgent: createAgentHandler,
|
|
getAgent: getAgentHandler,
|
|
getAgentVersions: getAgentVersionsHandler,
|
|
updateAgent: updateAgentHandler,
|
|
duplicateAgent: duplicateAgentHandler,
|
|
deleteAgent: deleteAgentHandler,
|
|
getListAgents: getListAgentsHandler,
|
|
uploadAgentAvatar: uploadAgentAvatarHandler,
|
|
revertAgentVersion: revertAgentVersionHandler,
|
|
getAgentCategories,
|
|
filterAuthorizedTools,
|
|
};
|