Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 1 deletion api/app/clients/tools/util/handleTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -451,10 +451,11 @@ const loadTools = async ({
/** MCP server tools are initialized sequentially by server */
let index = -1;
const failedMCPServers = new Set();
const safeUser = createSafeUser(options.req?.user);
const safeUser = createSafeUser(options.req?.user, user);
const requestScopedConnections =
options.requestScopedConnections ?? getMCPRequestContext(options.req, options.res);


for (const [serverName, toolConfigs] of Object.entries(requestedMCPTools)) {
index++;
/** @type {LCAvailableTools} */
Expand Down
48 changes: 48 additions & 0 deletions api/app/clients/tools/util/handleTools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -451,4 +451,52 @@ describe('Tool Handlers', () => {
);
});
});

describe('loadTools MCP user id propagation', () => {
// Regression guard: MCP tool-call requests shipped the literal "{{LIBRECHAT_USER_ID}}"
// placeholder in x-user-id headers because `loadTools` built the MCP `user` object from
// `options.req?.user` alone. When `req.user` is a plain object deserialized from the
// passport session (no `id` virtual, no `_id`), that object resolves to no usable id even
// though `loadTools` is already given the caller's resolved id via its own `user` param
// (see ToolService.js: `loadTools({ user: req.user.id, ... })`). Assert that id makes it
// into the object handed to `createMCPTools`.
const mcpServerName = 'test-mcp-server';
const mcpAllToolName = `${Constants.mcp_all}${Constants.mcp_delimiter}${mcpServerName}`;

beforeEach(() => {
mockGetServerConfig.mockResolvedValue({ startup: true });
mockCreateMCPTools.mockResolvedValue([]);
});

it('falls back to the loadTools `user` param when req.user has neither id nor _id', async () => {
const userId = fakeUser._id.toString();
const sessionUser = { email: 'fakeuser@example.com', provider: 'local' };

await loadTools({
user: userId,
tools: [mcpAllToolName],
options: { req: { user: sessionUser } },
});

expect(mockCreateMCPTools).toHaveBeenCalledWith(
expect.objectContaining({
user: expect.objectContaining({ id: userId }),
}),
);
});

it('prefers a resolvable req.user id over the loadTools `user` param', async () => {
await loadTools({
user: fakeUser._id.toString(),
tools: [mcpAllToolName],
options: { req: { user: { id: 'explicit-req-user-id' } } },
});

expect(mockCreateMCPTools).toHaveBeenCalledWith(
expect.objectContaining({
user: expect.objectContaining({ id: 'explicit-req-user-id' }),
}),
);
});
});
});
1 change: 1 addition & 0 deletions api/server/controllers/agents/v1.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
'author',
'avatar',
'category',
'conversation_starters',
'description',
'id',
'is_promoted',
Expand Down
7 changes: 4 additions & 3 deletions packages/api/src/mcp/__tests__/mcp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,9 @@ describe('Environment Variable Extraction (MCP)', () => {
const result1 = processMCPEnv({ options: obj1, user: userWithId });
expect('headers' in result1 && result1.headers?.['User-Id']).toBe('user-123');

// Test with '_id' property only (should not work since we only check 'id')
// Test with '_id' property only. Our fork accepts _id as a fallback for id
// (PRs #29/#30/#31/#32 threaded this through createSafeUser and loadTools)
// because passport-deserialized session user objects only carry _id.
const userWithUnderscore = createTestUser({
id: undefined, // Remove default id to test _id
_id: 'user-456',
Expand All @@ -601,8 +603,7 @@ describe('Environment Variable Extraction (MCP)', () => {
};

const result2 = processMCPEnv({ options: obj2, user: userWithUnderscore });
// Since we don't check _id, the placeholder should remain unchanged
expect('headers' in result2 && result2.headers?.['User-Id']).toBe('{{LIBRECHAT_USER_ID}}');
expect('headers' in result2 && result2.headers?.['User-Id']).toBe('user-456');

// Test with both properties (id takes precedence)
const userWithBoth = createTestUser({
Expand Down
Loading