mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🛡️ 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:
parent
9e6d677751
commit
a5b10c78cf
23 changed files with 3984 additions and 7 deletions
|
|
@ -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';
|
||||
|
|
|
|||
63
packages/api/src/mcp/authority/README.md
Normal file
63
packages/api/src/mcp/authority/README.md
Normal 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.
|
||||
221
packages/api/src/mcp/authority/index.spec.ts
Normal file
221
packages/api/src/mcp/authority/index.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
182
packages/api/src/mcp/authority/index.ts
Normal file
182
packages/api/src/mcp/authority/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
1157
packages/data-schemas/src/methods/mcpAuthority.spec.ts
Normal file
1157
packages/data-schemas/src/methods/mcpAuthority.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
1737
packages/data-schemas/src/methods/mcpAuthority.ts
Normal file
1737
packages/data-schemas/src/methods/mcpAuthority.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
export { dropSupersededTenantIndexes } from './tenantIndexes';
|
||||
export { dropSupersededPromptGroupIndexes } from './promptGroupIndexes';
|
||||
export { createMCPAuthorityLookupIndexes } from './mcpAuthorityIndexes';
|
||||
export { MCPServerNameMigrationError, backfillMCPServerNormalizedNames } from './mcpServerNames';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
45
packages/data-schemas/src/migrations/mcpAuthorityIndexes.ts
Normal file
45
packages/data-schemas/src/migrations/mcpAuthorityIndexes.ts
Normal 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;
|
||||
}
|
||||
90
packages/data-schemas/src/migrations/mcpServerNames.spec.ts
Normal file
90
packages/data-schemas/src/migrations/mcpServerNames.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
108
packages/data-schemas/src/migrations/mcpServerNames.ts
Normal file
108
packages/data-schemas/src/migrations/mcpServerNames.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,6 @@ groupSchema.index(
|
|||
partialFilterExpression: { idOnTheSource: { $exists: true } },
|
||||
},
|
||||
);
|
||||
groupSchema.index({ memberIds: 1 });
|
||||
groupSchema.index({ memberIds: 1, tenantId: 1 });
|
||||
|
||||
export default groupSchema;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -43,3 +43,4 @@ export * from './admin';
|
|||
export * from './web';
|
||||
/* MCP Servers */
|
||||
export * from './mcp';
|
||||
export * from './mcpAuthority';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
141
packages/data-schemas/src/types/mcpAuthority.ts
Normal file
141
packages/data-schemas/src/types/mcpAuthority.ts
Normal 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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue