Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
131c93f
🎚️ feat: Add user_toggle Contract for Agent Tools
TomasPalsson Sep 29, 2026
e3bd2c9
🎚️ feat: Filter Saved Agent Tools by User Switches
TomasPalsson Sep 29, 2026
106d98d
🎚️ feat: Let Agent Creators Make Built-in Tools Switchable
TomasPalsson Sep 29, 2026
96e30c9
🎚️ feat: Start Chat Tool Switches at the Agent's Defaults
TomasPalsson Sep 29, 2026
0cd46b2
🎚️ fix: Limit Tool Switch Filtering to the Primary Agent and Drop Sta…
TomasPalsson Sep 29, 2026
4c81880
🎚️ feat: Let Agent Creators Make MCP Servers Switchable
TomasPalsson Sep 29, 2026
783cdb0
🎚️ feat: Show Saved Agent Tool Switches in the Chat Input
TomasPalsson Sep 29, 2026
8131819
🎚️ fix: Select Default-On MCP Servers in Saved Agent Chats
TomasPalsson Sep 29, 2026
3856d08
🎚️ fix: Keep Tool Switch Choices When a Saved Agent Chat Gets Its ID
TomasPalsson Sep 29, 2026
c0a0d30
🎚️ fix: Keep Switched-Off MCP Servers Off After Reload
TomasPalsson Sep 29, 2026
75493e5
🎚️ fix: Keep Switched-Off MCP Servers Off in Existing Agent Chats Aft…
TomasPalsson Sep 29, 2026
6a122a2
🧹 chore: Sort Agent Tool Option Imports
TomasPalsson Sep 29, 2026
0f9767f
🎚️ fix: Match MCP Server Switches by Configured Server Name
TomasPalsson Sep 29, 2026
322b1b2
🎚️ fix: Send Agent Tool Switches to Chat Users With View Access
TomasPalsson Sep 29, 2026
05b2fcf
🎚️ fix: Resume a Paused Agent Turn With the Chat's MCP Switches
TomasPalsson Sep 29, 2026
e19614d
πŸ“˜ docs: Regenerate Agents OpenAPI Spec for Tool User Switches
TomasPalsson Sep 29, 2026
1891ed5
🎚️ fix: Offer a Saved Agent's Switchable MCP Servers the Chat Menu Hides
TomasPalsson Sep 29, 2026
1dce2f9
🎚️ fix: Clear the Previous Agent's Tool Switches When Switching Agent…
TomasPalsson Sep 29, 2026
cbc9a1e
β™Ώ fix: Label the MCP Server Switch Setting for Server Names With Spaces
TomasPalsson Sep 29, 2026
cfa09e3
🧹 chore: Drop the Unread Web Search Info-Only Card Path
TomasPalsson Sep 29, 2026
26026db
πŸ§ͺ test: Cover the Built-In Tool Switch Setting in the Agent Builder
TomasPalsson Sep 29, 2026
c372590
🎚️ fix: Drop Raw-Name MCP Placeholder and Wildcard Tokens When a Norm…
TomasPalsson Sep 29, 2026
ff29cbd
Merge branch 'dev' into feat/agent-tool-user-toggles
TomasPalsson Sep 29, 2026
643e709
Merge branch 'dev' into feat/agent-tool-user-toggles
TomasPalsson Sep 30, 2026
2135e14
Merge branch 'dev' into feat/agent-tool-user-toggles
TomasPalsson Sep 30, 2026
c62daba
Merge branch 'dev' into feat/agent-tool-user-toggles
TomasPalsson Sep 30, 2026
9ce03e0
Merge branch 'dev' into feat/agent-tool-user-toggles
TomasPalsson Sep 30, 2026
bf86bfa
πŸ”€ chore: Merge dev into feat/agent-tool-user-toggles
TomasPalsson Oct 1, 2026
e6b5c38
πŸ”€ chore: Merge dev into feat/agent-tool-user-toggles
TomasPalsson Oct 1, 2026
aee59ee
πŸ”€ chore: Merge dev into feat/agent-tool-user-toggles
TomasPalsson Oct 1, 2026
3625e75
πŸ”€ chore: Merge dev into feat/agent-tool-user-toggles
TomasPalsson Oct 2, 2026
dcd24c5
πŸ”€ chore: Merge dev into feat/agent-tool-user-toggles
TomasPalsson Oct 2, 2026
33c9672
Merge branch 'dev' into feat/agent-tool-user-toggles
danny-avila Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/server/controllers/agents/v1.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const {
EModelEndpoint,
FileSources,
removeCodeExecutionCaller,
pickUserToggleOptions,
hasActivePiiFields,
hasActivePiiPatterns,
openapiToFunction,
Expand Down Expand Up @@ -958,6 +959,8 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
provider: agent.provider,
model: agent.model,
model_parameters: getSafeModelParameters(agent.model_parameters),
tools: agent.tools,
tool_options: pickUserToggleOptions(agent.tool_options),
isPublic: agent.isPublic,
version: agent.version,
// Safe metadata
Expand Down
24 changes: 24 additions & 0 deletions api/server/controllers/agents/v1.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,30 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
const response = mockRes.json.mock.calls[0][0];
expect(response.conversation_starters).toEqual(starters);
});

test('should include the tools and only their user switches in the basic VIEW response', async () => {
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
name: 'Switchable Agent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: ['web_search', 'search_mcp_docs'],
tool_options: {
web_search: { user_toggle: 'off', defer_loading: true },
search_mcp_docs: { allowed_callers: ['direct'] },
},
});

mockReq.params = { id: agent.id };

await getAgentHandler(mockReq, mockRes);

expect(mockRes.status).toHaveBeenCalledWith(200);
const response = mockRes.json.mock.calls[0][0];
expect(response.tools).toEqual(['web_search', 'search_mcp_docs']);
expect(response.tool_options).toEqual({ web_search: { user_toggle: 'off' } });
});
});

describe('getAgentVersionsHandler', () => {
Expand Down
1 change: 1 addition & 0 deletions api/server/services/Endpoints/agents/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
endpoint,
model_parameters,
userToolSwitches: req.body?.ephemeralAgent ?? null,
}).catch((error) => {
logger.error(`[/agents/:${agent_id}] Error retrieving agent during build options step`, error);
return undefined;
Expand Down
33 changes: 30 additions & 3 deletions client/src/Providers/BadgeRowContext.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { useSetRecoilState } from 'recoil';
import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider';
import type { TAgentsEndpoint, TEphemeralAgent } from 'librechat-data-provider';
import type { AgentToolSwitches, TAgentsEndpoint, TEphemeralAgent } from 'librechat-data-provider';
import {
useMCPServerManager,
useSearchApiKeyForm,
Expand All @@ -16,6 +16,8 @@ interface BadgeRowContextType {
conversationId?: string | null;
storageContextKey?: string;
agentsConfig?: TAgentsEndpoint | null;
/** Present only in a saved agent's chat: the tools its creator made switchable. */
agentToolSwitches?: AgentToolSwitches;
skills: ReturnType<typeof useToolToggle>;
memory: ReturnType<typeof useToolToggle>;
webSearch: ReturnType<typeof useToolToggle>;
Expand All @@ -38,6 +40,7 @@ interface BadgeRowProviderProps {
conversationId?: string | null;
specName?: string | null;
observeToolAuthorization?: boolean;
agentToolSwitches?: AgentToolSwitches;
}

export default function BadgeRowProvider({
Expand All @@ -46,6 +49,7 @@ export default function BadgeRowProvider({
conversationId,
specName,
observeToolAuthorization = false,
agentToolSwitches,
}: BadgeRowProviderProps) {
const lastContextKeyRef = useRef<string>('');
const hasInitializedRef = useRef(false);
Expand Down Expand Up @@ -272,14 +276,35 @@ export default function BadgeRowProvider({
isAuthenticated: true,
});

const agentServers = useMemo(
() => (agentToolSwitches ? Object.keys(agentToolSwitches.mcp) : undefined),
[agentToolSwitches],
);
const mcpServerManager = useMCPServerManager({
conversationId,
storageContextKey,
specName,
agentServers,
ownsChatSelection: true,
observeToolAuthorization,
});

/** In a saved agent's chat the MCP menu offers exactly the servers its creator made
* switchable, including ones `chatMenu: false` hides from plain chats. */
const chatMcpServerManager = useMemo(() => {
if (!agentToolSwitches) {
return mcpServerManager;
}
const isSwitchable = ({ serverName }: { serverName: string }) =>
serverName in agentToolSwitches.mcp;
const agentMCPServers = mcpServerManager.availableMCPServers.filter(isSwitchable);
return {
...mcpServerManager,
availableMCPServers: agentMCPServers,
selectableServers: agentMCPServers,
};
}, [mcpServerManager, agentToolSwitches]);

/* Memoized because this is an inline child of `ChatForm`, which re-renders on
every keystroke: a fresh value here invalidated every consumer's memo, and
the palette rebuilt its whole tool, skill and server catalog per character
Expand All @@ -292,11 +317,12 @@ export default function BadgeRowProvider({
artifacts,
fileSearch,
agentsConfig,
agentToolSwitches,
conversationId,
storageContextKey,
codeInterpreter,
searchApiKeyForm,
mcpServerManager,
mcpServerManager: chatMcpServerManager,
}),
[
skills,
Expand All @@ -305,11 +331,12 @@ export default function BadgeRowProvider({
artifacts,
fileSearch,
agentsConfig,
agentToolSwitches,
conversationId,
storageContextKey,
codeInterpreter,
searchApiKeyForm,
mcpServerManager,
chatMcpServerManager,
],
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render } from '@testing-library/react';
import type { AgentToolSwitches } from 'librechat-data-provider';
import BadgeRowProvider, { useBadgeRowContext } from '../BadgeRowContext';

const mockUseMCPServerManager = jest.fn();

const server = (serverName: string, chatMenu?: boolean) => ({
serverName,
config: { type: 'sse', url: 'http://mcp', chatMenu },
});

jest.mock('~/hooks', () => ({
useMCPServerManager: (args: unknown) => mockUseMCPServerManager(args),
useSearchApiKeyForm: () => ({}),
useGetAgentsConfig: () => ({ agentsConfig: undefined }),
useToolToggle: () => ({}),
}));

jest.mock('~/data-provider', () => ({
useGetStartupConfig: () => ({ data: undefined }),
}));

let context: ReturnType<typeof useBadgeRowContext>;
const Consumer = () => {
context = useBadgeRowContext();
return null;
};

const renderProvider = (agentToolSwitches?: AgentToolSwitches) =>
render(
<RecoilRoot>
<BadgeRowProvider conversationId="convo_1" agentToolSwitches={agentToolSwitches}>
<Consumer />
</BadgeRowProvider>
</RecoilRoot>,
);

describe('BadgeRowProvider agent switches', () => {
beforeEach(() => {
mockUseMCPServerManager.mockReset();
const hidden = server('hidden', false);
const visible = server('visible');
const other = server('other');
mockUseMCPServerManager.mockReturnValue({
availableMCPServers: [hidden, visible, other],
selectableServers: [visible, other],
});
});

it("offers the agent's switchable servers, including one hidden from the chat menu", () => {
renderProvider({ builtins: {}, mcp: { hidden: true, visible: false } });

expect(mockUseMCPServerManager).toHaveBeenCalledWith(
expect.objectContaining({ agentServers: ['hidden', 'visible'] }),
);
const names = (list: Array<{ serverName: string }>) => list.map((s) => s.serverName);
expect(names(context!.mcpServerManager.selectableServers)).toEqual(['hidden', 'visible']);
expect(names(context!.mcpServerManager.availableMCPServers)).toEqual(['hidden', 'visible']);
});

it('leaves the manager untouched outside a saved agent chat', () => {
renderProvider();

expect(mockUseMCPServerManager).toHaveBeenCalledWith(
expect.objectContaining({ agentServers: undefined }),
);
expect(context!.mcpServerManager.selectableServers).toHaveLength(2);
});
});
41 changes: 28 additions & 13 deletions client/src/components/Chat/Input/ChatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,12 @@ import {
PermissionTypes,
isAgentsEndpoint,
isAssistantsEndpoint,
getAgentToolSwitches,
} from 'librechat-data-provider';
import type { TChatProject, TMessage, TConversation } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { ExtendedFile, FileSetter, ConvoGenerator, TAskFunction } from '~/common';
import type { QueuedMessageContext } from '~/hooks/Chat/useSteering';
import {
cn,
getModelSpec,
hasIncompleteFiles,
removeFocusRings,
getComposerDraftId,
getPendingDraftId,
clearAllDrafts,
getFilesDraftCached,
isPastedTextFileMarked,
} from '~/utils';
import {
useTextarea,
useAutoSave,
Expand All @@ -34,7 +24,19 @@ import {
useSubmitMessage,
useFocusChatEffect,
useCodeWorkspace,
useApplyAgentToolSwitches,
} from '~/hooks';
import {
cn,
getModelSpec,
hasIncompleteFiles,
removeFocusRings,
getComposerDraftId,
getPendingDraftId,
clearAllDrafts,
getFilesDraftCached,
isPastedTextFileMarked,
} from '~/utils';
import {
useChatContext,
useChatFormContext,
Expand All @@ -47,6 +49,7 @@ import {
PendingToolApprovalButton,
PendingToolApprovalPanel,
} from '~/components/Chat/approval/Review';
import { useGetAgentByIdQuery, useGetStartupConfig } from '~/data-provider';
import useComposerRestore from '~/hooks/Input/useComposerRestore';
import { useChatSettings } from '~/Providers/ChatSettingsContext';
import usePastedTextEdit from '~/hooks/Files/usePastedTextEdit';
Expand All @@ -61,7 +64,6 @@ import DuringRunSendButton from './DuringRunSendButton';
import ProjectLandingChip from '../ProjectLandingChip';
import useHasAccess from '~/hooks/Roles/useHasAccess';
import useDictation from '~/hooks/Input/useDictation';
import { useGetStartupConfig } from '~/data-provider';
import CodeWorkspaceMenu from './CodeWorkspaceMenu';
import useSteering from '~/hooks/Chat/useSteering';
import CodeApprovalMenu from './CodeApprovalMenu';
Expand Down Expand Up @@ -240,6 +242,18 @@ const ChatForm = memo(function ChatForm({
() => conversation?.conversationId ?? Constants.NEW_CONVO,
[conversation?.conversationId],
);
/** A saved agent's chat offers the tools its creator made switchable, in the
* same palette and bar the ephemeral tools use. */
const toolSwitchAgentId =
isAgentsEndpoint(endpoint) && modelSpec?.hideBadgeRow !== true
? conversation?.agent_id
: undefined;
const { data: switchAgent } = useGetAgentByIdQuery(toolSwitchAgentId);
useApplyAgentToolSwitches({ agent: switchAgent, conversationId });
const agentToolSwitches = useMemo(
() => (toolSwitchAgentId && switchAgent ? getAgentToolSwitches(switchAgent) : undefined),
[toolSwitchAgentId, switchAgent],
);
const isNewConversation = conversationId === '' || conversationId === Constants.NEW_CONVO;
/**
* The quote feature merges excerpts server-side in `BaseClient.sendMessage`,
Expand Down Expand Up @@ -963,6 +977,7 @@ const ChatForm = memo(function ChatForm({
specName={conversation?.spec}
isSubmitting={isSubmitting}
observeToolAuthorization={showTools}
agentToolSwitches={agentToolSwitches}
>
<Bar
index={index}
Expand All @@ -985,7 +1000,7 @@ const ChatForm = memo(function ChatForm({
setFilesLoading={setFilesLoading}
canAttach={attachTarget.canAttach}
anchorRef={composerBoxRef}
showTools={showTools}
showTools={showTools || agentToolSwitches != null}
isSubmitting={isSubmitting}
showSpeech={SpeechToText}
speechDisabled={speechDisabled}
Expand Down
Loading