mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-09 17:31:19 +00:00
🏟️ fix: Restrict MCP OAuth Audience in User-Managed Configs (#13418)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
This commit is contained in:
parent
6db059b8a9
commit
5bfef51ed2
4 changed files with 281 additions and 92 deletions
|
|
@ -1314,6 +1314,34 @@ describe('ServerConfigsDB', () => {
|
|||
expect(typeof created.config.updatedAt).toBe('number');
|
||||
expect(created.config.updatedAt).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it('should strip admin-only OAuth audience fields from existing DB-backed user configs', async () => {
|
||||
const config = createSSEConfig('Legacy Audience Test', undefined, {
|
||||
client_id: 'public-client-id',
|
||||
});
|
||||
const created = await serverConfigsDB.add('temp', config, userId);
|
||||
|
||||
await mongoose.models.MCPServer.updateOne(
|
||||
{ serverName: created.serverName },
|
||||
{
|
||||
$set: {
|
||||
'config.oauth.audience': 'https://api.example.com',
|
||||
'config.oauth.forward_audience_on_refresh': false,
|
||||
'config.oauth.authorization_url':
|
||||
'https://auth.example.com/authorize?audience=https%3A%2F%2Fapi.example.com&client=ok',
|
||||
'config.oauth.token_url':
|
||||
'https://auth.example.com/token?resource=https%3A%2F%2Fapi.example.com&foo=bar',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await serverConfigsDB.get(created.serverName, userId);
|
||||
expect(result?.oauth?.client_id).toBe('public-client-id');
|
||||
expect(result?.oauth?.authorization_url).toBe('https://auth.example.com/authorize?client=ok');
|
||||
expect(result?.oauth?.token_url).toBe('https://auth.example.com/token?foo=bar');
|
||||
expect(result?.oauth?.audience).toBeUndefined();
|
||||
expect(result?.oauth?.forward_audience_on_refresh).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ const DANGEROUS_CREDENTIAL_PATTERNS = [
|
|||
/\{\{LIBRECHAT_BODY_[^}]+\}\}/g,
|
||||
];
|
||||
|
||||
const BLOCKED_USER_OAUTH_ENDPOINT_PARAMS = ['audience', 'resource'] as const;
|
||||
|
||||
/**
|
||||
* Sanitizes headers by removing dangerous credential placeholders.
|
||||
* This prevents credential exfiltration when MCP servers are shared between users.
|
||||
|
|
@ -52,6 +54,44 @@ function sanitizeCredentialPlaceholders(
|
|||
return sanitized;
|
||||
}
|
||||
|
||||
function stripBlockedOAuthEndpointParams(url?: string): string | undefined {
|
||||
if (!url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
BLOCKED_USER_OAUTH_ENDPOINT_PARAMS.forEach((param) => parsed.searchParams.delete(param));
|
||||
return parsed.href;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeUserManagedOAuthConfig(config: ParsedServerConfig): ParsedServerConfig {
|
||||
if (!config.oauth) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const {
|
||||
audience: _audience,
|
||||
forward_audience_on_refresh: _forwardAudienceOnRefresh,
|
||||
...oauth
|
||||
} = config.oauth;
|
||||
return {
|
||||
...config,
|
||||
oauth: {
|
||||
...oauth,
|
||||
...(config.oauth.authorization_url && {
|
||||
authorization_url: stripBlockedOAuthEndpointParams(config.oauth.authorization_url),
|
||||
}),
|
||||
...(config.oauth.token_url && {
|
||||
token_url: stripBlockedOAuthEndpointParams(config.oauth.token_url),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DB backed config storage
|
||||
* Handles CRUD Methods of dynamic mcp servers
|
||||
|
|
@ -126,12 +166,12 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
|
|||
);
|
||||
}
|
||||
|
||||
const sanitizedConfig = {
|
||||
const sanitizedConfig = sanitizeUserManagedOAuthConfig({
|
||||
...config,
|
||||
headers: sanitizeCredentialPlaceholders(
|
||||
(config as ParsedServerConfig & { headers?: Record<string, string> }).headers,
|
||||
),
|
||||
} as ParsedServerConfig;
|
||||
} as ParsedServerConfig);
|
||||
|
||||
/** Transformed user-provided API key config (adds customUserVars and headers) */
|
||||
const transformedConfig = this.transformUserApiKeyConfig(sanitizedConfig);
|
||||
|
|
@ -175,12 +215,12 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
|
|||
|
||||
const existingServer = await this._dbMethods.findMCPServerByServerName(serverName);
|
||||
|
||||
let configToSave: ParsedServerConfig = {
|
||||
let configToSave: ParsedServerConfig = sanitizeUserManagedOAuthConfig({
|
||||
...config,
|
||||
headers: sanitizeCredentialPlaceholders(
|
||||
(config as ParsedServerConfig & { headers?: Record<string, string> }).headers,
|
||||
),
|
||||
} as ParsedServerConfig;
|
||||
} as ParsedServerConfig);
|
||||
|
||||
/** Transformed user-provided API key config (adds customUserVars and headers) */
|
||||
configToSave = this.transformUserApiKeyConfig(configToSave);
|
||||
|
|
@ -421,7 +461,7 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
|
|||
source: 'user',
|
||||
updatedAt: serverDBDoc.updatedAt?.getTime(),
|
||||
};
|
||||
return await this.decryptConfig(config);
|
||||
return sanitizeUserManagedOAuthConfig(await this.decryptConfig(config));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -203,6 +203,81 @@ describe('MCP schemas', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('user-managed OAuth audience restrictions', () => {
|
||||
it('should reject audience from user-managed OAuth configuration', () => {
|
||||
const result = MCPServerUserInputSchema.safeParse({
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp-server.com/http',
|
||||
oauth: {
|
||||
audience: 'https://api.example.com',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject refresh audience forwarding from user-managed OAuth configuration', () => {
|
||||
const result = MCPServerUserInputSchema.safeParse({
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp-server.com/http',
|
||||
oauth: {
|
||||
forward_audience_on_refresh: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject audience query parameters in user-managed OAuth authorization URLs', () => {
|
||||
const result = MCPServerUserInputSchema.safeParse({
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp-server.com/http',
|
||||
oauth: {
|
||||
authorization_url: 'https://auth.example.com/authorize?audience=https://api.example.com',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
client_id: 'public-client-id',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject resource query parameters in user-managed OAuth token URLs', () => {
|
||||
const result = MCPServerUserInputSchema.safeParse({
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp-server.com/http',
|
||||
oauth: {
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token?resource=https://api.example.com',
|
||||
client_id: 'public-client-id',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should continue accepting non-audience OAuth fields from user-managed configuration', () => {
|
||||
const result = MCPServerUserInputSchema.safeParse({
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp-server.com/http',
|
||||
oauth: {
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
client_id: 'public-client-id',
|
||||
scope: 'read execute',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success && result.data.oauth) {
|
||||
expect(result.data.oauth.authorization_url).toBe('https://auth.example.com/authorize');
|
||||
expect(result.data.oauth.token_url).toBe('https://auth.example.com/token');
|
||||
expect(result.data.oauth.client_id).toBe('public-client-id');
|
||||
expect(result.data.oauth.scope).toBe('read execute');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth confidential client endpoint pinning', () => {
|
||||
it('should reject client_secret without client_id', () => {
|
||||
const result = MCPOptionsSchema.safeParse({
|
||||
|
|
|
|||
|
|
@ -2,90 +2,130 @@ import { z } from 'zod';
|
|||
import { TokenExchangeMethodEnum } from './types/agents';
|
||||
import { extractEnvVariable } from './utils';
|
||||
|
||||
const OAuthOptionsSchema = z
|
||||
.object({
|
||||
/** OAuth authorization endpoint (optional - can be auto-discovered) */
|
||||
authorization_url: z.string().url().optional(),
|
||||
/** OAuth token endpoint (optional - can be auto-discovered) */
|
||||
token_url: z.string().url().optional(),
|
||||
/** OAuth client ID (optional - can use dynamic registration) */
|
||||
client_id: z.string().optional(),
|
||||
/** OAuth client secret (requires explicit authorization and token endpoints) */
|
||||
client_secret: z.string().optional(),
|
||||
/** OAuth scopes to request */
|
||||
scope: z.string().optional(),
|
||||
/** OAuth redirect URI (defaults to /api/mcp/{serverName}/oauth/callback) */
|
||||
redirect_uri: z.string().url().optional(),
|
||||
/** Token exchange method */
|
||||
token_exchange_method: z.nativeEnum(TokenExchangeMethodEnum).optional(),
|
||||
/** Supported grant types (defaults to ['authorization_code', 'refresh_token']) */
|
||||
grant_types_supported: z.array(z.string()).optional(),
|
||||
/** Supported token endpoint authentication methods (defaults to ['client_secret_basic', 'client_secret_post']) */
|
||||
token_endpoint_auth_methods_supported: z.array(z.string()).optional(),
|
||||
/** Supported response types (defaults to ['code']) */
|
||||
response_types_supported: z.array(z.string()).optional(),
|
||||
/** Supported code challenge methods (defaults to ['S256', 'plain']) */
|
||||
code_challenge_methods_supported: z.array(z.string()).optional(),
|
||||
/** Skip code challenge validation and force S256 (useful for providers like AWS Cognito that support S256 but don't advertise it) */
|
||||
skip_code_challenge_check: z.boolean().optional(),
|
||||
/**
|
||||
* Auth0/Cognito-style `audience` parameter. Authorization servers that pre-date
|
||||
* RFC 8707 — most prominently Auth0 — issue API-scoped access tokens only when
|
||||
* the `/authorize` request advertises an `audience`. RFC 8707 `resource` (set
|
||||
* automatically from Protected Resource Metadata) is the standards-conformant
|
||||
* route; `audience` covers the providers that ignore it.
|
||||
*
|
||||
* When set, the value is forwarded as-is on `/authorize` (both pre-configured
|
||||
* and DCR-discovered paths). Whether it is also forwarded on the
|
||||
* `refresh_token` grant is controlled by `forward_audience_on_refresh` below.
|
||||
*
|
||||
* The `authorization_code` exchange intentionally never receives `audience` —
|
||||
* Auth0 binds audience from the original `/authorize` request and embeds it
|
||||
* in the issued access token; sending it again is redundant.
|
||||
*
|
||||
* No canonicalization is applied — the audience identifier is provider-defined
|
||||
* and may differ from the MCP server URL.
|
||||
*/
|
||||
audience: z.string().min(1).optional(),
|
||||
/**
|
||||
* Whether to also forward `audience` on the `refresh_token` grant body.
|
||||
*
|
||||
* Default: `true`. Required for Auth0, which strips the API audience from
|
||||
* refreshed access tokens unless `audience` is re-supplied on every refresh
|
||||
* — without it the next MCP call 401s once the initial access token expires.
|
||||
*
|
||||
* Set to `false` for providers that document refresh requests as
|
||||
* `grant_type` + `client_id` + `refresh_token` only (Cognito and other
|
||||
* strict OAuth 2.0 token endpoints). Those providers maintain the original
|
||||
* `aud` claim across refreshes when the initial token was resource-bound,
|
||||
* so the extra parameter is redundant and may be rejected as
|
||||
* `invalid_request`.
|
||||
*
|
||||
* Ignored when `audience` itself is not configured.
|
||||
*/
|
||||
forward_audience_on_refresh: z.boolean().optional(),
|
||||
/** OAuth revocation endpoint (optional - can be auto-discovered) */
|
||||
revocation_endpoint: z.string().url().optional(),
|
||||
/** OAuth revocation endpoint authentication methods supported (optional - can be auto-discovered) */
|
||||
revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(),
|
||||
})
|
||||
.superRefine((oauth, ctx) => {
|
||||
if (oauth.client_secret && !oauth.client_id) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['client_secret'],
|
||||
message: 'OAuth client_secret requires client_id',
|
||||
});
|
||||
}
|
||||
const validateOAuthClientCredentials = (
|
||||
oauth: {
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
authorization_url?: string;
|
||||
token_url?: string;
|
||||
},
|
||||
ctx: z.RefinementCtx,
|
||||
): void => {
|
||||
if (oauth.client_secret && !oauth.client_id) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['client_secret'],
|
||||
message: 'OAuth client_secret requires client_id',
|
||||
});
|
||||
}
|
||||
|
||||
if (oauth.client_id && oauth.client_secret && (!oauth.authorization_url || !oauth.token_url)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['client_secret'],
|
||||
message: 'OAuth client_secret with client_id requires both authorization_url and token_url',
|
||||
});
|
||||
}
|
||||
});
|
||||
if (oauth.client_id && oauth.client_secret && (!oauth.authorization_url || !oauth.token_url)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['client_secret'],
|
||||
message: 'OAuth client_secret with client_id requires both authorization_url and token_url',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const OAuthOptionsBaseSchema = z.object({
|
||||
/** OAuth authorization endpoint (optional - can be auto-discovered) */
|
||||
authorization_url: z.string().url().optional(),
|
||||
/** OAuth token endpoint (optional - can be auto-discovered) */
|
||||
token_url: z.string().url().optional(),
|
||||
/** OAuth client ID (optional - can use dynamic registration) */
|
||||
client_id: z.string().optional(),
|
||||
/** OAuth client secret (requires explicit authorization and token endpoints) */
|
||||
client_secret: z.string().optional(),
|
||||
/** OAuth scopes to request */
|
||||
scope: z.string().optional(),
|
||||
/** OAuth redirect URI (defaults to /api/mcp/{serverName}/oauth/callback) */
|
||||
redirect_uri: z.string().url().optional(),
|
||||
/** Token exchange method */
|
||||
token_exchange_method: z.nativeEnum(TokenExchangeMethodEnum).optional(),
|
||||
/** Supported grant types (defaults to ['authorization_code', 'refresh_token']) */
|
||||
grant_types_supported: z.array(z.string()).optional(),
|
||||
/** Supported token endpoint authentication methods (defaults to ['client_secret_basic', 'client_secret_post']) */
|
||||
token_endpoint_auth_methods_supported: z.array(z.string()).optional(),
|
||||
/** Supported response types (defaults to ['code']) */
|
||||
response_types_supported: z.array(z.string()).optional(),
|
||||
/** Supported code challenge methods (defaults to ['S256', 'plain']) */
|
||||
code_challenge_methods_supported: z.array(z.string()).optional(),
|
||||
/** Skip code challenge validation and force S256 (useful for providers like AWS Cognito that support S256 but don't advertise it) */
|
||||
skip_code_challenge_check: z.boolean().optional(),
|
||||
/**
|
||||
* Auth0/Cognito-style `audience` parameter. Authorization servers that pre-date
|
||||
* RFC 8707 — most prominently Auth0 — issue API-scoped access tokens only when
|
||||
* the `/authorize` request advertises an `audience`. RFC 8707 `resource` (set
|
||||
* automatically from Protected Resource Metadata) is the standards-conformant
|
||||
* route; `audience` covers the providers that ignore it.
|
||||
*
|
||||
* When set, the value is forwarded as-is on `/authorize` (both pre-configured
|
||||
* and DCR-discovered paths). Whether it is also forwarded on the
|
||||
* `refresh_token` grant is controlled by `forward_audience_on_refresh` below.
|
||||
*
|
||||
* The `authorization_code` exchange intentionally never receives `audience` —
|
||||
* Auth0 binds audience from the original `/authorize` request and embeds it
|
||||
* in the issued access token; sending it again is redundant.
|
||||
*
|
||||
* No canonicalization is applied — the audience identifier is provider-defined
|
||||
* and may differ from the MCP server URL. This field is only accepted from
|
||||
* trusted/admin MCP configuration and is rejected from user-managed servers.
|
||||
*/
|
||||
audience: z.string().min(1).optional(),
|
||||
/**
|
||||
* Whether to also forward `audience` on the `refresh_token` grant body.
|
||||
*
|
||||
* Default: `true`. Required for Auth0, which strips the API audience from
|
||||
* refreshed access tokens unless `audience` is re-supplied on every refresh
|
||||
* — without it the next MCP call 401s once the initial access token expires.
|
||||
*
|
||||
* Set to `false` for providers that document refresh requests as
|
||||
* `grant_type` + `client_id` + `refresh_token` only (Cognito and other
|
||||
* strict OAuth 2.0 token endpoints). Those providers maintain the original
|
||||
* `aud` claim across refreshes when the initial token was resource-bound,
|
||||
* so the extra parameter is redundant and may be rejected as
|
||||
* `invalid_request`.
|
||||
*
|
||||
* Ignored when `audience` itself is not configured.
|
||||
*/
|
||||
forward_audience_on_refresh: z.boolean().optional(),
|
||||
/** OAuth revocation endpoint (optional - can be auto-discovered) */
|
||||
revocation_endpoint: z.string().url().optional(),
|
||||
/** OAuth revocation endpoint authentication methods supported (optional - can be auto-discovered) */
|
||||
revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const OAuthOptionsSchema = OAuthOptionsBaseSchema.superRefine(validateOAuthClientCredentials);
|
||||
|
||||
const BLOCKED_USER_OAUTH_ENDPOINT_PARAMS = ['audience', 'resource'] as const;
|
||||
|
||||
const userOAuthEndpointUrlSchema = z
|
||||
.string()
|
||||
.url()
|
||||
.refine(
|
||||
(value) => {
|
||||
try {
|
||||
const { searchParams } = new URL(value);
|
||||
return BLOCKED_USER_OAUTH_ENDPOINT_PARAMS.every((param) => !searchParams.has(param));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{ message: 'OAuth endpoint URLs cannot include audience or resource query parameters' },
|
||||
);
|
||||
|
||||
const UserOAuthOptionsSchema = OAuthOptionsBaseSchema.omit({
|
||||
audience: true,
|
||||
forward_audience_on_refresh: true,
|
||||
})
|
||||
.extend({
|
||||
authorization_url: userOAuthEndpointUrlSchema.optional(),
|
||||
token_url: userOAuthEndpointUrlSchema.optional(),
|
||||
audience: z.never().optional(),
|
||||
forward_audience_on_refresh: z.never().optional(),
|
||||
})
|
||||
.superRefine(validateOAuthClientCredentials);
|
||||
|
||||
const BaseOptionsSchema = z.object({
|
||||
/** Display name for the MCP server - only letters, numbers, and spaces allowed */
|
||||
|
|
@ -300,6 +340,11 @@ const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T
|
|||
oauth_headers: true,
|
||||
});
|
||||
|
||||
const userManagedServerFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) =>
|
||||
omitServerManagedFields(schema).extend({
|
||||
oauth: UserOAuthOptionsSchema.optional(),
|
||||
});
|
||||
|
||||
const envVarPattern = /\$\{[^}]+\}/;
|
||||
const isWsProtocol = (val: string): boolean => /^wss?:/i.test(val);
|
||||
const isHttpProtocol = (val: string): boolean => /^https?:/i.test(val);
|
||||
|
|
@ -320,7 +365,8 @@ const userUrlSchema = (protocolCheck: (val: string) => boolean, message: string)
|
|||
/**
|
||||
* MCP Server configuration that comes from UI/API input only.
|
||||
* Omits server-managed fields like startup, timeout, customUserVars, etc.
|
||||
* Allows: title, description, url, iconPath, oauth (user credentials)
|
||||
* Allows: title, description, url, iconPath, oauth (user credentials).
|
||||
* Admin-only OAuth audience fields are rejected for user-managed servers.
|
||||
*
|
||||
* SECURITY: Stdio transport is intentionally excluded from user input.
|
||||
* Stdio allows arbitrary command execution and should only be configured
|
||||
|
|
@ -334,14 +380,14 @@ const userUrlSchema = (protocolCheck: (val: string) => boolean, message: string)
|
|||
* file://, ftp://, javascript:, and other non-network schemes.
|
||||
*/
|
||||
export const MCPServerUserInputSchema = z.union([
|
||||
omitServerManagedFields(WebSocketOptionsSchema).extend({
|
||||
userManagedServerFields(WebSocketOptionsSchema).extend({
|
||||
url: userUrlSchema(isWsProtocol, 'WebSocket URL must use ws:// or wss://'),
|
||||
}),
|
||||
omitServerManagedFields(SSEOptionsSchema).extend({
|
||||
userManagedServerFields(SSEOptionsSchema).extend({
|
||||
proxy: z.never().optional(),
|
||||
url: userUrlSchema(isHttpProtocol, 'SSE URL must use http:// or https://'),
|
||||
}),
|
||||
omitServerManagedFields(StreamableHTTPOptionsSchema).extend({
|
||||
userManagedServerFields(StreamableHTTPOptionsSchema).extend({
|
||||
proxy: z.never().optional(),
|
||||
url: userUrlSchema(isHttpProtocol, 'Streamable HTTP URL must use http:// or https://'),
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue