Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2162788
feat: enforce scheduled MCP read-only authority
lia-librechat Oct 2, 2026
b0e3697
fix: preserve startup admission and enforce consent deadline
lia-librechat Oct 2, 2026
1e18bee
fix: fail closed on malformed scheduled consent
lia-librechat Oct 2, 2026
6c193b0
fix: preserve scheduled MCP authority failure receipts
lia-librechat Oct 2, 2026
d68b6a4
fix: preserve scheduled MCP handoffs and manual runs
lia-librechat Oct 2, 2026
a4bbde2
fix: persist mandatory scheduled tool policy denials
lia-librechat Oct 2, 2026
4454668
fix: fence late scheduled consent and retain denial recovery
lia-librechat Oct 2, 2026
7f65fa4
fix: retain scheduled transport denials and prevent replay
lia-librechat Oct 2, 2026
c04a688
fix: merge retained scheduled denials during settlement
lia-librechat Oct 2, 2026
18fffee
fix: prevent enrolled schedule completion wakeup escapes
lia-librechat Oct 2, 2026
09dc5f7
fix: close scheduled consent admission and receipt races
lia-librechat Oct 3, 2026
095123e
merge: sync dev into scheduled MCP authority
lia-librechat Oct 3, 2026
bc4fde6
fix: retain scheduled completion authority across approval
lia-librechat Oct 3, 2026
2d80eea
fix: retain safe scheduled authorization dependency denials
lia-librechat Oct 3, 2026
35ea38c
fix: sanitize scheduled authorization preparation failures
lia-librechat Oct 3, 2026
94d6b5d
fix: bind schedule completion authority to task origin
lia-librechat Oct 3, 2026
be3882d
Merge origin/dev into lia/scheduled-mcp-ceiling
lia-librechat Oct 3, 2026
1304c67
Merge origin/dev and preserve scheduled MCP authority
lia-librechat Oct 3, 2026
055b068
Merge origin/dev into lia/scheduled-mcp-ceiling
lia-librechat Oct 3, 2026
9474e14
Merge origin/dev URL prompt lifecycle fix
lia-librechat Oct 4, 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
1 change: 1 addition & 0 deletions api/app/clients/tools/util/handleTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,7 @@ const loadTools = async ({
getMCPServerTools(userId, serverName, config, capabilityProfile),
context: {
mcpPermissionContext,
agentId: agent?.id,
signal,
user: safeUser,
userMCPAuthMap,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4470,6 +4470,58 @@ describe('ResumableAgentController resume metadata', () => {
},
);

it('never copies a preparation dependency diagnostic into the stream or scheduled outcome', async () => {
const { initializeWithScheduleMCPExecution } = jest.requireActual('@librechat/api');
const privateError = new Error('PRIVATE schedule database query');
const provider = jest.fn();
const req = {
_isScheduledFire: true,
_isAgentTrigger: true,
user: { id: 'user-123' },
body: {
text: 'Read',
messageId: 'read',
conversationId: 'conversation-123',
scheduleId: 'schedule',
scheduledFor: '2026-10-03T00:00:00.000Z',
endpointOption: { endpoint: 'agents', agent_id: 'root' },
},
config: {},
};
const initializeClient = ({ signal }) =>
initializeWithScheduleMCPExecution(
{ req, signal },
() => ({
prepare: async () => {
throw privateError;
},
}),
provider,
);
const res = createResumableResponse();
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(provider).not.toHaveBeenCalled();
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(
'conversation-123',
expect.stringContaining('dependency_unavailable'),
1000,
expect.anything(),
);
expect(mockRecordScheduleOutcome).toHaveBeenCalledWith(
expect.objectContaining({
status: 'error',
error: expect.stringContaining('dependency_unavailable'),
}),
);
expect(
JSON.stringify([
mockGenerationJobManager.completeJob.mock.calls,
mockRecordScheduleOutcome.mock.calls,
res.json.mock.calls,
]),
).not.toContain('PRIVATE');
});

it('finalizes the failed job before releasing the idempotency claim', async () => {
mockGenerationJobManager.claimGeneration.mockResolvedValue(wonGenerationClaim());
const initializeClient = jest.fn().mockRejectedValue(new Error('init boom after res.json'));
Expand Down
27 changes: 27 additions & 0 deletions api/server/controllers/agents/__tests__/resume.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,33 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
});
});

it('passes retained completion lineage to authenticated approval initialization without claiming an occurrence', async () => {
const lineage = {
scheduleId: 'original-schedule',
ownerId: USER_ID,
tenantId: TENANT_ID,
agentId: 'original-root',
invocationMode: 'delegated',
};
mockGenerationJobManager.getJob.mockResolvedValue(
makeToolApprovalJob({ metadata: { scheduleMCPCompletion: lineage } }),
);
const res = await post(
approveBody({
agentCompletion: { scheduleMCPIdentity: { ...lineage, agentId: 'forged-root' } },
}),
);
expect(res.status).toBe(200);
await settled;
expect(mockInitializeClient.mock.calls[0][0].scheduleJobIdentity.scheduleMCPCompletion).toEqual(
lineage,
);
expect(mockInitializeClient.mock.calls[0][0].scheduledTokenContext).toBeUndefined();
expect(capturedInit.isScheduledFire).not.toBe(true);
expect(mockClaimScheduleResume).not.toHaveBeenCalled();
expect(mockRecordScheduleOutcome).not.toHaveBeenCalled();
});

describe('scheduled occurrence lifecycle', () => {
const scheduledFor = '2026-08-17T12:00:00.000Z';
const makeScheduledJob = () =>
Expand Down
25 changes: 25 additions & 0 deletions api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const { getMCPRequestContext } = require('~/server/services/MCPRequestContext');
require('events').EventEmitter.defaultMaxListeners = 100;
const { logger, MAX_AGENT_EVENT_ACTOR_ENCODING_LENGTH } = require('@librechat/data-schemas');
const { getBufferString, HumanMessage } = require('@librechat/agents/langchain/messages');
Expand All @@ -23,6 +24,8 @@ const {
isMemoryAgentEnabled,
recordCollectedUsage,
resolveRunUsageContext,
getScheduleMCPExecution,
createScheduledMCPPolicyRecorder,
recordFallbackTokenUsage,
createDetachedSubagentUsageRecorder,
sendEvent,
Expand Down Expand Up @@ -5012,6 +5015,17 @@ class AgentClient extends BaseClient {
activityPhase?.handlers(offsetHandlers) ??
(activityLabel ? createAssistantPhaseStampingHandlers(offsetHandlers) : offsetHandlers);
const createRunPromise = createRun({
scheduledMCPExecution: getScheduleMCPExecution(getMCPRequestContext(this.options.req)),
recordScheduledMCPDenial: createScheduledMCPPolicyRecorder(
getScheduleMCPExecution(getMCPRequestContext(this.options.req)),
{
streamId,
jobCreatedAt: this.jobCreatedAt,
userId: this.options.req?.user?.id,
tenantId: this.options.req?.user?.tenantId,
},
require('~/server/services/Schedules').recordMCPToolAuthFailure,
),
agents,
// Conversation-stable identity for the e2e run hook; a resumed run
// carries no messages, so history cannot identify the conversation.
Expand Down Expand Up @@ -5808,6 +5822,17 @@ class AgentClient extends BaseClient {
activityPhase?.handlers(offsetHandlers) ??
(activityLabel ? createAssistantPhaseStampingHandlers(offsetHandlers) : offsetHandlers);
run = await createRun({
scheduledMCPExecution: getScheduleMCPExecution(getMCPRequestContext(this.options.req)),
recordScheduledMCPDenial: createScheduledMCPPolicyRecorder(
getScheduleMCPExecution(getMCPRequestContext(this.options.req)),
{
streamId,
jobCreatedAt: this.jobCreatedAt,
userId: this.options.req?.user?.id,
tenantId: this.options.req?.user?.tenantId,
},
require('~/server/services/Schedules').recordMCPToolAuthFailure,
),
agents,
conversationId: this.conversationId,
modelCallbacks: [
Expand Down
1 change: 1 addition & 0 deletions api/server/controllers/agents/resume.js
Original file line number Diff line number Diff line change
Expand Up @@ -1947,6 +1947,7 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
});
const result = await initializeClient({
scheduledTokenContext: restoreScheduledTokenContext(req, job.metadata),
scheduleJobIdentity: job.metadata,
req,
res,
endpointOption: req.body.endpointOption,
Expand Down
26 changes: 1 addition & 25 deletions api/server/routes/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ const {
SCHEDULE_FILE_HOLD,
generateCheckAccess,
createSchedulesHandlers,
createScheduleMCPConsentHost,
createScheduleMCPEnrollmentResolver,
} = require('@librechat/api');
const { requireJwtAuth, configMiddleware, messageIpLimiter } = require('~/server/middleware');
const {
Expand Down Expand Up @@ -88,29 +86,7 @@ const handlers = createSchedulesHandlers({
isUserDeleting,
});

const consentHost = createScheduleMCPConsentHost({
resolveEnrollment: createScheduleMCPEnrollmentResolver({
canUseRoot: async (agentId, user) => (await resolveAgentFireAccess(agentId, user)) === 'ok',
findUser: (id) => methods.findUser({ _id: id }),
getAppConfig: require('~/server/services/Config/app').getAppConfig,
resolveGraphAccess: (user) =>
methods.resolveAgentGraphAccess({
userId: user.id,
role: user.role,
idOnTheSource: user.idOnTheSource,
}),
getNodes: methods.getAgentGraphNodes,
getModelsConfig: (user) =>
require('~/server/controllers/ModelController').getModelsConfig({ user }),
getServers: (user, config) =>
require('~/config').getMCPServersRegistry().getAllServerConfigs(user.id, config, user.role),
}),
methods,
getLimits,
findUser: (id) => methods.findUser({ _id: id }),
getRoleByName,
canViewAgent: async (agentId, user) => (await resolveAgentFireAccess(agentId, user)) === 'ok',
});
const consentHost = require('~/server/services/Schedules/consent').consent;

router.get('/:id/mcp-consent', consentHost.handlers.get);
router.post('/:id/mcp-consent', checkSchedulesCreate, consentHost.handlers.confirm);
Expand Down
2 changes: 2 additions & 0 deletions api/server/services/Agents/triggers.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ const getGenerationAdmissionEvidence = (userId, clientRequestId, streamId, conve
const subagentCompletionAdapter = createSubagentCompletionWakeupResolver({
methods,
getGenerationJob: (conversationId) => GenerationJobManager.getJob(conversationId),
getScheduleMCPCompletionState: methods.getScheduleMCPCompletionState,
getWaitMaxIntervalMs: () => service.getCompletionWaitMaxIntervalMs(),
});
const backgroundToolCompletionAdapter = createBackgroundToolCompletionWakeupResolver({
methods,
getGenerationJob: (conversationId) => GenerationJobManager.getJob(conversationId),
getScheduleMCPCompletionState: methods.getScheduleMCPCompletionState,
getResultBatchSize: () => service.getBackgroundCompletionResultBatchSize(),
getGenerationAdmissionEvidence,
recoverDeadClaim: createBackgroundToolDeadClaimRecovery(
Expand Down
34 changes: 32 additions & 2 deletions api/server/services/Endpoints/agents/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ const {
encodeAndFormatVideos,
extractFileContext,
createScheduleUpstreamTokenProviderResolver,
initializeWithScheduleMCPExecution,
retainScheduleMCPCompletion,
getScheduleMCPExecution,
getMCPRequestContext,
} = require('@librechat/api');
const {
ResourceType,
Expand Down Expand Up @@ -441,6 +445,7 @@ const initializeClientWithProvider = async ({

const invokedSkillIdentities = new Map();
const toolExecuteOptions = {
scheduledMCPExecution: getScheduleMCPExecution(getMCPRequestContext(req, res)),
// Keep foreground cancellation owned by this request even when the agents
// SDK rebuilds a graph for approval resume. The SDK event's breaker signal
// is composed with this authoritative job signal by the handler.
Expand Down Expand Up @@ -1643,7 +1648,10 @@ const initializeClientWithProvider = async ({
? { tenantId: req.user.tenantId }
: {}),
},
{ completionWakeups: completionWakeupsEnabled },
{
completionWakeups: completionWakeupsEnabled,
scheduleMCPIdentity: getScheduleMCPExecution(getMCPRequestContext(req, res))?.identity,
},
)
: undefined;
let hasExistingSubagentTask = false;
Expand Down Expand Up @@ -1923,7 +1931,29 @@ function createInitializeClient(dependencies = {}) {
params.signal,
params.scheduledTokenContext,
);
return initializeClientWithProvider({ ...params, upstreamTokenProviderResolver });
return initializeWithScheduleMCPExecution(
{
req: params.req,
signal: params.signal,
context: require('~/server/services/MCPRequestContext').getMCPRequestContext(
params.req,
params.res,
),
restoredContext: params.scheduledTokenContext,
restoredJob: params.scheduleJobIdentity,
},
() => require('~/server/services/Schedules/consent'),
() => initializeClientWithProvider({ ...params, upstreamTokenProviderResolver }),
(identity) =>
retainScheduleMCPCompletion(
identity,
{
streamId: params.req._resumableStreamId,
createdAt: params.jobCreatedAt,
},
GenerationJobManager.getJobStore(),
),
);
};
}

Expand Down
Loading
Loading