diff --git a/apps/docs/content/docs/platform/connected-accounts.mdx b/apps/docs/content/docs/platform/connected-accounts.mdx index 9d5d451c43f..d53631ecc38 100644 --- a/apps/docs/content/docs/platform/connected-accounts.mdx +++ b/apps/docs/content/docs/platform/connected-accounts.mdx @@ -38,6 +38,7 @@ In **Providers**, select **Add provider** and search the catalog. Complete any r | OAuth providers, such as Gmail or Google Drive | Add the provider from the available catalog. Each invited person authorizes their account. | | Slack | Supply the Slack App ID, Slack workspace ID, OAuth client ID, and client secret, then complete app verification. The organization uses one app and Slack workspace. Existing workspace Slack bots remain separate. | | Fireflies and Granola | Add the provider. Sim supplies the MCP endpoint and handles OAuth client registration. People only complete their own authorization. | +| HubSpot (member access) | The deployment supplies a registered HubSpot MCP OAuth app. People sign in and allow CRM read access. See [HubSpot Search](/search/hubspot). | | Databricks | Enter a name, the tenant's MCP URL, a registered OAuth client ID, and a client secret if required by that client. | For Databricks, **Add** validates and saves the configuration before the provider appears in the list. Cancelling the form leaves nothing added. Use an official Databricks HTTPS MCP endpoint, such as `https://your-workspace.cloud.databricks.com/api/2.0/mcp/sql`, and the OAuth client registered for your deployment. People connecting later use this organization configuration. diff --git a/apps/docs/content/docs/search/hubspot.mdx b/apps/docs/content/docs/search/hubspot.mdx new file mode 100644 index 00000000000..de8b464db5d --- /dev/null +++ b/apps/docs/content/docs/search/hubspot.mdx @@ -0,0 +1,22 @@ +--- +title: HubSpot +description: Search contacts, companies, deals, and tickets with your HubSpot permissions +--- + +HubSpot live Search uses **Member accounts** through HubSpot's official MCP service. An administrator enables HubSpot under **Settings → Sources**, then each person selects **Connect** under **Integrations** and signs in to HubSpot. Allow read access to contacts, companies, deals, and tickets. Write access is not needed. + +## What you can search + +Search names, domains, email addresses, deal names, and ticket subjects using concise keywords. Select a CRM kind to focus on contacts, companies, deals, or tickets; an unrestricted search checks all four. HubSpot searches each object's default searchable properties. It is lexical search, so a long natural-language question may need shorter keywords. + +Read a result to see its returned CRM properties, including available custom properties. Links open the verified record in HubSpot. Reads do not include activity histories, notes, or associated records. + +Dates and date sorting use the record's last modification time. To list records without keywords, select newest/oldest sorting or a date range. Continue an individual CRM kind with the returned cursor and the same query and filters. Searches are bounded and do not establish aggregate totals or revenue. Ownership, pipeline, lifecycle-stage filters, and custom object types are not supported; “my deals” is not treated as “all visible deals.” + +## Access and setup + +Every request uses the connected person's current HubSpot permissions. Search uses a fixed read-only tool allowlist and cannot create or change CRM records. Account permissions, subscription restrictions, and HubSpot request limits still apply. + +The deployment configures one HubSpot MCP OAuth app; teammates only sign in. This is separate from the ordinary HubSpot OAuth app used by workflow integrations. For a self-hosted deployment, register an MCP app in HubSpot, set `HUBSPOT_MCP_CLIENT_ID` and `HUBSPOT_MCP_CLIENT_SECRET`, and register the exact callback `/api/mcp/oauth/callback`. Sim uses `https://mcp.hubspot.com` and handles HubSpot's regional redirect. Replacing the shared registration requires people to reconnect. + +If HubSpot is unavailable in the source picker, ask the deployment administrator to configure the MCP app. If read access is missing, reconnect with the needed read permissions. See [HubSpot's remote MCP server documentation](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/integrate-with-the-remote-hubspot-mcp-server). diff --git a/apps/docs/content/docs/search/meta.json b/apps/docs/content/docs/search/meta.json index 1a7204395be..b9a091a9ca9 100644 --- a/apps/docs/content/docs/search/meta.json +++ b/apps/docs/content/docs/search/meta.json @@ -13,6 +13,7 @@ "google-calendar", "google-drive", "granola", + "hubspot", "jira", "linear", "notion", diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 33b321ba57d..983c7c3c209 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -261,3 +261,8 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # SLACK_SEARCH_CLIENT_SECRET= # SLACK_SEARCH_SIGNING_SECRET= # SLACK_SEARCH_SHARED_APP=false # Off-production fallback for the global slack-search-shared-app flag + +# HubSpot member search: deployment-owned MCP connector (separate from the REST OAuth app). +# Register ${NEXT_PUBLIC_APP_URL}/api/mcp/oauth/callback in the HubSpot MCP connector. +# HUBSPOT_MCP_CLIENT_ID= +# HUBSPOT_MCP_CLIENT_SECRET= diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx index aad02cc2093..2e4afdf955d 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx @@ -998,6 +998,7 @@ describe('live integrations backend selection', () => { }) mockUseOrganizationAccounts.mockReturnValue({ data: { + availableMcpConnectors: [], credentialGroup: { status: 'active', mcpServers: [], @@ -1031,6 +1032,7 @@ describe('live integrations backend selection', () => { mocks.integrations.mockReturnValue({ data: [{ connectorType: 'slack', approved: true }] }) mockUseOrganizationAccounts.mockReturnValue({ data: { + availableMcpConnectors: [], credentialGroup: { status: 'active', mcpServers: [], diff --git a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx index 628f33ac59a..67bd8e42897 100644 --- a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx @@ -66,6 +66,7 @@ const group = { } const inventory = (overrides = {}) => ({ credentialGroup: group, + availableMcpConnectors: [], viewerAccounts: [], viewerMcpAccounts: [], canManage: false, diff --git a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx index 514638ba675..ea564bebf92 100644 --- a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx @@ -75,6 +75,9 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt const available = LIVE_SEARCH_SOURCE_TYPES.filter( ([provider]) => LIVE_SEARCH_SCOPE_FIELDS[provider] && + (provider !== 'hubspot' || + data.availableMcpConnectors.includes('hubspot') || + mcpAccounts(provider).length > 0) && (approvals.get(provider)?.approved || data.viewerAccounts?.some( (account) => liveSearchProviderForCredential(account.providerId) === provider @@ -130,7 +133,8 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt group?.status === 'active' && Boolean(option || server) && approved && - (!option || option.configurationStatus === 'ready') + (!option || option.configurationStatus === 'ready') && + (provider !== 'hubspot' || data.availableMcpConnectors.includes('hubspot')) const scope = approval?.policy?.accessMode === 'service_account' ? 'Selected resources you can access' diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx index b44fd323bef..2ec2f6c5a3e 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx @@ -27,6 +27,7 @@ interface AddOrganizationSourceModalProps { type: string meta: Pick & { auth?: ConnectorMeta['auth'] } access: { admin: boolean; members: boolean } + availabilityStatus?: 'loading' | 'error' }[] pending: boolean ready: boolean @@ -52,8 +53,9 @@ export function AddOrganizationSourceModal({ const visible = sources.filter(({ meta }) => meta.name.toLowerCase().includes(query)) const list = (
- {visible.map(({ type, meta, access }) => { + {visible.map(({ type, meta, access, availabilityStatus }) => { const available = access.admin || access.members + const sourceReady = ready && !availabilityStatus return ( onSelect(type, access.admin ? 'admin' : 'members')} clickLabel={`Set up ${meta.name}`} - navigable={ready && available} + navigable={sourceReady && available} /> ) })} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx index 433aad623a5..191f224ecc8 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx @@ -86,8 +86,15 @@ export function LiveSearchSettings() { ).map(([type, meta]) => ({ type, meta, + availabilityStatus: + type !== 'hubspot' || accounts.isSuccess + ? undefined + : accounts.isError + ? ('error' as const) + : ('loading' as const), access: getLiveSearchAccessAvailability(type, availability.integrationAvailability, { memberAccessAvailable: searchAccess.memberScoped, + availableMcpConnectors: accounts.data?.availableMcpConnectors, mirroredAccessAvailable: searchAccess.sourceMirrored, oauthServiceAvailability: availability.oauthServiceAvailability, isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, @@ -301,7 +308,17 @@ export function LiveSearchSettings() { )} pending={update.isPending} ready={availability.isIntegrationAvailabilityReady} - feedback={null} + feedback={ + accounts.error ? ( + void accounts.refetch()} + variant='inline' + /> + ) : null + } onClose={() => setAdding(false)} onSelect={(type, mode) => { if (type === GENERIC_SECRETS_SOURCE_TYPE) { diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts index 12dcfe6e29c..f078177c862 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts @@ -1,4 +1,5 @@ export { + BrandTile, RESOURCE_TILE_BASE, RESOURCE_TILE_FILL, RESOURCE_TILE_PLAIN, diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx index e334f69116a..4bd7a0a773e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx @@ -1,5 +1,6 @@ import type { ComponentType } from 'react' import { cn } from '@sim/emcn' +import { getTileIconColorClass } from '@/blocks/icon-color' interface ResourceTileProps { icon: ComponentType<{ className?: string }> @@ -31,3 +32,23 @@ export function ResourceTile({ icon: Icon }: ResourceTileProps) {
) } + +interface BrandTileProps { + icon: ComponentType<{ className?: string }> + background: string | null | undefined +} + +/** Shared brand treatment for integration and credential rows. */ +export function BrandTile({ icon: Icon, background }: BrandTileProps) { + return ( +
+ +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx index 21335506d23..a461e2ad280 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx @@ -1,9 +1,6 @@ import type { ComponentType } from 'react' import { cn } from '@sim/emcn' -import { - RESOURCE_TILE_BASE, - RESOURCE_TILE_PLAIN, -} from '@/app/workspace/[workspaceId]/components/resource-tile' +import { BrandTile } from '@/app/workspace/[workspaceId]/components/resource-tile' import { getBlock } from '@/blocks' import { getTileIconColorClass } from '@/blocks/icon-color' @@ -71,16 +68,7 @@ interface IntegrationTileProps { export function IntegrationTile({ blockType, icon: Icon, framed = false }: IntegrationTileProps) { const brandBg = resolveBrandTileBg(blockType) - if (!framed) { - return ( -
- -
- ) - } + if (!framed) return return (
diff --git a/apps/sim/ee/credential-groups/components/credential-group-details.tsx b/apps/sim/ee/credential-groups/components/credential-group-details.tsx index 07e54e01afe..073d0b0d6f5 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-details.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-details.tsx @@ -32,6 +32,7 @@ import { SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { CredentialGroupProviderTile } from '@/ee/credential-groups/components/credential-group-provider-tile' import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal' import { useCreateCredentialGroupMcpConnector, @@ -199,6 +200,11 @@ export function CredentialGroupDetails({ return getCredentialGroupProviderService(provider).name.toLowerCase().includes(providerQuery) }) const shownMcpConnectors = MANAGED_MCP_CONNECTOR_IDS.filter((connectorId) => { + if ( + !credentialGroup.mcpServers.some((server) => server.managedConnectorId === connectorId) && + !accounts.data?.availableMcpConnectors.includes(connectorId) + ) + return false if (!providerQuery) return true const connector = MANAGED_MCP_CONNECTORS[connectorId] return ( @@ -245,7 +251,8 @@ export function CredentialGroupDetails({ return ( } + iconVariant='custom' + icon={} title={service.name} description={descriptionText} badge={ @@ -318,7 +325,8 @@ export function CredentialGroupDetails({ return ( } + iconVariant='custom' + icon={} title={server?.name ?? connector.name} description={ connectorId === 'databricks' diff --git a/apps/sim/ee/credential-groups/components/credential-group-provider-tile.tsx b/apps/sim/ee/credential-groups/components/credential-group-provider-tile.tsx new file mode 100644 index 00000000000..34ff2c1e2c6 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-provider-tile.tsx @@ -0,0 +1,24 @@ +import type { ComponentType } from 'react' +import { getIntegrationTypesForOAuthServiceId } from '@sim/deployment-config/integration-availability' +import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata' +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +import { blockTypeToIconMap } from '@/lib/integrations/icon-mapping' +import { BrandTile } from '@/app/workspace/[workspaceId]/components/resource-tile' + +const INTEGRATION_BY_TYPE = new Map(INTEGRATION_METADATA.map((entry) => [entry.type, entry])) + +interface CredentialGroupProviderTileProps { + provider: CredentialGroupProvider | ManagedMcpConnectorId + icon: ComponentType<{ className?: string }> +} + +export function CredentialGroupProviderTile({ provider, icon }: CredentialGroupProviderTileProps) { + const blockType = getIntegrationTypesForOAuthServiceId(provider)[0] ?? provider + return ( + + ) +} diff --git a/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx b/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx index ad925272d4c..584e7120479 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx @@ -27,6 +27,7 @@ import { RESOURCE_LIST_STACK, SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { CredentialGroupProviderTile } from '@/ee/credential-groups/components/credential-group-provider-tile' export type OrganizationAccountProviderChoice = | { kind: 'oauth'; provider: CredentialGroupProvider } @@ -68,7 +69,10 @@ export function OrganizationAccountProviderCatalog({ ) ) .map((connectorId) => ({ - name: MANAGED_MCP_CONNECTORS[connectorId].name, + name: + connectorId === 'hubspot' + ? 'HubSpot (member access)' + : MANAGED_MCP_CONNECTORS[connectorId].name, icon: getManagedMcpConnectorIcon(connectorId), choice: { kind: 'mcp', connectorId } as const, })), @@ -108,8 +112,18 @@ export function OrganizationAccountProviderCatalog({
{providers.map(({ name, icon: Icon, choice }) => ( } + key={ + choice.kind === 'oauth' + ? `oauth:${choice.provider}` + : `mcp:${choice.connectorId}` + } + iconVariant='custom' + icon={ + + } title={name} trailing={ - (!availableMcpConnectors || availableMcpConnectors.includes(server.managedConnectorId)) && - (server.managedConnectorId !== 'databricks' || server.enabled) - ) + .filter((server) => server.managedConnectorId !== 'databricks' || server.enabled) .map((server) => ({ id: server.id, - name: MANAGED_MCP_CONNECTORS[server.managedConnectorId].name, + name: + server.managedConnectorId === 'hubspot' + ? 'HubSpot (member access)' + : MANAGED_MCP_CONNECTORS[server.managedConnectorId].name, icon: getManagedMcpConnectorIcon(server.managedConnectorId), configure: server.managedConnectorId === 'databricks' ? () => setDatabricksOpen(true) : undefined, @@ -221,7 +221,13 @@ export function OrganizationAccountProviders({ {visibleRows.map(({ id, name, icon: Icon, configure, choice }) => ( } + iconVariant='custom' + icon={ + + } title={name} trailing={
diff --git a/apps/sim/ee/credential-groups/components/organization-connected-accounts.tsx b/apps/sim/ee/credential-groups/components/organization-connected-accounts.tsx index 31d65ed7a13..6c2ea23fe51 100644 --- a/apps/sim/ee/credential-groups/components/organization-connected-accounts.tsx +++ b/apps/sim/ee/credential-groups/components/organization-connected-accounts.tsx @@ -80,6 +80,7 @@ export function OrganizationConnectedAccounts({ organizationId={organizationId} group={group} availableProviders={accounts.data.availableProviders} + availableMcpConnectors={accounts.data.availableMcpConnectors} /> )} {tab === 'people' && } diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index 724fdec320e..91349131b7f 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -371,6 +371,7 @@ export const createCredentialGroupMcpConnectorBodySchema = z.discriminatedUnion( z.object({ connectorId: z.literal('granola') }).strict(), z.object({ connectorId: z.literal('notion') }).strict(), z.object({ connectorId: z.literal('coda') }).strict(), + z.object({ connectorId: z.literal('hubspot') }).strict(), z .object({ connectorId: z.literal('databricks'), @@ -409,6 +410,7 @@ export const workspaceAccountsSettingsSchema = z.object({ * so an admin is never shown an account type nobody could finish connecting. */ availableProviders: z.array(credentialGroupProviderSchema), + availableMcpConnectors: z.array(managedMcpConnectorIdSchema), }) export type WorkspaceAccountsSettings = z.output diff --git a/apps/sim/lib/api/contracts/mothership-assistant-tools.ts b/apps/sim/lib/api/contracts/mothership-assistant-tools.ts index 3441e477f52..70289cee3b0 100644 --- a/apps/sim/lib/api/contracts/mothership-assistant-tools.ts +++ b/apps/sim/lib/api/contracts/mothership-assistant-tools.ts @@ -11,24 +11,30 @@ export const NOTION_SEARCH_TERMS_REQUIRED = * Native queries one call may send to the same provider account. Alternatives run as separate * provider searches and fuse into one ranking, so the bound keeps a call within the provider's * burst limits (Slack allows about ten searches per user per minute) while leaving room for the - * four GitHub or GitLab kinds. + * four independently searchable kinds in GitHub, GitLab, or HubSpot. */ export const MAX_NATIVE_QUERIES_PER_ACCOUNT = 4 /** - * Providers whose `kind` selects a separate search endpoint. They take one query per kind: their - * query languages already join alternatives with OR, and each extra query fans out into several - * repository or project requests against strict search rate limits. + * Providers whose kind selects a distinct search collection. One query per kind bounds fanout + * while retaining independently searchable collections within the per-account request limit. */ -const KIND_PROVIDERS: ReadonlySet = new Set(['github', 'gitlab']) +const PROVIDER_KIND_SCHEMAS = { + github: z.enum(['issues', 'code', 'repositories', 'commits']), + gitlab: z.enum(['issues', 'code', 'merge_requests', 'wiki']), + hubspot: z.enum(['contacts', 'companies', 'deals', 'tickets']), +} as const + +function hasSearchKinds( + provider: LiveSearchProvider +): provider is keyof typeof PROVIDER_KIND_SCHEMAS { + return Object.hasOwn(PROVIDER_KIND_SCHEMAS, provider) +} const nativeSearchKindSchema = z.enum([ - 'issues', - 'code', - 'repositories', - 'commits', - 'merge_requests', - 'wiki', + ...PROVIDER_KIND_SCHEMAS.github.options, + ...PROVIDER_KIND_SCHEMAS.gitlab.options, + ...PROVIDER_KIND_SCHEMAS.hubspot.options, ]) /** Queries are data for fixed read-only provider endpoints, never URLs or credentials. */ @@ -46,6 +52,15 @@ export const nativeSearchQuerySchema = z }) .strict() .superRefine((input, context) => { + if (input.kind && hasSearchKinds(input.provider)) { + const kinds = PROVIDER_KIND_SCHEMAS[input.provider] + if (!kinds.safeParse(input.kind).success) + context.addIssue({ + code: 'custom', + path: ['kind'], + message: `${input.provider} kind must be one of: ${kinds.options.join(', ')}.`, + }) + } if (input.provider === 'notion' && !input.query) context.addIssue({ code: 'custom', @@ -78,7 +93,7 @@ export const nativeSearchQueriesSchema = z } /** The search a query runs, ignoring its account and any kind its provider does not use. */ const searchKey = ({ accountId: _, kind, ...query }: NativeSearchQuery) => - JSON.stringify({ ...query, kind: KIND_PROVIDERS.has(query.provider) ? kind : undefined }) + JSON.stringify({ ...query, kind: hasSearchKinds(query.provider) ? kind : undefined }) for (const [index, query] of queries.entries()) { const addIssue = (message: string) => context.addIssue({ code: 'custom', path: [index], message }) @@ -86,11 +101,11 @@ export const nativeSearchQueriesSchema = z if (earlier.some((previous) => searchKey(previous) === searchKey(query))) addIssue('Duplicate native query.') else if ( - KIND_PROVIDERS.has(query.provider) && + hasSearchKinds(query.provider) && earlier.some((previous) => !previous.kind || !query.kind || previous.kind === query.kind) ) addIssue( - 'Send one GitHub or GitLab query per account and kind; join alternatives with OR in one query (GitHub code search has no OR, so search code alternatives in another call).' + 'Send one query per account and kind for GitHub, GitLab, or HubSpot. Use provider-supported operators for alternatives, or send another call.' ) else if (busiestAccountLoad(earlier) >= MAX_NATIVE_QUERIES_PER_ACCOUNT) addIssue( @@ -172,7 +187,7 @@ export const searchWorkspaceInputSchema = workspaceSearchFiltersSchema nativeQueries: nativeSearchQueriesSchema .optional() .describe( - `Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies terms, Granola natural-language questions, Notion keywords or AI questions when available). Notion requires nonempty search terms even with dates or sorting. Up to ${MAX_NATIVE_QUERIES_PER_ACCOUNT} per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.` + `Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies/HubSpot terms, Granola natural-language questions, Notion keywords or AI questions when available). Blank queries require a date bound or sortBy newest/oldest; Notion always requires search terms. Up to ${MAX_NATIVE_QUERIES_PER_ACCOUNT} per account run separately and merge; GitHub, GitLab, and HubSpot take one per kind. HubSpot kinds are contacts, companies, deals, and tickets; ownership filters are unsupported. Write queries from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.` ), query: z .string() diff --git a/apps/sim/lib/api/contracts/organization-accounts.ts b/apps/sim/lib/api/contracts/organization-accounts.ts index 81cd37a2ead..376098f9f30 100644 --- a/apps/sim/lib/api/contracts/organization-accounts.ts +++ b/apps/sim/lib/api/contracts/organization-accounts.ts @@ -45,6 +45,7 @@ export const getOrganizationAccountsContract = defineRouteContract({ schema: z.object({ credentialGroup: organizationCredentialGroupSchema.nullable(), availableProviders: z.array(credentialGroupProviderSchema), + availableMcpConnectors: z.array(managedMcpConnectorIdSchema), canManage: z.boolean(), indexingAvailable: z.boolean(), viewerMcpAccounts: z diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index d213f8fe180..0beda040bd2 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -540,6 +540,8 @@ export const env = createEnv({ DOCUSIGN_AUTH_HOST: z.string().optional(), // DocuSign auth host: account-d.docusign.com (demo, default) or account.docusign.com (production) MICROSOFT_CLIENT_ID: z.string().optional(), // Microsoft OAuth client ID for Office 365/Teams MICROSOFT_CLIENT_SECRET: z.string().optional(), // Microsoft OAuth client secret + HUBSPOT_MCP_CLIENT_ID: z.string().optional(), // HubSpot member MCP OAuth client ID + HUBSPOT_MCP_CLIENT_SECRET: z.string().optional(), // HubSpot member MCP OAuth client secret HUBSPOT_CLIENT_ID: z.string().optional(), // HubSpot OAuth client ID HUBSPOT_CLIENT_SECRET: z.string().optional(), // HubSpot OAuth client secret SALESFORCE_CLIENT_ID: z.string().optional(), // Salesforce OAuth client ID diff --git a/apps/sim/lib/credential-groups/__integration__/hubspot-mcp.integration.ts b/apps/sim/lib/credential-groups/__integration__/hubspot-mcp.integration.ts new file mode 100644 index 00000000000..4f7dab8928c --- /dev/null +++ b/apps/sim/lib/credential-groups/__integration__/hubspot-mcp.integration.ts @@ -0,0 +1,339 @@ +/** Real storage, encryption, and runtime grant binding for the shared HubSpot client. */ + +import { auth } from '@modelcontextprotocol/sdk/client/auth.js' +import { db } from '@sim/db' +import { + credential, + credentialGroupEnrollment, + mcpServers, + organization, + user, +} from '@sim/db/schema' +import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure' +import { sha256Hex } from '@sim/security/hash' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { env } from '@/lib/core/config/env' +import { encryptSecret } from '@/lib/core/security/encryption' +import { + completePublicCredentialGroupMcpOAuth, + startPublicCredentialGroupMcpOAuth, +} from '@/lib/credential-groups/application/public-enrollment' +import { createManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-service' +import { consumeCredentialGroupMcpOAuthAttempt } from '@/lib/credential-groups/mcp-oauth-state' +import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service' +import { + encryptManagedMcpTokens, + loadScopedManagedMcpRuntimeCredential, + saveManagedMcpRuntimeTokens, +} from '@/lib/credentials/managed-mcp' +import * as oauth from '@/lib/mcp/oauth/auth' +import { loadPreregisteredClient } from '@/lib/mcp/oauth/provider' +import { getOrCreateOauthRow, saveClientInformation } from '@/lib/mcp/oauth/storage' +import { mcpService } from '@/lib/mcp/service' + +const SECRET = 'isolated-shared-client-secret' +describe('HubSpot shared member connector', () => { + let owner: string + let org: string + let group: string + beforeEach(async () => { + Object.assign(env, { + REDIS_URL: readTestRedisUrl(), + HUBSPOT_MCP_CLIENT_ID: 'fixture-shared-client', + HUBSPOT_MCP_CLIENT_SECRET: SECRET, + }) + owner = generateId() + org = generateId() + await db.insert(user).values({ + id: owner, + name: 'Fixture', + email: `${owner}@fixture.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + await db.insert(organization).values({ id: org, name: 'Fixture', slug: org }) + group = ( + await ensureWorkspaceAccountsGroup({ kind: 'organization', organizationId: org }, owner) + ).id + }) + afterEach(async () => { + vi.restoreAllMocks() + await db.delete(organization).where(eq(organization.id, org)) + await db.delete(user).where(eq(user.id, owner)) + }) + const create = () => + createManagedMcpConnector({ + organizationId: org, + credentialGroupId: group, + userId: owner, + input: { connectorId: 'hubspot' }, + }) + const stored = async (id: string) => { + const [row] = await db.select().from(mcpServers).where(eq(mcpServers.id, id)) + if (!row) throw new Error('Missing fixture server') + return row + } + const grant = async (serverId: string, fingerprint?: string) => { + const enrollmentId = generateId() + const id = `mcp-cg-${generateId()}` + await db.insert(credentialGroupEnrollment).values({ + id: enrollmentId, + credentialGroupId: group, + userId: owner, + email: `${owner}@fixture.test`, + status: 'completed', + invitationTokenHash: sha256Hex(generateId()), + invitationExpiresAt: new Date(Date.now() + 60_000), + invitedAt: new Date(), + }) + await db.insert(credential).values({ + id, + organizationId: org, + type: 'managed_mcp', + displayName: 'Fixture', + grantedAt: new Date(), + mcpTools: [], + credentialGroupEnrollmentId: enrollmentId, + mcpServerId: serverId, + mcpOauthConfigVersion: (await stored(serverId)).oauthConfigVersion, + managedOauthStatus: 'active', + encryptedOauthTokenSet: await encryptManagedMcpTokens( + { + access_token: 'fixture-access', + refresh_token: 'fixture-refresh', + token_type: 'Bearer', + }, + fingerprint + ), + }) + return id + } + const runtime = (id: string) => + loadScopedManagedMcpRuntimeCredential(id, { kind: 'organization', organizationId: org }, owner) + + it('uses shared registration without copying credentials into organization storage or responses', async () => { + const result = await create() + expect(JSON.stringify(result)).not.toContain(SECRET) + const row = await stored(result.mcpServer.id) + expect(row.oauthClientId).toBeNull() + expect(row.oauthClientSecret).toBeNull() + expect(await loadPreregisteredClient(row.id)).toMatchObject({ + clientId: 'fixture-shared-client', + clientSecret: SECRET, + }) + }) + it('rejects incomplete shared configuration instead of falling back to the ordinary OAuth app', async () => { + Object.assign(env, { + HUBSPOT_MCP_CLIENT_SECRET: undefined, + HUBSPOT_CLIENT_ID: 'rest-client', + HUBSPOT_CLIENT_SECRET: 'rest-secret', + }) + await expect(create()).rejects.toThrow(/HubSpot.*configured/i) + expect( + await db.select().from(mcpServers).where(eq(mcpServers.credentialGroupId, group)) + ).toEqual([]) + }) + it('never releases shared credentials to a substituted endpoint, different provider, disabled, or deleted server', async () => { + const { mcpServer } = await create() + const baseline = await stored(mcpServer.id) + for (const change of [ + { url: 'https://example.com/mcp' }, + { managedConnectorId: 'notion' }, + { enabled: false }, + { deletedAt: new Date() }, + ]) { + await db.update(mcpServers).set(change).where(eq(mcpServers.id, mcpServer.id)) + await expect(loadPreregisteredClient(mcpServer.id)).resolves.toBeUndefined() + await db + .update(mcpServers) + .set({ + url: baseline.url, + managedConnectorId: baseline.managedConnectorId, + credentialGroupId: baseline.credentialGroupId, + enabled: baseline.enabled, + deletedAt: baseline.deletedAt, + }) + .where(eq(mcpServers.id, mcpServer.id)) + } + }) + it('retains saved registrations and rejects partial or corrupt saved data without switching clients', async () => { + const { mcpServer } = await create() + const encrypted = (await encryptSecret('saved-secret')).encrypted + await db + .update(mcpServers) + .set({ oauthClientId: 'saved-client', oauthClientSecret: encrypted }) + .where(eq(mcpServers.id, mcpServer.id)) + Object.assign(env, { HUBSPOT_MCP_CLIENT_ID: undefined, HUBSPOT_MCP_CLIENT_SECRET: undefined }) + const existingGrant = await grant(mcpServer.id) + expect((await runtime(existingGrant)).tokens.access_token).toBe('fixture-access') + expect(await loadPreregisteredClient(mcpServer.id)).toEqual({ + clientId: 'saved-client', + clientSecret: 'saved-secret', + }) + for (const change of [ + { oauthClientId: null, oauthClientSecret: encrypted }, + { oauthClientId: 'saved-client', oauthClientSecret: null }, + { oauthClientId: 'saved-client', oauthClientSecret: 'corrupt' }, + ]) { + await db.update(mcpServers).set(change).where(eq(mcpServers.id, mcpServer.id)) + await expect(loadPreregisteredClient(mcpServer.id)).rejects.toThrow() + } + }) + it('rejects a grant after shared client rotation and rejects an unbound platform grant', async () => { + const { mcpServer } = await create() + const client = await loadPreregisteredClient(mcpServer.id) + const id = await grant(mcpServer.id, client?.configurationFingerprint) + expect((await runtime(id)).tokens.access_token).toBe('fixture-access') + Object.assign(env, { HUBSPOT_MCP_CLIENT_SECRET: 'rotated-secret' }) + await expect(runtime(id)).rejects.toThrow(/authorization/) + Object.assign(env, { HUBSPOT_MCP_CLIENT_SECRET: SECRET }) + await db + .update(credential) + .set({ + encryptedOauthTokenSet: await encryptManagedMcpTokens({ + access_token: 'unbound-fixture', + token_type: 'Bearer', + }), + }) + .where(eq(credential.id, id)) + await expect(runtime(id)).rejects.toThrow(/authorization/) + }) + it('preserves shared registration binding through refresh and retains token compare-and-swap', async () => { + const { mcpServer } = await create() + const client = await loadPreregisteredClient(mcpServer.id) + const id = await grant(mcpServer.id, client?.configurationFingerprint) + const before = await runtime(id) + await saveManagedMcpRuntimeTokens( + id, + { access_token: 'refreshed', token_type: 'Bearer' }, + before.tokenVersion + ) + expect((await runtime(id)).tokens.access_token).toBe('refreshed') + await expect( + saveManagedMcpRuntimeTokens( + id, + { access_token: 'stale', token_type: 'Bearer' }, + before.tokenVersion + ) + ).rejects.toThrow(/changed/) + Object.assign(env, { HUBSPOT_MCP_CLIENT_SECRET: 'rotated-secret' }) + await expect(runtime(id)).rejects.toThrow(/authorization/) + }) + it('binds the public OAuth round trip to the shared client and rejects rotation before exchange', async () => { + const { mcpServer } = await create() + const token = generateId() + const enrollmentId = generateId() + const email = `${owner}@fixture.test` + await db.insert(credentialGroupEnrollment).values({ + id: enrollmentId, + credentialGroupId: group, + userId: owner, + email, + status: 'in_progress', + invitationTokenHash: sha256Hex(token), + invitationExpiresAt: new Date(Date.now() + 60_000), + invitedAt: new Date(), + }) + const principal = { + kind: 'credential_group_enrollment' as const, + userId: owner, + organizationId: org, + credentialGroupId: group, + enrollmentId, + email, + invitationTokenHash: sha256Hex(token), + } + const row = await getOrCreateOauthRow({ mcpServerId: mcpServer.id, organizationId: org }) + await saveClientInformation(row.id, { + client_id: 'stale-dynamic-client', + redirect_uris: ['http://localhost:3000/api/mcp/oauth/callback'], + }) + let exchanges = 0 + let challenge: string | null = null + const issuer = 'https://oauth.fixture.test' + /** Only provider HTTP is substituted; SDK, PKCE, Redis, use cases, encryption and database are real. */ + const fetchFn: typeof fetch = async (request, init) => { + const url = new URL( + typeof request === 'string' ? request : request instanceof URL ? request : request.url + ) + if (url.pathname.includes('oauth-protected-resource')) + return Response.json({ + resource: 'https://mcp.hubspot.com', + authorization_servers: [issuer], + }) + if ( + url.pathname.includes('oauth-authorization-server') || + url.pathname.includes('openid-configuration') + ) + return Response.json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['client_secret_post'], + }) + if (url.href === `${issuer}/token`) { + exchanges++ + const body = new URLSearchParams(String(init?.body)) + expect(body.get('client_id')).toBe('fixture-shared-client') + expect(body.get('client_secret')).toBe(SECRET) + expect( + Buffer.from(sha256Hex(body.get('code_verifier') ?? ''), 'hex').toString('base64url') + ).toBe(challenge) + return Response.json({ + access_token: 'exchanged-token', + refresh_token: 'exchanged-refresh', + token_type: 'Bearer', + }) + } + throw new Error(`Unexpected OAuth fixture request: ${url.origin}${url.pathname}`) + } + vi.spyOn(oauth, 'mcpAuthGuarded').mockImplementation((provider, options) => + auth(provider, { ...options, fetchFn }) + ) + vi.spyOn(mcpService, 'discoverManagedMcpTools').mockResolvedValue([]) + const start = async () => { + const result = await startPublicCredentialGroupMcpOAuth.execute({ + principal, + input: { mcpServerId: mcpServer.id, invitationToken: token }, + }) + const url = new URL(result.authorizationUrl) + expect(url.searchParams.get('client_id')).toBe('fixture-shared-client') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + challenge = url.searchParams.get('code_challenge') + expect(challenge).toBeTruthy() + const attempt = await consumeCredentialGroupMcpOAuthAttempt(url.searchParams.get('state')!) + expect(attempt).not.toBeNull() + return attempt! + } + const attempt = await start() + await completePublicCredentialGroupMcpOAuth.execute({ + principal, + input: { attempt, code: 'fixture-code' }, + }) + const [saved] = await db + .select({ id: credential.id }) + .from(credential) + .where(eq(credential.credentialGroupEnrollmentId, enrollmentId)) + expect((await runtime(saved!.id)).tokens.access_token).toBe('exchanged-token') + expect(exchanges).toBe(1) + const next = await start() + Object.assign(env, { HUBSPOT_MCP_CLIENT_SECRET: 'rotated-secret' }) + await expect( + completePublicCredentialGroupMcpOAuth.execute({ + principal, + input: { attempt: next, code: 'another-code' }, + }) + ).rejects.toThrow(/changed/) + expect(exchanges).toBe(1) + const grants = await db + .select({ id: credential.id }) + .from(credential) + .where(eq(credential.credentialGroupEnrollmentId, enrollmentId)) + expect(grants).toEqual([{ id: saved!.id }]) + }) +}) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.ts b/apps/sim/lib/credential-groups/application/manage-groups.ts index 29724aa8d29..f42af48462c 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.ts @@ -16,7 +16,10 @@ import { CredentialGroupEnrollmentError, listCredentialGroupEnrollments, } from '@/lib/credential-groups/enrollments' -import { listConfiguredCredentialGroupProviders } from '@/lib/credential-groups/provider-availability' +import { + listConfiguredCredentialGroupProviders, + listConfiguredManagedMcpConnectors, +} from '@/lib/credential-groups/provider-availability' import { ensureWorkspaceAccountsGroup, getCredentialGroup, @@ -36,9 +39,11 @@ export const getWorkspaceAccountsSettings = defineAuthorizedWorkspaceUseCase({ authorizationOptions: { delegation: workspaceAccountsSettingsDelegationPolicy }, async execute({ context }) { await requireCredentialGroupSettingsAvailable(context.workspaceId) + const credentialGroup = await getWorkspaceAccountsGroup(context.workspaceId) return { - credentialGroup: await getWorkspaceAccountsGroup(context.workspaceId), + credentialGroup, availableProviders: listConfiguredCredentialGroupProviders(), + availableMcpConnectors: await listConfiguredManagedMcpConnectors(credentialGroup?.id), } }, }) diff --git a/apps/sim/lib/credential-groups/application/organization-access.test.ts b/apps/sim/lib/credential-groups/application/organization-access.test.ts index d6a0118f311..49fe9deaab7 100644 --- a/apps/sim/lib/credential-groups/application/organization-access.test.ts +++ b/apps/sim/lib/credential-groups/application/organization-access.test.ts @@ -45,6 +45,7 @@ vi.mock('@/lib/permission-groups/resolve.server', () => permissionGroupsResolveM vi.mock('@/lib/credential-groups/service', () => credentialGroupsServiceMock) vi.mock('@/lib/credential-groups/provider-availability', () => ({ listConfiguredCredentialGroupProviders: vi.fn(), + listConfiguredManagedMcpConnectors: vi.fn().mockResolvedValue([]), })) vi.mock('@/lib/knowledge/access/availability', () => knowledgeAvailabilityMock) vi.mock('@/lib/credential-groups/self-enrollment', () => credentialGroupsSelfEnrollmentMock) diff --git a/apps/sim/lib/credential-groups/application/organization-accounts.ts b/apps/sim/lib/credential-groups/application/organization-accounts.ts index 99ff62b27ed..bd51cf4cf16 100644 --- a/apps/sim/lib/credential-groups/application/organization-accounts.ts +++ b/apps/sim/lib/credential-groups/application/organization-accounts.ts @@ -23,7 +23,10 @@ import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollme import { ManagedMcpConnectorError } from '@/lib/credential-groups/managed-mcp-service' import type { CredentialGroupConnectionIntent } from '@/lib/credential-groups/oauth-intent' import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' -import { listConfiguredCredentialGroupProviders } from '@/lib/credential-groups/provider-availability' +import { + listConfiguredCredentialGroupProviders, + listConfiguredManagedMcpConnectors, +} from '@/lib/credential-groups/provider-availability' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' import { startViewerCredentialGroupOAuth } from '@/lib/credential-groups/self-enrollment-oauth' @@ -173,6 +176,7 @@ export const getOrganizationAccountsSettings = defineOrganizationAccountsUseCase }) : [], availableProviders: listConfiguredCredentialGroupProviders(), + availableMcpConnectors: await listConfiguredManagedMcpConnectors(credentialGroup?.id), canManage: context.role === 'owner' || context.role === 'admin', indexingAvailable: await isKnowledgeMemberAccessAvailable({ organizationId: context.organizationId, diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.test.ts b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts index 427b83f463d..eeffeececc7 100644 --- a/apps/sim/lib/credential-groups/application/public-enrollment.test.ts +++ b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts @@ -397,12 +397,6 @@ describe('public Credential Group enrollment application operations', () => { await expect( completePublicCredentialGroupMcpOAuth.execute({ principal, input: { attempt, code: 'code' } }) ).resolves.toEqual({ connectionId: 'mcp-cg-person', mcpServerId: 'mcp-server-1' }) - expect(mocks.completeMcpOAuth).toHaveBeenCalledWith( - expect.any(Object), - 'verifier', - 'code', - invitationToken - ) expect(mocks.fireTrigger).toHaveBeenCalledWith( expect.objectContaining({ event: 'credential_added', diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.ts b/apps/sim/lib/credential-groups/application/public-enrollment.ts index a45f06f9135..d0f0e44a3df 100644 --- a/apps/sim/lib/credential-groups/application/public-enrollment.ts +++ b/apps/sim/lib/credential-groups/application/public-enrollment.ts @@ -357,7 +357,8 @@ export const completePublicCredentialGroupMcpOAuth = context.oauth, input.attempt.codeVerifier, input.code, - input.attempt.invitationToken + input.attempt.invitationToken, + input.attempt.configurationFingerprint ) if (context.organizationId) await fireCredentialGroupTrigger({ diff --git a/apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts b/apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts index c4cdaaeebb2..ed3ce05a2b8 100644 --- a/apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts +++ b/apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts @@ -3,6 +3,7 @@ import { DatabricksIcon, FirefliesIcon, GranolaIcon, + HubspotIcon, NotionIcon, } from '@/components/icons' import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' @@ -10,6 +11,7 @@ import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp- export const MANAGED_MCP_CONNECTOR_ICONS = { fireflies: FirefliesIcon, granola: GranolaIcon, + hubspot: HubspotIcon, coda: CodaIcon, notion: NotionIcon, databricks: DatabricksIcon, diff --git a/apps/sim/lib/credential-groups/managed-mcp-connectors.ts b/apps/sim/lib/credential-groups/managed-mcp-connectors.ts index c772fb15b60..3c6f71632d2 100644 --- a/apps/sim/lib/credential-groups/managed-mcp-connectors.ts +++ b/apps/sim/lib/credential-groups/managed-mcp-connectors.ts @@ -4,12 +4,13 @@ export const MANAGED_MCP_CONNECTOR_IDS = [ 'databricks', 'coda', 'notion', + 'hubspot', ] as const export type ManagedMcpConnectorId = (typeof MANAGED_MCP_CONNECTOR_IDS)[number] interface FixedManagedMcpConnector { - id: Exclude + id: Exclude name: string description: string url: string @@ -23,9 +24,27 @@ interface DatabricksManagedMcpConnector { oauthClientRegistration: 'preregistered' } -export type ManagedMcpConnector = FixedManagedMcpConnector | DatabricksManagedMcpConnector +interface HubSpotManagedMcpConnector { + id: 'hubspot' + name: string + description: string + url: string + oauthClientRegistration: 'preregistered' +} + +export type ManagedMcpConnector = + | FixedManagedMcpConnector + | DatabricksManagedMcpConnector + | HubSpotManagedMcpConnector export const MANAGED_MCP_CONNECTORS = { + hubspot: { + id: 'hubspot', + name: 'HubSpot', + description: 'Search CRM records using each person’s HubSpot permissions', + url: 'https://mcp.hubspot.com', + oauthClientRegistration: 'preregistered', + }, coda: { id: 'coda', name: 'Coda', diff --git a/apps/sim/lib/credential-groups/managed-mcp-service.ts b/apps/sim/lib/credential-groups/managed-mcp-service.ts index 656c249e5b0..3bf6c120675 100644 --- a/apps/sim/lib/credential-groups/managed-mcp-service.ts +++ b/apps/sim/lib/credential-groups/managed-mcp-service.ts @@ -28,6 +28,7 @@ import { validateMcpDomain, validateMcpServerSsrf, } from '@/lib/mcp/domain-check' +import { getSharedHubSpotMcpClient } from '@/lib/mcp/oauth/shared-clients' import { generateMcpServerId } from '@/lib/mcp/utils' export class ManagedMcpConnectorError extends Error { @@ -143,6 +144,11 @@ export interface ValidatedManagedMcpConnectorInput { export async function validateManagedMcpConnectorInput( input: CreateManagedMcpConnectorInput ): Promise { + if (input.connectorId === 'hubspot' && !getSharedHubSpotMcpClient()) + throw new ManagedMcpConnectorError( + 'HubSpot sign-in is not configured. Ask your Sim administrator to configure the HubSpot MCP OAuth client.', + 'validation' + ) const url = resolveManagedMcpConnectorUrl( input.connectorId, input.connectorId === 'databricks' ? input.url : undefined diff --git a/apps/sim/lib/credential-groups/mcp-oauth-state.ts b/apps/sim/lib/credential-groups/mcp-oauth-state.ts index b8dfd1bf875..63dd0b4d2e3 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth-state.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth-state.ts @@ -28,6 +28,7 @@ return #keys interface StoredCredentialGroupMcpOAuthAttempt { oauthConfigVersion: number + configurationFingerprint?: string userId: string version: typeof MCP_OAUTH_ATTEMPT_VERSION workspaceId?: string @@ -43,6 +44,7 @@ interface StoredCredentialGroupMcpOAuthAttempt { export interface CredentialGroupMcpOAuthAttempt { oauthConfigVersion: number + configurationFingerprint?: string userId: string state: string workspaceId?: string @@ -78,6 +80,9 @@ function isStoredAttempt(value: unknown): value is StoredCredentialGroupMcpOAuth typeof candidate.oauthConfigVersion === 'number' && Number.isInteger(candidate.oauthConfigVersion) && candidate.oauthConfigVersion > 0 && + (candidate.configurationFingerprint === undefined || + (typeof candidate.configurationFingerprint === 'string' && + /^[a-f0-9]{64}$/.test(candidate.configurationFingerprint))) && typeof candidate.userId === 'string' && candidate.userId.length > 0 && ((typeof candidate.workspaceId === 'string' && @@ -104,6 +109,7 @@ export function isCredentialGroupMcpOAuthState(state: string): boolean { export async function createCredentialGroupMcpOAuthAttempt(params: { oauthConfigVersion: number + configurationFingerprint?: string userId: string state: string workspaceId?: string @@ -126,6 +132,7 @@ export async function createCredentialGroupMcpOAuthAttempt(params: { const attempt: StoredCredentialGroupMcpOAuthAttempt = { version: MCP_OAUTH_ATTEMPT_VERSION, oauthConfigVersion: params.oauthConfigVersion, + configurationFingerprint: params.configurationFingerprint, userId: params.userId, ...resourceScopeFields(resourceScopeFromOwner(params)), email: params.email, @@ -167,6 +174,7 @@ export async function consumeCredentialGroupMcpOAuthAttempt( return { state, oauthConfigVersion: parsed.oauthConfigVersion, + configurationFingerprint: parsed.configurationFingerprint, userId: parsed.userId, ...resourceScopeFields(resourceScopeFromOwner(parsed)), email: parsed.email, diff --git a/apps/sim/lib/credential-groups/mcp-oauth.ts b/apps/sim/lib/credential-groups/mcp-oauth.ts index 98cf25f6ee4..2ab6715cfe2 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth.ts @@ -1,5 +1,6 @@ import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' import { sha256Hex } from '@sim/security/hash' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import type { CredentialGroupMcpOAuthContext } from '@/lib/credential-groups/enrollments' import { createCredentialGroupMcpOAuthAttempt } from '@/lib/credential-groups/mcp-oauth-state' @@ -45,6 +46,7 @@ export async function startCredentialGroupMcpOAuth( const attempt = provider.requireAuthorizationAttempt() await createCredentialGroupMcpOAuthAttempt({ oauthConfigVersion: context.server.oauthConfigVersion, + configurationFingerprint: preregistered?.configurationFingerprint, ...attempt, userId: context.userId, ...resourceScopeFields(resourceScopeFromOwner(context)), @@ -63,7 +65,8 @@ export async function completeCredentialGroupMcpOAuth( context: CredentialGroupMcpOAuthContext, codeVerifier: string, authorizationCode: string, - invitationToken: string + invitationToken: string, + expectedConfigurationFingerprint?: string ) { assertSafeOauthServerUrl(context.server.url) const clientRow = await getOrCreateOauthRow({ @@ -71,6 +74,8 @@ export async function completeCredentialGroupMcpOAuth( ...resourceScopeFields(resourceScopeFromOwner(context)), }) const preregistered = await loadPreregisteredClient(context.server.id) + if (expectedConfigurationFingerprint !== preregistered?.configurationFingerprint) + throw new OrchestrationError('conflict', 'MCP setup changed. Start authorization again.') let grantedTokens: OAuthTokens | undefined const provider = new ManagedMcpOauthProvider({ clientRow, @@ -102,6 +107,7 @@ export async function completeCredentialGroupMcpOAuth( const completion = await persistManagedMcpCredential({ invitationTokenHash: sha256Hex(invitationToken), oauthConfigVersion: context.server.oauthConfigVersion, + configurationFingerprint: preregistered?.configurationFingerprint, enrollmentId: context.enrollmentId, credentialGroupId: context.credentialGroupId, email: context.email, diff --git a/apps/sim/lib/credential-groups/provider-availability.ts b/apps/sim/lib/credential-groups/provider-availability.ts index fcc2e806906..39a9c5cbe2c 100644 --- a/apps/sim/lib/credential-groups/provider-availability.ts +++ b/apps/sim/lib/credential-groups/provider-availability.ts @@ -1,4 +1,11 @@ +import { db } from '@sim/db' +import { mcpServers } from '@sim/db/schema' +import { and, eq, isNotNull, isNull, ne } from 'drizzle-orm' import { inspectConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { + MANAGED_MCP_CONNECTOR_IDS, + MANAGED_MCP_CONNECTORS, +} from '@/lib/credential-groups/managed-mcp-connectors' import { CREDENTIAL_GROUP_PROVIDER_IDS, type CredentialGroupProvider, @@ -25,3 +32,30 @@ export function listConfiguredCredentialGroupProviders(): CredentialGroupProvide return inspectConfiguredOAuthClient(getCredentialGroupProviderId(provider)).state === 'ready' }) } + +/** Existing group registrations remain usable when the deployment has no shared HubSpot client. */ +export async function listConfiguredManagedMcpConnectors(credentialGroupId?: string) { + let hubspotReady = inspectConfiguredOAuthClient('hubspot-mcp').state === 'ready' + if (!hubspotReady && credentialGroupId) { + const [registration] = await db + .select({ id: mcpServers.id }) + .from(mcpServers) + .where( + and( + eq(mcpServers.credentialGroupId, credentialGroupId), + eq(mcpServers.managedConnectorId, 'hubspot'), + eq(mcpServers.url, MANAGED_MCP_CONNECTORS.hubspot.url), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt), + isNotNull(mcpServers.oauthClientId), + ne(mcpServers.oauthClientId, ''), + isNotNull(mcpServers.oauthClientSecret), + ne(mcpServers.oauthClientSecret, '') + ) + ) + .limit(1) + hubspotReady = Boolean(registration) + } + return MANAGED_MCP_CONNECTOR_IDS.filter((id) => id !== 'hubspot' || hubspotReady) +} diff --git a/apps/sim/lib/credentials/managed-mcp.test.ts b/apps/sim/lib/credentials/managed-mcp.test.ts index c8e18484cef..13756237c0f 100644 --- a/apps/sim/lib/credentials/managed-mcp.test.ts +++ b/apps/sim/lib/credentials/managed-mcp.test.ts @@ -103,6 +103,13 @@ describe('managed MCP grant persistence', () => { }) it('compares refresh writes with the encrypted token version', async () => { + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify({ + type: 'managed-mcp-oauth-token-set', + version: 1, + tokens: input.tokens, + }), + }) queueTableRows(schemaMock.credential, [{ enrollmentId: 'enrollment-1' }]) dbChainMockFns.returning.mockResolvedValue([{ id: 'mcp-cg-person' }]) await saveManagedMcpRuntimeTokens('mcp-cg-person', input.tokens, 'previous-encrypted-token') diff --git a/apps/sim/lib/credentials/managed-mcp.ts b/apps/sim/lib/credentials/managed-mcp.ts index c928f9ce4b7..3eb2d8b4b24 100644 --- a/apps/sim/lib/credentials/managed-mcp.ts +++ b/apps/sim/lib/credentials/managed-mcp.ts @@ -27,6 +27,7 @@ import { requireManagedMcpConnectorUrl, } from '@/lib/credential-groups/managed-mcp-connectors' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' +import { loadPreregisteredClient } from '@/lib/mcp/oauth/provider' import { generateManagedMcpConnectionId } from '@/lib/mcp/utils' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' @@ -37,6 +38,7 @@ interface ManagedMcpTokenEnvelope { type: typeof MANAGED_MCP_TOKEN_SET_TYPE version: typeof MANAGED_MCP_TOKEN_SET_VERSION tokens: OAuthTokens + configurationFingerprint?: string } export interface ManagedMcpCredentialApplicationContext extends WorkspaceAuthorizationContext { @@ -53,6 +55,7 @@ export interface ManagedMcpRuntimeCredential { credentialType: OrganizationCredentialType grantedAt: Date oauthConfigVersion: number + configurationFingerprint?: string scope: ResourceScope credentialGroupId: string credentialId: string @@ -86,17 +89,21 @@ function isOAuthTokens(value: unknown): value is OAuthTokens { ) } -export async function encryptManagedMcpTokens(tokens: OAuthTokens): Promise { +export async function encryptManagedMcpTokens( + tokens: OAuthTokens, + configurationFingerprint?: string +): Promise { if (!isOAuthTokens(tokens)) throw new ManagedMcpCredentialError('Invalid MCP OAuth tokens', 500) const envelope: ManagedMcpTokenEnvelope = { type: MANAGED_MCP_TOKEN_SET_TYPE, version: MANAGED_MCP_TOKEN_SET_VERSION, tokens, + ...(configurationFingerprint ? { configurationFingerprint } : {}), } return (await encryptSecret(JSON.stringify(envelope))).encrypted } -export async function decryptManagedMcpTokens(encrypted: string): Promise { +async function decryptManagedMcpEnvelope(encrypted: string): Promise { try { const { decrypted } = await decryptSecret(encrypted) const parsed: unknown = JSON.parse(decrypted) @@ -105,11 +112,21 @@ export async function decryptManagedMcpTokens(encrypted: string): Promise { + return (await decryptManagedMcpEnvelope(encrypted)).tokens +} + export async function loadManagedMcpCredentialApplicationContext( credentialId: string, executingWorkspaceId?: string @@ -264,8 +285,17 @@ export async function loadScopedManagedMcpRuntimeCredential( if (!row.tools) throw new ManagedMcpCredentialError('Managed MCP tool metadata is missing', 500) if (!row.grantedAt) throw new ManagedMcpCredentialError('Managed MCP grant version is missing', 500) + const envelope = await decryptManagedMcpEnvelope(row.encryptedTokens) + const client = + connector.id === 'hubspot' ? await loadPreregisteredClient(row.mcpServerId) : undefined + if (envelope.configurationFingerprint !== client?.configurationFingerprint) + throw new ManagedMcpCredentialError( + 'Managed MCP credential needs authorization after app configuration changed', + 401 + ) return { credentialType: `mcp:${connector.id}`, + configurationFingerprint: envelope.configurationFingerprint, credentialId: row.credentialId, oauthConfigVersion: row.serverOauthConfigVersion, credentialGroupId: row.credentialGroupId, @@ -274,12 +304,13 @@ export async function loadScopedManagedMcpRuntimeCredential( mcpServerName: row.mcpServerName, tokenVersion: row.encryptedTokens, grantedAt: row.grantedAt, - tokens: await decryptManagedMcpTokens(row.encryptedTokens), + tokens: envelope.tokens, tools: row.tools, } } export async function persistManagedMcpCredential(params: { + configurationFingerprint?: string invitationTokenHash: string oauthConfigVersion: number userId: string @@ -298,7 +329,10 @@ export async function persistManagedMcpCredential(params: { enrollmentStatus: 'in_progress' | 'completed' }> { const scope = resourceScopeFromOwner(params) - const encryptedOauthTokenSet = await encryptManagedMcpTokens(params.tokens) + const encryptedOauthTokenSet = await encryptManagedMcpTokens( + params.tokens, + params.configurationFingerprint + ) const now = new Date() const accessTokenExpiresAt = typeof params.tokens.expires_in === 'number' @@ -433,7 +467,10 @@ export async function saveManagedMcpRuntimeTokens( expectedTokenVersion: string ): Promise { const now = new Date() - const encryptedOauthTokenSet = tokens ? await encryptManagedMcpTokens(tokens) : null + const envelope = tokens ? await decryptManagedMcpEnvelope(expectedTokenVersion) : undefined + const encryptedOauthTokenSet = tokens + ? await encryptManagedMcpTokens(tokens, envelope?.configurationFingerprint) + : null return db.transaction(async (tx) => { const [source] = await tx .select({ enrollmentId: credential.credentialGroupEnrollmentId }) diff --git a/apps/sim/lib/mcp/application/managed-auth-provider.ts b/apps/sim/lib/mcp/application/managed-auth-provider.ts index aed86ebe52e..03d2d684270 100644 --- a/apps/sim/lib/mcp/application/managed-auth-provider.ts +++ b/apps/sim/lib/mcp/application/managed-auth-provider.ts @@ -3,6 +3,7 @@ import { resourceScopeFields } from '@/lib/core/resource-scope' import { requireOrganizationAccountsWorkspaceAccess } from '@/lib/credential-groups/application/organization-workspace-access' import { loadManagedMcpRuntimeCredential, + ManagedMcpCredentialError, type ManagedMcpRuntimeCredential, saveManagedMcpRuntimeTokens, } from '@/lib/credentials/managed-mcp' @@ -37,6 +38,11 @@ export async function createManagedMcpAuthProvider( ...resourceScopeFields(current.scope), }) const preregistered = await loadPreregisteredClient(current.mcpServerId) + if (current.configurationFingerprint !== preregistered?.configurationFingerprint) + throw new ManagedMcpCredentialError( + 'Managed MCP credential needs authorization after app configuration changed', + 401 + ) let tokenVersion: string | null = current.tokenVersion return new ManagedMcpOauthProvider({ clientRow, diff --git a/apps/sim/lib/mcp/oauth/managed-provider.ts b/apps/sim/lib/mcp/oauth/managed-provider.ts index 1ca319716de..40a314bcec4 100644 --- a/apps/sim/lib/mcp/oauth/managed-provider.ts +++ b/apps/sim/lib/mcp/oauth/managed-provider.ts @@ -60,8 +60,7 @@ export class ManagedMcpOauthProvider implements OAuthClientProvider { } clientInformation(): OAuthClientInformationMixed | undefined { - if (this.clientRow.clientInformation) return this.clientRow.clientInformation - if (!this.preregistered) return undefined + if (!this.preregistered) return this.clientRow.clientInformation ?? undefined return { client_id: this.preregistered.clientId, client_secret: this.preregistered.clientSecret, diff --git a/apps/sim/lib/mcp/oauth/provider.ts b/apps/sim/lib/mcp/oauth/provider.ts index f567607e066..080a20a632b 100644 --- a/apps/sim/lib/mcp/oauth/provider.ts +++ b/apps/sim/lib/mcp/oauth/provider.ts @@ -12,6 +12,8 @@ import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { decryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' +import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors' +import { getSharedHubSpotMcpClient } from '@/lib/mcp/oauth/shared-clients' import { clearClient, clearState, @@ -36,6 +38,7 @@ export class McpOauthRedirectRequired extends Error { export interface PreregisteredClient { clientId: string clientSecret?: string + configurationFingerprint?: string } interface SimMcpOauthProviderInit { @@ -157,11 +160,38 @@ export async function loadPreregisteredClient( .select({ clientId: mcpServers.oauthClientId, clientSecret: mcpServers.oauthClientSecret, + connectorId: mcpServers.managedConnectorId, + url: mcpServers.url, + authType: mcpServers.authType, + groupId: mcpServers.credentialGroupId, + enabled: mcpServers.enabled, + deletedAt: mcpServers.deletedAt, }) .from(mcpServers) .where(eq(mcpServers.id, serverId)) .limit(1) - if (!row?.clientId) return undefined + if (!row) return undefined + if (row.connectorId === 'hubspot') { + if ( + row.url !== MANAGED_MCP_CONNECTORS.hubspot.url || + row.authType !== 'oauth' || + !row.groupId || + !row.enabled || + row.deletedAt + ) + return undefined + if (!row.clientId && !row.clientSecret) { + const shared = getSharedHubSpotMcpClient() + if (!shared) + throw new Error( + 'HubSpot sign-in is not configured. Ask your Sim administrator to configure the HubSpot MCP OAuth client.' + ) + return shared + } + if (!row.clientId || !row.clientSecret) + throw new Error('HubSpot OAuth registration is incomplete') + } + if (!row.clientId) return undefined let clientSecret: string | undefined if (row.clientSecret) { try { diff --git a/apps/sim/lib/mcp/oauth/shared-clients.ts b/apps/sim/lib/mcp/oauth/shared-clients.ts new file mode 100644 index 00000000000..b0214975ed6 --- /dev/null +++ b/apps/sim/lib/mcp/oauth/shared-clients.ts @@ -0,0 +1,28 @@ +import { sha256Hex } from '@sim/security/hash' +import { + inspectConfiguredOAuthClient, + requireConfiguredOAuthClient, +} from '@/lib/core/config/env-capabilities.server' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors' + +/** Deployment-owned registration; access tokens remain personal managed grants. */ +export function getSharedHubSpotMcpClient() { + if (inspectConfiguredOAuthClient('hubspot-mcp').state !== 'ready') return undefined + const configuration = requireConfiguredOAuthClient('hubspot-mcp') + const clientId = configuration.values.HUBSPOT_MCP_CLIENT_ID + const clientSecret = configuration.values.HUBSPOT_MCP_CLIENT_SECRET + return { + clientId, + clientSecret, + configurationFingerprint: sha256Hex( + JSON.stringify([ + 'shared-hubspot-mcp', + MANAGED_MCP_CONNECTORS.hubspot.url, + `${getBaseUrl().replace(/\/$/, '')}/api/mcp/oauth/callback`, + clientId, + clientSecret, + ]) + ), + } +} diff --git a/apps/sim/lib/mothership/generated/docs-manifest.ts b/apps/sim/lib/mothership/generated/docs-manifest.ts index 08eb01b7978..0b2d18c507b 100644 --- a/apps/sim/lib/mothership/generated/docs-manifest.ts +++ b/apps/sim/lib/mothership/generated/docs-manifest.ts @@ -435,6 +435,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'search/google-calendar.mdx', 'search/google-drive.mdx', 'search/granola.mdx', + 'search/hubspot.mdx', 'search/jira.mdx', 'search/linear.mdx', 'search/mcp.mdx', diff --git a/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts b/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts index c807fbc0035..a41b1ea417a 100644 --- a/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts +++ b/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts @@ -13,6 +13,7 @@ export const liveSearchProviderSchema = z.enum([ 'github', 'gitlab', 'linear', + 'hubspot', 'fireflies', 'granola', 'notion', @@ -27,24 +28,30 @@ export const NOTION_SEARCH_TERMS_REQUIRED = * Native queries one call may send to the same provider account. Alternatives run as separate * provider searches and fuse into one ranking, so the bound keeps a call within the provider's * burst limits (Slack allows about ten searches per user per minute) while leaving room for the - * four GitHub or GitLab kinds. + * four independently searchable kinds in GitHub, GitLab, or HubSpot. */ export const MAX_NATIVE_QUERIES_PER_ACCOUNT = 4 /** - * Providers whose `kind` selects a separate search endpoint. They take one query per kind: their - * query languages already join alternatives with OR, and each extra query fans out into several - * repository or project requests against strict search rate limits. + * Providers whose kind selects a distinct search collection. One query per kind bounds fanout + * while retaining independently searchable collections within the per-account request limit. */ -const KIND_PROVIDERS: ReadonlySet = new Set(['github', 'gitlab']) +const PROVIDER_KIND_SCHEMAS = { + github: z.enum(['issues', 'code', 'repositories', 'commits']), + gitlab: z.enum(['issues', 'code', 'merge_requests', 'wiki']), + hubspot: z.enum(['contacts', 'companies', 'deals', 'tickets']), +} as const + +function hasSearchKinds( + provider: LiveSearchProvider +): provider is keyof typeof PROVIDER_KIND_SCHEMAS { + return Object.hasOwn(PROVIDER_KIND_SCHEMAS, provider) +} const nativeSearchKindSchema = z.enum([ - 'issues', - 'code', - 'repositories', - 'commits', - 'merge_requests', - 'wiki', + ...PROVIDER_KIND_SCHEMAS.github.options, + ...PROVIDER_KIND_SCHEMAS.gitlab.options, + ...PROVIDER_KIND_SCHEMAS.hubspot.options, ]) /** Queries are data for fixed read-only provider endpoints, never URLs or credentials. */ @@ -62,6 +69,15 @@ export const nativeSearchQuerySchema = z }) .strict() .superRefine((input, context) => { + if (input.kind && hasSearchKinds(input.provider)) { + const kinds = PROVIDER_KIND_SCHEMAS[input.provider] + if (!kinds.safeParse(input.kind).success) + context.addIssue({ + code: 'custom', + path: ['kind'], + message: `${input.provider} kind must be one of: ${kinds.options.join(', ')}.`, + }) + } if (input.provider === 'notion' && !input.query) context.addIssue({ code: 'custom', @@ -94,7 +110,7 @@ export const nativeSearchQueriesSchema = z } /** The search a query runs, ignoring its account and any kind its provider does not use. */ const searchKey = ({ accountId: _, kind, ...query }: NativeSearchQuery) => - JSON.stringify({ ...query, kind: KIND_PROVIDERS.has(query.provider) ? kind : undefined }) + JSON.stringify({ ...query, kind: hasSearchKinds(query.provider) ? kind : undefined }) for (const [index, query] of queries.entries()) { const addIssue = (message: string) => context.addIssue({ code: 'custom', path: [index], message }) @@ -102,11 +118,11 @@ export const nativeSearchQueriesSchema = z if (earlier.some((previous) => searchKey(previous) === searchKey(query))) addIssue('Duplicate native query.') else if ( - KIND_PROVIDERS.has(query.provider) && + hasSearchKinds(query.provider) && earlier.some((previous) => !previous.kind || !query.kind || previous.kind === query.kind) ) addIssue( - 'Send one GitHub or GitLab query per account and kind; join alternatives with OR in one query (GitHub code search has no OR, so search code alternatives in another call).' + 'Send one query per account and kind for GitHub, GitLab, or HubSpot. Use provider-supported operators for alternatives, or send another call.' ) else if (busiestAccountLoad(earlier) >= MAX_NATIVE_QUERIES_PER_ACCOUNT) addIssue( @@ -188,7 +204,7 @@ export const searchWorkspaceInputSchema = workspaceSearchFiltersSchema nativeQueries: nativeSearchQueriesSchema .optional() .describe( - `Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies terms, Granola natural-language questions, Notion keywords or AI questions when available). Notion requires nonempty search terms even with dates or sorting. Up to ${MAX_NATIVE_QUERIES_PER_ACCOUNT} per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.` + `Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies/HubSpot terms, Granola natural-language questions, Notion keywords or AI questions when available). Blank queries require a date bound or sortBy newest/oldest; Notion always requires search terms. Up to ${MAX_NATIVE_QUERIES_PER_ACCOUNT} per account run separately and merge; GitHub, GitLab, and HubSpot take one per kind. HubSpot kinds are contacts, companies, deals, and tickets; ownership filters are unsupported. Write queries from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.` ), query: z .string() diff --git a/apps/sim/lib/mothership/generated/tool-catalog-v1.ts b/apps/sim/lib/mothership/generated/tool-catalog-v1.ts index ad38b8801be..2d05e9fb9b5 100644 --- a/apps/sim/lib/mothership/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/mothership/generated/tool-catalog-v1.ts @@ -6096,7 +6096,7 @@ export const SearchWorkspace: ToolCatalogEntry = { }, nativeQueries: { description: - "Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies terms, Granola natural-language questions, Notion keywords or AI questions when available). Notion requires nonempty search terms even with dates or sorting. Up to 4 per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.", + "Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies/HubSpot terms, Granola natural-language questions, Notion keywords or AI questions when available). Blank queries require a date bound or sortBy newest/oldest; Notion always requires search terms. Up to 4 per account run separately and merge; GitHub, GitLab, and HubSpot take one per kind. HubSpot kinds are contacts, companies, deals, and tickets; ownership filters are unsupported. Write queries from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.", minItems: 1, maxItems: 9, type: 'array', @@ -6115,6 +6115,7 @@ export const SearchWorkspace: ToolCatalogEntry = { 'github', 'gitlab', 'linear', + 'hubspot', 'fireflies', 'granola', 'notion', @@ -6125,7 +6126,18 @@ export const SearchWorkspace: ToolCatalogEntry = { accountId: { type: 'string', minLength: 1, maxLength: 200 }, kind: { type: 'string', - enum: ['issues', 'code', 'repositories', 'commits', 'merge_requests', 'wiki'], + enum: [ + 'issues', + 'code', + 'repositories', + 'commits', + 'merge_requests', + 'wiki', + 'contacts', + 'companies', + 'deals', + 'tickets', + ], }, project: { type: 'string', minLength: 1, maxLength: 300 }, cursor: { type: 'string', maxLength: 4000 }, diff --git a/apps/sim/lib/mothership/generated/tool-schemas-v1.ts b/apps/sim/lib/mothership/generated/tool-schemas-v1.ts index ea4eb8e9c12..1ec5eafcda1 100644 --- a/apps/sim/lib/mothership/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/mothership/generated/tool-schemas-v1.ts @@ -6044,7 +6044,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, nativeQueries: { description: - "Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies terms, Granola natural-language questions, Notion keywords or AI questions when available). Notion requires nonempty search terms even with dates or sorting. Up to 4 per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.", + "Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies/HubSpot terms, Granola natural-language questions, Notion keywords or AI questions when available). Blank queries require a date bound or sortBy newest/oldest; Notion always requires search terms. Up to 4 per account run separately and merge; GitHub, GitLab, and HubSpot take one per kind. HubSpot kinds are contacts, companies, deals, and tickets; ownership filters are unsupported. Write queries from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.", minItems: 1, maxItems: 9, type: 'array', @@ -6063,6 +6063,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'github', 'gitlab', 'linear', + 'hubspot', 'fireflies', 'granola', 'notion', @@ -6080,7 +6081,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, kind: { type: 'string', - enum: ['issues', 'code', 'repositories', 'commits', 'merge_requests', 'wiki'], + enum: [ + 'issues', + 'code', + 'repositories', + 'commits', + 'merge_requests', + 'wiki', + 'contacts', + 'companies', + 'deals', + 'tickets', + ], }, project: { type: 'string', diff --git a/apps/sim/lib/sim-search/live/account-session.ts b/apps/sim/lib/sim-search/live/account-session.ts index 52cfd8e88f5..c4c7b034956 100644 --- a/apps/sim/lib/sim-search/live/account-session.ts +++ b/apps/sim/lib/sim-search/live/account-session.ts @@ -11,6 +11,7 @@ import { NATIVE_SEARCH_REQUEST_BUDGET, NativeSearchError, } from '@/lib/sim-search/live/http' +import { readHubSpotMcp, searchHubSpotMcp } from '@/lib/sim-search/live/hubspot-mcp' import { createManagedSearchMcpClient } from '@/lib/sim-search/live/managed-mcp' import { isManagedSearchMcpProvider } from '@/lib/sim-search/live/managed-mcp-config' import { readNotionMcp, searchNotionMcp } from '@/lib/sim-search/live/notion-mcp' @@ -110,6 +111,8 @@ export async function openLiveAccountSession( return searchGranolaMcp(mcp, search) case 'notion': return searchNotionMcp(mcp, search) + case 'hubspot': + return searchHubSpotMcp(mcp, search) default: throw new NativeSearchError('unavailable', 'Unsupported managed MCP provider.') } @@ -125,6 +128,8 @@ export async function openLiveAccountSession( return readGranolaMcp(mcp, id) case 'notion': return readNotionMcp(mcp, id) + case 'hubspot': + return readHubSpotMcp(mcp, id) default: throw new NativeSearchError('unavailable', 'Unsupported managed MCP provider.') } diff --git a/apps/sim/lib/sim-search/live/application.test.ts b/apps/sim/lib/sim-search/live/application.test.ts index 05fb108d72d..35920713c3e 100644 --- a/apps/sim/lib/sim-search/live/application.test.ts +++ b/apps/sim/lib/sim-search/live/application.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ accounts: vi.fn(), resolveAccount: vi.fn(), search: vi.fn(), + mcpCall: vi.fn(), read: vi.fn(), admin: vi.fn(), adminSearch: vi.fn(), @@ -41,6 +42,9 @@ vi.mock('@/lib/sim-search/live/policy-store', () => ({ loadLiveSearchPolicies: vi.fn(async () => ({})), livePolicyFor: vi.fn(() => defaultLiveSearchPolicy()), })) +vi.mock('@/lib/sim-search/live/managed-mcp', () => ({ + createManagedSearchMcpClient: async () => ({ call: mocks.mcpCall }), +})) vi.mock('@/lib/sim-search/live/coda-mcp', () => ({ createCodaMcpClient: vi.fn(), searchCodaMcp: vi.fn(), @@ -132,6 +136,157 @@ describe('authorized live retrieval', () => { }) mocks.adminVerify.mockResolvedValue(true) }) + describe('HubSpot continuation context', () => { + const connected = { + ...account, + provider: 'hubspot' as const, + providerId: 'mcp:hubspot', + type: 'managed_mcp' as const, + displayName: 'Fixture CRM', + } + const native = { + provider: 'hubspot' as const, + accountId: connected.id, + kind: 'contacts' as const, + query: 'launch', + } + const filters = { startDate: '2026-08-01T00:00:00Z', endDate: '2026-10-01T00:00:00Z' } + const row = (id: number, date: string) => ({ + id, + displayName: `Fixture ${id}`, + properties: { name: `Fixture ${id}`, lastmodifieddate: date, hs_lastmodifieddate: date }, + }) + const response = (args: Record, rows: unknown[], total: number) => ({ + results: rows, + total, + offset: Number(args.offset ?? 0) + rows.length, + urlTemplate: `https://app.hubspot.com/contacts/12345/record/${args.objectType === 'COMPANY' ? '0-2' : '0-1'}/{id}`, + }) + const details = { + accountId: 12345, + toolInformation: { + crmObjectTypeAvailability: { + CONTACT: { read: 'AVAILABLE' }, + COMPANY: { read: 'AVAILABLE' }, + }, + }, + } + beforeEach(() => { + mocks.accounts.mockResolvedValue([connected, { ...connected, id: 'another-account' }]) + mocks.mcpCall.mockImplementation(async (name: string, args: Record) => { + if (name === 'get_user_details') return details + if (name !== 'search_crm_objects') throw new Error('Unexpected CRM tool') + return response(args, [row(args.offset ? 43 : 42, '2026-09-01T00:00:00Z')], 2) + }) + }) + it.each([ + { name: 'query', native: { ...native, query: 'release' }, filters }, + { name: 'kind', native: { ...native, kind: 'companies' as const }, filters }, + { name: 'date', native, filters: { ...filters, endDate: '2026-09-30T00:00:00Z' } }, + { name: 'sort', native, filters: { ...filters, sortBy: 'oldest' as const } }, + { name: 'account', native: { ...native, accountId: 'another-account' }, filters }, + ])( + 'rejects a cursor replayed after changing $name while the fresh search remains valid', + async (changed) => { + const first = await searchLiveKnowledge.execute({ + principal, + input: { ...input, topK: 1, filters, nativeQueries: [native] }, + }) + const cursor = first.live?.accounts[0]?.nextCursor + expect(cursor).toBeTruthy() + const fresh = await searchLiveKnowledge.execute({ + principal, + input: { ...input, topK: 1, filters: changed.filters, nativeQueries: [changed.native] }, + }) + expect(fresh.results).toHaveLength(1) + const replay = await searchLiveKnowledge.execute({ + principal, + input: { + ...input, + topK: 1, + filters: changed.filters, + nativeQueries: [{ ...changed.native, cursor }], + }, + }) + expect(replay.results).toEqual([]) + expect(replay.live?.accounts[0]).toMatchObject({ + status: 'unavailable', + message: expect.stringMatching(/cursor|continuation|restart/i), + }) + } + ) + it.each(['remove implicit cutoff', 'override explicit cutoff'] as const)( + 'rejects a continuation that would %s', + async (mode) => { + const query = mode === 'remove implicit cutoff' ? '' : 'launch' + const searchInput = { + ...input, + query, + topK: 1, + filters: mode === 'remove implicit cutoff' ? { sortBy: 'newest' as const } : filters, + nativeQueries: [{ ...native, query }], + } + const first = await searchLiveKnowledge.execute({ principal, input: searchInput }) + expect(first.results).toHaveLength(1) + const cursor = first.live?.accounts[0]?.nextCursor + expect(cursor).toBeTruthy() + const payload = JSON.parse(Buffer.from(cursor!.slice(8), 'base64url').toString('utf8')) + if (mode === 'remove implicit cutoff') payload.listingEndDate = undefined + else payload.listingEndDate = '2026-11-01T00:00:00Z' + const altered = `hubspot:${Buffer.from(JSON.stringify(payload)).toString('base64url')}` + const result = await searchLiveKnowledge.execute({ + principal, + input: { ...searchInput, nativeQueries: [{ ...native, query, cursor: altered }] }, + }) + expect(result.results).toEqual([]) + expect(result.live?.accounts[0]).toMatchObject({ status: 'unavailable' }) + } + ) + it.each(['provider window', 'local date filtering'] as const)( + 'keeps the original implicit listing boundary after time advances: %s', + async (stage) => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + vi.setSystemTime(new Date('2026-09-01T12:00:00Z')) + const old = row(43, '2026-09-01T11:30:00Z') + const later = row(44, '2026-09-01T12:30:00Z') + mocks.mcpCall.mockImplementation(async (name: string, args: Record) => { + if (name === 'get_user_details') return details + if (name !== 'search_crm_objects') throw new Error('Unexpected CRM tool') + if (!args.offset) return response(args, [row(42, '2026-09-01T11:45:00Z')], 3) + if (stage === 'local date filtering') return response(args, [old, later], 3) + const groups = args.filterGroups as { filters: { operator: string; value: string }[] }[] + const upper = Number( + groups[0].filters.find((filter) => filter.operator === 'LT')?.value + ) + return response(args, [upper <= Date.parse('2026-09-01T12:00:00Z') ? old : later], 2) + }) + const listing = { + ...input, + query: '', + topK: 1, + filters: { sortBy: 'newest' as const }, + nativeQueries: [{ ...native, query: '' }], + } + const first = await searchLiveKnowledge.execute({ principal, input: listing }) + const cursor = first.live?.accounts[0]?.nextCursor + expect(first.results).toHaveLength(1) + expect(cursor).toBeTruthy() + vi.setSystemTime(new Date('2026-09-01T13:00:00Z')) + const continued = await searchLiveKnowledge.execute({ + principal, + input: { ...listing, topK: 2, nativeQueries: [{ ...native, query: '', cursor }] }, + }) + expect( + continued.results.map((result) => decodeLiveReference(result.documentId).id) + ).toEqual(['hubspot:12345:contacts:43']) + expect(continued.live?.accounts[0]?.status).not.toBe('unavailable') + } finally { + vi.useRealTimers() + } + } + ) + }) it.each([ { startDate: '2026-08-01T00:00:00Z' }, { source: ' notion ', startDate: '2026-08-01T00:00:00Z' }, diff --git a/apps/sim/lib/sim-search/live/application.ts b/apps/sim/lib/sim-search/live/application.ts index 9ebe3978b6d..3eb5ddb69af 100644 --- a/apps/sim/lib/sim-search/live/application.ts +++ b/apps/sim/lib/sim-search/live/application.ts @@ -14,6 +14,7 @@ import { nativeSearchQueriesSchema, workspaceSearchFiltersSchema, } from '@/lib/api/contracts/mothership-assistant-tools' +import { canonicalJson, fingerprint, instantScopePart } from '@/lib/api/cursor-binding' import { isLiveEnterpriseSearchEnabled } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { @@ -57,6 +58,34 @@ import type { LiveAccount, NativeDocument } from '@/lib/sim-search/live/types' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +const hubspotContinuationSchema = z + .object({ + v: z.literal(1), + scope: z.string().regex(/^[A-Za-z0-9_-]{22}$/), + cursor: z.string().regex(/^[1-9]\d{0,3}$/), + listingEndDate: z.string().datetime({ offset: true }).optional(), + }) + .strict() +type HubSpotContinuation = z.output + +function readHubSpotContinuation(value: string): HubSpotContinuation { + try { + if (!value.startsWith('hubspot:') || value.length > 512) throw new Error('Invalid continuation') + return hubspotContinuationSchema.parse( + JSON.parse(Buffer.from(value.slice(8), 'base64url').toString('utf8')) + ) + } catch { + throw new NativeSearchError( + 'unavailable', + 'Invalid HubSpot cursor. Restart this search without a cursor.' + ) + } +} + +function writeHubSpotContinuation(value: HubSpotContinuation): string { + return `hubspot:${Buffer.from(JSON.stringify(hubspotContinuationSchema.parse(value))).toString('base64url')}` +} + const referenceSchema = z .object({ v: z.literal(1), @@ -295,6 +324,10 @@ export const searchLiveKnowledge = defineAuthorizedKnowledgeUseCase({ const queries = input.nativeQueries ? nativeSearchQueriesSchema.parse(input.nativeQueries) : undefined + const requestedFilters = input.filters + ? workspaceSearchFiltersSchema.parse(input.filters) + : undefined + input = { ...input, filters: requestedFilters } if ( (!input.query.trim() && !queries?.some((query) => query.query)) || queries?.some((query) => !query.query) @@ -310,8 +343,6 @@ export const searchLiveKnowledge = defineAuthorizedKnowledgeUseCase({ input.topK > 50 ) throw new OrchestrationError('validation', 'Invalid live search query or result limit') - if (input.filters) - input = { ...input, filters: workspaceSearchFiltersSchema.parse(input.filters) } const filters = input.filters if (!queries && filters?.source === 'notion' && !input.query.trim()) throw new OrchestrationError('validation', NOTION_SEARCH_TERMS_REQUIRED) @@ -360,12 +391,57 @@ export const searchLiveKnowledge = defineAuthorizedKnowledgeUseCase({ native: NativeSearchQuery | undefined, status: Pick ): Promise => { + let queryFilters = filters + let queryNative = native + let hubspotScope: string | undefined + let listingEndDate: string | undefined + if (account.provider === 'hubspot') { + hubspotScope = fingerprint( + canonicalJson({ + provider: 'hubspot', + user: userId, + owner: resourceScopeKey(resourceScopeFromOwner(input)), + account: account.id, + query: (native?.query ?? input.query).trim(), + kind: native?.kind, + sort: requestedFilters?.sortBy ?? 'relevance', + startDate: instantScopePart(requestedFilters?.startDate), + endDate: instantScopePart(requestedFilters?.endDate), + modifiedAfter: instantScopePart(requestedFilters?.modifiedAfter), + modifiedBefore: instantScopePart(requestedFilters?.modifiedBefore), + documentIds: requestedFilters?.documentIds + ? [...new Set(requestedFilters.documentIds)].sort(compareStrings) + : undefined, + }) + ) + if (native?.cursor) { + const continuation = readHubSpotContinuation(native.cursor) + const allowsListingBound = + Boolean(dateSortDirection(requestedFilters)) && !hasDateBounds(requestedFilters) + if ( + continuation.scope !== hubspotScope || + (continuation.listingEndDate && !allowsListingBound) || + (allowsListingBound && !native.query && !continuation.listingEndDate) + ) + throw new NativeSearchError( + 'unavailable', + 'HubSpot cursor does not match this account, query, kind, or filters. Restart without a cursor.' + ) + listingEndDate = continuation.listingEndDate + queryFilters = listingEndDate + ? { ...requestedFilters, endDate: listingEndDate } + : requestedFilters + queryNative = { ...native, cursor: continuation.cursor } + } else if (!hasDateBounds(requestedFilters)) { + listingEndDate = filters?.endDate + } + } const page = await measureSearchStage('live.search', () => session.search({ - filters, + filters: queryFilters, policy: session.policy, query: input.query, - native, + native: queryNative, limit: input.topK, scopes: resolved.account.scopes, }) @@ -382,18 +458,18 @@ export const searchLiveKnowledge = defineAuthorizedKnowledgeUseCase({ session, candidates.filter( ({ document, documentId }) => - matchesLiveFilters(document, documentId, account.provider, filters) || - lacksFilterDate(document, account.provider, filters) + matchesLiveFilters(document, documentId, account.provider, queryFilters) || + lacksFilterDate(document, account.provider, queryFilters) ) ) ) const matching = firstOfEachDocument( permitted.filter(({ document, documentId }) => - matchesLiveFilters(document, documentId, account.provider, filters) + matchesLiveFilters(document, documentId, account.provider, queryFilters) ) ) const undatedExcluded = permitted.some(({ document }) => - lacksFilterDate(document, account.provider, filters) + lacksFilterDate(document, account.provider, queryFilters) ) const undatedUnsorted = dateSorted && matching.some(({ document }) => !sourceDate(document, account.provider)) @@ -438,7 +514,15 @@ export const searchLiveKnowledge = defineAuthorizedKnowledgeUseCase({ ? 'No readable matches on this page. Continue with nextCursor for more.' : undefined, ]), - nextCursor: page.nextCursor, + nextCursor: + page.nextCursor && hubspotScope + ? writeHubSpotContinuation({ + v: 1, + scope: hubspotScope, + cursor: page.nextCursor, + ...(listingEndDate ? { listingEndDate } : {}), + }) + : page.nextCursor, }, results: matching.map((candidate, index) => { const result = resultFor( diff --git a/apps/sim/lib/sim-search/live/hubspot-mcp.test.ts b/apps/sim/lib/sim-search/live/hubspot-mcp.test.ts new file mode 100644 index 00000000000..9e235765df6 --- /dev/null +++ b/apps/sim/lib/sim-search/live/hubspot-mcp.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest' +import { readHubSpotMcp, searchHubSpotMcp } from '@/lib/sim-search/live/hubspot-mcp' +import type { ManagedSearchMcpClient } from '@/lib/sim-search/live/managed-mcp' + +const ACCOUNT = 12345 +const permissions = { + accountId: ACCOUNT, + toolInformation: { + crmObjectTypeAvailability: { + CONTACT: { read: 'AVAILABLE' }, + COMPANY: { read: 'AVAILABLE' }, + DEAL: { read: 'AVAILABLE' }, + TICKET: { read: 'AVAILABLE' }, + }, + }, +} +const url = `https://app.hubspot.com/contacts/${ACCOUNT}/record/0-1/42` +const row = { + id: 42, + displayName: 'Fixture contact', + properties: { firstname: 'Fixture', lastmodifieddate: '2026-09-01T12:00:00Z' }, +} +const page = { results: [row], total: 1, offset: 1, urlTemplate: url.replace('/42', '/{id}') } +const input = { + query: 'Fixture', + limit: 10, + scopes: [], + native: { provider: 'hubspot' as const, kind: 'contacts' as const, query: 'Fixture' }, +} +const fixture = (responses: Record): ManagedSearchMcpClient => ({ + async call(name) { + if (name === 'get_user_details') return permissions + if (!(name in responses)) throw new Error('Unexpected HubSpot operation') + return responses[name] + }, +}) + +describe('HubSpot member CRM boundary', () => { + it('fails closed on missing permission metadata and surfaces scope reauthorization', async () => { + for (const details of [ + { accountId: ACCOUNT }, + { + ...permissions, + toolInformation: { + crmObjectTypeAvailability: { CONTACT: { read: 'REQUIRES_REAUTHORIZATION' } }, + }, + }, + ]) { + const client: ManagedSearchMcpClient = { + call: async (name) => (name === 'get_user_details' ? details : page), + } + await expect(searchHubSpotMcp(client, input)).rejects.toMatchObject({ + status: 'toolInformation' in details ? 'reconnect' : 'unavailable', + }) + } + }) + it('rejects foreign-account, wrong-kind, and attacker-origin result links instead of emitting trusted references', async () => { + for (const template of [ + page.urlTemplate.replace(String(ACCOUNT), '99999'), + page.urlTemplate.replace('/0-1/', '/0-2/'), + page.urlTemplate.replace('app.hubspot.com', 'attacker.example'), + ]) { + const result = await searchHubSpotMcp( + fixture({ search_crm_objects: { ...page, urlTemplate: template } }), + input + ) + expect(result.documents).toEqual([]) + expect(result.partial).toBe(true) + } + }) + it('does not round unsafe numeric record IDs into another record', async () => { + const result = await searchHubSpotMcp( + fixture({ + search_crm_objects: { ...page, results: [{ ...row, id: Number.MAX_SAFE_INTEGER + 1 }] }, + }), + input + ) + expect(result.documents).toEqual([]) + expect(result.partial).toBe(true) + }) + it('requires a supported search envelope instead of reporting schema drift as zero matches', async () => { + for (const invalid of [{}, { results: null }, { ...page, total: -1 }, { ...page, offset: 0 }]) + await expect( + searchHubSpotMcp(fixture({ search_crm_objects: invalid }), input) + ).rejects.toThrow() + }) + it('continues a typed page only when the provider reports more matches', async () => { + const first = await searchHubSpotMcp(fixture({ search_crm_objects: { ...page, total: 2 } }), { + ...input, + limit: 1, + }) + expect(first.nextCursor).toBe('1') + const last = await searchHubSpotMcp( + fixture({ + search_crm_objects: { ...page, total: 2, offset: 2, results: [{ ...row, id: 43 }] }, + }), + { ...input, limit: 1, native: { ...input.native, cursor: first.nextCursor } } + ) + expect(last.nextCursor).toBeUndefined() + expect(last.documents).toHaveLength(1) + await expect( + searchHubSpotMcp(fixture({ search_crm_objects: { ...page, total: 2 } }), { + ...input, + native: { ...input.native, cursor: '1' }, + }) + ).rejects.toThrow() + }) + it('preserves valid collection results with explicit partial coverage when another collection fails', async () => { + const client: ManagedSearchMcpClient = { + async call(name, args) { + if (name === 'get_user_details') return permissions + if (name === 'search_crm_objects' && args.objectType === 'CONTACT') return page + throw new Error('Collection unavailable') + }, + } + const result = await searchHubSpotMcp(client, { query: 'Fixture', limit: 10, scopes: [] }) + expect(result.documents).toHaveLength(1) + expect(result.partial).toBe(true) + }) + it('reads only the exact returned record and refuses a changed account or mismatched record', async () => { + const search = await searchHubSpotMcp(fixture({ search_crm_objects: page }), input) + const id = search.documents[0].id + const valid = { ...row, url, archived: false, updatedAt: '2026-09-02T12:00:00Z' } + const result = await readHubSpotMcp( + fixture({ get_crm_objects: { objects: [valid], notFound: [] } }), + id + ) + expect(Date.parse(result.modifiedAt!)).toBe(Date.parse(valid.updatedAt)) + for (const replacement of [ + { ...valid, id: 43 }, + { ...valid, archived: true }, + { ...valid, url: url.replace('/42', '/43') }, + ]) + await expect( + readHubSpotMcp(fixture({ get_crm_objects: { objects: [replacement], notFound: [] } }), id) + ).rejects.toThrow() + const otherAccount: ManagedSearchMcpClient = { + call: async () => ({ ...permissions, accountId: 99999 }), + } + await expect(readHubSpotMcp(otherAccount, id)).rejects.toThrow() + }) + it('marks oversized record content instead of silently returning a complete-looking read', async () => { + const search = await searchHubSpotMcp(fixture({ search_crm_objects: page }), input) + const result = await readHubSpotMcp( + fixture({ + get_crm_objects: { + objects: [{ ...row, url, properties: { description: 'x'.repeat(220_000) } }], + notFound: [], + }, + }), + search.documents[0].id + ) + expect(result.content.length).toBeLessThanOrEqual(200_000) + expect(result.content).toMatch(/truncated/i) + }) +}) diff --git a/apps/sim/lib/sim-search/live/hubspot-mcp.ts b/apps/sim/lib/sim-search/live/hubspot-mcp.ts new file mode 100644 index 00000000000..6a922f8961a --- /dev/null +++ b/apps/sim/lib/sim-search/live/hubspot-mcp.ts @@ -0,0 +1,320 @@ +import { isRecordLike, toRecord } from '@sim/utils/object' +import { truncateAtCodePoint } from '@sim/utils/string' +import { + dateSortDirection, + hasDateBounds, + nativeDateBounds, + nativeText, +} from '@/lib/sim-search/live/dates' +import { NativeSearchError, string } from '@/lib/sim-search/live/http' +import type { ManagedSearchMcpClient } from '@/lib/sim-search/live/managed-mcp' +import { collectNativePages } from '@/lib/sim-search/live/pages' +import { providerText } from '@/lib/sim-search/live/text' +import type { NativeDocument, NativePage, NativeSearchInput } from '@/lib/sim-search/live/types' + +const CRM_TYPES = { + contacts: { + api: 'CONTACT', + typeId: '0-1', + modified: 'lastmodifieddate', + label: 'Contact', + properties: ['firstname', 'lastname', 'email', 'company', 'jobtitle', 'lifecyclestage'], + }, + companies: { + api: 'COMPANY', + typeId: '0-2', + modified: 'hs_lastmodifieddate', + label: 'Company', + properties: ['name', 'domain', 'website', 'description', 'industry'], + }, + deals: { + api: 'DEAL', + typeId: '0-3', + modified: 'hs_lastmodifieddate', + label: 'Deal', + properties: [ + 'dealname', + 'description', + 'dealstage', + 'pipeline', + 'amount', + 'deal_currency_code', + 'closedate', + ], + }, + tickets: { + api: 'TICKET', + typeId: '0-5', + modified: 'hs_lastmodifieddate', + label: 'Ticket', + properties: ['subject', 'content', 'hs_pipeline', 'hs_pipeline_stage', 'hs_ticket_priority'], + }, +} as const + +type CrmKind = keyof typeof CRM_TYPES +const CRM_KINDS = Object.keys(CRM_TYPES) as CrmKind[] +const MAX_CONTENT = 200_000 +const MAX_SEARCH_OFFSET = 10_000 +const GUIDANCE = + 'HubSpot searches the default searchable properties of contacts, companies, deals, and tickets using your current permissions. Read a result for its returned CRM properties; activity histories and associations are not included. For more matches, select one kind and continue its returned cursor. Results are records, not aggregate counts.' + +function fail(message: string): never { + throw new NativeSearchError('unavailable', message) +} + +function identifier(value: unknown): string | undefined { + if (typeof value !== 'string' && typeof value !== 'number') return undefined + const text = String(value) + return /^[1-9]\d{0,15}$/.test(text) && Number.isSafeInteger(Number(text)) ? text : undefined +} + +function isCrmKind(value: string): value is CrmKind { + return Object.hasOwn(CRM_TYPES, value) +} + +async function permissions(client: ManagedSearchMcpClient) { + const response = toRecord( + await client.call('get_user_details', { include: ['TOOL_INFORMATION'] }) + ) + const accountId = identifier(response.accountId) + const availability = toRecord(response.toolInformation).crmObjectTypeAvailability + if (!accountId || !isRecordLike(availability)) + fail('HubSpot did not return account identity and CRM read permissions.') + return { accountId, availability } +} + +function requireRead(availability: Record, kind: CrmKind) { + const status = toRecord(availability[CRM_TYPES[kind].api]).read + if (status === 'AVAILABLE') return + throw new NativeSearchError( + status === 'REQUIRES_REAUTHORIZATION' ? 'reconnect' : 'unavailable', + status === 'REQUIRES_REAUTHORIZATION' + ? `Reconnect HubSpot and allow read access to ${kind}.` + : `Your HubSpot account does not currently allow reading ${kind}.` + ) +} + +/** URLs are citations only. The account, type, and record must match independently verified identity. */ +function recordUrl( + value: unknown, + accountId: string, + kind: CrmKind, + id: string +): string | undefined { + if (typeof value !== 'string' || value.length > 2048) return undefined + try { + const url = new URL(value.replace('{id}', id)) + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + url.hash || + !/^app(?:-[a-z]+\d+)?\.hubspot\.com$/.test(url.hostname) || + url.pathname !== `/contacts/${accountId}/record/${CRM_TYPES[kind].typeId}/${id}` + ) + return undefined + return url.toString() + } catch { + return undefined + } +} + +function recordDocument( + value: unknown, + accountId: string, + kind: CrmKind, + urlTemplate?: unknown +): NativeDocument | undefined { + const row = toRecord(value) + const id = identifier(row.id) + if (!id || row.archived === true || !isRecordLike(row.properties)) return undefined + const properties = row.properties + if (properties.hs_object_id !== undefined && identifier(properties.hs_object_id) !== id) + return undefined + const url = recordUrl(row.url ?? urlTemplate, accountId, kind, id) + if (!url) return undefined + const title = + string(row.displayName) || + string(properties.name ?? properties.dealname ?? properties.subject) || + [string(properties.firstname), string(properties.lastname)].filter(Boolean).join(' ') || + `${CRM_TYPES[kind].label} ${id}` + const rawDate = row.updatedAt ?? properties[CRM_TYPES[kind].modified] + const date = typeof rawDate === 'string' ? Date.parse(rawDate) : Number.NaN + const fields = Object.entries(properties) + if ( + fields.some( + ([, value]) => + value !== null && + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' + ) + ) + return undefined + const content = [ + `${CRM_TYPES[kind].label}: ${title}`, + ...fields + .slice(0, 1000) + .flatMap(([key, value]) => + value === null || value === '' ? [] : [`${key}: ${providerText(String(value), 'auto')}`] + ), + ].join('\n') + const notice = '[CRM content truncated. Open the HubSpot record for the remainder.]\n\n' + return { + id: `hubspot:${accountId}:${kind}:${id}`, + container: accountId, + kind, + title: truncateAtCodePoint(title, 1000), + url, + content: + content.length > MAX_CONTENT || fields.length > 1000 + ? notice + truncateAtCodePoint(content, MAX_CONTENT - notice.length, '') + : content, + ...(Number.isFinite(date) ? { modifiedAt: new Date(date).toISOString() } : {}), + } +} + +/** Each collection uses its documented searchable properties and the member's current CRM permissions. */ +export async function searchHubSpotMcp( + client: ManagedSearchMcpClient, + input: NativeSearchInput +): Promise { + const query = nativeText(input) + if (query.length > 200) + fail( + 'HubSpot search accepts at most 200 characters. Use a name, domain, email, or concise keywords.' + ) + const requested = input.native?.kind + if (requested && !isCrmKind(requested)) + fail('HubSpot kind must be contacts, companies, deals, or tickets.') + if ( + input.native?.project || + input.native?.termClauses?.length || + input.native?.modifiers || + input.native?.keywordOnly + ) + fail( + 'HubSpot accepts plain keywords and a CRM kind; project and other provider operators are not supported.' + ) + const cursor = input.native?.cursor + if (cursor && (!requested || !/^(?:0|[1-9]\d{0,3})$/.test(cursor))) + fail( + 'Continue HubSpot with the returned cursor and the same explicit CRM kind, query, and date filters.' + ) + const offset = cursor ? Number(cursor) : 0 + const limit = Math.min(25, Math.max(1, input.limit), MAX_SEARCH_OFFSET - offset) + const bounds = nativeDateBounds(input) + if (bounds.start && bounds.end && Date.parse(bounds.start) >= Date.parse(bounds.end)) + return { documents: [] } + const { accountId, availability } = await permissions(client) + const searchKind = async (kind: CrmKind): Promise => { + requireRead(availability, kind) + const type = CRM_TYPES[kind] + const filters = [ + ...(bounds.start + ? [ + { + propertyName: type.modified, + operator: 'GTE', + value: String(Date.parse(bounds.start)), + }, + ] + : []), + ...(bounds.end + ? [{ propertyName: type.modified, operator: 'LT', value: String(Date.parse(bounds.end)) }] + : []), + ] + const response = toRecord( + await client.call('search_crm_objects', { + objectType: type.api, + ...(query ? { query } : {}), + limit, + ...(offset ? { offset } : {}), + properties: [...type.properties, type.modified, 'hs_object_id'], + ...(filters.length ? { filterGroups: [{ filters }] } : {}), + sorts: [ + { + propertyName: type.modified, + direction: dateSortDirection(input.filters) === 'asc' ? 'ASCENDING' : 'DESCENDING', + }, + ], + }) + ) + const rows = response.results + const total = response.total + const next = response.offset + if ( + !Array.isArray(rows) || + rows.length > limit || + typeof total !== 'number' || + !Number.isSafeInteger(total) || + total < 0 || + typeof next !== 'number' || + !Number.isSafeInteger(next) || + next < 0 || + next !== offset + rows.length || + next > total + ) + fail('HubSpot returned an unsupported CRM search page or pagination cursor.') + if (!rows.length && next < total) + fail('HubSpot could not advance its CRM search page. Narrow the query.') + const documents = rows.flatMap((row) => { + const document = recordDocument(row, accountId, kind, response.urlTemplate) + return document ? [document] : [] + }) + const more = next < total + const capped = more && next >= MAX_SEARCH_OFFSET + return { + documents, + ...(more && !capped ? { nextCursor: String(next) } : {}), + ...(capped ? { hasMore: true } : {}), + partial: + capped || + documents.length !== rows.length || + (hasDateBounds(input.filters) && documents.some((document) => !document.modifiedAt)), + message: capped + ? `${GUIDANCE} HubSpot's 10,000-result window was reached. Narrow the query or dates.` + : GUIDANCE, + } + } + return requested ? searchKind(requested) : collectNativePages(CRM_KINDS.map(searchKind), GUIDANCE) +} + +/** A read verifies account, type, and record identity again using the current member grant. */ +export async function readHubSpotMcp( + client: ManagedSearchMcpClient, + reference: string +): Promise { + const parts = reference.split(':') + const [, accountId, kind, id] = parts + if ( + parts.length !== 4 || + parts[0] !== 'hubspot' || + !identifier(accountId) || + !kind || + !isCrmKind(kind) || + !identifier(id) + ) + fail('HubSpot requires a record reference returned by search.') + const current = await permissions(client) + if (current.accountId !== accountId) + fail('The HubSpot account changed. Search again before reading this record.') + requireRead(current.availability, kind) + const response = toRecord( + await client.call('get_crm_objects', { + objectType: CRM_TYPES[kind].api, + objectIds: [Number(id)], + }) + ) + if ( + !Array.isArray(response.objects) || + response.objects.length !== 1 || + (Array.isArray(response.notFound) && response.notFound.length) + ) + fail('HubSpot did not return the requested CRM record.') + const document = recordDocument(response.objects[0], accountId, kind) + if (!document || document.id !== reference) + fail('HubSpot did not verify the requested CRM record identity.') + return document +} diff --git a/apps/sim/lib/sim-search/live/managed-mcp-config.ts b/apps/sim/lib/sim-search/live/managed-mcp-config.ts index fe889f109db..f9e38090465 100644 --- a/apps/sim/lib/sim-search/live/managed-mcp-config.ts +++ b/apps/sim/lib/sim-search/live/managed-mcp-config.ts @@ -3,6 +3,7 @@ export const MANAGED_SEARCH_MCP_READ_TOOLS = { coda: ['search', 'url_convert', 'content_read', 'document_outline', 'table_rows_read'], fireflies: ['fireflies_get_transcripts', 'fireflies_get_transcript', 'fireflies_get_summary'], granola: ['query_granola_meetings', 'list_meetings', 'get_meetings', 'get_meeting_transcript'], + hubspot: ['get_user_details', 'search_crm_objects', 'get_crm_objects'], notion: ['notion-get-tool-access', 'notion-search', 'notion-ai-search', 'notion-fetch'], } as const diff --git a/apps/sim/lib/sim-search/live/policy-schema.ts b/apps/sim/lib/sim-search/live/policy-schema.ts index 9134606a3ea..52dddaecc75 100644 --- a/apps/sim/lib/sim-search/live/policy-schema.ts +++ b/apps/sim/lib/sim-search/live/policy-schema.ts @@ -105,6 +105,11 @@ export const LIVE_SEARCH_SCOPE_FIELDS: Record< example: 'ENG, TEAM', }, linear: { label: 'Projects', hint: 'Member accounts search all accessible issues.', example: '' }, + hubspot: { + label: 'CRM records', + hint: 'Member accounts search CRM records allowed by their HubSpot permissions.', + example: '', + }, fireflies: { label: 'Meetings', hint: 'Member accounts search accessible meeting transcripts.', diff --git a/apps/sim/lib/sim-search/live/provider-catalog.ts b/apps/sim/lib/sim-search/live/provider-catalog.ts index aacc49d4b71..320c45f0c61 100644 --- a/apps/sim/lib/sim-search/live/provider-catalog.ts +++ b/apps/sim/lib/sim-search/live/provider-catalog.ts @@ -51,6 +51,11 @@ export const LIVE_SEARCH_PROVIDER_CATALOG = { credentialProviderIds: ['linear'], modes: ['member'], }, + hubspot: { + origin: 'https://mcp.hubspot.com', + credentialProviderIds: ['mcp:hubspot'], + modes: ['member'], + }, fireflies: { origin: 'https://api.fireflies.ai', credentialProviderIds: ['mcp:fireflies'], diff --git a/apps/sim/lib/sim-search/live/providers.ts b/apps/sim/lib/sim-search/live/providers.ts index adb6db4b070..107d45afae5 100644 --- a/apps/sim/lib/sim-search/live/providers.ts +++ b/apps/sim/lib/sim-search/live/providers.ts @@ -186,6 +186,18 @@ export const LIVE_SEARCH_PROVIDERS = { search: searchLinear, read: (client, reference) => readLinear(client, reference.id), }, + hubspot: { + transport: 'managed_mcp', + guide: { + syntax: + 'Plain text, at most 200 characters, searched in default CRM properties. Use a company name, domain, contact email, deal name, ticket subject, or concise keywords. Empty queries list records and require sortBy newest/oldest or a date bound. Search is lexical, not semantic; remove common stop words if no matches.', + scope: + 'kind selects contacts, companies, deals, or tickets; omitted searches all four. Dates and newest/oldest sorting use record modification time. Continue only an explicit kind with its returned cursor and unchanged query/filters. Read matches for CRM properties, including custom properties returned by HubSpot.', + example: 'example.com', + avoid: + 'Boolean/field operators, project, inferred ownership, pipeline/lifecycle filters, custom object types, association or activity-history claims, and totals or revenue aggregation from a bounded result set. "My deals" requires an owner filter this adapter does not support; do not silently treat it as all visible deals.', + }, + }, fireflies: { transport: 'managed_mcp', guide: { diff --git a/apps/sim/lib/sim-search/live/source-catalog.ts b/apps/sim/lib/sim-search/live/source-catalog.ts index 4d56a0d117d..02f2cf2b842 100644 --- a/apps/sim/lib/sim-search/live/source-catalog.ts +++ b/apps/sim/lib/sim-search/live/source-catalog.ts @@ -1,5 +1,8 @@ import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' -import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { + getManagedMcpConnector, + type ManagedMcpConnectorId, +} from '@/lib/credential-groups/managed-mcp-connectors' import { findCredentialGroupProviderFromProviderId, isCredentialGroupStandardOAuthProvider, @@ -49,7 +52,9 @@ export function liveSearchMemberAccountProvider(type: string) { export function getLiveSearchAccessAvailability( type: LiveSearchProviderId, integrationAvailability: Parameters[1], - context: Parameters[2] + context: Parameters[2] & { + availableMcpConnectors?: readonly ManagedMcpConnectorId[] + } ) { const meta = CONNECTOR_META_REGISTRY[type] const access = meta @@ -60,7 +65,9 @@ export function getLiveSearchAccessAvailability( members: supportsLiveSearchMode(type, 'member') && (liveSearchMcpConnector(type) - ? context.isIntegrationAvailabilityReady && context.memberAccessAvailable + ? context.isIntegrationAvailabilityReady && + context.memberAccessAvailable && + (type !== 'hubspot' || context.availableMcpConnectors?.includes('hubspot') === true) : access.members), } } diff --git a/apps/sim/scripts/test-search-hubspot-live.ts b/apps/sim/scripts/test-search-hubspot-live.ts new file mode 100644 index 00000000000..68a023b9ab5 --- /dev/null +++ b/apps/sim/scripts/test-search-hubspot-live.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { z } from 'zod' +import { readHubSpotMcp, searchHubSpotMcp } from '@/lib/sim-search/live/hubspot-mcp' +import { createManagedSearchMcpClient } from '@/lib/sim-search/live/managed-mcp' + +/** + * Read-only real HubSpot acceptance using the current member grant. Run from apps/sim: + * SEARCH_HUBSPOT_CASES_PATH=/private/cases.json SEARCH_HUBSPOT_REPORT_PATH=/private/report.json + * bun --env-file=.env scripts/test-search-hubspot-live.ts + * Cases contain organizationId, userId, credentialId and queries: [{name, query, kind, + * expectedIds, filters?}]. Expectations come from independently inspected CRM records; include + * at least one positive case. Keep private inputs and reports outside the repository. + * Exercises member credentials and provider transport, not browser authentication or model + * query selection. No remote records are created or changed. + */ +const logger = createLogger('SearchHubSpotLive') +const reportPath = process.env.SEARCH_HUBSPOT_REPORT_PATH +const casesPath = process.env.SEARCH_HUBSPOT_CASES_PATH +assert(reportPath && casesPath, 'Set SEARCH_HUBSPOT_REPORT_PATH and SEARCH_HUBSPOT_CASES_PATH') +const config = z + .object({ + organizationId: z.string().min(1), + userId: z.string().min(1), + credentialId: z.string().min(1), + queries: z + .array( + z.object({ + name: z.string().min(1), + query: z.string().max(200), + kind: z.enum(['contacts', 'companies', 'deals', 'tickets']), + expectedIds: z.array(z.string().regex(/^[1-9]\d*$/)), + filters: z + .object({ + startDate: z.iso.datetime().optional(), + endDate: z.iso.datetime().optional(), + sortBy: z.enum(['newest', 'oldest']).optional(), + }) + .optional(), + }) + ) + .min(2) + .max(20), + }) + .parse(JSON.parse(await readFile(casesPath, 'utf8'))) +assert( + config.queries.some((test) => test.expectedIds.length), + 'A positive record fixture is required' +) +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] +async function check(name: string, run: () => Promise) { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: Math.round(performance.now() - started) }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: Math.round(performance.now() - started), + error: getErrorMessage(error), + }) + } +} +const client = () => + createManagedSearchMcpClient( + { organizationId: config.organizationId }, + config.userId, + config.credentialId, + 'hubspot', + AbortSignal.timeout(30_000), + 4 + ) +const ids = (documents: { id: string }[]) => + documents.map((document) => document.id.split(':').at(-1)!).sort() +for (const test of config.queries) { + await check(test.name, async () => { + const provider = await client() + const input = { + query: test.query, + native: { provider: 'hubspot' as const, query: test.query, kind: test.kind }, + filters: test.filters, + scopes: [], + limit: 25, + } + const result = await searchHubSpotMcp(provider, input) + assert( + !result.partial && !result.nextCursor && !result.hasMore, + 'Fixture must fit one complete page' + ) + assert.deepEqual( + ids(result.documents), + [...test.expectedIds].sort(), + 'Exact CRM record identity differs' + ) + for (const document of result.documents) { + assert(document.url && document.content, 'Result must include source evidence') + if (test.filters?.startDate) + assert( + Date.parse(document.modifiedAt!) >= Date.parse(test.filters.startDate), + 'Record precedes lower bound' + ) + if (test.filters?.endDate) + assert( + Date.parse(document.modifiedAt!) < Date.parse(test.filters.endDate), + 'Record exceeds exclusive upper bound' + ) + } + if (test.filters?.sortBy) { + const dates = result.documents.map((document) => Date.parse(document.modifiedAt!)) + assert(dates.every(Number.isFinite), 'Sorted records must include valid modification dates') + assert.deepEqual( + dates, + [...dates].sort((a, b) => (test.filters?.sortBy === 'oldest' ? a - b : b - a)) + ) + } + if (result.documents.length) { + const read = await readHubSpotMcp(provider, result.documents[0].id) + assert.equal(read.id, result.documents[0].id) + assert.equal(new URL(read.url).pathname, new URL(result.documents[0].url).pathname) + assert(read.content.includes('hs_object_id:'), 'Read must include returned record properties') + } + if (result.documents.length > 1) { + const first = await searchHubSpotMcp(provider, { ...input, limit: 1 }) + assert(first.nextCursor, 'First page must advertise continuation') + const second = await searchHubSpotMcp(provider, { + ...input, + limit: 1, + native: { ...input.native, cursor: first.nextCursor }, + }) + assert.deepEqual( + ids([...first.documents, ...second.documents]), + ids(result.documents.slice(0, 2)) + ) + if (result.documents.length === 2) + assert.equal(second.nextCursor, undefined, 'Final page must terminate') + } + }) +} +await mkdir(dirname(reportPath), { recursive: true }) +await writeFile( + reportPath, + JSON.stringify({ boundary: 'real HubSpot member grant and provider API', checks }, null, 2), + { mode: 0o600 } +) +const failed = checks.filter((check) => check.status === 'failed') +logger.info('HubSpot acceptance complete', { + checks: checks.length, + passed: checks.length - failed.length, + failed: failed.length, +}) +process.exit(failed.length ? 1 : 0) diff --git a/packages/deployment-config/src/env-capabilities.ts b/packages/deployment-config/src/env-capabilities.ts index 9d8eb803a24..949f43db7ef 100644 --- a/packages/deployment-config/src/env-capabilities.ts +++ b/packages/deployment-config/src/env-capabilities.ts @@ -1503,6 +1503,7 @@ export const OAUTH_CLIENT_CAPABILITIES = { asana: ['ASANA_CLIENT_ID', 'ASANA_CLIENT_SECRET'], pipedrive: ['PIPEDRIVE_CLIENT_ID', 'PIPEDRIVE_CLIENT_SECRET'], hubspot: ['HUBSPOT_CLIENT_ID', 'HUBSPOT_CLIENT_SECRET'], + 'hubspot-mcp': ['HUBSPOT_MCP_CLIENT_ID', 'HUBSPOT_MCP_CLIENT_SECRET'], linkedin: ['LINKEDIN_CLIENT_ID', 'LINKEDIN_CLIENT_SECRET'], instagram: ['INSTAGRAM_CLIENT_ID', 'INSTAGRAM_CLIENT_SECRET'], salesforce: ['SALESFORCE_CLIENT_ID', 'SALESFORCE_CLIENT_SECRET'], diff --git a/packages/sim-setup/src/capability-config.ts b/packages/sim-setup/src/capability-config.ts index 9e7578c7951..48d7f85b037 100644 --- a/packages/sim-setup/src/capability-config.ts +++ b/packages/sim-setup/src/capability-config.ts @@ -1109,6 +1109,10 @@ export const OAUTH_CLIENT_SETUP_FIELDS = { HUBSPOT_CLIENT_ID: { input: 'text' }, HUBSPOT_CLIENT_SECRET: { input: 'secret' }, }, + 'hubspot-mcp': { + HUBSPOT_MCP_CLIENT_ID: { input: 'text' }, + HUBSPOT_MCP_CLIENT_SECRET: { input: 'secret' }, + }, linkedin: { LINKEDIN_CLIENT_ID: { input: 'text' }, LINKEDIN_CLIENT_SECRET: { input: 'secret' },