mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
* fix: resolve MCP tool-name delimiter collision at invocation time
MCP tool keys are identified internally as `${rawToolName}${mcp_delimiter}${serverName}`
(delimiter `_mcp_`). Several call sites parsed this back apart with a naive
`toolKey.split(Constants.mcp_delimiter)`, assuming the delimiter occurs exactly once.
When the raw upstream tool name itself contains the delimiter substring - which
happens whenever it's exposed through a gateway that prefixes aggregated tool names by
server (e.g. a gateway's own "gitlab-get_mcp_server_version" for GitLab's
"get_mcp_server_version" tool) - the combined key has the delimiter more than once.
`.split()` then produces more than two segments, and destructuring
`[toolName, serverName]` silently keeps only the first two, yielding a bogus server
name that matches no configured server. Tool listing still worked (a different code
path builds keys directly without re-splitting), but invocation failed with
`Tool {name} not found`, and `filterAuthorizedTools` rejected such keys outright as
malformed.
Add `splitMCPToolKey`, which splits on the *last* occurrence of the delimiter instead:
the server-name half is always LibreChat's own normalized suffix (guaranteed not to
contain the delimiter), while the raw tool-name half is untrusted and may legitimately
contain it. This matches `.split()`'s result whenever the delimiter occurs once, and
correctly resolves the collision case. Update the four call sites that parsed this
manually (`handleTools.js`, `MCP.js`, `mcp.js` controller, `filterAuthorizedTools` in
`v1.js`) plus one in the client (`useVisibleTools.ts`) to use it.
Fixes #14440
* fix: resolve MCP tool-key boundary against configured server names
splitMCPToolKey moves to librechat-data-provider so the client and backend
share one parser, and takes the configured server names when the caller has
them: the longest name the key actually ends with wins, which is exact.
Position alone cannot identify the boundary because both halves may contain
the delimiter. lastIndexOf alone fixes gateway-prefixed tool names but
regresses servers whose own name contains it, which ToolService.spec.js
already covered; the last-delimiter path now only serves as the fallback for
callers with no configured set.
Also converts the remaining first-occurrence parsers that the delimiter fix
missed - mcp/auth.ts (custom user vars silently unresolved), mcp/oauth/events.ts,
agents/initialize.ts, and the three client parsers that labelled tool calls
with the wrong server.
* fix: keep client tool-call labels on first-delimiter parsing
The three client parsers had deliberate, tested first-delimiter semantics
(ToolCall.test.tsx asserts the full server name for 'foo_mcp_bar' and the
synthetic 'oauth_mcp_server' call), and the client has no configured server
list in scope to resolve the boundary exactly, so they are left as they were.
Threads the configured names into the event-driven definition loader so it
resolves the same boundary as the authorization filter that admits the key,
and documents the one case that stays undecidable without provenance.
* fix: resolve tool-key boundary against all configured servers
resolveConfigServers only returns lazily-initialized config overrides -
ensureConfigServers skips unmodified YAML servers - so on a stock deployment
the known-name list was empty and suffix resolution never engaged. Adds
resolveMcpServerNames, which keeps every configured server in the normalized
form tool keys carry, and uses it at the loading, auth-map and definition
sites.
Background-tool eligibility now resolves against all configured names before
testing ephemeral membership, so a non-ephemeral server whose name ends in an
ephemeral one is no longer misclassified, and useVisibleTools resolves against
the server map it already receives.
* fix: use resolved server provenance and one app-config read
createMCPTool now uses the serverName loadTools already resolved for the key
and only parses as a fallback, so an unmodified YAML server whose name
contains the delimiter no longer resolves to the wrong server for auth,
reconnection and callTool.
resolveMcpServerContext derives config servers and all configured names from
a single getAppConfigForRequest, replacing two independent lookups on the
chat startup path, and degrades to empty like resolveConfigServers instead of
aborting tool loading when the config lookup fails.
* chore: drop unused resolveConfigServers import
* fix: forward server provenance on the all-tools path and read config once
createMCPTools builds each toolKey from the server name it already has but did
not forward it, so the sys__all__sys path re-derived it by parsing and bound
an unmodified YAML server whose name contains the delimiter to the wrong auth
and invocation context.
loadAgentTools now resolves the MCP server context once and threads it into
loadTools, replacing the second app-config read it had introduced on the
non-event-driven chat startup path.
* fix: carry resolved MCP server name through tool classification
definitions.ts resolves the server for each key and then dropped it when
building loadedTools, so buildToolClassification re-derived it with a
last-segment split and recorded 'Workspace' for a server configured as
'Google_mcp_Workspace'. The resolved name now rides along on the tool
instance and classification prefers it over re-parsing.
* fix: consume carried server name when extracting MCP servers
extractMCPServers re-derived the name with a last-segment split, so a server
configured as Google_mcp_Workspace resolved to Workspace and its instructions
were silently omitted. Prefers the name carried on the tool definition
instance, falling back to the split.
* fix: fail closed on ambiguous MCP keys when persisting server names
Persisted mcpServerNames grant agent-scoped access to a DB server by name
(ServerConfigsDB.getAccessibleServers), so a wrong guess exposes an unrelated
server to everyone who can view the agent. The last-segment split turned
search_mcp_Google_mcp_workspace into 'workspace'; such keys were previously
rejected outright at agent save, so admitting them opened this path.
Derives a name only from unambiguous single-delimiter keys. This is #12250's
guard moved to the boundary it was actually protecting, instead of blocking
tool admission.
* fix: keep DB server access for multi-delimiter tool keys
The fail-closed guard was wrong for the case this PR exists to fix. This index
only grants DB-backed servers, and DB names are slugs that cannot contain the
delimiter (generateServerNameFromTitle strips underscores), so the trailing
segment is always the real server for them - dropping it cost every consumer
of a gateway-prefixed tool their shared-agent access.
Also gates the MCP server-context lookup on the filtered MCP set, so an agent
with no MCP tools no longer pays an app-config read on startup.
* fix: resolve tool-call display names without breaking OAuth calls
The display parsers could not use the shared boundary parser because their
tested behavior depends on first-delimiter semantics. That constraint only
applies to synthetic MCP OAuth calls, whose tool half is always exactly
'oauth', so everything after the first delimiter is the server even when the
server name carries one.
splitToolCallName special-cases that form and defers to splitMCPToolKey for
real tool keys, so a gateway-prefixed tool now renders its own name and
server while oauth_mcp_foo_mcp_bar still resolves to foo_mcp_bar.
* fix: persist resolved MCP server provenance on agents
Deriving mcpServerNames from the tool key cannot tell a config server's
trailing segment from a real DB server name, so a config server named
a_mcp_b indexed an unrelated DB server b and shared the agent's viewers into
it. Neither string rule works: the suffix guess exposes, and failing closed
drops legitimate DB access for gateway-prefixed tools.
filterAuthorizedTools already resolves each tool's server against the merged
registry config, so it now collects those names and create, update and
duplicate persist them. No extra registry queries: the update path unions the
newly resolved names with what the agent already had, and duplicate replaces
the copied list rather than inheriting the source's servers.
Display parsing also takes the configured names, so a real tool call on a
delimiter-bearing server renders the right server and icon.
* test: teach MCP hook mocks about useMCPServerNames
Three specs mock ~/hooks/MCP with a hand-listed factory, so adding the hook
to ToolCall made useMCPServerNames undefined under test and every render
threw. Returns a stable array so the mock cannot perturb render counts.
* fix: rebuild agent MCP server index from surviving tools
Unioning the prior names kept a server indexed after its last tool was
detached, so viewers of a shared agent retained agent-scoped access to it.
The index is now rebuilt from the tools that survive the edit: a prior name
carries forward only while some retained tool still resolves to it, using the
agent's own persisted names as the candidate set, and the rebuild runs on any
tool change rather than only when a new MCP tool is added.
* fix: keep duplicate indexes on registry fallback and harden the oauth split
Duplication blanked mcpServerNames when the registry was unavailable, because
filterAuthorizedTools grandfathers the source's tools without resolving them -
the copy kept tools it could no longer resolve. Source names now carry forward
for the tools that still point at them.
splitToolCallName also treated any oauth_mcp_ prefix as a synthetic OAuth
call, so a genuine upstream tool by that name resolved to the wrong server. A
configured server name now decides when one matches, since a real key always
ends in its server, and the prefix only breaks ties for unconfigured servers.
* fix: thread configured server names through display parsing
parseToolName and getMCPServerName resolved context-free, so a configured
server whose name contains the delimiter showed the wrong server in grouped
tool summaries and subagent tool labels, and stacked icons missed its entry in
the icon map. Both take the configured names now, supplied by the components
that render them.
Adds the hook to SubagentCall's mock factory: the spec renders the real
component, so an unmocked useMCPServerNames would reach the query with no
provider.
* test: cover the auth-map boundary, server provenance and context fallback
Adds regression coverage for three behaviors this PR changed that no test
exercised: customUserVars resolving under the right plugin key for a
gateway-prefixed tool name (the failure that made these tools loadable but
unusable), the resolved server name reaching createMCPTool instead of being
re-parsed, and resolveMcpServerContext degrading to empty rather than
aborting tool loading when the config lookup fails.
Each was checked against a mutated source to confirm it fails when the
behavior is broken.
* fix: normalize server-name candidates and cover the boundary guard
Tool keys embed normalizeServerName's output while the config is keyed by the
raw name, so callers passing raw keys never matched a server whose name needs
normalizing and silently fell back to the last delimiter. filterAuthorizedTools
now maps normalized names back to their config key, and createMCPTool
normalizes its candidates.
Adds the cases an audit found surviving mutation: a configured name that is a
bare but not delimiter-aligned suffix must not match, an empty candidate list
behaves as no list, and splitToolCallName still falls back to the oauth prefix
when a list is supplied but nothing in it matches.
* fix: keep resolved server names when a non-owner retains MCP tools
The shared-agent path keeps an agent's existing MCP tools verbatim but supplied
no mcpServerNames, so persistence re-derived them and reduced a configured
server like Google_mcp_Workspace to Workspace - which ServerConfigsDB then
treats as a DB server, granting the agent's viewers access to an unrelated one.
Carries the existing resolved names across instead, and clears the index on the
owner path where every MCP tool is removed.
* fix: preserve resolved MCP names for every tools update
extractMCPServerNames was reachable from any caller that writes tools without
mcpServerNames - the Action edit path does exactly that - so a configured
Google_mcp_Workspace was reindexed as Workspace and ServerConfigsDB granted
shared-agent viewers an unrelated DB server by that name.
updateAgent now rebuilds the index from the agent's own resolved names: one
carries forward while a retained tool still resolves to it, and only keys
matching none of them fall back to derivation. Callers are safe by default
rather than by remembering to pass the set.
normalizeServerName moves to librechat-data-provider so the client can match
its candidates against tool keys, which embed the normalized form; the icon map
is keyed the same way since it is looked up with a parsed server name.
* refactor: move MCP context resolution into packages/api
New backend logic belongs in the TypeScript workspace per CLAUDE.md, with /api
kept to a thin wrapper. resolveMCPServerContext now lives in
packages/api/src/mcp/context.ts and takes ensureConfigServers by injection,
since the registry accessor is still legacy-only; the /api function is reduced
to loading the request app config and translating failures into the empty
degrade it already promised.
* test: teach the MCP service mock about resolveMCPServerContext
The spec mocks @librechat/api with a hand-listed factory, so moving the
resolver into that package left it undefined and the wrapper degraded into its
own catch, returning empty config servers. The stub mirrors the real resolver
so these tests still cover what the wrapper owns - loading the request config
and degrading on failure - while the resolution logic is unit-tested in
packages/api.
* fix: only persist an authoritative MCP server index on update
Assigning the resolved set unconditionally pinned the index to [] whenever
nothing authoritative was available - a legacy agent holding MCP tools with no
stored mcpServerNames - which suppressed updateAgent's derivation and stripped
agent-scoped access to its DB-backed server.
The field is now supplied only when the result is authoritative: names were
resolved, or no MCP tool survives so the index genuinely is empty. The
retained-tools branch likewise leaves it unset when the agent has none stored.
---------
Co-authored-by: Jens Schumann <schumajs@gmail.com>
1561 lines
52 KiB
JavaScript
1561 lines
52 KiB
JavaScript
const { z } = require('zod');
|
|
const fs = require('fs').promises;
|
|
const { nanoid } = require('nanoid');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const {
|
|
refreshS3Url,
|
|
splitMCPToolKey,
|
|
normalizeServerName,
|
|
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 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;
|
|
}
|
|
configNamesByNormalized = new Map(
|
|
Object.keys(mcpServerConfigs).map((name) => [normalizeServerName(name), name]),
|
|
);
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
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,
|
|
};
|