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). */}
+
+
+
+
+
+
+
+ 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 (
+
+ );
+}
+
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