LibreChat/api/server/controllers/agents/v1.js
Danny Avila 6f45a9e32e
🔗 fix: Normalize MCP Tool Keys at Every Producer, Resolve Raw Names via Aliases (#14553)
* 🔗 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.
2026-08-01 07:39:24 -04:00

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,
};