From a5b10c78cf9d00f809c662fc7a715b7e00e11e29 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 7 Aug 2026 12:23:25 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20feat:=20Add=20Batched?= =?UTF-8?q?=20MCP=20Authority=20Proofs=20(#14688)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(data-schemas): add MCP authority proof substrate * feat(api): add default-off MCP authority fences --- packages/api/src/index.ts | 1 + packages/api/src/mcp/authority/README.md | 63 + packages/api/src/mcp/authority/index.spec.ts | 221 +++ packages/api/src/mcp/authority/index.ts | 182 ++ .../misc/documentdb/compat.documentdb.spec.ts | 122 ++ packages/data-schemas/src/index.ts | 15 +- packages/data-schemas/src/methods/index.ts | 36 +- .../src/methods/mcpAuthority.spec.ts | 1157 +++++++++++ .../data-schemas/src/methods/mcpAuthority.ts | 1737 +++++++++++++++++ .../data-schemas/src/methods/mcpServer.ts | 6 +- packages/data-schemas/src/migrations/index.ts | 2 + .../migrations/mcpAuthorityIndexes.spec.ts | 40 + .../src/migrations/mcpAuthorityIndexes.ts | 45 + .../src/migrations/mcpServerNames.spec.ts | 90 + .../src/migrations/mcpServerNames.ts | 108 + packages/data-schemas/src/schema/agent.ts | 2 +- packages/data-schemas/src/schema/group.ts | 2 +- packages/data-schemas/src/schema/mcpServer.ts | 16 + .../data-schemas/src/schema/pluginAuth.ts | 2 + packages/data-schemas/src/schema/token.ts | 1 + packages/data-schemas/src/types/index.ts | 1 + packages/data-schemas/src/types/mcp.ts | 1 + .../data-schemas/src/types/mcpAuthority.ts | 141 ++ 23 files changed, 3984 insertions(+), 7 deletions(-) create mode 100644 packages/api/src/mcp/authority/README.md create mode 100644 packages/api/src/mcp/authority/index.spec.ts create mode 100644 packages/api/src/mcp/authority/index.ts create mode 100644 packages/data-schemas/src/methods/mcpAuthority.spec.ts create mode 100644 packages/data-schemas/src/methods/mcpAuthority.ts create mode 100644 packages/data-schemas/src/migrations/mcpAuthorityIndexes.spec.ts create mode 100644 packages/data-schemas/src/migrations/mcpAuthorityIndexes.ts create mode 100644 packages/data-schemas/src/migrations/mcpServerNames.spec.ts create mode 100644 packages/data-schemas/src/migrations/mcpServerNames.ts create mode 100644 packages/data-schemas/src/types/mcpAuthority.ts diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 479104cd36..ec61eee882 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -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'; diff --git a/packages/api/src/mcp/authority/README.md b/packages/api/src/mcp/authority/README.md new file mode 100644 index 0000000000..8919d2ff30 --- /dev/null +++ b/packages/api/src/mcp/authority/README.md @@ -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. diff --git a/packages/api/src/mcp/authority/index.spec.ts b/packages/api/src/mcp/authority/index.spec.ts new file mode 100644 index 0000000000..34819bfcb5 --- /dev/null +++ b/packages/api/src/mcp/authority/index.spec.ts @@ -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) { + 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((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'); + }); +}); diff --git a/packages/api/src/mcp/authority/index.ts b/packages/api/src/mcp/authority/index.ts new file mode 100644 index 0000000000..ae208aa6f5 --- /dev/null +++ b/packages/api/src/mcp/authority/index.ts @@ -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; +} + +export interface MCPAuthorityResolutionInput { + userId: string; + tenantId?: string; + targets: readonly MCPAuthorityTargetInput[]; + parsedConfig: TParsedConfig; + schemas: TSchemas; + calculateArtifactRevision: (artifacts: { + parsedConfig: TParsedConfig; + schemas: TSchemas; + }) => string; + session?: ClientSession; +} + +export interface MCPAuthorityResolution { + 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({ + userId, + tenantId, + targets, + parsedConfig, + schemas, + calculateArtifactRevision, + session, + }: MCPAuthorityResolutionInput): Promise< + MCPAuthorityResolution + > { + 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 { + await this.methods.assertMCPAuthorityProofsCurrent({ proofs, boot: this.boot, session }); + } + + private async useIssuedResolution( + resolution: MCPAuthorityResolution, + action: (current: MCPAuthorityResolution) => Result | Promise, + session?: ClientSession, + ): Promise { + 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( + resolution: MCPAuthorityResolution, + publish: (current: MCPAuthorityResolution) => Result | Promise, + session?: ClientSession, + ): Promise { + return await this.useIssuedResolution(resolution, publish, session); + } + + public async bindWithCurrentAuthority( + resolution: MCPAuthorityResolution, + bind: (current: MCPAuthorityResolution) => Result | Promise, + session?: ClientSession, + ): Promise { + return await this.useIssuedResolution(resolution, bind, session); + } + + public async executeWithCurrentAuthority( + resolution: MCPAuthorityResolution, + execute: (current: MCPAuthorityResolution) => Result | Promise, + session?: ClientSession, + ): Promise { + await this.beforeExecute?.(); + return await this.useIssuedResolution(resolution, execute, session); + } +} diff --git a/packages/data-schemas/misc/documentdb/compat.documentdb.spec.ts b/packages/data-schemas/misc/documentdb/compat.documentdb.spec.ts index e31db558c7..9f73ef3bcb 100644 --- a/packages/data-schemas/misc/documentdb/compat.documentdb.spec.ts +++ b/packages/data-schemas/misc/documentdb/compat.documentdb.spec.ts @@ -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 }); diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index d844d2086b..a7ac5a80ab 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -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'; diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index e21115b8c0..f3b71cda70 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -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, }; diff --git a/packages/data-schemas/src/methods/mcpAuthority.spec.ts b/packages/data-schemas/src/methods/mcpAuthority.spec.ts new file mode 100644 index 0000000000..5f58caa357 --- /dev/null +++ b/packages/data-schemas/src/methods/mcpAuthority.spec.ts @@ -0,0 +1,1157 @@ +import mongoose from 'mongoose'; +import { MongoMemoryReplSet } from 'mongodb-memory-server'; +import { + Permissions, + PermissionBits, + ResourceType, + PrincipalType, + PrincipalModel, + PermissionTypes, +} from 'librechat-data-provider'; +import type { MCPOptions } from 'librechat-data-provider'; +import type { MCPAuthorityTargetInput } from '~/types'; +import { + MCPAuthorityProofError, + createMCPAuthorityConfigSourceRevision, + createMCPAuthorityCredentialRevision, + createMCPAuthorityDatabaseSourceRevision, + createMCPAuthorityMethods, + createMCPAuthorityBootRevision, +} from './mcpAuthority'; +import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities'; +import { tenantStorage } from '~/config/tenantContext'; +import { createModels } from '~/models'; + +jest.setTimeout(60_000); + +const TENANT_ID = 'tenant-authority'; +const USER_ROLE = 'MCP_USER'; +const SERVER_NAME = 'selected-server'; +const PLUGIN_KEY = `mcp_${SERVER_NAME}`; + +let mongoServer: MongoMemoryReplSet; +let models: ReturnType; +let methods: ReturnType; +let userId: mongoose.Types.ObjectId; +let groupId: mongoose.Types.ObjectId; +let serverId: mongoose.Types.ObjectId; +let serverSourceRevision: string; +let credentialSourceRevision: string; + +const immutableConfig = { + mcpServers: { + 'operator-server': { type: 'sse' as const, url: 'https://operator.example/mcp' }, + }, + mcpSettings: { allowedDomains: ['operator.example'] }, +}; +const boot = createMCPAuthorityBootRevision('boot-1', immutableConfig); +const EMPTY_CREDENTIAL_REVISION = createMCPAuthorityCredentialRevision([], []); + +const target = ( + serverName = SERVER_NAME, + databaseId = serverId.toHexString(), + sourceRevision = serverSourceRevision, + expectedOAuthGrantGeneration: string | null = 'oauth-generation-1', + expectedCredentialRevision = credentialSourceRevision, +) => ({ + serverName, + source: 'database' as const, + databaseId, + sourceRevision, + expectedCredentialRevision, + expectedOAuthGrantGeneration, + resolvedConfig: { + type: 'sse' as const, + url: `https://${serverName}.example/mcp`, + customUserVars: { + API_KEY: { title: 'API key', description: 'Credential' }, + }, + }, + requiresOAuth: true, +}); + +const configAuthorityTarget = ( + serverName: string, + sourceRevision: string, + resolvedConfig: MCPOptions = { type: 'sse', url: `https://${serverName}.example/mcp` }, +): MCPAuthorityTargetInput => ({ + serverName, + source: 'config', + sourceRevision, + expectedCredentialRevision: EMPTY_CREDENTIAL_REVISION, + expectedOAuthGrantGeneration: null, + resolvedConfig, +}); + +function inTenant(fn: () => Promise): Promise { + return tenantStorage.run({ tenantId: TENANT_ID, userId: userId?.toHexString() }, fn); +} + +async function seedFixture(): Promise { + await mongoose.connection.dropDatabase(); + userId = new mongoose.Types.ObjectId(); + groupId = new mongoose.Types.ObjectId(); + serverId = new mongoose.Types.ObjectId(); + const now = new Date(); + await inTenant(async () => { + await Promise.all([ + models.User.create({ + _id: userId, + email: 'authority@example.com', + emailVerified: true, + provider: 'openid', + role: USER_ROLE, + idOnTheSource: 'source-user-1', + openidId: 'openid-user-1', + openidIssuer: 'https://issuer.example', + }), + models.Role.create({ + name: USER_ROLE, + permissions: { + [PermissionTypes.MCP_SERVERS]: { [Permissions.USE]: true }, + }, + }), + models.Group.create({ + _id: groupId, + name: 'MCP group', + source: 'entra', + idOnTheSource: 'source-group-1', + memberIds: ['source-user-1'], + }), + models.MCPServer.create({ + _id: serverId, + serverName: SERVER_NAME, + config: { type: 'sse', url: `https://${SERVER_NAME}.example/mcp` }, + author: userId, + }), + models.Config.create({ + principalType: PrincipalType.USER, + principalId: userId.toHexString(), + principalModel: PrincipalModel.USER, + priority: 30, + overrides: { mcpServers: { [SERVER_NAME]: { startup: false } } }, + tombstones: [], + isActive: true, + configVersion: 1, + }), + models.AclEntry.create({ + principalType: PrincipalType.USER, + principalId: userId, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.MCPSERVER, + resourceId: serverId, + permBits: PermissionBits.VIEW, + grantedBy: userId, + }), + models.PluginAuth.create({ + userId: userId.toHexString(), + pluginKey: PLUGIN_KEY, + authField: 'API_KEY', + value: 'secret-generation-1', + }), + models.Token.insertMany( + [ + ['mcp_oauth', `mcp:${SERVER_NAME}`], + ['mcp_oauth_refresh', `mcp:${SERVER_NAME}:refresh`], + ['mcp_oauth_client', `mcp:${SERVER_NAME}:client`], + ].map(([type, identifier]) => ({ + userId, + type, + identifier, + token: `${type}-secret`, + metadata: { credential_set_id: 'oauth-generation-1' }, + createdAt: now, + expiresAt: new Date(now.getTime() + 3_600_000), + })), + ), + ]); + const [server, credential] = await Promise.all([ + models.MCPServer.findById(serverId).lean(), + models.PluginAuth.findOne({ userId: userId.toHexString(), pluginKey: PLUGIN_KEY }).lean(), + ]); + if (!server || !credential) { + throw new Error('MCP authority fixture source records were not created'); + } + serverSourceRevision = createMCPAuthorityDatabaseSourceRevision({ + databaseId: server._id.toHexString(), + serverName: server.serverName, + author: server.author.toString(), + config: server.config, + createdAt: server.createdAt, + updatedAt: server.updatedAt, + }); + credentialSourceRevision = createMCPAuthorityCredentialRevision(['API_KEY'], [credential]); + }); +} + +async function currentConfigSourceRevision(): Promise { + const configs = await inTenant(() => + models.Config.find({ + $or: [ + { principalType: PrincipalType.ROLE, principalId: BASE_CONFIG_PRINCIPAL_ID }, + { principalType: PrincipalType.ROLE, principalId: USER_ROLE }, + { principalType: PrincipalType.GROUP, principalId: groupId.toHexString() }, + { principalType: PrincipalType.USER, principalId: userId.toHexString() }, + ], + }).lean(), + ); + return createMCPAuthorityConfigSourceRevision(boot.digest, configs); +} + +async function resolve(targets: readonly MCPAuthorityTargetInput[] = [target()]) { + return await inTenant(() => + methods.resolveMCPAuthorityProof({ + userId: userId.toHexString(), + tenantId: TENANT_ID, + boot, + targets, + }), + ); +} + +async function assertCurrent(proof: Awaited>): Promise { + await inTenant(() => methods.assertMCPAuthorityProofsCurrent({ proofs: proof, boot })); +} + +function expectReason(reason: MCPAuthorityProofError['reason']) { + return expect.objectContaining({ name: 'MCPAuthorityProofError', reason }); +} + +beforeAll(async () => { + mongoServer = await MongoMemoryReplSet.create({ replSet: { count: 1 } }); + const uri = new URL(mongoServer.getUri()); + uri.searchParams.set('readPreference', 'secondaryPreferred'); + await mongoose.connect(uri.href); + models = createModels(mongoose); + methods = createMCPAuthorityMethods(mongoose); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await seedFixture(); +}); + +describe('MCP authority proofs', () => { + test('creates one immutable shared snapshot and current per-server revisions', async () => { + const proof = await resolve(); + + expect(proof.version).toBe(1); + expect(Object.isFrozen(proof)).toBe(true); + expect(Object.isFrozen(proof.shared)).toBe(true); + expect(Object.isFrozen(proof.servers)).toBe(true); + expect(proof.shared.user).toMatchObject({ + userId: userId.toHexString(), + tenantId: TENANT_ID, + role: USER_ROLE, + provider: 'openid', + }); + expect(proof.shared.groups.map((group) => group.id)).toEqual([groupId.toHexString()]); + expect(proof.shared.configs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + principalType: PrincipalType.USER, + principalId: userId.toHexString(), + present: true, + active: true, + configVersion: 1, + }), + ]), + ); + expect(proof.servers[0]).toMatchObject({ + serverName: SERVER_NAME, + databaseId: serverId.toHexString(), + directAccess: true, + agentAccess: false, + credentialFields: ['API_KEY'], + oauthGrantGeneration: 'oauth-generation-1', + }); + expect(proof.servers[0].resolvedConfigDigest).not.toContain('secret'); + await expect(assertCurrent(proof)).resolves.toBeUndefined(); + }); + + test('pins every mutable collection read to one primary snapshot transaction', async () => { + const session = await mongoose.startSession(); + const startTransactionSpy = jest.spyOn(session, 'startTransaction'); + const querySessionSpy = jest.spyOn(mongoose.Query.prototype, 'session'); + const aggregateSessionSpy = jest.spyOn(mongoose.Aggregate.prototype, 'session'); + const readSpy = jest.spyOn(mongoose.Query.prototype, 'read'); + const readConcernSpy = jest.spyOn(mongoose.Query.prototype, 'readConcern'); + const findSpy = jest.spyOn(mongoose.mongo.Collection.prototype, 'find'); + + try { + await inTenant(() => + methods.resolveMCPAuthorityProof({ + userId: userId.toHexString(), + tenantId: TENANT_ID, + boot, + targets: [target()], + session, + }), + ); + } finally { + await session.endSession(); + } + + expect(startTransactionSpy).toHaveBeenCalledWith({ + readPreference: 'primary', + readConcern: { level: 'snapshot' }, + writeConcern: { w: 'majority' }, + }); + expect(querySessionSpy).toHaveBeenCalledTimes(12); + expect(querySessionSpy.mock.calls.every(([attached]) => attached === session)).toBe(true); + expect(aggregateSessionSpy).toHaveBeenCalledTimes(2); + expect(aggregateSessionSpy.mock.calls.every(([attached]) => attached === session)).toBe(true); + expect(readSpy).not.toHaveBeenCalled(); + expect(readConcernSpy).not.toHaveBeenCalled(); + expect(findSpy).toHaveBeenCalledTimes(7); + expect(findSpy.mock.calls.every(([, options]) => options?.singleBatch === true)).toBe(true); + }); + + test('commits an owned snapshot on a supplied non-transaction session', async () => { + const session = await mongoose.startSession(); + const commitSpy = jest.spyOn(session, 'commitTransaction'); + + try { + await inTenant(() => + methods.resolveMCPAuthorityProof({ + userId: userId.toHexString(), + tenantId: TENANT_ID, + boot, + targets: [target()], + session, + }), + ); + } finally { + await session.endSession(); + } + + expect(commitSpy).toHaveBeenCalledTimes(1); + expect(session.inTransaction()).toBe(false); + }); + + test('fails closed without an exact active tenant context', async () => { + const input = { + userId: userId.toHexString(), + tenantId: TENANT_ID, + boot, + targets: [target()], + }; + + await expect(methods.resolveMCPAuthorityProof(input)).rejects.toEqual( + expectReason('proof_unavailable'), + ); + await expect( + tenantStorage.run({ tenantId: 'different-tenant' }, () => + methods.resolveMCPAuthorityProof(input), + ), + ).rejects.toEqual(expectReason('proof_unavailable')); + }); + + test('does not use a foreign tenant role for a tenantless principal', async () => { + const tenantlessUserId = new mongoose.Types.ObjectId(); + const roleName = 'TENANTLESS_AUTHORITY_USER'; + await models.User.create({ + _id: tenantlessUserId, + email: 'tenantless-authority@example.com', + provider: 'local', + role: roleName, + }); + await tenantStorage.run({ tenantId: 'foreign-tenant' }, async () => { + await models.Role.create({ + name: roleName, + permissions: { + [PermissionTypes.MCP_SERVERS]: { [Permissions.USE]: true }, + }, + }); + }); + + await expect( + methods.resolveMCPAuthorityProof({ + userId: tenantlessUserId.toHexString(), + boot, + targets: [configAuthorityTarget('tenantless-config', 'tenantless-source-revision')], + }), + ).rejects.toEqual(expectReason('role_changed')); + }); + + test('rejects a previously pinned active transaction snapshot', async () => { + const proof = await resolve(); + const session = await mongoose.startSession(); + session.startTransaction({ + readPreference: 'primary', + readConcern: { level: 'snapshot' }, + }); + try { + await inTenant(() => + models.AclEntry.findOne({ resourceId: serverId }).session(session).lean(), + ); + await inTenant(() => + models.AclEntry.deleteMany({ resourceId: serverId }).then(() => undefined), + ); + await expect( + inTenant(() => models.AclEntry.findOne({ resourceId: serverId }).session(session).lean()), + ).resolves.not.toBeNull(); + + await expect( + inTenant(() => methods.assertMCPAuthorityProofsCurrent({ proofs: proof, boot, session })), + ).rejects.toEqual(expectReason('proof_unavailable')); + } finally { + await session.abortTransaction(); + await session.endSession(); + } + }); + + test('does not combine an early group snapshot with a later injected ACL grant', async () => { + await inTenant(() => + models.AclEntry.deleteMany({ resourceId: serverId }).then(() => undefined), + ); + const snapshotMethods = createMCPAuthorityMethods(mongoose, { + afterPrincipalSnapshot: () => + inTenant(async () => { + await models.Group.updateOne({ _id: groupId }, { $pull: { memberIds: 'source-user-1' } }); + await models.AclEntry.create({ + principalType: PrincipalType.GROUP, + principalId: groupId, + principalModel: PrincipalModel.GROUP, + resourceType: ResourceType.MCPSERVER, + resourceId: serverId, + permBits: PermissionBits.VIEW, + grantedBy: userId, + }); + }), + }); + + await expect( + inTenant(() => + snapshotMethods.resolveMCPAuthorityProof({ + userId: userId.toHexString(), + tenantId: TENANT_ID, + boot, + targets: [target()], + }), + ), + ).rejects.toEqual(expectReason('access_revoked')); + }); + + test('keeps query count bounded by collection for many selected servers', async () => { + const extraServers = Array.from({ length: 24 }, (_, index) => { + const id = new mongoose.Types.ObjectId(); + return { id, name: `selected-${index + 2}` }; + }); + const sourceRevisions = new Map(); + await inTenant(async () => { + await models.MCPServer.insertMany( + extraServers.map(({ id, name }) => ({ + _id: id, + serverName: name, + config: { type: 'sse', url: `https://${name}.example/mcp` }, + author: userId, + })), + ); + await models.AclEntry.insertMany( + extraServers.map(({ id }) => ({ + principalType: PrincipalType.USER, + principalId: userId, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.MCPSERVER, + resourceId: id, + permBits: PermissionBits.VIEW, + grantedBy: userId, + })), + ); + const insertedServers = await models.MCPServer.find({ + _id: { $in: extraServers.map(({ id }) => id) }, + }).lean(); + for (const server of insertedServers) { + sourceRevisions.set( + server.serverName, + createMCPAuthorityDatabaseSourceRevision({ + databaseId: server._id.toHexString(), + serverName: server.serverName, + author: server.author.toString(), + config: server.config, + createdAt: server.createdAt, + updatedAt: server.updatedAt, + }), + ); + } + }); + const targets = [ + target(), + ...extraServers.map(({ id, name }) => { + const sourceRevision = sourceRevisions.get(name); + if (!sourceRevision) { + throw new Error(`Missing source revision for ${name}`); + } + return { + ...target(name, id.toHexString(), sourceRevision, null, EMPTY_CREDENTIAL_REVISION), + resolvedConfig: { type: 'sse' as const, url: `https://${name}.example/mcp` }, + requiresOAuth: false, + }; + }), + ]; + const operations: string[] = []; + mongoose.set('debug', (collection, method) => { + if ( + method === 'find' || + method === 'findOne' || + method === 'aggregate' || + method === 'countDocuments' + ) { + operations.push(`${collection}.${method}`); + } + }); + + const proof = await resolve(targets); + + mongoose.set('debug', false); + expect(proof.servers).toHaveLength(25); + expect(operations).toHaveLength(14); + expect(new Set(operations)).toEqual( + new Set([ + 'users.findOne', + 'groups.countDocuments', + 'groups.find', + 'roles.findOne', + 'configs.aggregate', + 'mcpservers.countDocuments', + 'mcpservers.find', + 'agents.aggregate', + 'pluginauths.countDocuments', + 'pluginauths.find', + 'tokens.countDocuments', + 'tokens.find', + 'aclentries.countDocuments', + 'aclentries.find', + ]), + ); + }); + + test('reads selected server identities only and fails closed on normalized collisions', async () => { + const unselectedId = new mongoose.Types.ObjectId(); + await inTenant(async () => { + await models.MCPServer.create({ + _id: unselectedId, + serverName: 'unselected-server', + config: { type: 'sse', url: 'https://unselected.example/mcp' }, + author: userId, + }); + await models.MCPServer.create({ + serverName: 'selected/server', + config: { type: 'sse', url: 'https://collision.example/mcp' }, + author: userId, + }); + }); + + const configSourceRevision = await currentConfigSourceRevision(); + await expect( + resolve([ + configAuthorityTarget('selected server', configSourceRevision, { + type: 'sse', + url: 'https://operator.example/mcp', + }), + ]), + ).rejects.toEqual(expectReason('server_changed')); + }); + + test('rejects invalid target sources before resolving database authority', async () => { + await expect( + resolve([ + { + ...target(), + source: 'DATABASE' as 'database', + }, + ]), + ).rejects.toEqual(expectReason('malformed_input')); + }); + + test('rejects an authority selector larger than the bounded proof batch', async () => { + await expect( + resolve( + Array.from({ length: 33 }, (_, index) => ({ + ...configAuthorityTarget(`config-${index}`, 'bounded-source-revision'), + })), + ), + ).rejects.toEqual(expectReason('malformed_input')); + }); + + test('rejects mutated proof identities and sources before authoritative reads', async () => { + const proof = await resolve(); + const server = proof.servers[0]; + const normalizedMutation: typeof proof = { + ...proof, + servers: [{ ...server, normalizedServerName: 'unrelated_identity' }], + }; + const sourceMutation: typeof proof = { + ...proof, + servers: [{ ...server, source: 'config' }], + }; + + await expect(assertCurrent(normalizedMutation)).rejects.toEqual( + expectReason('malformed_input'), + ); + await expect(assertCurrent(sourceMutation)).rejects.toEqual(expectReason('malformed_input')); + }); + + test('rejects normalized identity collisions across separately resolved proofs', async () => { + const sourceRevision = await currentConfigSourceRevision(); + const [spaceProof, slashProof] = await Promise.all([ + resolve([configAuthorityTarget('alias server', sourceRevision)]), + resolve([configAuthorityTarget('alias/server', sourceRevision)]), + ]); + + await expect( + inTenant(() => + methods.assertMCPAuthorityProofsCurrent({ + proofs: [spaceProof, slashProof], + boot, + }), + ), + ).rejects.toEqual(expectReason('malformed_input')); + }); + + test('composes separately resolved config proofs with target-independent shared revisions', async () => { + await inTenant(() => + models.Config.updateOne( + { principalType: PrincipalType.USER, principalId: userId.toHexString() }, + { + $set: { + 'overrides.mcpServers.config-a': { startup: false }, + 'overrides.mcpServers.config-b': { startup: true }, + }, + $inc: { configVersion: 1 }, + }, + ).then(() => undefined), + ); + const sourceRevision = await currentConfigSourceRevision(); + const [first, second] = await Promise.all([ + resolve([configAuthorityTarget('config-a', sourceRevision)]), + resolve([configAuthorityTarget('config-b', sourceRevision)]), + ]); + + expect(first.shared.revision).toBe(second.shared.revision); + expect(first.shared.configs.every((config) => config.mcpOverrideDigest === null)).toBe(true); + await expect( + inTenant(() => methods.assertMCPAuthorityProofsCurrent({ proofs: [first, second], boot })), + ).resolves.toBeUndefined(); + }); + + test('rejects composed proofs over the selector bound before authoritative reads', async () => { + const sourceRevision = await currentConfigSourceRevision(); + const configTargets = Array.from( + { length: 34 }, + (_, index): MCPAuthorityTargetInput => + configAuthorityTarget(`composed-${index}`, sourceRevision), + ); + const [first, second] = await Promise.all([ + resolve(configTargets.slice(0, 17)), + resolve(configTargets.slice(17)), + ]); + const startSessionSpy = jest.spyOn(mongoose, 'startSession'); + + await expect( + inTenant(() => methods.assertMCPAuthorityProofsCurrent({ proofs: [first, second], boot })), + ).rejects.toEqual(expectReason('malformed_input')); + expect(startSessionSpy).not.toHaveBeenCalled(); + }); + + test('fails closed when authority row cardinality exceeds a single proof batch', async () => { + await inTenant(() => + models.Group.insertMany( + Array.from({ length: 64 }, (_, index) => ({ + name: `extra-group-${index}`, + source: 'local', + memberIds: ['source-user-1'], + })), + ).then(() => undefined), + ); + + await expect(resolve()).rejects.toEqual(expectReason('proof_unavailable')); + }); + + test('fails closed when projected Config tombstones exceed the bounded proof shape', async () => { + await inTenant(() => + models.Config.updateOne( + { principalType: PrincipalType.USER, principalId: userId.toHexString() }, + { + $set: { + tombstones: Array.from({ length: 65 }, (_, index) => `mcpSettings.path-${index}`), + }, + }, + ).then(() => undefined), + ); + + await expect(resolve()).rejects.toEqual(expectReason('proof_unavailable')); + }); + + test('rejects a database source mutation between parsing and proof resolution', async () => { + const staleTarget = target(); + await inTenant(() => + models.MCPServer.updateOne( + { _id: serverId }, + { $set: { 'config.url': 'https://changed-before-proof.example/mcp' } }, + ).then(() => undefined), + ); + + await expect(resolve([staleTarget])).rejects.toEqual(expectReason('server_changed')); + }); + + test('does not allow explicit credential or OAuth flags to suppress configured authority', async () => { + const credentialProof = await resolve([{ ...target(), credentialFields: [] }]); + expect(credentialProof.servers[0].credentialFields).toEqual(['API_KEY']); + await inTenant(() => + models.PluginAuth.updateOne( + { userId: userId.toHexString(), pluginKey: PLUGIN_KEY }, + { $set: { value: 'suppression-test-rotation' } }, + ).then(() => undefined), + ); + await expect(assertCurrent(credentialProof)).rejects.toEqual( + expectReason('credential_changed'), + ); + + await seedFixture(); + const baseTarget = target(); + const oauthProof = await resolve([ + { + ...baseTarget, + requiresOAuth: false, + resolvedConfig: { ...baseTarget.resolvedConfig, oauth: {} }, + }, + ]); + expect(oauthProof.servers[0].requiresOAuth).toBe(true); + await inTenant(() => + models.Token.updateMany( + { userId, identifier: { $regex: `^mcp:${SERVER_NAME}` } }, + { $set: { 'metadata.credential_set_id': 'suppression-test-oauth-rotation' } }, + ).then(() => undefined), + ); + await expect(assertCurrent(oauthProof)).rejects.toEqual(expectReason('oauth_grant_changed')); + }); + + test('rejects credential and OAuth rotations between artifact creation and proof resolution', async () => { + const staleTarget = target(); + await inTenant(async () => { + await models.PluginAuth.updateOne( + { userId: userId.toHexString(), pluginKey: PLUGIN_KEY }, + { $set: { value: 'rotated-before-proof' } }, + ); + await models.Token.updateMany( + { userId, identifier: { $regex: `^mcp:${SERVER_NAME}` } }, + { $set: { 'metadata.credential_set_id': 'rotated-before-proof' } }, + ); + }); + + await expect(resolve([staleTarget])).rejects.toEqual(expectReason('credential_changed')); + + const rotatedCredential = await inTenant(() => + models.PluginAuth.findOne({ userId: userId.toHexString(), pluginKey: PLUGIN_KEY }).lean(), + ); + if (!rotatedCredential) { + throw new Error('Rotated credential was not found'); + } + const currentCredentialRevision = createMCPAuthorityCredentialRevision( + ['API_KEY'], + [rotatedCredential], + ); + await expect( + resolve([ + target( + SERVER_NAME, + serverId.toHexString(), + serverSourceRevision, + 'oauth-generation-1', + currentCredentialRevision, + ), + ]), + ).rejects.toEqual(expectReason('oauth_grant_changed')); + }); + + test('rejects a Config mutation between parsing and proof resolution', async () => { + const staleSourceRevision = await currentConfigSourceRevision(); + await inTenant(() => + models.Config.updateOne( + { principalType: PrincipalType.USER, principalId: userId.toHexString() }, + { $inc: { configVersion: 1 } }, + ).then(() => undefined), + ); + + await expect( + resolve([ + configAuthorityTarget( + 'operator-server', + staleSourceRevision, + immutableConfig.mcpServers['operator-server'], + ), + ]), + ).rejects.toEqual(expectReason('config_changed')); + }); + + test('requires a new proof after first OAuth generation storage', async () => { + await inTenant(() => + models.Token.deleteMany({ userId, identifier: { $regex: `^mcp:${SERVER_NAME}` } }).then( + () => undefined, + ), + ); + const preStoreProof = await resolve([ + target(SERVER_NAME, serverId.toHexString(), serverSourceRevision, null), + ]); + expect(preStoreProof.servers[0].oauthGrantGeneration).toBeNull(); + + const now = new Date(); + await inTenant(() => + models.Token.insertMany( + [ + ['mcp_oauth', `mcp:${SERVER_NAME}`], + ['mcp_oauth_refresh', `mcp:${SERVER_NAME}:refresh`], + ['mcp_oauth_client', `mcp:${SERVER_NAME}:client`], + ].map(([type, identifier]) => ({ + userId, + type, + identifier, + token: `${type}-first-install-secret`, + metadata: { credential_set_id: 'oauth-first-install' }, + createdAt: now, + expiresAt: new Date(now.getTime() + 3_600_000), + })), + ).then(() => undefined), + ); + + await expect(assertCurrent(preStoreProof)).rejects.toEqual(expectReason('oauth_grant_changed')); + const postStoreTarget = target( + SERVER_NAME, + serverId.toHexString(), + serverSourceRevision, + 'oauth-first-install', + ); + await inTenant(() => + models.Token.updateMany( + { userId, identifier: { $regex: `^mcp:${SERVER_NAME}` } }, + { $set: { 'metadata.credential_set_id': 'concurrent-newer-generation' } }, + ).then(() => undefined), + ); + await expect(resolve([postStoreTarget])).rejects.toEqual(expectReason('oauth_grant_changed')); + await inTenant(() => + models.Token.updateMany( + { userId, identifier: { $regex: `^mcp:${SERVER_NAME}` } }, + { $set: { 'metadata.credential_set_id': 'oauth-first-install' } }, + ).then(() => undefined), + ); + const postStoreProof = await resolve([postStoreTarget]); + expect(postStoreProof.servers[0].oauthGrantGeneration).toBe('oauth-first-install'); + await expect(assertCurrent(postStoreProof)).resolves.toBeUndefined(); + }); + + test('keys OAuth grants by canonical token type and identifier', async () => { + const sourceRevision = await currentConfigSourceRevision(); + const now = new Date(); + await inTenant(() => + models.Token.create({ + userId, + type: 'mcp_oauth_refresh', + identifier: 'mcp:foo:refresh', + token: 'unselected-refresh-token', + metadata: { credential_set_id: 'unselected-foo' }, + createdAt: now, + expiresAt: new Date(now.getTime() + 3_600_000), + }).then(() => undefined), + ); + const selectedTarget = configAuthorityTarget('foo:refresh', sourceRevision, { + type: 'sse', + url: 'https://foo-refresh.example/mcp', + oauth: {}, + }); + const proof = await resolve([selectedTarget]); + expect(proof.servers[0].oauthGrantGeneration).toBeNull(); + + await inTenant(() => + models.Token.updateOne( + { type: 'mcp_oauth_refresh', identifier: 'mcp:foo:refresh' }, + { $set: { 'metadata.credential_set_id': 'unselected-foo-rotated' } }, + ).then(() => undefined), + ); + await expect(assertCurrent(proof)).resolves.toBeUndefined(); + + await inTenant(() => + models.Token.create({ + userId, + type: 'mcp_oauth', + identifier: 'mcp:foo:refresh', + token: 'selected-access-token', + metadata: { credential_set_id: 'selected-foo-refresh' }, + createdAt: now, + expiresAt: new Date(now.getTime() + 3_600_000), + }).then(() => undefined), + ); + const selectedProof = await resolve([ + { ...selectedTarget, expectedOAuthGrantGeneration: 'selected-foo-refresh' }, + ]); + expect(selectedProof.servers[0].oauthGrantGeneration).toBe('selected-foo-refresh'); + }); + + test.each([ + { + name: 'user deletion', + reason: 'user_revoked' as const, + mutate: () => inTenant(() => models.User.deleteOne({ _id: userId }).then(() => undefined)), + }, + { + name: 'tenant move', + reason: 'user_revoked' as const, + mutate: () => + models.User.collection + .updateOne({ _id: userId }, { $set: { tenantId: 'different-tenant' } }) + .then(() => undefined), + }, + { + name: 'source identity change', + reason: 'principal_changed' as const, + mutate: () => + inTenant(() => + models.User.updateOne({ _id: userId }, { $set: { idOnTheSource: 'source-user-2' } }).then( + () => undefined, + ), + ), + }, + { + name: 'group revocation', + reason: 'groups_changed' as const, + mutate: () => + inTenant(() => + models.Group.updateOne({ _id: groupId }, { $pull: { memberIds: 'source-user-1' } }).then( + () => undefined, + ), + ), + }, + { + name: 'role USE revocation', + reason: 'mcp_use_revoked' as const, + mutate: () => + inTenant(() => + models.Role.updateOne( + { name: USER_ROLE }, + { $set: { [`permissions.${PermissionTypes.MCP_SERVERS}.${Permissions.USE}`]: false } }, + ).then(() => undefined), + ), + }, + { + name: 'role reassignment', + reason: 'role_changed' as const, + mutate: () => + inTenant(() => + models.User.updateOne({ _id: userId }, { $set: { role: 'REASSIGNED_ROLE' } }).then( + () => undefined, + ), + ), + }, + { + name: 'Config toggle', + reason: 'config_changed' as const, + mutate: () => + inTenant(() => + models.Config.updateOne( + { principalType: PrincipalType.USER, principalId: userId.toHexString() }, + { $set: { isActive: false } }, + ).then(() => undefined), + ), + }, + { + name: 'Config deletion', + reason: 'config_changed' as const, + mutate: () => + inTenant(() => + models.Config.deleteOne({ + principalType: PrincipalType.USER, + principalId: userId.toHexString(), + }).then(() => undefined), + ), + }, + { + name: 'Config tombstone and version mutation', + reason: 'config_changed' as const, + mutate: () => + inTenant(() => + models.Config.updateOne( + { principalType: PrincipalType.USER, principalId: userId.toHexString() }, + { $addToSet: { tombstones: `mcpServers.${SERVER_NAME}` }, $inc: { configVersion: 1 } }, + ).then(() => undefined), + ), + }, + { + name: 'server deletion', + reason: 'server_revoked' as const, + mutate: () => + inTenant(() => models.MCPServer.deleteOne({ _id: serverId }).then(() => undefined)), + }, + { + name: 'server tenant move', + reason: 'server_revoked' as const, + mutate: () => + models.MCPServer.collection + .updateOne({ _id: serverId }, { $set: { tenantId: 'different-tenant' } }) + .then(() => undefined), + }, + { + name: 'server configuration mutation', + reason: 'server_changed' as const, + mutate: () => + inTenant(() => + models.MCPServer.updateOne( + { _id: serverId }, + { $set: { 'config.url': 'https://changed.example/mcp' } }, + ).then(() => undefined), + ), + }, + { + name: 'direct ACL revocation', + reason: 'access_revoked' as const, + mutate: () => + inTenant(() => + models.AclEntry.deleteMany({ + resourceType: ResourceType.MCPSERVER, + resourceId: serverId, + }).then(() => undefined), + ), + }, + { + name: 'ACL resource move', + reason: 'access_revoked' as const, + mutate: () => + inTenant(() => + models.AclEntry.updateMany( + { resourceType: ResourceType.MCPSERVER, resourceId: serverId }, + { $set: { resourceId: new mongoose.Types.ObjectId() } }, + ).then(() => undefined), + ), + }, + { + name: 'new ACL grant', + reason: 'authorization_changed' as const, + mutate: () => + inTenant(() => + models.AclEntry.create({ + principalType: PrincipalType.GROUP, + principalId: groupId, + principalModel: PrincipalModel.GROUP, + resourceType: ResourceType.MCPSERVER, + resourceId: serverId, + permBits: PermissionBits.VIEW, + grantedBy: userId, + }).then(() => undefined), + ), + }, + { + name: 'custom credential rotation', + reason: 'credential_changed' as const, + mutate: () => + inTenant(() => + models.PluginAuth.updateOne( + { userId: userId.toHexString(), pluginKey: PLUGIN_KEY }, + { $set: { value: 'secret-generation-2' } }, + ).then(() => undefined), + ), + }, + { + name: 'custom credential deletion', + reason: 'credential_changed' as const, + mutate: () => + inTenant(() => + models.PluginAuth.deleteOne({ + userId: userId.toHexString(), + pluginKey: PLUGIN_KEY, + }).then(() => undefined), + ), + }, + { + name: 'OAuth grant generation rotation', + reason: 'oauth_grant_changed' as const, + mutate: () => + inTenant(() => + models.Token.updateMany( + { + userId, + identifier: { + $in: [ + `mcp:${SERVER_NAME}`, + `mcp:${SERVER_NAME}:refresh`, + `mcp:${SERVER_NAME}:client`, + ], + }, + }, + { $set: { 'metadata.credential_set_id': 'oauth-generation-2' } }, + ).then(() => undefined), + ), + }, + { + name: 'OAuth grant deletion', + reason: 'oauth_grant_changed' as const, + mutate: () => + inTenant(() => + models.Token.deleteOne({ + userId, + type: 'mcp_oauth_refresh', + identifier: `mcp:${SERVER_NAME}:refresh`, + }).then(() => undefined), + ), + }, + ])('rejects $name after the primary-backed fence snapshot', async ({ reason, mutate }) => { + const proof = await resolve(); + + await mutate(); + + await expect(assertCurrent(proof)).rejects.toEqual(expectReason(reason)); + }); + + test('rejects targeted Agent linkage revocation when Agent access was the only path', async () => { + const agentObjectId = new mongoose.Types.ObjectId(); + await inTenant(async () => { + await models.AclEntry.deleteMany({ + resourceType: ResourceType.MCPSERVER, + resourceId: serverId, + }); + await models.Agent.create({ + _id: agentObjectId, + id: 'agent-with-selected-server', + name: 'MCP agent', + provider: 'openAI', + model: 'test-model', + author: userId, + mcpServerNames: [SERVER_NAME], + }); + await models.AclEntry.create({ + principalType: PrincipalType.USER, + principalId: userId, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: agentObjectId, + permBits: PermissionBits.VIEW, + grantedBy: userId, + }); + }); + const proof = await resolve(); + expect(proof.servers[0]).toMatchObject({ directAccess: false, agentAccess: true }); + + await inTenant(() => + models.Agent.updateOne({ _id: agentObjectId }, { $set: { mcpServerNames: [] } }).then( + () => undefined, + ), + ); + + await expect(assertCurrent(proof)).rejects.toEqual(expectReason('access_revoked')); + }); + + test('treats a post-snapshot primary mutation as authoritative despite secondaryPreferred default', async () => { + const proof = await resolve(); + await inTenant(() => + models.AclEntry.updateOne( + { resourceType: ResourceType.MCPSERVER, resourceId: serverId }, + { $set: { permBits: 0 } }, + ).then(() => undefined), + ); + + await expect(assertCurrent(proof)).rejects.toEqual(expectReason('access_revoked')); + }); +}); diff --git a/packages/data-schemas/src/methods/mcpAuthority.ts b/packages/data-schemas/src/methods/mcpAuthority.ts new file mode 100644 index 0000000000..4d0e08380c --- /dev/null +++ b/packages/data-schemas/src/methods/mcpAuthority.ts @@ -0,0 +1,1737 @@ +import { Types } from 'mongoose'; +import { createHash } from 'crypto'; +import { + Constants, + Permissions, + PermissionBits, + ResourceType, + PrincipalType, + PermissionTypes, + normalizeServerName, +} from 'librechat-data-provider'; +import type { Model, ClientSession, RootFilterQuery } from 'mongoose'; +import type { + IRole, + IUser, + IGroup, + IConfig, + IAgent, + IAclEntry, + IPluginAuth, + IToken, + MCPServerDocument, + MCPAuthorityProofV1, + MCPAuthorityBootRevision, + MCPAuthorityResolveInput, + MCPAuthorityAssertInput, + MCPAuthorityDatabaseMethods, + MCPAuthorityGroupProof, + MCPAuthorityConfigProof, + MCPAuthorityServerProofV1, + MCPAuthorityRejectionReason, + MCPAuthorityImmutableConfig, + MCPAuthorityServerSource, +} from '~/types'; +import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities'; +import { MCP_AUTHORITY_PROOF_VERSION } from '~/types'; +import { getTenantId } from '~/config/tenantContext'; + +interface PinnableQuery { + session(session: ClientSession): this; +} + +interface PreparedTarget { + readonly serverName: string; + readonly normalizedServerName: string; + readonly source: MCPAuthorityServerSource; + readonly databaseId: string | null; + readonly sourceRevision: string; + readonly expectedCredentialRevision: string; + readonly expectedOAuthGrantGeneration: string | null; + readonly resolvedConfigDigest: string; + readonly credentialFields: readonly string[]; + readonly requiresOAuth: boolean; +} + +interface UserProjection { + _id: Types.ObjectId; + tenantId?: string; + role?: string; + provider?: string; + idOnTheSource?: string; + openidIssuer?: string; + googleId?: string; + facebookId?: string; + openidId?: string; + samlId?: string; + ldapId?: string; + githubId?: string; + discordId?: string; + appleId?: string; + createdAt?: Date; + updatedAt?: Date; +} + +interface GroupProjection { + _id: Types.ObjectId; + source: string; + idOnTheSource?: string; + createdAt?: Date; + updatedAt?: Date; +} + +interface ConfigProjection { + _id: Types.ObjectId; + principalType: PrincipalType; + principalId: string | Types.ObjectId; + priority: number; + mcpSettingsOverride?: object; + tombstones?: string[]; + isActive: boolean; + configVersion: number; + updatedAt?: Date; +} + +interface RoleProjection { + _id: Types.ObjectId; + name: string; + permissions?: IRole['permissions']; +} + +interface ServerProjection { + _id: Types.ObjectId; + serverName: string; + normalizedServerName: string; + config: MCPServerDocument['config']; + author: Types.ObjectId; + createdAt?: Date; + updatedAt?: Date; +} + +interface AgentProjection { + _id: Types.ObjectId; + id: string; + mcpServerNames?: string[]; + createdAt?: Date; + updatedAt?: Date; +} + +interface AclProjection { + _id: Types.ObjectId; + principalType: PrincipalType; + principalId?: Types.ObjectId | string; + resourceType: ResourceType; + resourceId: Types.ObjectId; + permBits: number; + expiredAt?: Date; + updatedAt?: Date; +} + +interface PluginAuthProjection { + _id: Types.ObjectId; + pluginKey?: string; + authField: string; + value: string; + createdAt?: Date; + updatedAt?: Date; +} + +interface TokenProjection { + _id: Types.ObjectId; + type?: string; + identifier?: string; + token: string; + metadata?: Map | Record; + createdAt: Date; + expiresAt: Date; +} + +interface AggregateBatch { + _id: null; + rows: Projection[]; + count: number; +} + +const OAUTH_TOKEN_TYPES = ['mcp_oauth', 'mcp_oauth_refresh', 'mcp_oauth_client'] as const; +type OAuthTokenType = (typeof OAUTH_TOKEN_TYPES)[number]; +export const MAX_MCP_AUTHORITY_TARGETS = 32; +const MAX_MCP_AUTHORITY_GROUPS = 64; +const MAX_MCP_AUTHORITY_AGENTS = 64; +const MAX_MCP_AUTHORITY_CREDENTIALS = 64; +const MAX_MCP_AUTHORITY_ACL_ENTRIES = 96; +const MAX_MCP_AUTHORITY_SERVER_NAME_LENGTH = 256; +const MAX_MCP_AUTHORITY_CREDENTIAL_FIELD_LENGTH = 256; +const MAX_MCP_AUTHORITY_CREDENTIAL_FIELDS = 64; +const MAX_MCP_AUTHORITY_SOURCE_REVISION_LENGTH = 256; +const MAX_MCP_AUTHORITY_CONFIG_TOMBSTONES = 64; + +export type MCPAuthorityMethods = MCPAuthorityDatabaseMethods; + +export interface MCPAuthorityMethodHooks { + afterPrincipalSnapshot?: () => void | Promise; +} + +export class MCPAuthorityProofError extends Error { + constructor( + public readonly reason: MCPAuthorityRejectionReason, + message: string, + public readonly serverName?: string, + ) { + super(message); + this.name = 'MCPAuthorityProofError'; + } +} + +function reject(reason: MCPAuthorityRejectionReason, message: string, serverName?: string): never { + throw new MCPAuthorityProofError(reason, message, serverName); +} + +function stableStringify(value: unknown, seen = new WeakSet()): string { + if (value === null) { + return 'null'; + } + if (typeof value === 'string' || typeof value === 'boolean') { + return JSON.stringify(value); + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + reject('malformed_input', 'Authority proof data contains a non-finite number'); + } + return JSON.stringify(value); + } + if (typeof value === 'undefined') { + return 'null'; + } + if (typeof value !== 'object') { + reject('malformed_input', 'Authority proof data contains an unsupported value'); + } + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) { + reject('malformed_input', 'Authority proof data contains an invalid date'); + } + return JSON.stringify(value.toISOString()); + } + if (value instanceof Types.ObjectId) { + return JSON.stringify(value.toHexString()); + } + if (seen.has(value)) { + reject('malformed_input', 'Authority proof data contains a circular value'); + } + seen.add(value); + if (Array.isArray(value)) { + const serialized = `[${value.map((item) => stableStringify(item, seen)).join(',')}]`; + seen.delete(value); + return serialized; + } + if (value instanceof Map) { + const entries = [...value.entries()].sort(([left], [right]) => + String(left).localeCompare(String(right)), + ); + const serialized = `{${entries + .map(([key, item]) => `${JSON.stringify(String(key))}:${stableStringify(item, seen)}`) + .join(',')}}`; + seen.delete(value); + return serialized; + } + const record = value as Record; + const keys = Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort(); + const serialized = `{${keys + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key], seen)}`) + .join(',')}}`; + seen.delete(value); + return serialized; +} + +export function digestMCPAuthorityValue(value: unknown): string { + return createHash('sha256').update(stableStringify(value)).digest('base64url'); +} + +function deepFreeze(value: T): Readonly { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) { + return value; + } + Object.freeze(value); + for (const child of Object.values(value as Record)) { + deepFreeze(child); + } + return value; +} + +export function createMCPAuthorityBootRevision( + revision: string, + immutableConfig: MCPAuthorityImmutableConfig, +): MCPAuthorityBootRevision { + const normalizedRevision = revision.trim(); + if (!normalizedRevision) { + reject('malformed_input', 'MCP boot revision is required'); + } + return deepFreeze({ + revision: normalizedRevision, + digest: digestMCPAuthorityValue(immutableConfig), + }); +} + +export interface MCPAuthorityConfigSourceDocument { + _id: Types.ObjectId | string; + principalType: PrincipalType; + principalId: Types.ObjectId | string; + priority: number; + overrides?: IConfig['overrides']; + tombstones?: readonly string[]; + isActive: boolean; + configVersion: number; + updatedAt?: Date | null; +} + +export function createMCPAuthorityConfigSourceRevision( + bootDigest: string, + documents: readonly MCPAuthorityConfigSourceDocument[], +): string { + if (typeof bootDigest !== 'string' || !bootDigest.trim() || !Array.isArray(documents)) { + reject('malformed_input', 'MCP config source revision inputs are malformed'); + } + if (documents.length > MAX_MCP_AUTHORITY_GROUPS + 3) { + reject('malformed_input', 'MCP config source revision exceeds the proof limit'); + } + const principals = new Set(); + const configs = documents + .map((document) => { + const id = document._id.toString(); + const principalId = document.principalId.toString(); + const key = principalKey(document.principalType, principalId); + const rawTombstones = document.tombstones ?? []; + if (!Array.isArray(rawTombstones) || rawTombstones.some((path) => typeof path !== 'string')) { + reject('malformed_input', 'MCP config source revision inputs are malformed'); + } + const tombstones = rawTombstones + .filter((path) => path === 'mcpSettings' || path.startsWith('mcpSettings.')) + .sort(); + if ( + !Types.ObjectId.isValid(id) || + !principalId || + principals.has(key) || + !Number.isFinite(document.priority) || + typeof document.isActive !== 'boolean' || + !Number.isInteger(document.configVersion) || + document.configVersion < 0 || + tombstones.length > MAX_MCP_AUTHORITY_CONFIG_TOMBSTONES + ) { + reject('malformed_input', 'MCP config source revision inputs are malformed'); + } + principals.add(key); + const mcpSettingsOverride = document.overrides?.mcpSettings; + return { + id, + principalType: document.principalType, + principalId, + priority: document.priority, + isActive: document.isActive, + configVersion: document.configVersion, + mcpSettingsOverrideDigest: + mcpSettingsOverride === undefined ? null : digestMCPAuthorityValue(mcpSettingsOverride), + tombstones, + updatedAt: document.updatedAt ?? null, + }; + }) + .sort((left, right) => + principalKey(left.principalType, left.principalId).localeCompare( + principalKey(right.principalType, right.principalId), + ), + ); + return digestMCPAuthorityValue({ bootDigest, configs }); +} + +export function createMCPAuthorityDatabaseSourceRevision(server: { + databaseId: string; + serverName: string; + author: string; + config: MCPServerDocument['config']; + createdAt?: Date | null; + updatedAt?: Date | null; +}): string { + if (!Types.ObjectId.isValid(server.databaseId) || !server.serverName || !server.author) { + reject('malformed_input', 'MCP database source revision inputs are malformed'); + } + return digestMCPAuthorityValue({ + id: server.databaseId, + name: server.serverName, + author: server.author, + config: server.config, + createdAt: server.createdAt ?? null, + updatedAt: server.updatedAt ?? null, + }); +} + +export interface MCPAuthorityCredentialSourceDocument { + _id: Types.ObjectId | string; + authField: string; + value: string; + createdAt?: Date | null; + updatedAt?: Date | null; +} + +export function createMCPAuthorityCredentialRevision( + credentialFields: readonly string[], + credentials: readonly MCPAuthorityCredentialSourceDocument[], +): string { + const fields = [...new Set(credentialFields)].sort(); + if ( + fields.length !== credentialFields.length || + fields.some( + (field) => + !field || + field.trim() !== field || + field.length > MAX_MCP_AUTHORITY_CREDENTIAL_FIELD_LENGTH, + ) + ) { + reject('malformed_input', 'MCP credential source fields are malformed'); + } + const fieldSet = new Set(fields); + const seen = new Set(); + const rows = credentials + .map((credential) => { + const id = credential._id.toString(); + if ( + !Types.ObjectId.isValid(id) || + !fieldSet.has(credential.authField) || + seen.has(credential.authField) || + typeof credential.value !== 'string' + ) { + reject('malformed_input', 'MCP credential source data is malformed'); + } + seen.add(credential.authField); + return { + id, + authField: credential.authField, + valueDigest: digestMCPAuthorityValue(credential.value), + createdAt: credential.createdAt ?? null, + updatedAt: credential.updatedAt ?? null, + }; + }) + .sort((left, right) => left.authField.localeCompare(right.authField)); + return digestMCPAuthorityValue({ expectedFields: fields, rows }); +} + +function pinAuthoritativeRead( + query: QueryType, + session: ClientSession, +): QueryType { + query.session(session); + return query; +} + +function normalizeCredentialFields( + target: MCPAuthorityResolveInput['targets'][number], +): readonly string[] { + const configured = target.resolvedConfig.customUserVars; + const providedFields = target.credentialFields ?? []; + if (!Array.isArray(providedFields) || providedFields.some((field) => typeof field !== 'string')) { + reject('malformed_input', `Credential fields for "${target.serverName}" are malformed`); + } + const normalizedProvided = [ + ...new Set(providedFields.map((field) => field.trim()).filter(Boolean)), + ]; + if ( + normalizedProvided.length !== providedFields.length || + normalizedProvided.some((field) => field.length > MAX_MCP_AUTHORITY_CREDENTIAL_FIELD_LENGTH) + ) { + reject('malformed_input', `Credential fields for "${target.serverName}" are malformed`); + } + const configuredFields = configured ? Object.keys(configured) : []; + if ( + configuredFields.some( + (field) => + !field || + field.trim() !== field || + field.length > MAX_MCP_AUTHORITY_CREDENTIAL_FIELD_LENGTH, + ) + ) { + reject('malformed_input', `Configured credentials for "${target.serverName}" are malformed`); + } + return [...new Set([...normalizedProvided, ...configuredFields])].sort(); +} + +function assertPreparedTargetBounds(targets: readonly PreparedTarget[]): void { + if (targets.length > MAX_MCP_AUTHORITY_TARGETS) { + reject('malformed_input', 'Too many selected MCP servers for one authority snapshot'); + } + const credentialFieldCount = targets.reduce( + (total, target) => total + target.credentialFields.length, + 0, + ); + if (credentialFieldCount > MAX_MCP_AUTHORITY_CREDENTIAL_FIELDS) { + reject('malformed_input', 'Too many MCP credential fields for one authority snapshot'); + } +} + +function prepareTargets(targets: MCPAuthorityResolveInput['targets']): readonly PreparedTarget[] { + if (!Array.isArray(targets)) { + reject('malformed_input', 'Selected MCP authority targets are malformed'); + } + if (targets.length === 0) { + reject('malformed_input', 'At least one selected MCP server is required'); + } + if (targets.length > MAX_MCP_AUTHORITY_TARGETS) { + reject('malformed_input', 'Too many selected MCP servers for one authority snapshot'); + } + const rawNames = new Set(); + const normalizedNames = new Set(); + const prepared = targets.map((target): PreparedTarget => { + if (!target || typeof target !== 'object') { + reject('malformed_input', 'Selected MCP authority target is malformed'); + } + const source: string = target.source; + if ( + !target.serverName || + target.serverName.trim() !== target.serverName || + target.serverName.length > MAX_MCP_AUTHORITY_SERVER_NAME_LENGTH + ) { + reject('malformed_input', 'Selected MCP server name is required'); + } + if (rawNames.has(target.serverName)) { + reject('malformed_input', `Selected MCP server "${target.serverName}" is duplicated`); + } + rawNames.add(target.serverName); + const normalizedServerName = normalizeServerName(target.serverName); + if (normalizedNames.has(normalizedServerName)) { + reject( + 'malformed_input', + `Selected MCP server "${target.serverName}" has an ambiguous normalized identity`, + target.serverName, + ); + } + normalizedNames.add(normalizedServerName); + if (source !== 'config' && source !== 'database') { + reject('malformed_input', `Source for "${target.serverName}" is invalid`); + } + if (source === 'config' && target.databaseId != null) { + reject('malformed_input', `Config server "${target.serverName}" has a database identity`); + } + if (source === 'database') { + if (!target.databaseId || !Types.ObjectId.isValid(target.databaseId)) { + reject('malformed_input', `Database identity for "${target.serverName}" is invalid`); + } + } + if ( + typeof target.sourceRevision !== 'string' || + !target.sourceRevision || + target.sourceRevision.trim() !== target.sourceRevision || + target.sourceRevision.length > MAX_MCP_AUTHORITY_SOURCE_REVISION_LENGTH + ) { + reject('malformed_input', `Source revision for "${target.serverName}" is required`); + } + if ( + typeof target.expectedCredentialRevision !== 'string' || + !target.expectedCredentialRevision || + target.expectedCredentialRevision.length > MAX_MCP_AUTHORITY_SOURCE_REVISION_LENGTH || + (target.expectedOAuthGrantGeneration !== null && + (typeof target.expectedOAuthGrantGeneration !== 'string' || + !target.expectedOAuthGrantGeneration || + target.expectedOAuthGrantGeneration.length > MAX_MCP_AUTHORITY_SOURCE_REVISION_LENGTH)) + ) { + reject('malformed_input', `Credential generation for "${target.serverName}" is malformed`); + } + if (target.requiresOAuth !== undefined && typeof target.requiresOAuth !== 'boolean') { + reject('malformed_input', `OAuth requirement for "${target.serverName}" is malformed`); + } + if ( + !target.resolvedConfig || + typeof target.resolvedConfig !== 'object' || + Array.isArray(target.resolvedConfig) + ) { + reject('malformed_input', `Resolved config for "${target.serverName}" is malformed`); + } + return { + serverName: target.serverName, + normalizedServerName, + source, + databaseId: target.databaseId ?? null, + sourceRevision: target.sourceRevision, + expectedCredentialRevision: target.expectedCredentialRevision, + expectedOAuthGrantGeneration: target.expectedOAuthGrantGeneration, + resolvedConfigDigest: digestMCPAuthorityValue(target.resolvedConfig), + credentialFields: normalizeCredentialFields(target), + requiresOAuth: target.resolvedConfig.oauth != null || target.requiresOAuth === true, + }; + }); + assertPreparedTargetBounds(prepared); + return prepared.sort((left, right) => left.serverName.localeCompare(right.serverName)); +} + +function assertRevisionIntegrity( + value: T, + description: string, +): void { + const { revision, ...revisionInput } = value; + if (!revision || digestMCPAuthorityValue(revisionInput) !== revision) { + reject('malformed_input', `${description} revision is invalid`); + } +} + +function assertProofIntegrity(proof: MCPAuthorityProofV1): void { + if (!proof || typeof proof !== 'object' || proof.version !== MCP_AUTHORITY_PROOF_VERSION) { + reject('malformed_input', 'MCP authority proof version is invalid'); + } + if (!proof.shared || typeof proof.shared !== 'object' || !Array.isArray(proof.servers)) { + reject('malformed_input', 'MCP authority proof contents are invalid'); + } + const shared = proof.shared; + if ( + !shared.user || + !shared.role || + !shared.boot || + !Array.isArray(shared.groups) || + !Array.isArray(shared.configs) || + !shared.user.userId || + !shared.user.revision || + !shared.role.revision || + !shared.boot.revision || + !shared.boot.digest + ) { + reject('malformed_input', 'MCP authority shared proof is invalid'); + } + if ( + shared.groups.length > MAX_MCP_AUTHORITY_GROUPS || + shared.configs.length > MAX_MCP_AUTHORITY_GROUPS + 3 || + shared.configs.some( + (config) => + !Array.isArray(config?.tombstones) || + config.tombstones.length > MAX_MCP_AUTHORITY_CONFIG_TOMBSTONES, + ) + ) { + reject('malformed_input', 'MCP authority shared proof exceeds its bounded shape'); + } + if (shared.groups.some((group) => !group?.revision)) { + reject('malformed_input', 'MCP authority group proof is invalid'); + } + if (shared.configs.some((config) => !config?.revision)) { + reject('malformed_input', 'MCP authority configuration proof is invalid'); + } + if (shared.groupsRevision !== digestMCPAuthorityValue(shared.groups)) { + reject('malformed_input', 'MCP authority group revision is invalid'); + } + if (shared.configsRevision !== digestMCPAuthorityValue(shared.configs)) { + reject('malformed_input', 'MCP authority configuration revision is invalid'); + } + assertRevisionIntegrity(shared, 'MCP authority shared proof'); + + if (proof.servers.length === 0 || proof.servers.length > MAX_MCP_AUTHORITY_TARGETS) { + reject('malformed_input', 'MCP authority proof contains no selected servers'); + } + const normalizedNames = new Set(); + for (const server of proof.servers) { + if ( + !server || + typeof server.serverName !== 'string' || + !server.serverName || + server.serverName.trim() !== server.serverName || + server.serverName.length > MAX_MCP_AUTHORITY_SERVER_NAME_LENGTH || + typeof server.normalizedServerName !== 'string' || + !server.normalizedServerName || + typeof server.sourceRevision !== 'string' || + !server.sourceRevision || + server.sourceRevision.trim() !== server.sourceRevision || + server.sourceRevision.length > MAX_MCP_AUTHORITY_SOURCE_REVISION_LENGTH || + !server.resolvedConfigDigest || + !server.serverRevision || + !server.authorizationRevision || + !server.credentialRevision || + !server.oauthRevision || + !server.effectivePolicyDigest || + !Array.isArray(server.linkedAgentIds) || + server.linkedAgentIds.length > MAX_MCP_AUTHORITY_AGENTS || + server.linkedAgentIds.some((id: string) => typeof id !== 'string') || + !Array.isArray(server.credentialFields) || + server.credentialFields.length > MAX_MCP_AUTHORITY_CREDENTIAL_FIELDS || + server.credentialFields.some((field: string) => typeof field !== 'string') || + typeof server.directAccess !== 'boolean' || + typeof server.agentAccess !== 'boolean' || + typeof server.requiresOAuth !== 'boolean' || + (server.oauthGrantGeneration !== null && typeof server.oauthGrantGeneration !== 'string') + ) { + reject('malformed_input', 'MCP authority server proof is invalid'); + } + const normalizedServerName = normalizeServerName(server.serverName); + if (server.normalizedServerName !== normalizedServerName) { + reject( + 'malformed_input', + 'MCP authority server normalized identity is invalid', + server.serverName, + ); + } + if (normalizedNames.has(normalizedServerName)) { + reject( + 'malformed_input', + 'MCP authority proof contains ambiguous server identities', + server.serverName, + ); + } + normalizedNames.add(normalizedServerName); + if ( + (server.source !== 'config' && server.source !== 'database') || + (server.source === 'config' && server.databaseId !== null) || + (server.source === 'database' && + (typeof server.databaseId !== 'string' || !Types.ObjectId.isValid(server.databaseId))) + ) { + reject('malformed_input', 'MCP authority server source is invalid', server.serverName); + } + const normalizedCredentialFields = [...new Set(server.credentialFields)].sort(); + if ( + normalizedCredentialFields.length !== server.credentialFields.length || + normalizedCredentialFields.some( + (field, index) => + !field || + field.trim() !== field || + field.length > MAX_MCP_AUTHORITY_CREDENTIAL_FIELD_LENGTH || + field !== server.credentialFields[index], + ) + ) { + reject('malformed_input', 'MCP authority credential fields are invalid', server.serverName); + } + const expectedPolicyDigest = digestMCPAuthorityValue({ + sharedRevision: shared.revision, + serverRevision: server.serverRevision, + sourceRevision: server.sourceRevision, + resolvedConfigDigest: server.resolvedConfigDigest, + authorizationRevision: server.authorizationRevision, + credentialRevision: server.credentialRevision, + oauthRevision: server.oauthRevision, + }); + if (server.effectivePolicyDigest !== expectedPolicyDigest) { + reject('malformed_input', 'MCP authority effective policy is invalid', server.serverName); + } + assertRevisionIntegrity(server, 'MCP authority server proof'); + } + assertRevisionIntegrity(proof, 'MCP authority proof'); +} + +function sourceIdentity(user: UserProjection): object { + return { + idOnTheSource: user.idOnTheSource ?? null, + openidIssuer: user.openidIssuer ?? null, + googleId: user.googleId ?? null, + facebookId: user.facebookId ?? null, + openidId: user.openidId ?? null, + samlId: user.samlId ?? null, + ldapId: user.ldapId ?? null, + githubId: user.githubId ?? null, + discordId: user.discordId ?? null, + appleId: user.appleId ?? null, + }; +} + +function principalKey(principalType: string, principalId: string): string { + return `${principalType}:${principalId}`; +} + +function appendIndexValue(index: Map, key: Key, value: Value): void { + const values = index.get(key); + if (values) { + values.push(value); + return; + } + index.set(key, [value]); +} + +function assertCompleteBatch( + rows: readonly Projection[], + count: number, + limit: number, + description: string, +): void { + if (!Number.isInteger(count) || count < 0 || count > limit || rows.length !== count) { + reject('proof_unavailable', `${description} exceeds one complete authoritative proof batch`); + } +} + +function unwrapAggregateBatch( + batches: readonly AggregateBatch[], + limit: number, + description: string, +): Projection[] { + if (batches.length === 0) { + return []; + } + if (batches.length !== 1 || !Array.isArray(batches[0].rows)) { + reject('proof_unavailable', `${description} proof batch is malformed`); + } + const batch = batches[0]; + assertCompleteBatch(batch.rows, batch.count, limit, description); + return batch.rows; +} + +function configSlots(userId: string, role: string, groups: readonly GroupProjection[]) { + return [ + { principalType: PrincipalType.ROLE, principalId: BASE_CONFIG_PRINCIPAL_ID }, + { principalType: PrincipalType.ROLE, principalId: role }, + ...groups.map((group) => ({ + principalType: PrincipalType.GROUP, + principalId: group._id.toHexString(), + })), + { principalType: PrincipalType.USER, principalId: userId }, + ].sort((left, right) => + principalKey(left.principalType, left.principalId).localeCompare( + principalKey(right.principalType, right.principalId), + ), + ); +} + +function buildGroupProof(group: GroupProjection): MCPAuthorityGroupProof { + const id = group._id.toHexString(); + const sourceIdentityDigest = digestMCPAuthorityValue({ + source: group.source, + idOnTheSource: group.idOnTheSource ?? null, + }); + return { + id, + source: group.source, + sourceIdentityDigest, + revision: digestMCPAuthorityValue({ + id, + sourceIdentityDigest, + createdAt: group.createdAt ?? null, + updatedAt: group.updatedAt ?? null, + }), + }; +} + +function buildConfigProofs( + slots: ReturnType, + documents: readonly ConfigProjection[], +): readonly MCPAuthorityConfigProof[] { + const byPrincipal = new Map(); + const allowed = new Set(slots.map((slot) => principalKey(slot.principalType, slot.principalId))); + for (const document of documents) { + const key = principalKey(document.principalType, document.principalId.toString()); + const tombstones = document.tombstones; + if (!allowed.has(key) || byPrincipal.has(key)) { + reject('proof_unavailable', 'Applicable MCP configuration proof is malformed'); + } + if ( + typeof document.isActive !== 'boolean' || + !Number.isFinite(document.priority) || + !Number.isInteger(document.configVersion) || + document.configVersion < 0 || + !Array.isArray(tombstones) || + tombstones.some((path) => typeof path !== 'string') + ) { + reject('proof_unavailable', 'Applicable MCP configuration proof is malformed'); + } + byPrincipal.set(key, document); + } + return slots.map((slot): MCPAuthorityConfigProof => { + const document = byPrincipal.get(principalKey(slot.principalType, slot.principalId)); + if (!document) { + const absent = { + principalType: slot.principalType, + principalId: slot.principalId, + present: false, + active: false, + priority: null, + configVersion: null, + mcpOverrideDigest: null, + tombstones: [] as readonly string[], + }; + return { ...absent, revision: digestMCPAuthorityValue(absent) }; + } + const tombstones = (document.tombstones ?? []) + .filter((path) => path === 'mcpSettings' || path.startsWith('mcpSettings.')) + .sort(); + if (tombstones.length > MAX_MCP_AUTHORITY_CONFIG_TOMBSTONES) { + reject('proof_unavailable', 'Applicable MCP configuration tombstones exceed the proof limit'); + } + const mcpOverrideDigest = + document.mcpSettingsOverride === undefined + ? null + : digestMCPAuthorityValue(document.mcpSettingsOverride); + const present = { + principalType: slot.principalType, + principalId: slot.principalId, + present: true, + active: document.isActive, + priority: document.priority, + configVersion: document.configVersion, + mcpOverrideDigest, + tombstones, + documentId: document._id.toHexString(), + updatedAt: document.updatedAt ?? null, + }; + return { + principalType: present.principalType, + principalId: present.principalId, + present: present.present, + active: present.active, + priority: present.priority, + configVersion: present.configVersion, + mcpOverrideDigest: present.mcpOverrideDigest, + tombstones: present.tombstones, + revision: digestMCPAuthorityValue(present), + }; + }); +} + +function tokenMetadata(token: TokenProjection): Record { + if (!token.metadata) { + return {}; + } + return token.metadata instanceof Map + ? Object.fromEntries(token.metadata.entries()) + : { ...token.metadata }; +} + +function oauthTokenIdentities( + serverName: string, +): readonly { type: OAuthTokenType; identifier: string }[] { + const base = `mcp:${serverName}`; + return [ + { type: 'mcp_oauth', identifier: base }, + { type: 'mcp_oauth_refresh', identifier: `${base}:refresh` }, + { type: 'mcp_oauth_client', identifier: `${base}:client` }, + ]; +} + +function oauthTokenKey(type: string, identifier: string): string { + return JSON.stringify([type, identifier]); +} + +function isActiveViewAcl(entry: AclProjection, now: Date): boolean { + if (!Number.isInteger(entry.permBits) || entry.permBits < 0) { + reject('proof_unavailable', 'MCP ACL proof is malformed'); + } + return ( + (entry.permBits & PermissionBits.VIEW) === PermissionBits.VIEW && + (!entry.expiredAt || entry.expiredAt > now) + ); +} + +function aclRevision(entry: AclProjection): object { + return { + id: entry._id.toHexString(), + principalType: entry.principalType, + principalId: entry.principalId?.toString() ?? null, + resourceType: entry.resourceType, + resourceId: entry.resourceId.toHexString(), + permBits: entry.permBits, + expiredAt: entry.expiredAt ?? null, + updatedAt: entry.updatedAt ?? null, + }; +} + +function buildCredentialProof( + target: PreparedTarget, + credentials: readonly PluginAuthProjection[], +): { fields: readonly string[]; revision: string } { + const pluginKey = `${Constants.mcp_prefix}${target.serverName}`; + const selected = credentials.filter((credential) => credential.pluginKey === pluginKey); + return { + fields: target.credentialFields, + revision: createMCPAuthorityCredentialRevision(target.credentialFields, selected), + }; +} + +function buildOAuthProof( + target: PreparedTarget, + tokens: readonly TokenProjection[], + now: Date, +): { generation: string | null; revision: string } { + const identities = new Set( + oauthTokenIdentities(target.serverName).map(({ type, identifier }) => + oauthTokenKey(type, identifier), + ), + ); + const seen = new Set(); + const generations = new Set(); + const rows = tokens + .map((token) => { + const type = token.type ?? ''; + const identifier = token.identifier ?? ''; + const key = oauthTokenKey(type, identifier); + if ( + !OAUTH_TOKEN_TYPES.includes(type as (typeof OAUTH_TOKEN_TYPES)[number]) || + !identifier || + !identities.has(key) || + seen.has(key) + ) { + reject('proof_unavailable', 'MCP OAuth grant proof is malformed', target.serverName); + } + seen.add(key); + const metadata = tokenMetadata(token); + const generation = metadata.credential_set_id; + if (typeof generation !== 'string' || generation.length === 0) { + reject('proof_unavailable', 'MCP OAuth grant proof is malformed', target.serverName); + } + generations.add(generation); + return { + id: token._id.toHexString(), + type, + identifier, + generation, + tokenDigest: digestMCPAuthorityValue(token.token), + metadataDigest: digestMCPAuthorityValue(metadata), + createdAt: token.createdAt, + expiresAt: token.expiresAt, + active: token.expiresAt > now, + }; + }) + .sort((left, right) => + `${left.type}:${left.identifier}`.localeCompare(`${right.type}:${right.identifier}`), + ); + if (generations.size > 1) { + reject('proof_unavailable', 'MCP OAuth grant generations disagree', target.serverName); + } + const generation = generations.values().next().value ?? null; + return { + generation, + revision: digestMCPAuthorityValue({ requiresOAuth: target.requiresOAuth, rows }), + }; +} + +function asMCPError(error: unknown): MCPAuthorityProofError { + if (error instanceof MCPAuthorityProofError) { + return error; + } + return new MCPAuthorityProofError( + 'proof_unavailable', + 'Authoritative MCP proof data is unavailable', + ); +} + +export function createMCPAuthorityMethods( + mongoose: typeof import('mongoose'), + hooks: MCPAuthorityMethodHooks = {}, +): MCPAuthorityDatabaseMethods { + async function loadCurrentProof( + userId: string, + tenantId: string | undefined, + boot: MCPAuthorityBootRevision, + targets: readonly PreparedTarget[], + session: ClientSession, + ): Promise { + if (!Types.ObjectId.isValid(userId)) { + reject('malformed_input', 'MCP authority user identity is invalid'); + } + if (!boot.revision.trim() || !boot.digest.trim()) { + reject('malformed_input', 'MCP boot authority revision is malformed'); + } + const tenantScope = + tenantId === undefined ? { tenantId: { $exists: false as const } } : { tenantId }; + + const User = mongoose.models.User as Model; + const userQuery = User.findOne({ _id: new Types.ObjectId(userId), ...tenantScope }).select( + '_id tenantId role provider idOnTheSource openidIssuer googleId facebookId openidId samlId ldapId githubId discordId appleId createdAt updatedAt', + ); + const user = await pinAuthoritativeRead(userQuery, session).lean(); + if (!user) { + reject('user_revoked', 'MCP authority user no longer exists'); + } + const actualTenantId = user.tenantId ?? null; + if (actualTenantId !== (tenantId ?? null)) { + reject('principal_changed', 'MCP authority tenant identity changed'); + } + const roleName = user.role?.trim(); + const provider = user.provider?.trim(); + if (!roleName || !provider) { + reject('proof_unavailable', 'MCP authority principal data is malformed'); + } + const memberId = user.idOnTheSource || userId; + + const Group = mongoose.models.Group as Model; + const Role = mongoose.models.Role as Model; + const groupFilter = { memberIds: memberId, ...tenantScope }; + const groupCountQuery = Group.countDocuments(groupFilter).limit(MAX_MCP_AUTHORITY_GROUPS + 1); + const groupQuery = Group.find(groupFilter) + .select('_id source idOnTheSource createdAt updatedAt') + .limit(MAX_MCP_AUTHORITY_GROUPS + 1) + .setOptions({ singleBatch: true }); + const roleQuery = Role.findOne({ name: roleName, ...tenantScope }).select( + '_id name permissions.MCP_SERVERS.USE', + ); + const groupCount = await pinAuthoritativeRead(groupCountQuery, session); + const groups = await pinAuthoritativeRead(groupQuery, session).lean(); + const role = await pinAuthoritativeRead(roleQuery, session).lean(); + assertCompleteBatch(groups, groupCount, MAX_MCP_AUTHORITY_GROUPS, 'MCP group membership'); + if (!role || role.name !== roleName) { + reject('role_changed', 'MCP authority role no longer exists'); + } + const useMCP = role.permissions?.[PermissionTypes.MCP_SERVERS]?.[Permissions.USE] === true; + if (!useMCP) { + reject('mcp_use_revoked', 'MCP server use permission is not current'); + } + await hooks.afterPrincipalSnapshot?.(); + const sortedGroups = groups.sort((left, right) => + left._id.toHexString().localeCompare(right._id.toHexString()), + ); + const slots = configSlots(userId, roleName, sortedGroups); + const lookupNames = [ + ...new Set(targets.flatMap((target) => [target.serverName, target.normalizedServerName])), + ]; + const oauthSelectors = targets + .filter((target) => target.requiresOAuth) + .flatMap((target) => oauthTokenIdentities(target.serverName)); + + const Config = mongoose.models.Config as Model; + const MCPServer = mongoose.models.MCPServer as Model; + const Agent = mongoose.models.Agent as Model; + const PluginAuth = mongoose.models.PluginAuth as Model; + const Token = mongoose.models.Token as Model; + const selectedServerNames = targets.map((target) => target.serverName); + const selectedDatabaseServerNames = targets + .filter((target) => target.source === 'database') + .map((target) => target.serverName); + const tombstoneConditions = [ + { $eq: ['$$path', 'mcpSettings'] }, + { $eq: [{ $indexOfCP: ['$$path', 'mcpSettings.'] }, 0] }, + ]; + const configQuery = Config.aggregate>([ + { $match: { $and: [tenantScope, { $or: slots }] } }, + { + $project: { + _id: 1, + principalType: 1, + principalId: 1, + priority: 1, + isActive: 1, + configVersion: 1, + updatedAt: 1, + mcpSettingsOverride: '$overrides.mcpSettings', + tombstones: { + $slice: [ + { + $filter: { + input: { $ifNull: ['$tombstones', []] }, + as: 'path', + cond: { $or: tombstoneConditions }, + }, + }, + MAX_MCP_AUTHORITY_CONFIG_TOMBSTONES + 1, + ], + }, + }, + }, + { $limit: MAX_MCP_AUTHORITY_GROUPS + 4 }, + { $group: { _id: null, rows: { $push: '$$ROOT' }, count: { $sum: 1 } } }, + ]); + const normalizedNames = targets.map((target) => target.normalizedServerName); + const serverFilter = { + ...tenantScope, + $or: [ + { serverName: { $in: lookupNames } }, + { normalizedServerName: { $in: normalizedNames } }, + ], + }; + const serverCountQuery = MCPServer.countDocuments(serverFilter).limit( + MAX_MCP_AUTHORITY_TARGETS * 2 + 1, + ); + const serverQuery = MCPServer.find(serverFilter) + .select('_id serverName normalizedServerName config author createdAt updatedAt') + .limit(MAX_MCP_AUTHORITY_TARGETS * 2 + 1) + .setOptions({ singleBatch: true }); + const agentQuery = Agent.aggregate>([ + { + $match: { + mcpServerNames: { $in: selectedDatabaseServerNames }, + ...tenantScope, + }, + }, + { + $project: { + _id: 1, + id: 1, + createdAt: 1, + updatedAt: 1, + mcpServerNames: { + $filter: { + input: { $ifNull: ['$mcpServerNames', []] }, + as: 'serverName', + cond: { $in: ['$$serverName', selectedDatabaseServerNames] }, + }, + }, + }, + }, + { $limit: MAX_MCP_AUTHORITY_AGENTS + 1 }, + { $group: { _id: null, rows: { $push: '$$ROOT' }, count: { $sum: 1 } } }, + ]); + const credentialSelectors = targets + .filter((target) => target.credentialFields.length > 0) + .map((target) => ({ + pluginKey: `${Constants.mcp_prefix}${target.serverName}`, + authField: { $in: target.credentialFields }, + })); + const credentialFilter = { + userId, + ...tenantScope, + $or: credentialSelectors.length > 0 ? credentialSelectors : [{ _id: null }], + }; + const credentialCountQuery = PluginAuth.countDocuments(credentialFilter).limit( + MAX_MCP_AUTHORITY_CREDENTIALS + 1, + ); + const credentialQuery = PluginAuth.find(credentialFilter) + .select('_id pluginKey authField value createdAt updatedAt') + .limit(MAX_MCP_AUTHORITY_CREDENTIALS + 1) + .setOptions({ singleBatch: true }); + const tokenFilter = { + userId: new Types.ObjectId(userId), + ...tenantScope, + $or: oauthSelectors.length > 0 ? oauthSelectors : [{ _id: null }], + }; + const tokenCountQuery = Token.countDocuments(tokenFilter).limit( + MAX_MCP_AUTHORITY_TARGETS * OAUTH_TOKEN_TYPES.length + 1, + ); + const tokenQuery = Token.find(tokenFilter) + .select('_id type identifier token metadata createdAt expiresAt') + .limit(MAX_MCP_AUTHORITY_TARGETS * OAUTH_TOKEN_TYPES.length + 1) + .setOptions({ singleBatch: true }); + const configBatch = await pinAuthoritativeRead(configQuery, session); + const configs = unwrapAggregateBatch(configBatch, slots.length, 'Applicable MCP configuration'); + const serverCount = await pinAuthoritativeRead(serverCountQuery, session); + const servers = await pinAuthoritativeRead(serverQuery, session).lean(); + assertCompleteBatch( + servers, + serverCount, + MAX_MCP_AUTHORITY_TARGETS * 2, + 'Selected MCP servers', + ); + const agentBatch = await pinAuthoritativeRead(agentQuery, session); + const agents = unwrapAggregateBatch( + agentBatch, + MAX_MCP_AUTHORITY_AGENTS, + 'Selected MCP Agent linkage', + ); + const credentialCount = await pinAuthoritativeRead(credentialCountQuery, session); + const credentials = await pinAuthoritativeRead(credentialQuery, session).lean< + PluginAuthProjection[] + >(); + assertCompleteBatch( + credentials, + credentialCount, + MAX_MCP_AUTHORITY_CREDENTIALS, + 'Selected MCP credentials', + ); + const tokenCount = await pinAuthoritativeRead(tokenCountQuery, session); + const tokens = await pinAuthoritativeRead(tokenQuery, session).lean(); + assertCompleteBatch( + tokens, + tokenCount, + MAX_MCP_AUTHORITY_TARGETS * OAUTH_TOKEN_TYPES.length, + 'Selected MCP OAuth grants', + ); + + const serverIds = servers.map((server) => server._id); + const agentIds = agents.map((agent) => agent._id); + let aclEntries: AclProjection[] = []; + if (serverIds.length > 0 || agentIds.length > 0) { + const AclEntry = mongoose.models.AclEntry as Model; + const principals: RootFilterQuery[] = [ + { principalType: PrincipalType.USER, principalId: new Types.ObjectId(userId) }, + { principalType: PrincipalType.ROLE, principalId: roleName }, + { principalType: PrincipalType.PUBLIC }, + ...sortedGroups.map((group) => ({ + principalType: PrincipalType.GROUP, + principalId: group._id, + })), + ]; + const resources: RootFilterQuery[] = []; + if (serverIds.length > 0) { + resources.push({ resourceType: ResourceType.MCPSERVER, resourceId: { $in: serverIds } }); + } + if (agentIds.length > 0) { + resources.push({ resourceType: ResourceType.AGENT, resourceId: { $in: agentIds } }); + } + const aclFilter = { + $and: [tenantScope, { $or: principals }, { $or: resources }], + }; + const aclCountQuery = AclEntry.countDocuments(aclFilter).limit( + MAX_MCP_AUTHORITY_ACL_ENTRIES + 1, + ); + const aclQuery = AclEntry.find(aclFilter) + .select( + '_id principalType principalId resourceType resourceId permBits expiredAt updatedAt', + ) + .limit(MAX_MCP_AUTHORITY_ACL_ENTRIES + 1) + .setOptions({ singleBatch: true }); + const aclCount = await pinAuthoritativeRead(aclCountQuery, session); + aclEntries = await pinAuthoritativeRead(aclQuery, session).lean(); + assertCompleteBatch( + aclEntries, + aclCount, + MAX_MCP_AUTHORITY_ACL_ENTRIES, + 'Selected MCP ACL entries', + ); + } + + const serversByNormalizedName = new Map(); + for (const server of servers) { + if ( + typeof server.serverName !== 'string' || + typeof server.normalizedServerName !== 'string' || + server.normalizedServerName !== normalizeServerName(server.serverName) + ) { + reject('proof_unavailable', 'MCP server name index is malformed'); + } + appendIndexValue(serversByNormalizedName, server.normalizedServerName, server); + } + const selectedServerNameSet = new Set(selectedServerNames); + const agentsByServerName = new Map(); + for (const agent of agents) { + if ( + agent.mcpServerNames !== undefined && + (!Array.isArray(agent.mcpServerNames) || + agent.mcpServerNames.some((serverName) => typeof serverName !== 'string')) + ) { + reject('proof_unavailable', 'MCP Agent linkage proof is malformed'); + } + const selectedLinks = new Set( + (agent.mcpServerNames ?? []).filter((serverName) => selectedServerNameSet.has(serverName)), + ); + for (const serverName of selectedLinks) { + appendIndexValue(agentsByServerName, serverName, agent); + } + } + const credentialsByPluginKey = new Map(); + for (const credential of credentials) { + if (typeof credential.pluginKey !== 'string') { + reject('proof_unavailable', 'MCP credential proof is malformed'); + } + appendIndexValue(credentialsByPluginKey, credential.pluginKey, credential); + } + const serverNameByOAuthTokenKey = new Map(); + for (const selected of targets) { + for (const { type, identifier } of oauthTokenIdentities(selected.serverName)) { + serverNameByOAuthTokenKey.set(oauthTokenKey(type, identifier), selected.serverName); + } + } + const tokensByServerName = new Map(); + for (const token of tokens) { + const serverName = + token.type && token.identifier + ? serverNameByOAuthTokenKey.get(oauthTokenKey(token.type, token.identifier)) + : undefined; + if (!serverName) { + reject('proof_unavailable', 'MCP OAuth grant proof is malformed'); + } + appendIndexValue(tokensByServerName, serverName, token); + } + const directAclByServerId = new Map(); + const agentAclByAgentId = new Map(); + for (const entry of aclEntries) { + const resourceId = entry.resourceId.toHexString(); + if (entry.resourceType === ResourceType.MCPSERVER) { + appendIndexValue(directAclByServerId, resourceId, entry); + } else if (entry.resourceType === ResourceType.AGENT) { + appendIndexValue(agentAclByAgentId, resourceId, entry); + } else { + reject('proof_unavailable', 'MCP ACL proof is malformed'); + } + } + + const userSourceIdentityDigest = digestMCPAuthorityValue(sourceIdentity(user)); + const userProof = { + userId, + tenantId: actualTenantId, + role: roleName, + provider, + sourceIdentityDigest: userSourceIdentityDigest, + revision: digestMCPAuthorityValue({ + userId, + tenantId: actualTenantId, + role: roleName, + provider, + sourceIdentityDigest: userSourceIdentityDigest, + createdAt: user.createdAt ?? null, + updatedAt: user.updatedAt ?? null, + }), + }; + const groupProofs = sortedGroups.map(buildGroupProof); + const groupsRevision = digestMCPAuthorityValue(groupProofs); + const configProofs = buildConfigProofs(slots, configs); + const configsRevision = digestMCPAuthorityValue(configProofs); + const configSourceRevision = createMCPAuthorityConfigSourceRevision( + boot.digest, + configs.map((config) => ({ + _id: config._id, + principalType: config.principalType, + principalId: config.principalId, + priority: config.priority, + overrides: + config.mcpSettingsOverride === undefined + ? undefined + : { mcpSettings: config.mcpSettingsOverride }, + tombstones: config.tombstones, + isActive: config.isActive, + configVersion: config.configVersion, + updatedAt: config.updatedAt, + })), + ); + const roleProof = { + id: role._id.toHexString(), + name: role.name, + use: useMCP, + revision: digestMCPAuthorityValue({ + id: role._id.toHexString(), + name: role.name, + use: useMCP, + }), + }; + const sharedWithoutRevision = { + user: userProof, + groups: groupProofs, + configs: configProofs, + role: roleProof, + boot, + groupsRevision, + configsRevision, + }; + const shared = { + ...sharedWithoutRevision, + revision: digestMCPAuthorityValue(sharedWithoutRevision), + }; + const now = new Date(); + const serverProofs = targets.map((target): MCPAuthorityServerProofV1 => { + const collidingServers = serversByNormalizedName.get(target.normalizedServerName) ?? []; + if (collidingServers.length > 1) { + reject('server_changed', 'MCP server normalized identity is ambiguous', target.serverName); + } + const databaseServer = collidingServers[0]; + if (target.source === 'config' && databaseServer) { + reject( + 'server_changed', + 'MCP config server collides with a database server identity', + target.serverName, + ); + } + if ( + target.source === 'database' && + (!databaseServer || databaseServer.serverName !== target.serverName) + ) { + reject( + 'server_revoked', + 'Selected MCP database server no longer exists', + target.serverName, + ); + } + const databaseId = databaseServer?._id.toHexString() ?? null; + if (target.databaseId && target.databaseId !== databaseId) { + reject('server_changed', 'Selected MCP database identity changed', target.serverName); + } + const serverRevision = databaseServer + ? createMCPAuthorityDatabaseSourceRevision({ + databaseId: databaseServer._id.toHexString(), + serverName: databaseServer.serverName, + author: databaseServer.author.toString(), + config: databaseServer.config, + createdAt: databaseServer.createdAt, + updatedAt: databaseServer.updatedAt, + }) + : digestMCPAuthorityValue({ source: 'config', name: target.serverName }); + const sourceRevision = target.source === 'database' ? serverRevision : configSourceRevision; + if (target.sourceRevision !== sourceRevision) { + reject( + target.source === 'database' ? 'server_changed' : 'config_changed', + 'Selected MCP source changed before authority was resolved', + target.serverName, + ); + } + const linkedAgents = (agentsByServerName.get(target.serverName) ?? []).sort((left, right) => + left._id.toHexString().localeCompare(right._id.toHexString()), + ); + const linkedAgentIds = linkedAgents.map((agent) => agent._id.toHexString()); + const directAcl = databaseId ? (directAclByServerId.get(databaseId) ?? []) : []; + const agentAcl = linkedAgentIds.flatMap((id) => agentAclByAgentId.get(id) ?? []); + const directAccess = directAcl.some((entry) => isActiveViewAcl(entry, now)); + const agentAccess = agentAcl.some((entry) => isActiveViewAcl(entry, now)); + if (target.source === 'database' && !directAccess && !agentAccess) { + reject('access_revoked', 'Selected MCP server access is not current', target.serverName); + } + const authorizationRevision = digestMCPAuthorityValue({ + linkedAgents: linkedAgents.map((agent) => ({ + id: agent._id.toHexString(), + agentId: agent.id, + serverNames: [...(agent.mcpServerNames ?? [])].sort(), + createdAt: agent.createdAt ?? null, + updatedAt: agent.updatedAt ?? null, + })), + directAcl: directAcl + .map(aclRevision) + .sort((left, right) => + String((left as { id: string }).id).localeCompare((right as { id: string }).id), + ), + agentAcl: agentAcl + .map(aclRevision) + .sort((left, right) => + String((left as { id: string }).id).localeCompare((right as { id: string }).id), + ), + directAccess, + agentAccess, + }); + const credential = buildCredentialProof( + target, + credentialsByPluginKey.get(`${Constants.mcp_prefix}${target.serverName}`) ?? [], + ); + const oauth = buildOAuthProof(target, tokensByServerName.get(target.serverName) ?? [], now); + if (credential.revision !== target.expectedCredentialRevision) { + reject( + 'credential_changed', + 'Selected MCP credential changed before authority was resolved', + target.serverName, + ); + } + if (oauth.generation !== target.expectedOAuthGrantGeneration) { + reject( + 'oauth_grant_changed', + 'Selected MCP OAuth grant changed before authority was resolved', + target.serverName, + ); + } + const effectivePolicyDigest = digestMCPAuthorityValue({ + sharedRevision: shared.revision, + serverRevision, + sourceRevision, + resolvedConfigDigest: target.resolvedConfigDigest, + authorizationRevision, + credentialRevision: credential.revision, + oauthRevision: oauth.revision, + }); + const proofWithoutRevision = { + serverName: target.serverName, + normalizedServerName: target.normalizedServerName, + source: target.source, + databaseId, + sourceRevision, + resolvedConfigDigest: target.resolvedConfigDigest, + serverRevision, + linkedAgentIds, + directAccess, + agentAccess, + authorizationRevision, + credentialFields: credential.fields, + credentialRevision: credential.revision, + requiresOAuth: target.requiresOAuth, + oauthGrantGeneration: oauth.generation, + oauthRevision: oauth.revision, + effectivePolicyDigest, + }; + return { + ...proofWithoutRevision, + revision: digestMCPAuthorityValue(proofWithoutRevision), + }; + }); + const proofWithoutRevision = { + version: MCP_AUTHORITY_PROOF_VERSION, + shared, + servers: serverProofs, + }; + return deepFreeze({ + ...proofWithoutRevision, + revision: digestMCPAuthorityValue(proofWithoutRevision), + }); + } + + async function loadAuthoritativeSnapshot( + userId: string, + tenantId: string | undefined, + boot: MCPAuthorityBootRevision, + targets: readonly PreparedTarget[], + suppliedSession?: ClientSession, + ): Promise { + if ((getTenantId() ?? null) !== (tenantId ?? null)) { + reject('proof_unavailable', 'MCP authority tenant context does not match its principal'); + } + if (suppliedSession?.inTransaction()) { + reject('proof_unavailable', 'MCP authority reads cannot use a caller transaction snapshot'); + } + const session = suppliedSession ?? (await mongoose.startSession()); + const ownsSession = suppliedSession == null; + try { + session.startTransaction({ + readPreference: 'primary', + readConcern: { level: 'snapshot' }, + writeConcern: { w: 'majority' }, + }); + const proof = await loadCurrentProof(userId, tenantId, boot, targets, session); + await session.commitTransaction(); + return proof; + } catch (error) { + if (session.inTransaction()) { + try { + await session.abortTransaction(); + } catch { + /** The authoritative read already failed closed. */ + } + } + throw error; + } finally { + if (ownsSession) { + await session.endSession(); + } + } + } + + async function resolveMCPAuthorityProof( + input: MCPAuthorityResolveInput, + ): Promise { + try { + const targets = prepareTargets(input.targets); + return await loadAuthoritativeSnapshot( + input.userId, + input.tenantId, + input.boot, + targets, + input.session, + ); + } catch (error) { + throw asMCPError(error); + } + } + + function assertionTargets(proofs: readonly MCPAuthorityProofV1[]): readonly PreparedTarget[] { + const targets = new Map(); + const normalizedTargets = new Map(); + for (const proof of proofs) { + for (const server of proof.servers) { + const current = targets.get(server.serverName); + if (current && current.revision !== server.revision) { + reject('malformed_input', 'MCP authority proofs disagree about a selected server'); + } + const normalizedTarget = normalizedTargets.get(server.normalizedServerName); + if (normalizedTarget && normalizedTarget !== server.serverName) { + reject('malformed_input', 'MCP authority proofs contain ambiguous server identities'); + } + normalizedTargets.set(server.normalizedServerName, server.serverName); + targets.set(server.serverName, { + serverName: server.serverName, + normalizedServerName: server.normalizedServerName, + source: server.source, + databaseId: server.databaseId, + sourceRevision: server.sourceRevision, + expectedCredentialRevision: server.credentialRevision, + expectedOAuthGrantGeneration: server.oauthGrantGeneration, + resolvedConfigDigest: server.resolvedConfigDigest, + credentialFields: server.credentialFields, + requiresOAuth: server.requiresOAuth, + revision: server.revision, + }); + } + } + const prepared = [...targets.values()] + .map(({ revision: _revision, ...target }) => target) + .sort((left, right) => left.serverName.localeCompare(right.serverName)); + assertPreparedTargetBounds(prepared); + return prepared; + } + + function assertSameSharedProofs(proofs: readonly MCPAuthorityProofV1[]): void { + const first = proofs[0]; + for (let index = 1; index < proofs.length; index++) { + const proof = proofs[index]; + if ( + proof.shared.revision !== first.shared.revision || + proof.shared.user.userId !== first.shared.user.userId || + proof.shared.user.tenantId !== first.shared.user.tenantId + ) { + reject('malformed_input', 'MCP authority proofs do not share one principal snapshot'); + } + } + } + + function compareProofs( + expectedProofs: readonly MCPAuthorityProofV1[], + current: MCPAuthorityProofV1, + ): void { + const expectedShared = expectedProofs[0].shared; + if (current.shared.user.revision !== expectedShared.user.revision) { + reject('principal_changed', 'MCP authority principal identity changed'); + } + if (current.shared.groupsRevision !== expectedShared.groupsRevision) { + reject('groups_changed', 'MCP authority group membership changed'); + } + if (current.shared.configsRevision !== expectedShared.configsRevision) { + reject('config_changed', 'MCP authority configuration changed'); + } + if (!current.shared.role.use) { + reject('mcp_use_revoked', 'MCP server use permission was revoked'); + } + if (current.shared.role.revision !== expectedShared.role.revision) { + reject('role_changed', 'MCP authority role changed'); + } + if (current.shared.revision !== expectedShared.revision) { + reject('principal_changed', 'MCP authority shared snapshot changed'); + } + const expectedServers = new Map( + expectedProofs.flatMap((proof) => proof.servers).map((server) => [server.serverName, server]), + ); + for (const server of current.servers) { + const expected = expectedServers.get(server.serverName); + if (!expected) { + reject('malformed_input', 'MCP authority assertion contains an unexpected server'); + } + if ( + server.normalizedServerName !== expected.normalizedServerName || + server.source !== expected.source || + server.databaseId !== expected.databaseId || + server.sourceRevision !== expected.sourceRevision || + server.serverRevision !== expected.serverRevision || + server.resolvedConfigDigest !== expected.resolvedConfigDigest + ) { + reject('server_changed', 'Selected MCP server configuration changed', server.serverName); + } + if (server.source === 'database' && !server.directAccess && !server.agentAccess) { + reject('access_revoked', 'Selected MCP server access was revoked', server.serverName); + } + if (server.authorizationRevision !== expected.authorizationRevision) { + reject( + 'authorization_changed', + 'Selected MCP server authorization changed', + server.serverName, + ); + } + if (server.credentialRevision !== expected.credentialRevision) { + reject('credential_changed', 'Selected MCP credential changed', server.serverName); + } + if (server.oauthRevision !== expected.oauthRevision) { + reject('oauth_grant_changed', 'Selected MCP OAuth grant changed', server.serverName); + } + if (server.effectivePolicyDigest !== expected.effectivePolicyDigest) { + reject('server_changed', 'Selected MCP effective policy changed', server.serverName); + } + if (server.revision !== expected.revision) { + reject('server_changed', 'Selected MCP authority proof changed', server.serverName); + } + } + } + + async function assertMCPAuthorityProofsCurrent(input: MCPAuthorityAssertInput): Promise { + try { + const proofs = Array.isArray(input.proofs) ? input.proofs : [input.proofs]; + if (proofs.length === 0 || proofs.length > MAX_MCP_AUTHORITY_TARGETS) { + reject('malformed_input', 'MCP authority proof composition exceeds its bounded shape'); + } + proofs.forEach(assertProofIntegrity); + assertSameSharedProofs(proofs); + const expectedShared = proofs[0].shared; + if ( + expectedShared.boot.revision !== input.boot.revision || + expectedShared.boot.digest !== input.boot.digest + ) { + reject('boot_revision_changed', 'Immutable MCP boot configuration changed'); + } + const targets = assertionTargets(proofs); + const current = await loadAuthoritativeSnapshot( + expectedShared.user.userId, + expectedShared.user.tenantId ?? undefined, + input.boot, + targets, + input.session, + ); + compareProofs(proofs, current); + } catch (error) { + throw asMCPError(error); + } + } + + return { + resolveMCPAuthorityProof, + assertMCPAuthorityProofsCurrent, + }; +} diff --git a/packages/data-schemas/src/methods/mcpServer.ts b/packages/data-schemas/src/methods/mcpServer.ts index f6d8868f62..3b3986e853 100644 --- a/packages/data-schemas/src/methods/mcpServer.ts +++ b/packages/data-schemas/src/methods/mcpServer.ts @@ -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, }); diff --git a/packages/data-schemas/src/migrations/index.ts b/packages/data-schemas/src/migrations/index.ts index eda2293cb0..e26cb521c7 100644 --- a/packages/data-schemas/src/migrations/index.ts +++ b/packages/data-schemas/src/migrations/index.ts @@ -1,2 +1,4 @@ export { dropSupersededTenantIndexes } from './tenantIndexes'; export { dropSupersededPromptGroupIndexes } from './promptGroupIndexes'; +export { createMCPAuthorityLookupIndexes } from './mcpAuthorityIndexes'; +export { MCPServerNameMigrationError, backfillMCPServerNormalizedNames } from './mcpServerNames'; diff --git a/packages/data-schemas/src/migrations/mcpAuthorityIndexes.spec.ts b/packages/data-schemas/src/migrations/mcpAuthorityIndexes.spec.ts new file mode 100644 index 0000000000..be10d0ea38 --- /dev/null +++ b/packages/data-schemas/src/migrations/mcpAuthorityIndexes.spec.ts @@ -0,0 +1,40 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { createMCPAuthorityLookupIndexes } from './mcpAuthorityIndexes'; + +let mongoServer: InstanceType; + +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); + }), + ); +}); diff --git a/packages/data-schemas/src/migrations/mcpAuthorityIndexes.ts b/packages/data-schemas/src/migrations/mcpAuthorityIndexes.ts new file mode 100644 index 0000000000..4062dc691c --- /dev/null +++ b/packages/data-schemas/src/migrations/mcpAuthorityIndexes.ts @@ -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 { + 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; +} diff --git a/packages/data-schemas/src/migrations/mcpServerNames.spec.ts b/packages/data-schemas/src/migrations/mcpServerNames.spec.ts new file mode 100644 index 0000000000..d3c3fdc839 --- /dev/null +++ b/packages/data-schemas/src/migrations/mcpServerNames.spec.ts @@ -0,0 +1,90 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { MCPServerNameMigrationError, backfillMCPServerNormalizedNames } from './mcpServerNames'; + +let mongoServer: InstanceType; + +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(); + } + }); +}); diff --git a/packages/data-schemas/src/migrations/mcpServerNames.ts b/packages/data-schemas/src/migrations/mcpServerNames.ts new file mode 100644 index 0000000000..8be0f51b45 --- /dev/null +++ b/packages/data-schemas/src/migrations/mcpServerNames.ts @@ -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 { + const collection = connection.db!.collection('mcpservers'); + const identities = new Map(); + 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 }; +} diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index 4d7d217015..4ea93acba7 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -116,7 +116,6 @@ const agentSchema: Schema = new Schema( 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 = new Schema( ); 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 }); diff --git a/packages/data-schemas/src/schema/group.ts b/packages/data-schemas/src/schema/group.ts index 73d8928233..bb19e4aa81 100644 --- a/packages/data-schemas/src/schema/group.ts +++ b/packages/data-schemas/src/schema/group.ts @@ -56,6 +56,6 @@ groupSchema.index( partialFilterExpression: { idOnTheSource: { $exists: true } }, }, ); -groupSchema.index({ memberIds: 1 }); +groupSchema.index({ memberIds: 1, tenantId: 1 }); export default groupSchema; diff --git a/packages/data-schemas/src/schema/mcpServer.ts b/packages/data-schemas/src/schema/mcpServer.ts index 9a0d63bff8..70aea67a7d 100644 --- a/packages/data-schemas/src/schema/mcpServer.ts +++ b/packages/data-schemas/src/schema/mcpServer.ts @@ -1,4 +1,5 @@ import { Schema } from 'mongoose'; +import { normalizeServerName } from 'librechat-data-provider'; import type { MCPServerDocument } from '~/types'; const mcpServerSchema: Schema = new Schema( @@ -8,6 +9,10 @@ const mcpServerSchema: Schema = new Schema index: true, required: true, }, + normalizedServerName: { + type: String, + required: true, + }, config: { type: Schema.Types.Mixed, required: true, @@ -29,7 +34,18 @@ const mcpServerSchema: Schema = new Schema }, ); +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; diff --git a/packages/data-schemas/src/schema/pluginAuth.ts b/packages/data-schemas/src/schema/pluginAuth.ts index e278e63d45..b32fce22f8 100644 --- a/packages/data-schemas/src/schema/pluginAuth.ts +++ b/packages/data-schemas/src/schema/pluginAuth.ts @@ -26,4 +26,6 @@ const pluginAuthSchema: Schema = new Schema( { timestamps: true }, ); +pluginAuthSchema.index({ userId: 1, pluginKey: 1, authField: 1, tenantId: 1 }); + export default pluginAuthSchema; diff --git a/packages/data-schemas/src/schema/token.ts b/packages/data-schemas/src/schema/token.ts index dae2118e64..44410036d9 100644 --- a/packages/data-schemas/src/schema/token.ts +++ b/packages/data-schemas/src/schema/token.ts @@ -40,5 +40,6 @@ const tokenSchema: Schema = new Schema({ }); tokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); +tokenSchema.index({ userId: 1, type: 1, identifier: 1, tenantId: 1 }); export default tokenSchema; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 878c093a98..7a96064f87 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -43,3 +43,4 @@ export * from './admin'; export * from './web'; /* MCP Servers */ export * from './mcp'; +export * from './mcpAuthority'; diff --git a/packages/data-schemas/src/types/mcp.ts b/packages/data-schemas/src/types/mcp.ts index 560d535737..23b1805c9e 100644 --- a/packages/data-schemas/src/types/mcp.ts +++ b/packages/data-schemas/src/types/mcp.ts @@ -8,6 +8,7 @@ import type { MCPServerDB } from 'librechat-data-provider'; export interface MCPServerDocument extends Omit, Document { + normalizedServerName: string; author: Types.ObjectId; // ObjectId reference in DB (vs string in API) tenantId?: string; } diff --git a/packages/data-schemas/src/types/mcpAuthority.ts b/packages/data-schemas/src/types/mcpAuthority.ts new file mode 100644 index 0000000000..e3571f63d4 --- /dev/null +++ b/packages/data-schemas/src/types/mcpAuthority.ts @@ -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; + assertMCPAuthorityProofsCurrent(input: MCPAuthorityAssertInput): Promise; +} + +export type MCPAuthorityImmutableConfig = Readonly< + Pick +>; + +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';