🛡️ feat: Add Batched MCP Authority Proofs (#14688)

* feat(data-schemas): add MCP authority proof substrate

* feat(api): add default-off MCP authority fences
This commit is contained in:
Danny Avila 2026-08-07 12:23:25 -04:00 committed by GitHub
parent 9e6d677751
commit a5b10c78cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 3984 additions and 7 deletions

View file

@ -11,6 +11,7 @@ export * from './auth';
export * from './apiKeys';
/* MCP */
export * from './mcp/mcpConfig';
export * from './mcp/authority';
export * from './mcp/registry/MCPServersRegistry';
export * from './mcp/MCPManager';
export * from './mcp/connection';

View file

@ -0,0 +1,63 @@
# MCP authority proof rollout
This module is an additive, default-off substrate. Existing MCP catalog, OAuth, connection, and
tool-call paths do not invoke it yet. A caller creates one `MCPAuthorityProofResolver` per immutable
boot configuration, resolves selected servers into an authority proof, and carries that proof with
the parsed configuration and schemas. Fence helpers accept only the resolution envelope issued by
that resolver, verify its artifact revision, and pass that exact envelope to the publication,
binding, or execution callback. They do not accept a detached proof with arbitrary artifacts, and
the resolver never freezes or otherwise mutates caller-owned config and schema objects.
Every selected target must carry the source generation captured by the parser that produced its
resolved config. Database targets use `createMCPAuthorityDatabaseSourceRevision`; config targets use
`createMCPAuthorityConfigSourceRevision` with the boot digest and the parser's complete applicable
Config document set, including inactive documents. Targets also carry the exact credential revision
from `createMCPAuthorityCredentialRevision` and the expected OAuth `credential_set_id` generation
(or `null`). The primary-backed resolve rejects when any generation is no longer current, closing
the parse-before-proof and credential-rotation windows. `calculateArtifactRevision` must canonically
cover the actual parsed config and schema identities; the resolver combines it with target names,
source generations, config digests, credential fields, and OAuth requirements, then checks it on
both sides of every awaited final assertion. This callback exists because real parsed tool
artifacts can contain functions and class instances that a generic JSON hasher cannot safely
canonicalize.
The boot digest is computed once by the resolver constructor. A current-authority assertion never
reloads YAML, calls the MCP registry, scans Redis, initializes a server, or performs network I/O. It
opens a fresh primary/snapshot Mongo transaction with majority read and write concerns, and batches
bounded operations per mutable collection, independent of the number of selected servers. Finds use
`singleBatch` plus a same-snapshot count equality check, while aggregations collapse their bounded
rows into one result document. An oversized, truncated, or malformed tenant fails closed without a
`getMore` inside a DocumentDB transaction.
## Integration fences
AI-1715 can adopt the substrate behind its existing default-off rollout gate:
- Use `publishWithCurrentAuthority` immediately around catalog, schema, HTTP response, and binding
publication callbacks.
- Use `executeWithCurrentAuthority` around the remote `tools/call` callback, after connection,
OAuth, Graph, and OBO work. The optional `beforeExecute` hook exists for deterministic race tests;
the authoritative assertion runs after the hook and immediately before the callback.
- OAuth callback integration must assert before token exchange. After exact-generation storage,
re-resolve a new proof bound to that stored generation, then assert the new proof immediately
before waking waiters. The pre-store proof is expected to reject once the grant exists and must
never be reused. If re-resolution or the post-store assertion rejects, delete only the credential
generation written by that callback.
- Validate that the route server name, parsed flow-id server name, and stored flow-state server name
are identical before resolving or asserting a proof.
Do not enable scoped catalog behavior merely by constructing this resolver. The caller owns the
feature gate and must carry the same proof through each final fence.
Before enabling that gate, run `backfillMCPServerNormalizedNames` while MCP server writes are
quiesced, then run `createMCPAuthorityLookupIndexes`. Resolve every collision reported by the
name migration before retrying; do not enable proofs until both migrations complete. These are
offline rollout steps and are never invoked by a hot proof path.
## Observability
`MCPAuthorityProofError.reason` is a bounded rejection code suitable for a counter or structured
log field. Log the reason, fence name, and optional server name. Do not log the proof, resolved
configuration, credential digests, OAuth generation, user source identifiers, or underlying query
error. Unexpected database and malformed-record failures are normalized to `proof_unavailable` so
the path fails closed without exposing stored data.

View file

@ -0,0 +1,221 @@
import type { MCPAuthorityProofV1, MCPAuthorityMethods } from '@librechat/data-schemas';
import { MCPAuthorityProofResolver } from './index';
const proof: MCPAuthorityProofV1 = Object.freeze({
version: 1,
shared: {
user: {
userId: '64b64c13a1136b7f18a7e111',
tenantId: null,
role: 'USER',
provider: 'local',
sourceIdentityDigest: 'source',
revision: 'user',
},
groups: [],
configs: [],
role: { id: 'role-id', name: 'USER', use: true, revision: 'role' },
boot: { revision: 'boot-1', digest: 'boot-digest' },
groupsRevision: 'groups',
configsRevision: 'configs',
revision: 'shared',
},
servers: [],
revision: 'proof',
});
function createResolver(beforeExecute?: () => void | Promise<void>) {
const resolveMCPAuthorityProof: jest.MockedFunction<
MCPAuthorityMethods['resolveMCPAuthorityProof']
> = jest.fn();
const assertMCPAuthorityProofsCurrent: jest.MockedFunction<
MCPAuthorityMethods['assertMCPAuthorityProofsCurrent']
> = jest.fn();
resolveMCPAuthorityProof.mockResolvedValue(proof);
assertMCPAuthorityProofsCurrent.mockResolvedValue(undefined);
const resolver = new MCPAuthorityProofResolver({
methods: { resolveMCPAuthorityProof, assertMCPAuthorityProofsCurrent },
bootRevision: 'boot-1',
immutableConfig: {
mcpServers: {
operator: { type: 'sse', url: 'https://operator.example/mcp' },
},
},
beforeExecute,
});
return { resolver, resolveMCPAuthorityProof, assertMCPAuthorityProofsCurrent };
}
async function resolveFixture(resolver: MCPAuthorityProofResolver) {
return await resolver.resolve({
userId: '64b64c13a1136b7f18a7e111',
targets: [
{
serverName: 'operator',
source: 'config',
sourceRevision: 'config-source-revision',
expectedCredentialRevision: 'credential-revision',
expectedOAuthGrantGeneration: null,
resolvedConfig: { type: 'sse', url: 'https://operator.example/mcp' },
},
],
parsedConfig: { operator: { type: 'sse' } },
schemas: [{ name: 'search' }],
calculateArtifactRevision: ({ parsedConfig, schemas }) =>
JSON.stringify({ parsedConfig, schemas }),
});
}
describe('MCPAuthorityProofResolver', () => {
test('returns parsed config and schemas with the resolved authority proof', async () => {
const { resolver, resolveMCPAuthorityProof } = createResolver();
const result = await resolveFixture(resolver);
expect(Object.isFrozen(result)).toBe(true);
expect(result).toMatchObject({
parsedConfig: { operator: { type: 'sse' } },
schemas: [{ name: 'search' }],
authorityProof: proof,
});
expect(resolveMCPAuthorityProof).toHaveBeenCalledWith(
expect.objectContaining({ boot: resolver.bootRevision }),
);
});
test('asserts immediately before publishing or binding results', async () => {
const events: string[] = [];
const { resolver, assertMCPAuthorityProofsCurrent } = createResolver();
const resolution = await resolveFixture(resolver);
assertMCPAuthorityProofsCurrent.mockImplementation(async () => {
events.push('assert');
});
await resolver.publishWithCurrentAuthority(resolution, (current) => {
expect(current).toBe(resolution);
events.push('publish');
});
await resolver.bindWithCurrentAuthority(resolution, (current) => {
expect(current).toBe(resolution);
events.push('bind');
});
expect(events).toEqual(['assert', 'publish', 'assert', 'bind']);
});
test('runs the injected mutation seam before the final remote-call assertion', async () => {
const events: string[] = [];
const { resolver, assertMCPAuthorityProofsCurrent } = createResolver(() => {
events.push('mutate');
});
const resolution = await resolveFixture(resolver);
assertMCPAuthorityProofsCurrent.mockImplementation(async () => {
events.push('assert');
});
await resolver.executeWithCurrentAuthority(resolution, () => events.push('execute'));
expect(events).toEqual(['mutate', 'assert', 'execute']);
});
test('does not publish, bind, or execute when the final assertion rejects', async () => {
const action = jest.fn();
const { resolver, assertMCPAuthorityProofsCurrent } = createResolver();
const resolution = await resolveFixture(resolver);
assertMCPAuthorityProofsCurrent.mockRejectedValue(new Error('revoked'));
await expect(resolver.publishWithCurrentAuthority(resolution, action)).rejects.toThrow(
'revoked',
);
await expect(resolver.bindWithCurrentAuthority(resolution, action)).rejects.toThrow('revoked');
await expect(resolver.executeWithCurrentAuthority(resolution, action)).rejects.toThrow(
'revoked',
);
expect(action).not.toHaveBeenCalled();
});
test('rejects structurally copied artifacts that were not issued by this resolver', async () => {
const action = jest.fn();
const { resolver, assertMCPAuthorityProofsCurrent } = createResolver();
const resolution = await resolveFixture(resolver);
const copiedResolution = { ...resolution };
await expect(resolver.publishWithCurrentAuthority(copiedResolution, action)).rejects.toEqual(
expect.objectContaining({ reason: 'malformed_input' }),
);
expect(assertMCPAuthorityProofsCurrent).not.toHaveBeenCalled();
expect(action).not.toHaveBeenCalled();
});
test('does not mutate inputs and rejects artifact changes at the final fence', async () => {
const { resolver, assertMCPAuthorityProofsCurrent } = createResolver();
const resolution = await resolveFixture(resolver);
resolution.schemas[0].name = 'changed';
const action = jest.fn();
await expect(resolver.publishWithCurrentAuthority(resolution, action)).rejects.toEqual(
expect.objectContaining({ reason: 'malformed_input' }),
);
expect(Object.isFrozen(resolution.schemas)).toBe(false);
expect(assertMCPAuthorityProofsCurrent).not.toHaveBeenCalled();
expect(action).not.toHaveBeenCalled();
});
test('rejects artifact mutation injected while the authority assertion is in flight', async () => {
let releaseAssertion: (() => void) | undefined;
const assertionGate = new Promise<void>((resolve) => {
releaseAssertion = resolve;
});
const { resolver, assertMCPAuthorityProofsCurrent } = createResolver();
const resolution = await resolveFixture(resolver);
const action = jest.fn();
assertMCPAuthorityProofsCurrent.mockReturnValue(assertionGate);
const publication = resolver.publishWithCurrentAuthority(resolution, action);
await Promise.resolve();
resolution.schemas[0].name = 'injected-change';
releaseAssertion?.();
await expect(publication).rejects.toEqual(
expect.objectContaining({ reason: 'malformed_input' }),
);
expect(action).not.toHaveBeenCalled();
});
test('invokes the fenced action before a post-check queued mutation can run', async () => {
const { resolver } = createResolver();
const schemas = [{ name: 'search' }];
let revisionCalls = 0;
const resolution = await resolver.resolve({
userId: '64b64c13a1136b7f18a7e111',
targets: [
{
serverName: 'operator',
source: 'config',
sourceRevision: 'config-source-revision',
expectedCredentialRevision: 'credential-revision',
expectedOAuthGrantGeneration: null,
resolvedConfig: { type: 'sse', url: 'https://operator.example/mcp' },
},
],
parsedConfig: { operator: { type: 'sse' } },
schemas,
calculateArtifactRevision: ({ parsedConfig, schemas: currentSchemas }) => {
revisionCalls++;
if (revisionCalls === 4) {
queueMicrotask(() => {
schemas[0].name = 'queued-mutation';
});
}
return JSON.stringify({ parsedConfig, schemas: currentSchemas });
},
});
const observedNames: string[] = [];
await resolver.publishWithCurrentAuthority(resolution, (current) => {
observedNames.push(current.schemas[0].name);
});
expect(observedNames).toEqual(['search']);
expect(schemas[0].name).toBe('queued-mutation');
});
});

View file

@ -0,0 +1,182 @@
import {
MCPAuthorityProofError,
digestMCPAuthorityValue,
createMCPAuthorityBootRevision,
} from '@librechat/data-schemas';
import type {
MCPAuthorityProofV1,
MCPAuthorityMethods,
MCPAuthorityTargetInput,
MCPAuthorityBootRevision,
MCPAuthorityImmutableConfig,
} from '@librechat/data-schemas';
import type { ClientSession } from 'mongoose';
export interface MCPAuthorityProofResolverOptions {
methods: Pick<
MCPAuthorityMethods,
'resolveMCPAuthorityProof' | 'assertMCPAuthorityProofsCurrent'
>;
bootRevision: string;
immutableConfig: MCPAuthorityImmutableConfig;
beforeExecute?: () => void | Promise<void>;
}
export interface MCPAuthorityResolutionInput<TParsedConfig, TSchemas> {
userId: string;
tenantId?: string;
targets: readonly MCPAuthorityTargetInput[];
parsedConfig: TParsedConfig;
schemas: TSchemas;
calculateArtifactRevision: (artifacts: {
parsedConfig: TParsedConfig;
schemas: TSchemas;
}) => string;
session?: ClientSession;
}
export interface MCPAuthorityResolution<TParsedConfig, TSchemas> {
readonly parsedConfig: TParsedConfig;
readonly schemas: TSchemas;
readonly authorityProof: MCPAuthorityProofV1;
}
export class MCPAuthorityProofResolver {
private readonly methods: MCPAuthorityProofResolverOptions['methods'];
private readonly boot: MCPAuthorityBootRevision;
private readonly beforeExecute?: MCPAuthorityProofResolverOptions['beforeExecute'];
private readonly issuedResolutions = new WeakMap<
object,
{ revision: string; getCurrentRevision: () => string }
>();
constructor(options: MCPAuthorityProofResolverOptions) {
this.methods = options.methods;
this.boot = createMCPAuthorityBootRevision(options.bootRevision, options.immutableConfig);
this.beforeExecute = options.beforeExecute;
}
public get bootRevision(): MCPAuthorityBootRevision {
return this.boot;
}
public async resolve<TParsedConfig, TSchemas>({
userId,
tenantId,
targets,
parsedConfig,
schemas,
calculateArtifactRevision,
session,
}: MCPAuthorityResolutionInput<TParsedConfig, TSchemas>): Promise<
MCPAuthorityResolution<TParsedConfig, TSchemas>
> {
const getCurrentRevision = (): string => {
let artifactRevision: string;
try {
artifactRevision = calculateArtifactRevision({ parsedConfig, schemas });
} catch {
throw new MCPAuthorityProofError(
'malformed_input',
'MCP authority artifact revision could not be calculated',
);
}
if (typeof artifactRevision !== 'string' || !artifactRevision.trim()) {
throw new MCPAuthorityProofError(
'malformed_input',
'MCP authority artifact revision is required',
);
}
artifactRevision = artifactRevision.trim();
return digestMCPAuthorityValue({
targets: targets.map((target) => ({
serverName: target.serverName,
source: target.source,
sourceRevision: target.sourceRevision,
expectedCredentialRevision: target.expectedCredentialRevision,
expectedOAuthGrantGeneration: target.expectedOAuthGrantGeneration,
databaseId: target.databaseId ?? null,
resolvedConfigDigest: digestMCPAuthorityValue(target.resolvedConfig),
credentialFields: target.credentialFields ?? null,
requiresOAuth: target.requiresOAuth ?? null,
})),
artifactRevision,
});
};
const artifactRevision = getCurrentRevision();
const authorityProof = await this.methods.resolveMCPAuthorityProof({
userId,
tenantId,
targets,
boot: this.boot,
session,
});
if (getCurrentRevision() !== artifactRevision) {
throw new MCPAuthorityProofError(
'malformed_input',
'MCP authority artifacts changed while resolving authority',
);
}
const resolution = Object.freeze({ parsedConfig, schemas, authorityProof });
this.issuedResolutions.set(resolution, { revision: artifactRevision, getCurrentRevision });
return resolution;
}
public async assertCurrent(
proofs: MCPAuthorityProofV1 | readonly MCPAuthorityProofV1[],
session?: ClientSession,
): Promise<void> {
await this.methods.assertMCPAuthorityProofsCurrent({ proofs, boot: this.boot, session });
}
private async useIssuedResolution<TParsedConfig, TSchemas, Result>(
resolution: MCPAuthorityResolution<TParsedConfig, TSchemas>,
action: (current: MCPAuthorityResolution<TParsedConfig, TSchemas>) => Result | Promise<Result>,
session?: ClientSession,
): Promise<Result> {
const issued = this.issuedResolutions.get(resolution);
if (!issued) {
throw new MCPAuthorityProofError(
'malformed_input',
'MCP authority resolution was not issued by this resolver',
);
}
const assertArtifactsCurrent = (): void => {
if (issued.getCurrentRevision() !== issued.revision) {
throw new MCPAuthorityProofError(
'malformed_input',
'MCP authority resolution artifacts changed after authority was resolved',
);
}
};
assertArtifactsCurrent();
await this.assertCurrent(resolution.authorityProof, session);
assertArtifactsCurrent();
return await action(resolution);
}
public async publishWithCurrentAuthority<TParsedConfig, TSchemas, Result>(
resolution: MCPAuthorityResolution<TParsedConfig, TSchemas>,
publish: (current: MCPAuthorityResolution<TParsedConfig, TSchemas>) => Result | Promise<Result>,
session?: ClientSession,
): Promise<Result> {
return await this.useIssuedResolution(resolution, publish, session);
}
public async bindWithCurrentAuthority<TParsedConfig, TSchemas, Result>(
resolution: MCPAuthorityResolution<TParsedConfig, TSchemas>,
bind: (current: MCPAuthorityResolution<TParsedConfig, TSchemas>) => Result | Promise<Result>,
session?: ClientSession,
): Promise<Result> {
return await this.useIssuedResolution(resolution, bind, session);
}
public async executeWithCurrentAuthority<TParsedConfig, TSchemas, Result>(
resolution: MCPAuthorityResolution<TParsedConfig, TSchemas>,
execute: (current: MCPAuthorityResolution<TParsedConfig, TSchemas>) => Result | Promise<Result>,
session?: ClientSession,
): Promise<Result> {
await this.beforeExecute?.();
return await this.useIssuedResolution(resolution, execute, session);
}
}

View file

@ -1,9 +1,24 @@
import mongoose from 'mongoose';
import { randomUUID } from 'crypto';
import {
Permissions,
PermissionBits,
ResourceType,
PrincipalType,
PrincipalModel,
PermissionTypes,
} from 'librechat-data-provider';
import type { ConnectOptions, Model } from 'mongoose';
import type { IConversationTag } from '~/schema/conversationTag';
import type * as t from '~/types';
import {
createMCPAuthorityMethods,
createMCPAuthorityBootRevision,
createMCPAuthorityCredentialRevision,
createMCPAuthorityDatabaseSourceRevision,
} from '~/methods/mcpAuthority';
import { decrementTagCounts } from '~/methods/conversationTag';
import { tenantStorage } from '~/config/tenantContext';
import { supportsTransactions } from '~/utils/transactions';
import { createUserMethods } from '~/methods/user';
import { createFileMethods } from '~/methods/file';
@ -192,6 +207,113 @@ describeLive('Amazon DocumentDB live compatibility', () => {
expect(typeof supported).toBe('boolean');
});
it('executes the bounded MCP authority snapshot transaction', async () => {
const tenantId = `authority-tenant-${runId}`;
const roleName = `AUTHORITY_${runId}`;
const serverName = `authority-server-${runId}`;
const models = mongoose.models;
const methods = createMCPAuthorityMethods(mongoose);
const boot = createMCPAuthorityBootRevision(`docdb-${runId}`, { mcpServers: {} });
const userId = new mongoose.Types.ObjectId();
const serverId = new mongoose.Types.ObjectId();
const agentIds = Array.from({ length: 3 }, () => new mongoose.Types.ObjectId());
try {
await tenantStorage.run({ tenantId, userId: userId.toHexString() }, async () => {
await models.User.create({
_id: userId,
name: 'DocumentDB authority probe',
email: testEmail('authority'),
provider: 'local',
role: roleName,
});
await models.Role.create({
name: roleName,
permissions: {
[PermissionTypes.MCP_SERVERS]: { [Permissions.USE]: true },
},
});
await models.Config.create({
principalType: PrincipalType.USER,
principalId: userId.toHexString(),
principalModel: PrincipalModel.USER,
priority: 30,
overrides: { mcpSettings: { allowedDomains: ['example.com'] } },
tombstones: ['mcpSettings.autoStart'],
isActive: true,
configVersion: 1,
});
await models.MCPServer.create({
_id: serverId,
serverName,
config: { type: 'sse', url: `https://${serverName}.example/mcp` },
author: userId,
});
await models.Agent.insertMany(
agentIds.map((agentId, index) => ({
_id: agentId,
id: `authority-agent-${runId}-${index}`,
name: `DocumentDB authority probe agent ${index}`,
provider: 'openAI',
model: 'probe-model',
author: userId,
mcpServerNames: [serverName, `unselected-${runId}`],
})),
);
await models.AclEntry.create({
principalType: PrincipalType.USER,
principalId: userId,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: serverId,
permBits: PermissionBits.VIEW,
grantedBy: userId,
});
const server = await models.MCPServer.findById(serverId).lean();
if (!server) {
throw new Error('DocumentDB authority probe server was not created');
}
const sourceRevision = createMCPAuthorityDatabaseSourceRevision({
databaseId: server._id.toHexString(),
serverName: server.serverName,
author: server.author.toString(),
config: server.config,
createdAt: server.createdAt,
updatedAt: server.updatedAt,
});
const proof = await methods.resolveMCPAuthorityProof({
userId: userId.toHexString(),
tenantId,
boot,
targets: [
{
serverName,
source: 'database',
databaseId: serverId.toHexString(),
sourceRevision,
expectedCredentialRevision: createMCPAuthorityCredentialRevision([], []),
expectedOAuthGrantGeneration: null,
resolvedConfig: server.config,
},
],
});
expect(proof.servers[0].linkedAgentIds).toHaveLength(agentIds.length);
await methods.assertMCPAuthorityProofsCurrent({ proofs: proof, boot });
});
capabilities['MCP authority snapshot'] = 'supported';
} finally {
await Promise.all([
getDb().collection('aclentries').deleteMany({ resourceId: serverId }),
getDb().collection('mcpservers').deleteMany({ _id: serverId }),
getDb().collection('configs').deleteMany({ principalId: userId.toHexString() }),
getDb()
.collection('agents')
.deleteMany({ _id: { $in: agentIds } }),
getDb().collection('roles').deleteMany({ name: roleName }),
getDb().collection('users').deleteMany({ _id: userId }),
]);
}
});
it('probes partial unique index support (OAuth id uniqueness relies on it)', async () => {
const probe = getDb().collection(`partial_index_probe_${runId}`);
await probe.insertOne({ seeded: true });

View file

@ -29,6 +29,13 @@ export {
MAX_AUDIT_LOG_LIMIT,
MAX_AUDIT_VERIFY_ROWS,
MAX_TOOL_FAVORITES,
MCPAuthorityProofError,
MAX_MCP_AUTHORITY_TARGETS,
createMCPAuthorityBootRevision,
createMCPAuthorityConfigSourceRevision,
createMCPAuthorityCredentialRevision,
createMCPAuthorityDatabaseSourceRevision,
digestMCPAuthorityValue,
} from './methods';
export { FAVORITE_ITEM_TYPES } from './types/favorite';
export type * from './types';
@ -57,4 +64,10 @@ export {
SYSTEM_TENANT_ID,
} from './config/tenantContext';
export type { TenantContext } from './config/tenantContext';
export { dropSupersededTenantIndexes, dropSupersededPromptGroupIndexes } from './migrations';
export {
MCPServerNameMigrationError,
createMCPAuthorityLookupIndexes,
dropSupersededTenantIndexes,
dropSupersededPromptGroupIndexes,
backfillMCPServerNormalizedNames,
} from './migrations';

View file

@ -101,8 +101,33 @@ import type {
import { createAgentMethods, type AgentMethods, type AgentDeps } from './agent';
/* Config */
import { createConfigMethods, type ConfigMethods } from './config';
import {
createMCPAuthorityMethods,
MCPAuthorityProofError,
MAX_MCP_AUTHORITY_TARGETS,
createMCPAuthorityBootRevision,
createMCPAuthorityConfigSourceRevision,
createMCPAuthorityCredentialRevision,
createMCPAuthorityDatabaseSourceRevision,
digestMCPAuthorityValue,
type MCPAuthorityMethods,
type MCPAuthorityMethodHooks,
type MCPAuthorityConfigSourceDocument,
type MCPAuthorityCredentialSourceDocument,
} from './mcpAuthority';
export { RoleConflictError, DEFAULT_REFRESH_TOKEN_EXPIRY, DEFAULT_SESSION_EXPIRY };
export {
RoleConflictError,
MCPAuthorityProofError,
MAX_MCP_AUTHORITY_TARGETS,
DEFAULT_REFRESH_TOKEN_EXPIRY,
DEFAULT_SESSION_EXPIRY,
createMCPAuthorityBootRevision,
createMCPAuthorityConfigSourceRevision,
createMCPAuthorityCredentialRevision,
createMCPAuthorityDatabaseSourceRevision,
digestMCPAuthorityValue,
};
export { tokenValues, cacheTokenValues, premiumTokenValues, defaultRate, createTxMethods };
export { permissionBitSupersets };
export {
@ -153,7 +178,8 @@ export type AllMethods = UserMethods &
SkillMethods &
SkillSyncMethods &
AgentMethods &
ConfigMethods;
ConfigMethods &
MCPAuthorityMethods;
/** Dependencies injected from the api layer into createMethods */
export interface CreateMethodsDeps {
@ -291,6 +317,8 @@ export function createMethods(
...agentMethods,
/* Config */
...createConfigMethods(mongoose),
/* MCP authority proofs */
...createMCPAuthorityMethods(mongoose),
};
}
@ -344,4 +372,8 @@ export type {
SkillSyncMethods,
AgentMethods,
ConfigMethods,
MCPAuthorityMethods,
MCPAuthorityMethodHooks,
MCPAuthorityConfigSourceDocument,
MCPAuthorityCredentialSourceDocument,
};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
import { nanoid } from 'nanoid';
import { normalizeServerName } from 'librechat-data-provider';
import type { Model, RootFilterQuery, Types } from 'mongoose';
import type { MCPOptions } from 'librechat-data-provider';
import type { MCPServerDocument } from '../types';
@ -10,8 +11,8 @@ const RETRY_BASE_DELAY_MS = 25;
/**
* Helper to check if an error is a MongoDB duplicate key error.
* Since serverName is the only unique index on MCPServer, any E11000 error
* during creation is necessarily a serverName collision.
* Both MCPServer unique indexes represent raw or normalized server-name collisions,
* so retrying name allocation is safe for either E11000 source.
*/
function isDuplicateKeyError(error: unknown): boolean {
if (error && typeof error === 'object' && 'code' in error) {
@ -149,6 +150,7 @@ export function createMCPServerMethods(mongoose: typeof import('mongoose')): {
const newServer = await MCPServer.create({
serverName,
normalizedServerName: normalizeServerName(serverName),
config: data.config,
author: data.author,
});

View file

@ -1,2 +1,4 @@
export { dropSupersededTenantIndexes } from './tenantIndexes';
export { dropSupersededPromptGroupIndexes } from './promptGroupIndexes';
export { createMCPAuthorityLookupIndexes } from './mcpAuthorityIndexes';
export { MCPServerNameMigrationError, backfillMCPServerNormalizedNames } from './mcpServerNames';

View file

@ -0,0 +1,40 @@
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { createMCPAuthorityLookupIndexes } from './mcpAuthorityIndexes';
let mongoServer: InstanceType<typeof MongoMemoryServer>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
});
test('creates every bounded MCP authority lookup index idempotently', async () => {
const expected = [
['groups', 'memberIds_1_tenantId_1'],
['agents', 'mcpServerNames_1_tenantId_1'],
['pluginauths', 'userId_1_pluginKey_1_authField_1_tenantId_1'],
['tokens', 'userId_1_type_1_identifier_1_tenantId_1'],
] as const;
await expect(createMCPAuthorityLookupIndexes(mongoose.connection)).resolves.toEqual(
expected.map(([, name]) => name),
);
await expect(createMCPAuthorityLookupIndexes(mongoose.connection)).resolves.toEqual(
expected.map(([, name]) => name),
);
await Promise.all(
expected.map(async ([collection, name]) => {
expect(await mongoose.connection.db!.collection(collection).indexExists(name)).toBe(true);
}),
);
});

View file

@ -0,0 +1,45 @@
import type { IndexSpecification } from 'mongodb';
import type { Connection } from 'mongoose';
interface AuthorityIndexDefinition {
collection: string;
keys: IndexSpecification;
name: string;
}
const AUTHORITY_INDEXES: readonly AuthorityIndexDefinition[] = [
{
collection: 'groups',
keys: { memberIds: 1, tenantId: 1 },
name: 'memberIds_1_tenantId_1',
},
{
collection: 'agents',
keys: { mcpServerNames: 1, tenantId: 1 },
name: 'mcpServerNames_1_tenantId_1',
},
{
collection: 'pluginauths',
keys: { userId: 1, pluginKey: 1, authField: 1, tenantId: 1 },
name: 'userId_1_pluginKey_1_authField_1_tenantId_1',
},
{
collection: 'tokens',
keys: { userId: 1, type: 1, identifier: 1, tenantId: 1 },
name: 'userId_1_type_1_identifier_1_tenantId_1',
},
];
/** Creates the bounded lookup indexes required before MCP authority proofs are enabled. */
export async function createMCPAuthorityLookupIndexes(
connection: Connection,
): Promise<readonly string[]> {
const created: string[] = [];
for (const definition of AUTHORITY_INDEXES) {
const name = await connection
.db!.collection(definition.collection)
.createIndex(definition.keys, { name: definition.name });
created.push(name);
}
return created;
}

View file

@ -0,0 +1,90 @@
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { MCPServerNameMigrationError, backfillMCPServerNormalizedNames } from './mcpServerNames';
let mongoServer: InstanceType<typeof MongoMemoryServer>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
});
describe('backfillMCPServerNormalizedNames', () => {
test('backfills legacy rows and creates the normalized identity index', async () => {
const collection = mongoose.connection.db!.collection('mcpservers');
await collection.insertMany([
{ serverName: 'selected server', tenantId: 'tenant-a' },
{ serverName: 'selected/server', tenantId: 'tenant-b' },
{ serverName: 'another-server' },
]);
await expect(backfillMCPServerNormalizedNames(mongoose.connection)).resolves.toEqual({
scanned: 3,
updated: 3,
});
expect(
await collection
.find({}, { projection: { _id: 0, serverName: 1, normalizedServerName: 1 } })
.sort({ serverName: 1 })
.toArray(),
).toEqual([
{ serverName: 'another-server', normalizedServerName: 'another-server' },
{ serverName: 'selected server', normalizedServerName: 'selected_server' },
{ serverName: 'selected/server', normalizedServerName: 'selected_server' },
]);
expect(await collection.indexExists('normalizedServerName_1_tenantId_1')).toBe(true);
await expect(backfillMCPServerNormalizedNames(mongoose.connection)).resolves.toEqual({
scanned: 3,
updated: 0,
});
});
test('detects all tenant collisions before writing any normalized names', async () => {
const collection = mongoose.connection.db!.collection('mcpservers');
await collection.insertMany([
{ serverName: 'selected server', tenantId: 'tenant-a' },
{ serverName: 'selected/server', tenantId: 'tenant-a' },
]);
await expect(backfillMCPServerNormalizedNames(mongoose.connection)).rejects.toEqual(
expect.objectContaining({
name: MCPServerNameMigrationError.name,
message: expect.stringContaining('normalize to the same identity'),
}),
);
expect(await collection.countDocuments({ normalizedServerName: { $exists: true } })).toBe(0);
expect(await collection.indexExists('normalizedServerName_1_tenantId_1')).toBe(false);
});
test('pins both migration scans to primary with majority read concern', async () => {
const collection = mongoose.connection.db!.collection('mcpservers');
await collection.insertOne({ serverName: 'selected server', tenantId: 'tenant-a' });
const findSpy = jest.spyOn(mongoose.mongo.Collection.prototype, 'find');
try {
await backfillMCPServerNormalizedNames(mongoose.connection);
expect(findSpy).toHaveBeenCalledTimes(2);
expect(
findSpy.mock.calls.every(
([, options]) =>
options?.readPreference === 'primary' &&
typeof options.readConcern === 'object' &&
options.readConcern.level === 'majority',
),
).toBe(true);
} finally {
findSpy.mockRestore();
}
});
});

View file

@ -0,0 +1,108 @@
import { normalizeServerName } from 'librechat-data-provider';
import type { Connection, Types } from 'mongoose';
interface MCPServerNameRow {
_id: Types.ObjectId;
serverName?: string;
normalizedServerName?: string;
tenantId?: string;
}
const MIGRATION_BATCH_SIZE = 500;
const AUTHORITATIVE_FIND_OPTIONS = {
projection: { _id: 1, serverName: 1, normalizedServerName: 1, tenantId: 1 },
readPreference: 'primary' as const,
readConcern: { level: 'majority' as const },
};
export interface MCPServerNameMigrationResult {
scanned: number;
updated: number;
}
export class MCPServerNameMigrationError extends Error {
constructor(message: string) {
super(message);
this.name = 'MCPServerNameMigrationError';
}
}
function normalizedIdentity(server: MCPServerNameRow): {
serverName: string;
normalizedServerName: string;
} {
if (typeof server.serverName !== 'string' || !server.serverName.trim()) {
throw new MCPServerNameMigrationError('MCP server name index contains a malformed name');
}
if (server.tenantId !== undefined && typeof server.tenantId !== 'string') {
throw new MCPServerNameMigrationError('MCP server name index contains a malformed tenant');
}
const normalizedServerName = normalizeServerName(server.serverName);
if (!normalizedServerName) {
throw new MCPServerNameMigrationError('MCP server name index contains an empty identity');
}
return { serverName: server.serverName, normalizedServerName };
}
/** Backfills the compact normalized-name index required before authority proofs are enabled. */
export async function backfillMCPServerNormalizedNames(
connection: Connection,
): Promise<MCPServerNameMigrationResult> {
const collection = connection.db!.collection<MCPServerNameRow>('mcpservers');
const identities = new Map<string, { id: string; serverName: string }>();
let scanned = 0;
let updated = 0;
for await (const server of collection.find({}, AUTHORITATIVE_FIND_OPTIONS)) {
scanned++;
const { serverName, normalizedServerName } = normalizedIdentity(server);
const identity = JSON.stringify([server.tenantId ?? null, normalizedServerName]);
const existing = identities.get(identity);
if (existing && existing.id !== server._id.toHexString()) {
throw new MCPServerNameMigrationError(
`MCP server names normalize to the same identity in one tenant: "${existing.serverName}" and "${serverName}"`,
);
}
identities.set(identity, { id: server._id.toHexString(), serverName });
if (server.normalizedServerName !== normalizedServerName) {
updated++;
}
}
let updates: Array<{
updateOne: {
filter: { _id: Types.ObjectId };
update: { $set: { normalizedServerName: string } };
};
}> = [];
for await (const server of collection.find({}, AUTHORITATIVE_FIND_OPTIONS)) {
const { normalizedServerName } = normalizedIdentity(server);
if (server.normalizedServerName !== normalizedServerName) {
updates.push({
updateOne: {
filter: { _id: server._id },
update: { $set: { normalizedServerName } },
},
});
}
if (updates.length === MIGRATION_BATCH_SIZE) {
// eslint-disable-next-line no-restricted-syntax -- offline all-tenant migration intentionally bypasses request tenant scoping
await collection.bulkWrite(updates, { ordered: true });
updates = [];
}
}
if (updates.length) {
// eslint-disable-next-line no-restricted-syntax -- offline all-tenant migration intentionally bypasses request tenant scoping
await collection.bulkWrite(updates, { ordered: true });
}
await collection.createIndex(
{ normalizedServerName: 1, tenantId: 1 },
{
name: 'normalizedServerName_1_tenantId_1',
unique: true,
partialFilterExpression: { normalizedServerName: { $exists: true } },
},
);
return { scanned, updated };
}

View file

@ -116,7 +116,6 @@ const agentSchema: Schema<IAgent> = new Schema<IAgent>(
mcpServerNames: {
type: [String],
default: [],
index: true,
},
/** Per-tool configuration (defer_loading, allowed_callers, run_in_background, describe_intent) */
tool_options: {
@ -145,6 +144,7 @@ const agentSchema: Schema<IAgent> = new Schema<IAgent>(
);
agentSchema.index({ id: 1, tenantId: 1 }, { unique: true });
agentSchema.index({ mcpServerNames: 1, tenantId: 1 });
agentSchema.index({ updatedAt: -1, _id: 1 });
agentSchema.index({ 'edges.to': 1 });

View file

@ -56,6 +56,6 @@ groupSchema.index(
partialFilterExpression: { idOnTheSource: { $exists: true } },
},
);
groupSchema.index({ memberIds: 1 });
groupSchema.index({ memberIds: 1, tenantId: 1 });
export default groupSchema;

View file

@ -1,4 +1,5 @@
import { Schema } from 'mongoose';
import { normalizeServerName } from 'librechat-data-provider';
import type { MCPServerDocument } from '~/types';
const mcpServerSchema: Schema<MCPServerDocument> = new Schema<MCPServerDocument>(
@ -8,6 +9,10 @@ const mcpServerSchema: Schema<MCPServerDocument> = new Schema<MCPServerDocument>
index: true,
required: true,
},
normalizedServerName: {
type: String,
required: true,
},
config: {
type: Schema.Types.Mixed,
required: true,
@ -29,7 +34,18 @@ const mcpServerSchema: Schema<MCPServerDocument> = new Schema<MCPServerDocument>
},
);
mcpServerSchema.pre('validate', function () {
this.normalizedServerName = normalizeServerName(this.serverName);
});
mcpServerSchema.index({ serverName: 1, tenantId: 1 }, { unique: true });
mcpServerSchema.index(
{ normalizedServerName: 1, tenantId: 1 },
{
unique: true,
partialFilterExpression: { normalizedServerName: { $exists: true } },
},
);
mcpServerSchema.index({ updatedAt: -1, _id: 1 });
export default mcpServerSchema;

View file

@ -26,4 +26,6 @@ const pluginAuthSchema: Schema<IPluginAuth> = new Schema(
{ timestamps: true },
);
pluginAuthSchema.index({ userId: 1, pluginKey: 1, authField: 1, tenantId: 1 });
export default pluginAuthSchema;

View file

@ -40,5 +40,6 @@ const tokenSchema: Schema<IToken> = new Schema({
});
tokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
tokenSchema.index({ userId: 1, type: 1, identifier: 1, tenantId: 1 });
export default tokenSchema;

View file

@ -43,3 +43,4 @@ export * from './admin';
export * from './web';
/* MCP Servers */
export * from './mcp';
export * from './mcpAuthority';

View file

@ -8,6 +8,7 @@ import type { MCPServerDB } from 'librechat-data-provider';
export interface MCPServerDocument
extends Omit<MCPServerDB, 'author' | '_id'>,
Document<Types.ObjectId> {
normalizedServerName: string;
author: Types.ObjectId; // ObjectId reference in DB (vs string in API)
tenantId?: string;
}

View file

@ -0,0 +1,141 @@
import type { MCPOptions, TCustomConfig } from 'librechat-data-provider';
import type { ClientSession } from 'mongoose';
export const MCP_AUTHORITY_PROOF_VERSION = 1 as const;
export type MCPAuthorityServerSource = 'config' | 'database';
export interface MCPAuthorityBootRevision {
readonly revision: string;
readonly digest: string;
}
interface MCPAuthorityTargetBase {
readonly serverName: string;
readonly sourceRevision: string;
readonly expectedCredentialRevision: string;
readonly expectedOAuthGrantGeneration: string | null;
readonly resolvedConfig: MCPOptions;
readonly credentialFields?: readonly string[];
readonly requiresOAuth?: boolean;
}
export type MCPAuthorityTargetInput = MCPAuthorityTargetBase &
(
| { readonly source: 'config'; readonly databaseId?: never }
| { readonly source: 'database'; readonly databaseId: string }
);
export interface MCPAuthorityResolveInput {
readonly userId: string;
readonly tenantId?: string;
readonly boot: MCPAuthorityBootRevision;
readonly targets: readonly MCPAuthorityTargetInput[];
readonly session?: ClientSession;
}
export interface MCPAuthorityUserProof {
readonly userId: string;
readonly tenantId: string | null;
readonly role: string;
readonly provider: string;
readonly sourceIdentityDigest: string;
readonly revision: string;
}
export interface MCPAuthorityGroupProof {
readonly id: string;
readonly source: string;
readonly sourceIdentityDigest: string;
readonly revision: string;
}
export interface MCPAuthorityConfigProof {
readonly principalType: string;
readonly principalId: string;
readonly present: boolean;
readonly active: boolean;
readonly priority: number | null;
readonly configVersion: number | null;
readonly mcpOverrideDigest: string | null;
readonly tombstones: readonly string[];
readonly revision: string;
}
export interface MCPAuthorityRoleProof {
readonly id: string;
readonly name: string;
readonly use: boolean;
readonly revision: string;
}
export interface MCPAuthoritySharedProofV1 {
readonly user: MCPAuthorityUserProof;
readonly groups: readonly MCPAuthorityGroupProof[];
readonly configs: readonly MCPAuthorityConfigProof[];
readonly role: MCPAuthorityRoleProof;
readonly boot: MCPAuthorityBootRevision;
readonly groupsRevision: string;
readonly configsRevision: string;
readonly revision: string;
}
export interface MCPAuthorityServerProofV1 {
readonly serverName: string;
readonly normalizedServerName: string;
readonly source: MCPAuthorityServerSource;
readonly databaseId: string | null;
readonly sourceRevision: string;
readonly resolvedConfigDigest: string;
readonly serverRevision: string;
readonly linkedAgentIds: readonly string[];
readonly directAccess: boolean;
readonly agentAccess: boolean;
readonly authorizationRevision: string;
readonly credentialFields: readonly string[];
readonly credentialRevision: string;
readonly requiresOAuth: boolean;
readonly oauthGrantGeneration: string | null;
readonly oauthRevision: string;
readonly effectivePolicyDigest: string;
readonly revision: string;
}
export interface MCPAuthorityProofV1 {
readonly version: typeof MCP_AUTHORITY_PROOF_VERSION;
readonly shared: MCPAuthoritySharedProofV1;
readonly servers: readonly MCPAuthorityServerProofV1[];
readonly revision: string;
}
export interface MCPAuthorityAssertInput {
readonly proofs: MCPAuthorityProofV1 | readonly MCPAuthorityProofV1[];
readonly boot: MCPAuthorityBootRevision;
readonly session?: ClientSession;
}
export interface MCPAuthorityDatabaseMethods {
resolveMCPAuthorityProof(input: MCPAuthorityResolveInput): Promise<MCPAuthorityProofV1>;
assertMCPAuthorityProofsCurrent(input: MCPAuthorityAssertInput): Promise<void>;
}
export type MCPAuthorityImmutableConfig = Readonly<
Pick<TCustomConfig, 'mcpServers' | 'mcpSettings'>
>;
export type MCPAuthorityRejectionReason =
| 'malformed_input'
| 'proof_unavailable'
| 'user_revoked'
| 'principal_changed'
| 'groups_changed'
| 'config_changed'
| 'mcp_use_revoked'
| 'role_changed'
| 'boot_revision_changed'
| 'server_revoked'
| 'server_changed'
| 'access_revoked'
| 'authorization_changed'
| 'credential_changed'
| 'oauth_grant_changed';