diff --git a/.sqlx/query-69895e1fdfef3998c26e8b62496504f96dc5d617be95ef79dd541acca264ddb4.json b/.sqlx/query-69895e1fdfef3998c26e8b62496504f96dc5d617be95ef79dd541acca264ddb4.json deleted file mode 100644 index 34a6493ba8a..00000000000 --- a/.sqlx/query-69895e1fdfef3998c26e8b62496504f96dc5d617be95ef79dd541acca264ddb4.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO mcp_servers (user_id, url, server_name, credentials, enabled)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (user_id, url) DO UPDATE\n SET server_name = EXCLUDED.server_name,\n credentials = COALESCE(EXCLUDED.credentials, mcp_servers.credentials),\n enabled = EXCLUDED.enabled,\n updated_at = NOW()\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Bytea", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "69895e1fdfef3998c26e8b62496504f96dc5d617be95ef79dd541acca264ddb4" -} diff --git a/.sqlx/query-94c66b7510d0a9f791b7a00521ae9975e965670662132acfc60e18667c53370d.json b/.sqlx/query-94c66b7510d0a9f791b7a00521ae9975e965670662132acfc60e18667c53370d.json new file mode 100644 index 00000000000..e06f6aa884f --- /dev/null +++ b/.sqlx/query-94c66b7510d0a9f791b7a00521ae9975e965670662132acfc60e18667c53370d.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO mcp_servers (user_id, url, server_name, credentials, enabled, client_id, client_secret)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (user_id, url) DO UPDATE\n SET server_name = EXCLUDED.server_name,\n credentials = COALESCE(EXCLUDED.credentials, mcp_servers.credentials),\n enabled = EXCLUDED.enabled,\n client_id = EXCLUDED.client_id,\n client_secret = EXCLUDED.client_secret,\n updated_at = NOW()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Bytea", + "Bool", + "Text", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "94c66b7510d0a9f791b7a00521ae9975e965670662132acfc60e18667c53370d" +} diff --git a/.sqlx/query-13a6d39ef2b895cae2162fcf65f7f64a5b303859b36186f4106144a2c5cc23ac.json b/.sqlx/query-aaa8d664de60cdcea278ee2e013ef2270b53cbc0c7b13ff2bae7617ff201ee2d.json similarity index 61% rename from .sqlx/query-13a6d39ef2b895cae2162fcf65f7f64a5b303859b36186f4106144a2c5cc23ac.json rename to .sqlx/query-aaa8d664de60cdcea278ee2e013ef2270b53cbc0c7b13ff2bae7617ff201ee2d.json index 6792d184f7f..31a114a117f 100644 --- a/.sqlx/query-13a6d39ef2b895cae2162fcf65f7f64a5b303859b36186f4106144a2c5cc23ac.json +++ b/.sqlx/query-aaa8d664de60cdcea278ee2e013ef2270b53cbc0c7b13ff2bae7617ff201ee2d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_id, url, server_name, credentials, enabled\n FROM mcp_servers\n WHERE user_id = $1\n ORDER BY created_at\n ", + "query": "\n SELECT user_id, url, server_name, credentials, enabled, client_id, client_secret\n FROM mcp_servers\n WHERE user_id = $1\n ORDER BY created_at\n ", "describe": { "columns": [ { @@ -27,6 +27,16 @@ "ordinal": 4, "name": "enabled", "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "client_secret", + "type_info": "Bytea" } ], "parameters": { @@ -39,8 +49,10 @@ false, false, true, - false + false, + true, + true ] }, - "hash": "13a6d39ef2b895cae2162fcf65f7f64a5b303859b36186f4106144a2c5cc23ac" + "hash": "aaa8d664de60cdcea278ee2e013ef2270b53cbc0c7b13ff2bae7617ff201ee2d" } diff --git a/.sqlx/query-60160128531d4bd143edb6e3af105954c5f749b5a76592d8ba8bd78d3ed410cc.json b/.sqlx/query-d90855304ae7f4a8ca06018d4dfd88b138c1b95d46a31c63b9e93ac2198b037c.json similarity index 63% rename from .sqlx/query-60160128531d4bd143edb6e3af105954c5f749b5a76592d8ba8bd78d3ed410cc.json rename to .sqlx/query-d90855304ae7f4a8ca06018d4dfd88b138c1b95d46a31c63b9e93ac2198b037c.json index 07556c03561..c7cfdb4301e 100644 --- a/.sqlx/query-60160128531d4bd143edb6e3af105954c5f749b5a76592d8ba8bd78d3ed410cc.json +++ b/.sqlx/query-d90855304ae7f4a8ca06018d4dfd88b138c1b95d46a31c63b9e93ac2198b037c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_id, url, server_name, credentials, enabled\n FROM mcp_servers\n WHERE user_id = $1 AND url = $2\n ", + "query": "\n SELECT user_id, url, server_name, credentials, enabled, client_id, client_secret\n FROM mcp_servers\n WHERE user_id = $1 AND url = $2\n ", "describe": { "columns": [ { @@ -27,6 +27,16 @@ "ordinal": 4, "name": "enabled", "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "client_secret", + "type_info": "Bytea" } ], "parameters": { @@ -40,8 +50,10 @@ false, false, true, - false + false, + true, + true ] }, - "hash": "60160128531d4bd143edb6e3af105954c5f749b5a76592d8ba8bd78d3ed410cc" + "hash": "d90855304ae7f4a8ca06018d4dfd88b138c1b95d46a31c63b9e93ac2198b037c" } diff --git a/apps/web/src/features/settings/Integrations.tsx b/apps/web/src/features/settings/Integrations.tsx index 5f5e989ddaa..d7ff016b951 100644 --- a/apps/web/src/features/settings/Integrations.tsx +++ b/apps/web/src/features/settings/Integrations.tsx @@ -6,9 +6,13 @@ import { } from '@core/component/AI/constant/mcpServers'; import { toast } from '@core/component/Toast/Toast'; import { openExternalUrl } from '@core/util/url'; +import CaretDownIcon from '@phosphor-icons/core/regular/caret-down.svg?component-solid'; import CheckIcon from '@phosphor-icons/core/regular/check.svg?component-solid'; +import EyeIcon from '@phosphor-icons/core/regular/eye.svg?component-solid'; +import EyeSlashIcon from '@phosphor-icons/core/regular/eye-slash.svg?component-solid'; import PlugIcon from '@phosphor-icons/core/regular/plug.svg?component-solid'; import PlusIcon from '@phosphor-icons/core/regular/plus.svg?component-solid'; +import SlidersHorizontalIcon from '@phosphor-icons/core/regular/sliders-horizontal.svg?component-solid'; import XIcon from '@phosphor-icons/core/regular/x.svg?component-solid'; import { useAddMcpServerMutation, @@ -35,18 +39,57 @@ function hostFromUrl(url: string): string { } } +/** A password-style input with a reveal toggle, for client secrets. */ +function SecretInput(props: { + value: string; + placeholder?: string; + onInput: (value: string) => void; +}) { + const [revealed, setRevealed] = createSignal(false); + return ( +
+ props.onInput(e.currentTarget.value)} + /> + +
+ ); +} + function AddServerForm(props: { open: boolean; onOpenChange: (open: boolean) => void; }) { const [name, setName] = createSignal(''); const [url, setUrl] = createSignal(''); + const [clientId, setClientId] = createSignal(''); + const [clientSecret, setClientSecret] = createSignal(''); + const [showOauth, setShowOauth] = createSignal(false); const addMutation = useAddMcpServerMutation(); const authMutation = useStartMcpAuthMutation(); const reset = () => { setName(''); setUrl(''); + setClientId(''); + setClientSecret(''); + setShowOauth(false); }; const startAuth = (serverName: string, serverUrl: string) => { @@ -68,8 +111,24 @@ function AddServerForm(props: { const u = url().trim(); if (!n || !u) return; + // Only send credentials when a client id was supplied; a secret without an + // id is unusable and the backend rejects it. + const clientIdValue = clientId().trim(); + const clientSecretValue = clientSecret().trim(); + addMutation.mutate( - { server_name: n, url: u }, + { + server_name: n, + url: u, + ...(clientIdValue + ? { + client_id: clientIdValue, + ...(clientSecretValue + ? { client_secret: clientSecretValue } + : {}), + } + : {}), + }, { onSuccess: () => { startAuth(n, u); @@ -130,6 +189,59 @@ function AddServerForm(props: { }} /> + + {/* Optional pre-registered OAuth credentials, for providers that + don't support Dynamic Client Registration (e.g. HubSpot). */} +
+ + +
+

+ For servers that don't support automatic client registration + (e.g. HubSpot). Leave blank to use the standard flow. +

+ +

+ Enter a client ID to use the client secret. +

+
+ + +
+
+
@@ -184,6 +296,116 @@ function writeAuthAttempted(url: string, attempted: boolean): void { } } +/** + * Edit the pre-registered OAuth client id/secret for a server. Only shown for + * servers without a completed OAuth grant, where these credentials are what + * lets a non-DCR provider (e.g. HubSpot) authorize at all. + */ +function ConfigureCredentialsDialog(props: { + onOpenChange: (open: boolean) => void; + server: ServerResponse; +}) { + const updateMutation = useUpdateMcpServerMutation(); + // Remounted on each open (see the keyed in ServerRow), so these + // always initialize from the latest server values without an effect. + const [clientId, setClientId] = createSignal(props.server.client_id ?? ''); + const [clientSecret, setClientSecret] = createSignal(''); + + const handleSave = () => { + const cid = clientId().trim(); + const csecret = clientSecret().trim(); + // Clearing the id also clears the stored secret, so we never leave a + // secret behind without an id (which the backend rejects). + const clearedId = cid === '' && (props.server.client_id ?? '') !== ''; + + updateMutation.mutate( + { + url: props.server.url, + client_id: cid, + ...(csecret !== '' || clearedId ? { client_secret: csecret } : {}), + }, + { + onSuccess: () => { + toast.success('OAuth credentials updated'); + props.onOpenChange(false); + }, + onError: () => { + toast.failure('Failed to update credentials'); + }, + } + ); + }; + + return ( + !open && props.onOpenChange(false)} + position="center" + class="w-100" + > + + + + OAuth credentials — {props.server.server_name} + + + +
+

+ Some providers (e.g. HubSpot) don't support automatic client + registration. Supply the client ID and secret from your provider's + developer settings to connect them. +

+ + +
+ +
+ + +
+
+
+
+ ); +} + function ServerRow(props: { server: ServerResponse }) { const updateMutation = useUpdateMcpServerMutation(); const deleteMutation = useDeleteMcpServerMutation(); @@ -192,6 +414,7 @@ function ServerRow(props: { server: ServerResponse }) { const [attempted, setAttempted] = createSignal( readAuthAttempted(props.server.url) ); + const [showConfigure, setShowConfigure] = createSignal(false); // A recorded attempt on a still-disconnected server means the last connect // attempt didn't succeed. Clear the flag once the server authenticates. @@ -293,6 +516,15 @@ function ServerRow(props: { server: ServerResponse }) { ? 'Try Again' : 'Connect'} +
@@ -340,6 +572,13 @@ function ServerRow(props: { server: ServerResponse }) { + + + + ); } diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/addServerRequest.ts b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/addServerRequest.ts index 08a439a5290..b3469e746ef 100644 --- a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/addServerRequest.ts +++ b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/addServerRequest.ts @@ -4,11 +4,18 @@ * Document Cognition Service * OpenAPI spec version: 1.0.0 */ +import type { AddServerRequestClientId } from './addServerRequestClientId'; +import type { AddServerRequestClientSecret } from './addServerRequestClientSecret'; /** * Request body for adding a new MCP server. */ export interface AddServerRequest { + /** Pre-registered OAuth client ID for providers that don't support +Dynamic Client Registration (e.g. HubSpot). */ + client_id?: AddServerRequestClientId; + /** Pre-registered OAuth client secret, if the provider issued one. */ + client_secret?: AddServerRequestClientSecret; /** Human-readable name for the server. */ server_name: string; /** The MCP server's streamable HTTP URL. */ diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/addServerRequestClientId.ts b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/addServerRequestClientId.ts new file mode 100644 index 00000000000..b48926ce6a3 --- /dev/null +++ b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/addServerRequestClientId.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Document Cognition Service + * OpenAPI spec version: 1.0.0 + */ + +/** + * Pre-registered OAuth client ID for providers that don't support +Dynamic Client Registration (e.g. HubSpot). + */ +export type AddServerRequestClientId = string | null; diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/addServerRequestClientSecret.ts b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/addServerRequestClientSecret.ts new file mode 100644 index 00000000000..8a9162ef621 --- /dev/null +++ b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/addServerRequestClientSecret.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Document Cognition Service + * OpenAPI spec version: 1.0.0 + */ + +/** + * Pre-registered OAuth client secret, if the provider issued one. + */ +export type AddServerRequestClientSecret = string | null; diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/index.ts b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/index.ts index c94e6314f47..89d9e5db736 100644 --- a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/index.ts +++ b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/index.ts @@ -7,6 +7,8 @@ export * from './accessLevel'; export * from './addServerRequest'; +export * from './addServerRequestClientId'; +export * from './addServerRequestClientSecret'; export * from './aiFeature'; export * from './assistantMessagePart'; export * from './assistantMessagePartOneOf'; @@ -180,6 +182,7 @@ export * from './sendChatMessagePayloadAllOfAdditionalInstructions'; export * from './sendChatMessagePayloadAllOfAttachments'; export * from './sendChatMessageResponse'; export * from './serverResponse'; +export * from './serverResponseClientId'; export * from './setPricingRequest'; export * from './sharePermissionV2'; export * from './sharePermissionV2ChannelSharePermissions'; @@ -216,6 +219,8 @@ export * from './updateChannelSharePermission'; export * from './updateChannelSharePermissionAccessLevel'; export * from './updateOperation'; export * from './updateServerRequest'; +export * from './updateServerRequestClientId'; +export * from './updateServerRequestClientSecret'; export * from './updateServerRequestEnabled'; export * from './updateServerRequestServerName'; export * from './updateSharePermissionRequestV2'; diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/serverResponse.ts b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/serverResponse.ts index 7ad53a105a4..f3a9fd198ba 100644 --- a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/serverResponse.ts +++ b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/serverResponse.ts @@ -4,6 +4,7 @@ * Document Cognition Service * OpenAPI spec version: 1.0.0 */ +import type { ServerResponseClientId } from './serverResponseClientId'; /** * An MCP server record as returned by the API. @@ -11,8 +12,12 @@ export interface ServerResponse { /** Whether the server has valid stored credentials. */ authenticated: boolean; + /** Pre-registered OAuth client ID, if the user supplied one. */ + client_id?: ServerResponseClientId; /** Whether the server is enabled for tool use. */ enabled: boolean; + /** Whether a pre-registered OAuth client secret is stored. */ + has_client_secret: boolean; /** Human-readable server name. */ server_name: string; /** The MCP server URL. */ diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/serverResponseClientId.ts b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/serverResponseClientId.ts new file mode 100644 index 00000000000..01d3dfdcd2b --- /dev/null +++ b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/serverResponseClientId.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Document Cognition Service + * OpenAPI spec version: 1.0.0 + */ + +/** + * Pre-registered OAuth client ID, if the user supplied one. + */ +export type ServerResponseClientId = string | null; diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/updateServerRequest.ts b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/updateServerRequest.ts index 14fe12b884f..10cc1989306 100644 --- a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/updateServerRequest.ts +++ b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/updateServerRequest.ts @@ -4,6 +4,8 @@ * Document Cognition Service * OpenAPI spec version: 1.0.0 */ +import type { UpdateServerRequestClientId } from './updateServerRequestClientId'; +import type { UpdateServerRequestClientSecret } from './updateServerRequestClientSecret'; import type { UpdateServerRequestEnabled } from './updateServerRequestEnabled'; import type { UpdateServerRequestServerName } from './updateServerRequestServerName'; @@ -11,6 +13,12 @@ import type { UpdateServerRequestServerName } from './updateServerRequestServerN * Request body for updating an MCP server. */ export interface UpdateServerRequest { + /** Pre-registered OAuth client ID for providers without DCR. Pass an +empty string to clear it. */ + client_id?: UpdateServerRequestClientId; + /** Pre-registered OAuth client secret, if the provider issued one. Pass +an empty string to clear it. */ + client_secret?: UpdateServerRequestClientSecret; /** Enable or disable the server. */ enabled?: UpdateServerRequestEnabled; /** New name for the server. */ diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/updateServerRequestClientId.ts b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/updateServerRequestClientId.ts new file mode 100644 index 00000000000..c8639f27a5e --- /dev/null +++ b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/updateServerRequestClientId.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Document Cognition Service + * OpenAPI spec version: 1.0.0 + */ + +/** + * Pre-registered OAuth client ID for providers without DCR. Pass an +empty string to clear it. + */ +export type UpdateServerRequestClientId = string | null; diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/schemas/updateServerRequestClientSecret.ts b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/updateServerRequestClientSecret.ts new file mode 100644 index 00000000000..fc6658e1cb2 --- /dev/null +++ b/apps/web/src/lib/service-clients/service-cognition/generated/schemas/updateServerRequestClientSecret.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Document Cognition Service + * OpenAPI spec version: 1.0.0 + */ + +/** + * Pre-registered OAuth client secret, if the provider issued one. Pass +an empty string to clear it. + */ +export type UpdateServerRequestClientSecret = string | null; diff --git a/apps/web/src/lib/service-clients/service-cognition/openapi.json b/apps/web/src/lib/service-clients/service-cognition/openapi.json index fa427b660d7..f5e68969e4f 100644 --- a/apps/web/src/lib/service-clients/service-cognition/openapi.json +++ b/apps/web/src/lib/service-clients/service-cognition/openapi.json @@ -1766,6 +1766,14 @@ "description": "Request body for adding a new MCP server.", "required": ["url", "server_name"], "properties": { + "client_id": { + "type": ["string", "null"], + "description": "Pre-registered OAuth client ID for providers that don't support\nDynamic Client Registration (e.g. HubSpot)." + }, + "client_secret": { + "type": ["string", "null"], + "description": "Pre-registered OAuth client secret, if the provider issued one." + }, "server_name": { "type": "string", "description": "Human-readable name for the server." @@ -3824,16 +3832,30 @@ "ServerResponse": { "type": "object", "description": "An MCP server record as returned by the API.", - "required": ["url", "server_name", "enabled", "authenticated"], + "required": [ + "url", + "server_name", + "enabled", + "authenticated", + "has_client_secret" + ], "properties": { "authenticated": { "type": "boolean", "description": "Whether the server has valid stored credentials." }, + "client_id": { + "type": ["string", "null"], + "description": "Pre-registered OAuth client ID, if the user supplied one." + }, "enabled": { "type": "boolean", "description": "Whether the server is enabled for tool use." }, + "has_client_secret": { + "type": "boolean", + "description": "Whether a pre-registered OAuth client secret is stored." + }, "server_name": { "type": "string", "description": "Human-readable server name." @@ -4160,6 +4182,14 @@ "description": "Request body for updating an MCP server.", "required": ["url"], "properties": { + "client_id": { + "type": ["string", "null"], + "description": "Pre-registered OAuth client ID for providers without DCR. Pass an\nempty string to clear it." + }, + "client_secret": { + "type": ["string", "null"], + "description": "Pre-registered OAuth client secret, if the provider issued one. Pass\nan empty string to clear it." + }, "enabled": { "type": ["boolean", "null"], "description": "Enable or disable the server." diff --git a/crates/macro_db_client/migrations/20260812191214_add_mcp_servers_oauth_credentials.down.sql b/crates/macro_db_client/migrations/20260812191214_add_mcp_servers_oauth_credentials.down.sql new file mode 100644 index 00000000000..b9918d4b73c --- /dev/null +++ b/crates/macro_db_client/migrations/20260812191214_add_mcp_servers_oauth_credentials.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE mcp_servers +DROP COLUMN IF EXISTS client_id, +DROP COLUMN IF EXISTS client_secret; diff --git a/crates/macro_db_client/migrations/20260812191214_add_mcp_servers_oauth_credentials.sql b/crates/macro_db_client/migrations/20260812191214_add_mcp_servers_oauth_credentials.sql new file mode 100644 index 00000000000..d7fe02924aa --- /dev/null +++ b/crates/macro_db_client/migrations/20260812191214_add_mcp_servers_oauth_credentials.sql @@ -0,0 +1,6 @@ +-- Pre-registered OAuth credentials for MCP providers that don't support +-- Dynamic Client Registration (e.g. HubSpot). `client_secret` is AES-256-GCM +-- encrypted at rest by the MCP server repo, matching `credentials`. +ALTER TABLE mcp_servers +ADD COLUMN IF NOT EXISTS client_id TEXT, +ADD COLUMN IF NOT EXISTS client_secret BYTEA; diff --git a/crates/mcp_client/src/domain/models/server.rs b/crates/mcp_client/src/domain/models/server.rs index 511998c7baa..396d9a78973 100644 --- a/crates/mcp_client/src/domain/models/server.rs +++ b/crates/mcp_client/src/domain/models/server.rs @@ -47,6 +47,13 @@ pub struct McpServerRecord { pub credentials: Option, /// Whether the user has this toolset enabled. pub enabled: bool, + /// Pre-registered OAuth client ID for providers that don't support Dynamic + /// Client Registration (e.g. HubSpot). When set, authorization uses this + /// client instead of attempting DCR. + pub client_id: Option, + /// Pre-registered OAuth client secret, if the provider issued one. + #[serde(skip)] + pub client_secret: Option, } impl McpConnector for McpServerRecord { diff --git a/crates/mcp_client/src/domain/service/persisting_credential_store/test.rs b/crates/mcp_client/src/domain/service/persisting_credential_store/test.rs index c59e2e8908d..d476ffbbeac 100644 --- a/crates/mcp_client/src/domain/service/persisting_credential_store/test.rs +++ b/crates/mcp_client/src/domain/service/persisting_credential_store/test.rs @@ -73,6 +73,8 @@ fn record() -> McpServerRecord { server_name: "Linear".to_string(), credentials: None, enabled: true, + client_id: None, + client_secret: None, } } diff --git a/crates/mcp_client/src/inbound/axum_router.rs b/crates/mcp_client/src/inbound/axum_router.rs index 51d2595885f..16199943e46 100644 --- a/crates/mcp_client/src/inbound/axum_router.rs +++ b/crates/mcp_client/src/inbound/axum_router.rs @@ -140,6 +140,13 @@ pub struct AddServerRequest { url: String, /// Human-readable name for the server. server_name: String, + /// Pre-registered OAuth client ID for providers that don't support + /// Dynamic Client Registration (e.g. HubSpot). + #[serde(default)] + client_id: Option, + /// Pre-registered OAuth client secret, if the provider issued one. + #[serde(default)] + client_secret: Option, } /// Request body for updating an MCP server. @@ -153,6 +160,14 @@ pub struct UpdateServerRequest { /// Enable or disable the server. #[serde(default)] enabled: Option, + /// Pre-registered OAuth client ID for providers without DCR. Pass an + /// empty string to clear it. + #[serde(default)] + client_id: Option, + /// Pre-registered OAuth client secret, if the provider issued one. Pass + /// an empty string to clear it. + #[serde(default)] + client_secret: Option, } /// Query parameters for deleting an MCP server. @@ -209,6 +224,10 @@ pub struct ServerResponse { enabled: bool, /// Whether the server has valid stored credentials. authenticated: bool, + /// Pre-registered OAuth client ID, if the user supplied one. + client_id: Option, + /// Whether a pre-registered OAuth client secret is stored. + has_client_secret: bool, } impl ServerResponse { @@ -218,6 +237,8 @@ impl ServerResponse { server_name: record.server_name.clone(), enabled: record.enabled, authenticated: record.credentials.is_some(), + client_id: record.client_id.clone(), + has_client_secret: record.client_secret.is_some(), } } } @@ -236,6 +257,9 @@ pub enum McpHandlerErr { /// The callback was missing both a code and an error parameter. #[error("malformed OAuth callback: missing code and error parameters")] MalformedCallback, + /// A client secret was supplied without a client id. + #[error("{0}")] + InvalidCredentials(String), /// An internal error occurred. #[error("{0}")] Internal(#[from] anyhow::Error), @@ -245,9 +269,9 @@ impl IntoResponse for McpHandlerErr { fn into_response(self) -> axum::response::Response { let status = match &self { McpHandlerErr::NotFound => StatusCode::NOT_FOUND, - McpHandlerErr::OAuthRejected(_) | McpHandlerErr::MalformedCallback => { - StatusCode::BAD_REQUEST - } + McpHandlerErr::OAuthRejected(_) + | McpHandlerErr::MalformedCallback + | McpHandlerErr::InvalidCredentials(_) => StatusCode::BAD_REQUEST, McpHandlerErr::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, }; ( @@ -262,6 +286,32 @@ impl IntoResponse for McpHandlerErr { // -- handlers ----------------------------------------------------------------- +/// Normalize an optional credential field: trim surrounding whitespace and +/// treat an empty string as "no value", so an empty form field clears it. +fn clean_credential(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +/// Validate that a pre-registered client secret is only ever set together with +/// a client id; a secret without an id would be silently unusable during the +/// OAuth flow. +fn validate_oauth_credentials( + client_id: Option<&str>, + client_secret: Option<&str>, +) -> Result<(), McpHandlerErr> { + if client_secret.is_some() && client_id.is_none() { + return Err(McpHandlerErr::InvalidCredentials( + "a client secret requires a client id".to_string(), + )); + } + Ok(()) +} + #[utoipa::path( get, path = "/mcp/servers", @@ -323,12 +373,34 @@ where anyhow::Error: From, { let user = &authorization.authorization.user; + + // Adding an existing URL is an upsert. Load the current row so the + // response reflects its stored OAuth grant, and so we preserve + // pre-registered client credentials when the request omits them. + let existing = state + .store + .load(&user.macro_user_id, &body.url) + .await + .map_err(anyhow::Error::from)?; + + let client_id = match body.client_id { + Some(value) => clean_credential(value), + None => existing.as_ref().and_then(|e| e.client_id.clone()), + }; + let client_secret = match body.client_secret { + Some(value) => clean_credential(value), + None => existing.as_ref().and_then(|e| e.client_secret.clone()), + }; + validate_oauth_credentials(client_id.as_deref(), client_secret.as_deref())?; + let record = McpServerRecord { user_id: user.macro_user_id.clone(), url: body.url, server_name: body.server_name, - credentials: None, + credentials: existing.as_ref().and_then(|e| e.credentials.clone()), enabled: true, + client_id, + client_secret, }; state @@ -383,6 +455,13 @@ where if let Some(enabled) = body.enabled { record.enabled = enabled; } + if let Some(client_id) = body.client_id { + record.client_id = clean_credential(client_id); + } + if let Some(client_secret) = body.client_secret { + record.client_secret = clean_credential(client_secret); + } + validate_oauth_credentials(record.client_id.as_deref(), record.client_secret.as_deref())?; state .store diff --git a/crates/mcp_client/src/inbound/axum_router/test.rs b/crates/mcp_client/src/inbound/axum_router/test.rs index 542e6b0b6ea..2a5ebb29b91 100644 --- a/crates/mcp_client/src/inbound/axum_router/test.rs +++ b/crates/mcp_client/src/inbound/axum_router/test.rs @@ -1,4 +1,6 @@ use super::*; +use crate::domain::models::MacroUserIdStr; +use macro_user_id::cowlike::CowLike; fn params( code: Option<&str>, @@ -66,3 +68,52 @@ fn missing_state_without_error_is_malformed() { let result = parse_callback_params(params(Some("a-code"), None, None, None)); assert!(matches!(result, Err(McpHandlerErr::MalformedCallback))); } + +#[test] +fn clean_credential_trims_and_rejects_empty() { + assert_eq!( + clean_credential(" abc ".to_string()).as_deref(), + Some("abc") + ); + assert_eq!(clean_credential(" ".to_string()), None); + assert_eq!(clean_credential("".to_string()), None); +} + +#[test] +fn client_secret_without_client_id_is_rejected() { + assert!(matches!( + validate_oauth_credentials(None, Some("secret")), + Err(McpHandlerErr::InvalidCredentials(_)) + )); + assert!(validate_oauth_credentials(Some("id"), Some("secret")).is_ok()); + assert!(validate_oauth_credentials(Some("id"), None).is_ok()); + assert!(validate_oauth_credentials(None, None).is_ok()); +} + +#[test] +fn response_reflects_pre_registered_credentials() { + let record = McpServerRecord { + user_id: MacroUserIdStr::parse_from_str("macro|test@example.com") + .expect("valid test user id") + .into_owned(), + url: "https://mcp.hubspot.com".to_string(), + server_name: "HubSpot".to_string(), + credentials: None, + enabled: true, + client_id: Some("client-123".to_string()), + client_secret: Some("secret".to_string()), + }; + let response = ServerResponse::from_record(&record); + assert_eq!(response.client_id.as_deref(), Some("client-123")); + assert!(response.has_client_secret); + assert!(!response.authenticated); + + let no_creds = McpServerRecord { + client_id: None, + client_secret: None, + ..record + }; + let response = ServerResponse::from_record(&no_creds); + assert_eq!(response.client_id, None); + assert!(!response.has_client_secret); +} diff --git a/crates/mcp_client/src/outbound/oauth.rs b/crates/mcp_client/src/outbound/oauth.rs index b6b8ae34bfa..4dd0a199ace 100644 --- a/crates/mcp_client/src/outbound/oauth.rs +++ b/crates/mcp_client/src/outbound/oauth.rs @@ -133,13 +133,48 @@ impl OAuthService { self.register_dcr_client(auth_manager, scopes).await } + /// Configure the OAuth client from pre-registered credentials stored on the + /// server record, when the user supplied a static client id (for providers + /// that don't support Dynamic Client Registration, e.g. HubSpot). Returns + /// `Ok(None)` when there is no client id to use, so callers fall through to + /// the provider registry or DCR. + fn configure_pre_registered_client( + &self, + record: Option<&McpServerRecord>, + auth_manager: &mut AuthorizationManager, + ) -> anyhow::Result> { + let Some(client_id) = record.and_then(|r| r.client_id.as_deref()) else { + return Ok(None); + }; + let client_secret = record.and_then(|r| r.client_secret.clone()); + + let mut config = + OAuthClientConfig::new(client_id.to_string(), self.client_metadata.redirect_uri()); + if let Some(secret) = &client_secret { + config = config.with_client_secret(secret.clone()); + } + auth_manager.configure_client(config)?; + + tracing::info!(%client_id, "using pre-registered OAuth client from server record"); + Ok(Some(ResolvedClient { + client_id: client_id.to_string(), + client_secret, + scopes: Vec::new(), + })) + } + #[cfg(feature = "providers")] async fn resolve_client_config( &self, server_url: &str, + record: Option<&McpServerRecord>, auth_manager: &mut AuthorizationManager, supports_client_metadata: bool, ) -> anyhow::Result { + if let Some(client) = self.configure_pre_registered_client(record, auth_manager)? { + return Ok(client); + } + if let Some(creds) = self.pre_registered.get(server_url) { let config = OAuthClientConfig::new( creds.client_id.clone(), @@ -166,9 +201,14 @@ impl OAuthService { async fn resolve_client_config( &self, _server_url: &str, + record: Option<&McpServerRecord>, auth_manager: &mut AuthorizationManager, supports_client_metadata: bool, ) -> anyhow::Result { + if let Some(client) = self.configure_pre_registered_client(record, auth_manager)? { + return Ok(client); + } + let scopes = auth_manager.select_scopes(None, &[]); self.resolve_dynamic_client(auth_manager, supports_client_metadata, scopes) .await @@ -197,8 +237,17 @@ where auth_manager.set_state_store(in_memory_state.clone()); auth_manager.set_credential_store(InMemoryCredentialStore::new()); + // Load any pre-registered client credentials the user supplied for this + // server (a static client id/secret for a provider without DCR). + let record = self.server_store.load(user_id, server_url).await?; + let resolved = self - .resolve_client_config(server_url, &mut auth_manager, supports_client_metadata) + .resolve_client_config( + server_url, + record.as_ref(), + &mut auth_manager, + supports_client_metadata, + ) .await?; let scope_refs: Vec<&str> = resolved.scopes.iter().map(String::as_str).collect(); @@ -277,12 +326,21 @@ where .map_err(|e| anyhow::anyhow!("invalid user_id in pending context: {e}"))? .into_owned(); + // Preserve any pre-registered client id/secret across the exchange: + // completing OAuth only replaces the token grant, not the static client + // credentials the user supplied for a provider without DCR. The load is + // propagated (not swallowed) so a transient failure can't silently wipe + // those credentials on save. + let existing = self.server_store.load(&user_id, &pending.server_url).await?; + let record = McpServerRecord { user_id, url: pending.server_url, server_name: pending.server_name, credentials: Some(credentials.clone()), enabled: true, + client_id: existing.as_ref().and_then(|r| r.client_id.clone()), + client_secret: existing.as_ref().and_then(|r| r.client_secret.clone()), }; self.server_store diff --git a/crates/mcp_client/src/outbound/oauth/test.rs b/crates/mcp_client/src/outbound/oauth/test.rs index ae31550837f..9fe26df6688 100644 --- a/crates/mcp_client/src/outbound/oauth/test.rs +++ b/crates/mcp_client/src/outbound/oauth/test.rs @@ -18,7 +18,17 @@ const CLIENT_METADATA_URL: &str = const REDIRECT_URI: &str = "https://document-cognition.macro.com/mcp/servers/auth/callback"; #[derive(Clone, Default)] -struct FakeServerStore; +struct FakeServerStore { + record: Arc>>, +} + +impl FakeServerStore { + fn with_record(record: McpServerRecord) -> Self { + Self { + record: Arc::new(Mutex::new(Some(record))), + } + } +} impl McpServerStore for FakeServerStore { type Err = anyhow::Error; @@ -32,7 +42,11 @@ impl McpServerStore for FakeServerStore { _user_id: &MacroUserIdStr<'static>, _server_url: &str, ) -> Result, Self::Err> { - Ok(None) + Ok(self + .record + .lock() + .expect("state mutex is not poisoned") + .clone()) } async fn delete( @@ -175,8 +189,14 @@ async fn spawn_authorization_server( } fn oauth_service() -> OAuthService { + oauth_service_with_store(FakeServerStore::default()) +} + +fn oauth_service_with_store( + server_store: FakeServerStore, +) -> OAuthService { OAuthService::new( - FakeServerStore, + server_store, FakeStateStore::default(), OAuthClientMetadata::new(CLIENT_METADATA_URL.to_string(), REDIRECT_URI.to_string()), PreRegisteredProviders::empty(), @@ -236,3 +256,33 @@ async fn dcr_remains_the_fallback_when_cimd_is_not_supported() { assert_eq!(registrations.load(Ordering::SeqCst), 1); server_task.abort(); } + +#[tokio::test] +async fn pre_registered_credentials_skip_dynamic_registration() { + let (base_url, registrations, server_task) = spawn_authorization_server(false).await; + let server_url = format!("{base_url}/mcp"); + + let record = McpServerRecord { + user_id: test_user_id(), + url: server_url.clone(), + server_name: "HubSpot".to_string(), + credentials: None, + enabled: true, + client_id: Some("hubspot-client-id".to_string()), + client_secret: Some("hubspot-client-secret".to_string()), + }; + let service = oauth_service_with_store(FakeServerStore::with_record(record)); + + let authorization_url = service + .start_authorization(&test_user_id(), &server_url, "HubSpot") + .await + .expect("pre-registered authorization starts"); + + // The user-supplied client id is used verbatim, and no DCR call is made. + assert_eq!( + query_parameter(&authorization_url, "client_id").as_deref(), + Some("hubspot-client-id") + ); + assert_eq!(registrations.load(Ordering::SeqCst), 0); + server_task.abort(); +} diff --git a/crates/mcp_client/src/outbound/pg_server_repo.rs b/crates/mcp_client/src/outbound/pg_server_repo.rs index a5c8a1de3ea..70ae118ea15 100644 --- a/crates/mcp_client/src/outbound/pg_server_repo.rs +++ b/crates/mcp_client/src/outbound/pg_server_repo.rs @@ -31,11 +31,16 @@ impl PgServerRepo { fn encrypt(&self, creds: &StoredCredentials) -> Result, sqlx::Error> { let plaintext = serde_json::to_vec(creds).map_err(|e| sqlx::Error::Protocol(e.to_string()))?; + self.encrypt_bytes(&plaintext) + } + + #[tracing::instrument(skip_all, err)] + fn encrypt_bytes(&self, plaintext: &[u8]) -> Result, sqlx::Error> { let cipher = Aes256Gcm::new(self.encryption_key.as_bytes().into()); let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher - .encrypt(&nonce, plaintext.as_ref()) - .map_err(|e| sqlx::Error::Protocol(format!("credential encryption failed: {e}")))?; + .encrypt(&nonce, plaintext) + .map_err(|e| sqlx::Error::Protocol(format!("encryption failed: {e}")))?; let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len()); out.extend_from_slice(&nonce); out.extend(ciphertext); @@ -44,20 +49,23 @@ impl PgServerRepo { #[tracing::instrument(skip_all, err)] fn decrypt(&self, data: &[u8]) -> Result { + let plaintext = self.decrypt_bytes(data)?; + serde_json::from_slice(&plaintext).map_err(|e| sqlx::Error::Decode(Box::new(e))) + } + + #[tracing::instrument(skip_all, err)] + fn decrypt_bytes(&self, data: &[u8]) -> Result, sqlx::Error> { if data.len() <= NONCE_LEN { - return Err(sqlx::Error::Decode( - "credential ciphertext too short".into(), - )); + return Err(sqlx::Error::Decode("ciphertext too short".into())); } let (nonce_bytes, ciphertext) = data.split_at(NONCE_LEN); let nonce: &[u8; NONCE_LEN] = nonce_bytes .try_into() .map_err(|_| sqlx::Error::Decode("invalid nonce length".into()))?; let cipher = Aes256Gcm::new(self.encryption_key.as_bytes().into()); - let plaintext = cipher.decrypt(nonce.into(), ciphertext).map_err(|e| { - sqlx::Error::Decode(format!("credential decryption failed: {e}").into()) - })?; - serde_json::from_slice(&plaintext).map_err(|e| sqlx::Error::Decode(Box::new(e))) + cipher + .decrypt(nonce.into(), ciphertext) + .map_err(|e| sqlx::Error::Decode(format!("decryption failed: {e}").into())) } } @@ -72,17 +80,28 @@ impl McpServerStore for PgServerRepo { .map(|c| self.encrypt(c)) .transpose()?; + let encrypted_secret: Option> = record + .client_secret + .as_deref() + .map(|s| self.encrypt_bytes(s.as_bytes())) + .transpose()?; + // Never clobber stored credentials with NULL on conflict: re-adding // an existing server (e.g. via the Add Server dialog) must not wipe - // a valid OAuth grant. + // a valid OAuth grant. The pre-registered client id/secret are + // overwritten outright because callers that omit them re-load and + // pass through the stored values (see the axum handlers), so a plain + // overwrite here still preserves them while allowing explicit clears. sqlx::query!( r#" - INSERT INTO mcp_servers (user_id, url, server_name, credentials, enabled) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO mcp_servers (user_id, url, server_name, credentials, enabled, client_id, client_secret) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id, url) DO UPDATE SET server_name = EXCLUDED.server_name, credentials = COALESCE(EXCLUDED.credentials, mcp_servers.credentials), enabled = EXCLUDED.enabled, + client_id = EXCLUDED.client_id, + client_secret = EXCLUDED.client_secret, updated_at = NOW() "#, record.user_id.as_ref(), @@ -90,6 +109,8 @@ impl McpServerStore for PgServerRepo { record.server_name, encrypted.as_deref(), record.enabled, + record.client_id.as_deref(), + encrypted_secret.as_deref(), ) .execute(&self.pool) .await?; @@ -105,7 +126,7 @@ impl McpServerStore for PgServerRepo { ) -> Result, Self::Err> { let row = sqlx::query!( r#" - SELECT user_id, url, server_name, credentials, enabled + SELECT user_id, url, server_name, credentials, enabled, client_id, client_secret FROM mcp_servers WHERE user_id = $1 AND url = $2 "#, @@ -115,8 +136,18 @@ impl McpServerStore for PgServerRepo { .fetch_optional(&self.pool) .await?; - row.map(|r| self.to_record(r.user_id, r.url, r.server_name, r.credentials, r.enabled)) - .transpose() + row.map(|r| { + self.to_record( + r.user_id, + r.url, + r.server_name, + r.credentials, + r.enabled, + r.client_id, + r.client_secret, + ) + }) + .transpose() } #[tracing::instrument(skip_all, err)] @@ -146,7 +177,7 @@ impl McpServerStore for PgServerRepo { ) -> Result, Self::Err> { let rows = sqlx::query!( r#" - SELECT user_id, url, server_name, credentials, enabled + SELECT user_id, url, server_name, credentials, enabled, client_id, client_secret FROM mcp_servers WHERE user_id = $1 ORDER BY created_at @@ -157,13 +188,24 @@ impl McpServerStore for PgServerRepo { .await?; rows.into_iter() - .map(|r| self.to_record(r.user_id, r.url, r.server_name, r.credentials, r.enabled)) + .map(|r| { + self.to_record( + r.user_id, + r.url, + r.server_name, + r.credentials, + r.enabled, + r.client_id, + r.client_secret, + ) + }) .collect() } } impl PgServerRepo { #[tracing::instrument(skip_all, err)] + #[expect(clippy::too_many_arguments, reason = "maps a DB row into a record")] fn to_record( &self, user_id: String, @@ -171,6 +213,8 @@ impl PgServerRepo { server_name: String, credentials: Option>, enabled: bool, + client_id: Option, + client_secret: Option>, ) -> Result { let user_id = MacroUserIdStr::parse_from_str(&user_id) .map_err(|e| sqlx::Error::Decode(Box::new(e)))? @@ -178,12 +222,21 @@ impl PgServerRepo { let credentials = credentials.map(|c| self.decrypt(&c)).transpose()?; + let client_secret = client_secret + .map(|b| { + let plaintext = self.decrypt_bytes(&b)?; + String::from_utf8(plaintext).map_err(|e| sqlx::Error::Decode(Box::new(e))) + }) + .transpose()?; + Ok(McpServerRecord { user_id, url, server_name, credentials, enabled, + client_id, + client_secret, }) } } diff --git a/crates/onboarding/src/domain/service/test.rs b/crates/onboarding/src/domain/service/test.rs index 2af0e6aefa6..1b101779acf 100644 --- a/crates/onboarding/src/domain/service/test.rs +++ b/crates/onboarding/src/domain/service/test.rs @@ -188,6 +188,8 @@ fn record(url: &str, authenticated: bool) -> McpServerRecord { credentials: authenticated .then(|| StoredCredentials::new("client".into(), None, Vec::new(), None)), enabled: true, + client_id: None, + client_secret: None, } } diff --git a/packages/sdk/generated/cognition/types.gen.ts b/packages/sdk/generated/cognition/types.gen.ts index 6e3edd236e4..e2bf2d6076d 100644 --- a/packages/sdk/generated/cognition/types.gen.ts +++ b/packages/sdk/generated/cognition/types.gen.ts @@ -13,6 +13,15 @@ export type AccessLevel = 'view' | 'comment' | 'edit' | 'owner'; * Request body for adding a new MCP server. */ export type AddServerRequest = { + /** + * Pre-registered OAuth client ID for providers that don't support + * Dynamic Client Registration (e.g. HubSpot). + */ + client_id?: string | null; + /** + * Pre-registered OAuth client secret, if the provider issued one. + */ + client_secret?: string | null; /** * Human-readable name for the server. */ @@ -1130,10 +1139,18 @@ export type ServerResponse = { * Whether the server has valid stored credentials. */ authenticated: boolean; + /** + * Pre-registered OAuth client ID, if the user supplied one. + */ + client_id?: string | null; /** * Whether the server is enabled for tool use. */ enabled: boolean; + /** + * Whether a pre-registered OAuth client secret is stored. + */ + has_client_secret: boolean; /** * Human-readable server name. */ @@ -1340,6 +1357,16 @@ export type UpdateOperation = 'add' | 'remove' | 'replace'; * Request body for updating an MCP server. */ export type UpdateServerRequest = { + /** + * Pre-registered OAuth client ID for providers without DCR. Pass an + * empty string to clear it. + */ + client_id?: string | null; + /** + * Pre-registered OAuth client secret, if the provider issued one. Pass + * an empty string to clear it. + */ + client_secret?: string | null; /** * Enable or disable the server. */ diff --git a/packages/sdk/specs/cognition.json b/packages/sdk/specs/cognition.json index fa427b660d7..f5e68969e4f 100644 --- a/packages/sdk/specs/cognition.json +++ b/packages/sdk/specs/cognition.json @@ -1766,6 +1766,14 @@ "description": "Request body for adding a new MCP server.", "required": ["url", "server_name"], "properties": { + "client_id": { + "type": ["string", "null"], + "description": "Pre-registered OAuth client ID for providers that don't support\nDynamic Client Registration (e.g. HubSpot)." + }, + "client_secret": { + "type": ["string", "null"], + "description": "Pre-registered OAuth client secret, if the provider issued one." + }, "server_name": { "type": "string", "description": "Human-readable name for the server." @@ -3824,16 +3832,30 @@ "ServerResponse": { "type": "object", "description": "An MCP server record as returned by the API.", - "required": ["url", "server_name", "enabled", "authenticated"], + "required": [ + "url", + "server_name", + "enabled", + "authenticated", + "has_client_secret" + ], "properties": { "authenticated": { "type": "boolean", "description": "Whether the server has valid stored credentials." }, + "client_id": { + "type": ["string", "null"], + "description": "Pre-registered OAuth client ID, if the user supplied one." + }, "enabled": { "type": "boolean", "description": "Whether the server is enabled for tool use." }, + "has_client_secret": { + "type": "boolean", + "description": "Whether a pre-registered OAuth client secret is stored." + }, "server_name": { "type": "string", "description": "Human-readable server name." @@ -4160,6 +4182,14 @@ "description": "Request body for updating an MCP server.", "required": ["url"], "properties": { + "client_id": { + "type": ["string", "null"], + "description": "Pre-registered OAuth client ID for providers without DCR. Pass an\nempty string to clear it." + }, + "client_secret": { + "type": ["string", "null"], + "description": "Pre-registered OAuth client secret, if the provider issued one. Pass\nan empty string to clear it." + }, "enabled": { "type": ["boolean", "null"], "description": "Enable or disable the server."