Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions api-extractor/reports/mongodb-mcp-server.public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { MetricDefinitions } from '@mongodb-js/mcp-metrics';
import { Metrics } from '@mongodb-js/mcp-metrics';
import type { MongoLogId } from 'mongodb-log-writer';
import type { NextFunction } from 'express';
import { NodeDriverServiceProvider } from '@mongosh/service-provider-node-driver';
import * as oauth from 'oauth4webapi';
import type { operations } from './openapi.js';
import { PrometheusMetrics } from '@mongodb-js/mcp-metrics';
import { PrometheusMetricsOptions } from '@mongodb-js/mcp-metrics';
import { Registry } from '@mongodb-js/mcp-metrics';
import type { Request as Request_2 } from 'express';
import type { Response as Response_2 } from 'express';
import { Secret } from 'mongodb-redact';
import type { SessionCloseReason } from '@mongodb-js/mcp-types';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
Expand Down Expand Up @@ -211,6 +215,14 @@ export function applyConfigOverrides<TUserConfig extends UserConfig = UserConfig
request?: RequestContext_2;
}): TUserConfig;

// @public
export interface AuthContext {
audience: string[];
issuer: string;
scopes: string[];
sub: string;
}

// @public
export interface AuthProvider {
// (undocumented)
Expand Down Expand Up @@ -424,6 +436,9 @@ export type CreateMcpHttpServerFn<TUserConfig extends UserConfig = UserConfig, T
// @public
export type CreateMonitoringServerFn<TMetrics extends DefaultMetrics = DefaultMetrics> = (args: MonitoringServerConstructorArgs<TMetrics>) => MonitoringServer<TMetrics> | undefined;

// @public
export function createOAuthMiddleware(input: OAuthMiddlewareOptions): (req: Request_2, res: Response_2, next: NextFunction) => Promise<void>;

// @public
export type CreateSessionConfigFn<TUserConfig extends UserConfig = UserConfig> = (context: {
userConfig: TUserConfig;
Expand Down Expand Up @@ -586,6 +601,9 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {

export { Gauge }

// @public
export function getAuthContext(req: Request_2): AuthContext | undefined;

export { Histogram }

// @public
Expand Down Expand Up @@ -625,6 +643,16 @@ export const JSON_RPC_ERROR_CODE_SESSION_ID_REQUIRED = -32001;
// @public
export const JSON_RPC_ERROR_CODE_SESSION_NOT_FOUND = -32003;

// @public
export class JwksCache {
constructor(input: {
ttlMs: number;
logger: LoggerBase;
});
clear(): void;
getAuthorizationServer(issuer: string): Promise<oauth.AuthorizationServer>;
}

// @public
export class Keychain {
constructor();
Expand Down Expand Up @@ -785,6 +813,14 @@ export class NullLogger extends LoggerBase {
protected type?: LoggerType;
}

// @public (undocumented)
export interface OAuthMiddlewareOptions {
audience: string;
issuer: string;
jwksCache: JwksCache;
logger: LoggerBase;
}

// @public (undocumented)
export type OIDCConnectionAuthType = "oidc-auth-flow" | "oidc-device-flow";

Expand Down Expand Up @@ -1213,6 +1249,9 @@ export const UserConfigSchema: z.ZodObject<{
httpHost: z.ZodDefault<z.ZodString>;
httpHeaders: z.ZodDefault<z.ZodObject<{}, z.core.$loose>>;
httpBodyLimit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
oauthIssuer: z.ZodOptional<z.ZodString>;
oauthAudience: z.ZodOptional<z.ZodString>;
oauthJwksCacheTtlMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
idleTimeoutMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
notificationTimeoutMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
maxBytesPerQuery: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
Expand Down
39 changes: 39 additions & 0 deletions api-extractor/reports/web.public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { MetricDefinitions } from '@mongodb-js/mcp-metrics';
import { Metrics } from '@mongodb-js/mcp-metrics';
import type { MongoLogId } from 'mongodb-log-writer';
import type { NextFunction } from 'express';
import { NodeDriverServiceProvider } from '@mongosh/service-provider-node-driver';
import * as oauth from 'oauth4webapi';
import type { operations } from './openapi.js';
import type { Request as Request_2 } from 'express';
import type { Response as Response_2 } from 'express';
import { Secret } from 'mongodb-redact';
import type { TelemetryEvents } from '@mongodb-js/mcp-types';
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
Expand Down Expand Up @@ -228,6 +232,14 @@ export type AtlasMetadata = {
org_id?: string;
};

// @public
export interface AuthContext {
audience: string[];
issuer: string;
scopes: string[];
sub: string;
}

// @public
export interface AuthProvider {
// (undocumented)
Expand Down Expand Up @@ -453,6 +465,9 @@ export type ConnectionTag = "connected" | "connecting" | "disconnected" | "error

export { createDefaultMetrics }

// @public
export function createOAuthMiddleware(input: OAuthMiddlewareOptions): (req: Request_2, res: Response_2, next: NextFunction) => Promise<void>;

// @public
export type CreateSessionConfigFn<TUserConfig extends UserConfig = UserConfig> = (context: {
userConfig: TUserConfig;
Expand Down Expand Up @@ -594,6 +609,9 @@ export type ExportsManagerEvents = {
"export-available": [string];
};

// @public
export function getAuthContext(req: Request_2): AuthContext | undefined;

// @public
export function getRandomUUID(): string;

Expand All @@ -612,6 +630,16 @@ export const jsonExportFormat: z.ZodEnum<{
canonical: "canonical";
}>;

// @public
export class JwksCache {
constructor(input: {
ttlMs: number;
logger: LoggerBase;
});
clear(): void;
getAuthorizationServer(issuer: string): Promise<oauth.AuthorizationServer>;
}

// @public
export class Keychain {
constructor();
Expand Down Expand Up @@ -691,6 +719,14 @@ export class MongoDBError<ErrorCode extends ErrorCodes = ErrorCodes> extends Err
code: ErrorCode;
}

// @public (undocumented)
export interface OAuthMiddlewareOptions {
audience: string;
issuer: string;
jwksCache: JwksCache;
logger: LoggerBase;
}

// @public (undocumented)
export type OIDCConnectionAuthType = "oidc-auth-flow" | "oidc-device-flow";

Expand Down Expand Up @@ -1132,6 +1168,9 @@ export const UserConfigSchema: z.ZodObject<{
httpHost: z.ZodDefault<z.ZodString>;
httpHeaders: z.ZodDefault<z.ZodObject<{}, z.core.$loose>>;
httpBodyLimit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
oauthIssuer: z.ZodOptional<z.ZodString>;
oauthAudience: z.ZodOptional<z.ZodString>;
oauthJwksCacheTtlMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
idleTimeoutMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
notificationTimeoutMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
maxBytesPerQuery: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
Expand Down
24 changes: 24 additions & 0 deletions src/common/config/userConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,30 @@ const ServerConfigSchema = z.object({
"Maximum size of the HTTP request body in bytes (only used when transport is 'http'). This value is passed as the optional limit parameter to the Express.js json() middleware."
)
.register(configRegistry, { overrideBehavior: "not-allowed" }),
oauthIssuer: z
.string()
.url()
.optional()
.describe(
"OAuth/OIDC issuer URL. When set, the HTTP transport requires Authorization: Bearer <jwt> on every request; the token must be issued by this issuer and have the configured oauthAudience claim. Required if httpHost is non-loopback."
)
.register(configRegistry, { overrideBehavior: "not-allowed" }),
oauthAudience: z
.string()
.optional()
.describe(
"Expected `aud` claim on incoming bearer tokens. Required when oauthIssuer is set. Tokens whose `aud` does not include this value are rejected with 401."
)
.register(configRegistry, { overrideBehavior: "not-allowed" }),
oauthJwksCacheTtlMs: z.coerce
.number()
.int()
.min(1_000, "Invalid oauthJwksCacheTtlMs: must be at least 1000")
.default(600_000)
.describe(
"How long (in ms) to cache the OIDC issuer's JWKS before re-fetching. Default 10 minutes. Lower values reduce key-rotation latency at the cost of more outbound requests to the issuer."
)
.register(configRegistry, { overrideBehavior: "not-allowed" }),
idleTimeoutMs: z.coerce
.number()
.default(600_000)
Expand Down
7 changes: 7 additions & 0 deletions src/common/logging/loggingDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export const LogId = {
toolDisabled: mongoLogId(1_003_003),
toolMetadataChange: mongoLogId(1_003_004),

httpOAuthDisabled: mongoLogId(1_006_200),
httpOAuthEnabled: mongoLogId(1_006_201),
httpOAuthMissingToken: mongoLogId(1_006_202),
httpOAuthInvalidToken: mongoLogId(1_006_203),
httpOAuthJwksFetchFailure: mongoLogId(1_006_204),
httpOAuthDiscoveryFailure: mongoLogId(1_006_205),

mongodbConnectFailure: mongoLogId(1_004_001),
mongodbDisconnectFailure: mongoLogId(1_004_002),
mongodbConnectTry: mongoLogId(1_004_003),
Expand Down
7 changes: 7 additions & 0 deletions src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ export { EventCache } from "./telemetry/eventCache.js";
export { Keychain, registerGlobalSecretToRedact } from "./common/keychain.js";
export type { Secret } from "./common/keychain.js";
export { Elicitation } from "./elicitation.js";
export {
JwksCache,
createOAuthMiddleware,
getAuthContext,
type AuthContext,
type OAuthMiddlewareOptions,
} from "./transports/auth/index.js";
export { applyConfigOverrides, ConfigOverrideError } from "./common/config/configOverrides.js";
export {
SessionStore,
Expand Down
7 changes: 7 additions & 0 deletions src/transports/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { JwksCache } from "./jwksCache.js";
export {
createOAuthMiddleware,
getAuthContext,
type AuthContext,
type OAuthMiddlewareOptions,
} from "./oauthMiddleware.js";
76 changes: 76 additions & 0 deletions src/transports/auth/jwksCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { LRUCache } from "lru-cache";
import * as oauth from "oauth4webapi";
import { createFetch } from "@mongodb-js/devtools-proxy-support";
import type { LoggerBase } from "../../common/logging/index.js";
import { LogId } from "../../common/logging/index.js";

/**
* A small wrapper around oauth4webapi's discovery + JWKS fetch that caches
* the resolved AuthorizationServer metadata for a configurable TTL.
*
* The OIDC discovery document and the JWKS itself rarely change between
* key-rotation events, so caching them per-issuer trades small amounts of
* staleness for a large reduction in outbound traffic and per-request
* latency on every authenticated MCP call.
*
* The cache key is the issuer URL. Failures are NOT cached; an issuer
* temporarily unreachable will be retried on the next request.
*/
export class JwksCache {
private readonly cache: LRUCache<string, oauth.AuthorizationServer>;
private readonly customFetch: typeof fetch;
private readonly logger: LoggerBase;

constructor({ ttlMs, logger }: { ttlMs: number; logger: LoggerBase }) {
this.cache = new LRUCache({ max: 8, ttl: ttlMs });
this.logger = logger;
this.customFetch = createFetch({ useEnvironmentVariableProxies: true }) as unknown as typeof fetch;
}

/**
* Resolve the AuthorizationServer metadata for the given issuer URL,
* fetching the discovery document on cache miss. Returned object is
* suitable for passing to oauth4webapi's verification helpers.
*/
public async getAuthorizationServer(issuer: string): Promise<oauth.AuthorizationServer> {
const cached = this.cache.get(issuer);
if (cached) {
return cached;
}

const issuerUrl = new URL(issuer);
let response: Response;
try {
response = await oauth.discoveryRequest(issuerUrl, {
[oauth.customFetch]: this.customFetch,
});
} catch (cause) {
this.logger.error({
id: LogId.httpOAuthDiscoveryFailure,
context: "oauthMiddleware",
message: `Failed to fetch OIDC discovery document from ${issuer}: ${(cause as Error).message}`,
});
throw cause;
}

let server: oauth.AuthorizationServer;
try {
server = await oauth.processDiscoveryResponse(issuerUrl, response);
} catch (cause) {
this.logger.error({
id: LogId.httpOAuthDiscoveryFailure,
context: "oauthMiddleware",
message: `Invalid OIDC discovery document from ${issuer}: ${(cause as Error).message}`,
});
throw cause;
}

this.cache.set(issuer, server);
return server;
}

/** Clear all cached issuer metadata. Useful after key-rotation alarms. */
public clear(): void {
this.cache.clear();
}
}
Loading