Skip to content

Commit 26edd25

Browse files
committed
fix(mothership): resolve integration discovery service identities
1 parent 14a3dd3 commit 26edd25

4 files changed

Lines changed: 258 additions & 6 deletions

File tree

‎apps/sim/app/api/mothership/integrations/catalog/route.test.ts‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ vi.mock('@/lib/mothership/tasks/application/context', () => ({ TASK_DELEGATION_A
3232
vi.mock('@/lib/mothership/tasks/wake', () => ({ runWakeTurn: vi.fn() }))
3333

3434
import { env } from '@/lib/core/config/env'
35+
import type { IntegrationCatalogRequest } from '@/lib/mothership/generated/integration-catalog'
3536
import { executeSimControl } from '@/lib/mothership/transport/control'
3637
import { POST } from '@/app/api/mothership/integrations/catalog/route'
3738

@@ -41,7 +42,10 @@ const scope = {
4142
chatId: '33333333-3333-4333-8333-333333333333',
4243
}
4344
const input = { mode: 'agent' as const, mcpServerIds: [], toolId: 'gmail_send', limit: 1 }
44-
function request(key = env.INTERNAL_API_SECRET ?? '') {
45+
function request(
46+
key = env.INTERNAL_API_SECRET ?? '',
47+
catalogInput: IntegrationCatalogRequest = input
48+
) {
4549
return new NextRequest('http://localhost/api/mothership/integrations/catalog', {
4650
method: 'POST',
4751
headers: {
@@ -51,15 +55,15 @@ function request(key = env.INTERNAL_API_SECRET ?? '') {
5155
'x-mothership-workspace-id': scope.workspaceId,
5256
'x-mothership-chat-id': scope.chatId,
5357
},
54-
body: JSON.stringify(input),
58+
body: JSON.stringify(catalogInput),
5559
})
5660
}
57-
function checkpoint() {
61+
function checkpoint(catalogInput: IntegrationCatalogRequest = input) {
5862
return executeSimControl({
5963
id: 'catalog-control',
6064
scope,
6165
expiresAt: Date.now() + 5000,
62-
operation: { kind: 'integration_catalog', input },
66+
operation: { kind: 'integration_catalog', input: catalogInput },
6367
})
6468
}
6569
beforeEach(() => {
@@ -80,6 +84,37 @@ beforeEach(() => {
8084
])
8185
})
8286
describe('direct and checkpoint catalog authorization parity', () => {
87+
it.each(['google-email', 'Gmail', 'gmail'])(
88+
'resolves %s identically through hosted HTTP and self-hosted outbound control',
89+
async (service) => {
90+
const query = { mode: 'agent' as const, mcpServerIds: [], service, query: 'email', limit: 5 }
91+
const direct = await POST(request(undefined, query))
92+
const outbound = await checkpoint(query)
93+
expect(direct.status).toBe(200)
94+
expect(outbound.status).toBe(200)
95+
const body = await direct.json()
96+
expect(body).toEqual(JSON.parse(outbound.body))
97+
expect(body.operations).toEqual([
98+
{
99+
toolId: 'gmail_send',
100+
service: 'gmail',
101+
description: 'Send email',
102+
inputSchema: { type: 'object' },
103+
},
104+
])
105+
}
106+
)
107+
108+
it('reports an invalid service filter as a validation error on both transports', async () => {
109+
const query = { ...input, service: 'not-a-service' }
110+
const direct = await POST(request(undefined, query))
111+
const outbound = await checkpoint(query)
112+
expect(direct.status).toBe(400)
113+
expect(outbound.status).toBe(400)
114+
expect(JSON.stringify(await direct.json())).toContain('Unknown integration service')
115+
expect(outbound.body).toContain('Unknown integration service')
116+
})
117+
83118
it('returns identical authorized schema responses through HTTP and outbound control', async () => {
84119
const direct = await POST(request())
85120
const outbound = await checkpoint()
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/** @vitest-environment node */
2+
import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata'
3+
import { stripVersionSuffix } from '@sim/utils/string'
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { getExposedIntegrationTools } from '@/lib/integrations/tool-catalog'
6+
import type { ToolSchema } from '@/lib/mothership/chat/payload'
7+
import { projectIntegrationCatalog } from '@/lib/mothership/integrations/application/catalog'
8+
9+
/** Registration assertions must exercise the executable registry, not the global empty mock. */
10+
vi.unmock('@/tools/registry')
11+
vi.mock('@/lib/mothership/chat/payload', () => ({ buildIntegrationToolSchemas: vi.fn() }))
12+
vi.mock('@/lib/mothership/mcp-tools', () => ({ buildTaggedMcpToolSchemas: vi.fn() }))
13+
vi.mock('@/lib/mcp/application/use-cases', () => ({ listMcpServersUseCase: { execute: vi.fn() } }))
14+
15+
const tools: ToolSchema[] = getExposedIntegrationTools().map((tool) => ({
16+
name: tool.toolId,
17+
service: tool.service,
18+
description: tool.config.description,
19+
input_schema: {},
20+
}))
21+
const input = { mode: 'agent' as const, mcpServerIds: [], limit: 0 }
22+
23+
describe('integration discovery identity registry', () => {
24+
it('preserves every existing callable service ID, including services outside the public catalog', () => {
25+
const services = new Set(tools.map((tool) => tool.service))
26+
expect(services.has('google_calendar')).toBe(true)
27+
for (const service of services) {
28+
const result = projectIntegrationCatalog(tools, { ...input, service })
29+
expect(result.operations.map((operation) => operation.toolId).sort()).toEqual(
30+
tools
31+
.filter((tool) => tool.service === service)
32+
.map((tool) => tool.name)
33+
.sort()
34+
)
35+
}
36+
})
37+
38+
it('keeps generated integration names and slugs aligned with the real callable catalog', () => {
39+
const services = new Set(tools.map((tool) => tool.service))
40+
for (const integration of INTEGRATION_METADATA) {
41+
const service = stripVersionSuffix(integration.type)
42+
if (!services.has(service)) continue
43+
const canonical = projectIntegrationCatalog(tools, { ...input, service })
44+
for (const alias of [integration.type, integration.name, integration.slug]) {
45+
expect(
46+
projectIntegrationCatalog(tools, { ...input, service: alias }),
47+
`${integration.name}: ${alias} must discover the same operations as ${service}`
48+
).toEqual(canonical)
49+
}
50+
}
51+
})
52+
})

‎apps/sim/lib/mothership/integrations/application/catalog.test.ts‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,106 @@ beforeEach(() => {
7474
mocks.listServers.mockResolvedValue({ servers: [{ id: 'mcp-abc' }] })
7575
})
7676
describe('integration catalog projection', () => {
77+
it.each(['google_calendar', 'google-calendar', 'Google Calendar', ' GOOGLE-CALENDAR '])(
78+
'resolves the registered Calendar service name %s without changing the operation',
79+
(service) => {
80+
const calendar = {
81+
name: 'google_calendar_list_v2',
82+
service: 'google_calendar',
83+
description: 'List calendar events',
84+
input_schema: { type: 'object', properties: { calendarId: { type: 'string' } } },
85+
}
86+
expect(projectIntegrationCatalog([calendar], { ...input, service, query: 'events' })).toEqual(
87+
projectIntegrationCatalog([calendar], {
88+
...input,
89+
service: 'google_calendar',
90+
query: 'events',
91+
})
92+
)
93+
expect(
94+
projectIntegrationCatalog([calendar], { ...input, service, query: 'events' }).operations
95+
).toHaveLength(1)
96+
}
97+
)
98+
99+
it.each([
100+
['google-email', 'gmail'],
101+
['github-repositories', 'github'],
102+
['Microsoft Teams', 'microsoft_teams'],
103+
['microsoft-excel', 'microsoft_excel'],
104+
['Cal.com', 'calcom'],
105+
['Zoho Desk', 'zoho_desk'],
106+
['salesforce-sandbox', 'salesforce'],
107+
])('resolves metadata-backed alias %s to %s', (alias, service) => {
108+
const tool = { ...tools[0]!, name: `${service}_read`, service }
109+
expect(
110+
projectIntegrationCatalog([tool], { ...input, service: alias }).operations.map(
111+
(operation) => operation.toolId
112+
)
113+
).toEqual([tool.name])
114+
})
115+
116+
it('keeps specific service filters narrow and shared credential families scoped to supplied tools', () => {
117+
const authorized = ['google_drive', 'google_slides', 'gmail', 'slack'].map((service) => ({
118+
...tools[0]!,
119+
name: `${service}_read`,
120+
service,
121+
}))
122+
for (const service of ['google_drive', 'google-drive', 'Google Drive']) {
123+
expect(
124+
projectIntegrationCatalog(authorized, { ...input, service }).operations.map(
125+
(operation) => operation.service
126+
)
127+
).toEqual(['google_drive'])
128+
}
129+
expect(
130+
projectIntegrationCatalog(authorized, {
131+
...input,
132+
service: 'google-service-account',
133+
}).operations.map((operation) => operation.service)
134+
).toEqual(['gmail', 'google_drive', 'google_slides'])
135+
expect(
136+
projectIntegrationCatalog(
137+
authorized.filter((tool) => tool.service === 'google_slides'),
138+
{
139+
...input,
140+
service: 'google-drive',
141+
}
142+
).operations
143+
).toEqual([])
144+
})
145+
146+
it('derives new provider bindings from callable tools before public metadata is published', () => {
147+
const preview = {
148+
...tools[0]!,
149+
name: 'preview_service_read',
150+
service: 'preview_service',
151+
oauth: { required: true, provider: 'preview-service-provider' },
152+
}
153+
expect(
154+
projectIntegrationCatalog([preview, ...tools], {
155+
...input,
156+
service: 'preview-service-provider',
157+
}).operations.map((operation) => operation.toolId)
158+
).toEqual([preview.name])
159+
})
160+
161+
it('distinguishes an unrecognized filter from valid empty results without revealing other tools', () => {
162+
expect(() => projectIntegrationCatalog(tools, { ...input, service: 'gogle-calndar' })).toThrow(
163+
'Unknown integration service'
164+
)
165+
expect(
166+
projectIntegrationCatalog(tools, { ...input, service: 'Google Calendar' }).operations
167+
).toEqual([])
168+
expect(
169+
projectIntegrationCatalog(tools, { ...input, service: 'gmail', query: 'nonexistent' })
170+
.operations
171+
).toEqual([])
172+
expect(
173+
projectIntegrationCatalog(tools, { ...input, service: 'mcp:unselected-server' }).operations
174+
).toEqual([])
175+
})
176+
77177
it('omits schemas from broad listings and treats zero as an explicit full listing', () => {
78178
expect(projectIntegrationCatalog(tools, { ...input, limit: 1 })).toEqual({
79179
total: 2,
@@ -102,6 +202,27 @@ describe('integration catalog projection', () => {
102202
})
103203
})
104204
describe('catalog authorization', () => {
205+
it.each(['assistant', 'agent'] as const)(
206+
'resolves provider aliases after the existing %s authorization and catalog projection',
207+
async (mode) => {
208+
queueChat(mode)
209+
const result = await readIntegrationCatalog.execute({
210+
principal: principal(),
211+
input: { ...input, mode, service: 'google-email', query: 'email' },
212+
})
213+
expect(result.operations.map((operation) => operation.toolId)).toEqual(['gmail_send'])
214+
expect(mocks.build).toHaveBeenCalledWith(
215+
'actor',
216+
{
217+
schemaSurface: 'copilot',
218+
personalAccountsOnly: mode === 'assistant',
219+
organizationId: 'org-1',
220+
},
221+
undefined
222+
)
223+
}
224+
)
225+
105226
it('preserves assistant personal-account and organization-approval projection and excludes MCP', async () => {
106227
queueChat()
107228
await readIntegrationCatalog.execute({

‎apps/sim/lib/mothership/integrations/application/catalog.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import {
66
resolvePrincipalExecutionActorUserId,
77
resolvePrincipalSubjectUserId,
88
} from '@sim/auth/principal'
9+
import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata'
10+
import { stripVersionSuffix } from '@sim/utils/string'
911
import { defineWorkspaceOperation } from '@/lib/core/application'
1012
import { defineOrganizationOperation } from '@/lib/core/application/organization-operation'
1113
import { OrchestrationError } from '@/lib/core/orchestration/types'
14+
import { getIntegrationsForCredentialProvider } from '@/lib/integrations/credential-display'
1215
import {
1316
createExecutorPrincipalFromExecutionContext,
1417
resolveExecutorOriginSubject,
@@ -46,10 +49,10 @@ export function projectIntegrationCatalog(
4649
): IntegrationCatalogResponse {
4750
const normalized = input.query?.trim().toLowerCase() ?? ''
4851
const terms = normalized.split(/[^a-z0-9]+/).filter(Boolean)
49-
const service = input.service?.trim().toLowerCase()
52+
const services = resolveServiceFilter(tools, input.service)
5053
const matches = tools.flatMap((tool) => {
5154
if (input.toolId && tool.name !== input.toolId) return []
52-
if (service && tool.service?.toLowerCase() !== service) return []
55+
if (services && (!tool.service || !services.has(tool.service.toLowerCase()))) return []
5356
const name = tool.name.toLowerCase()
5457
const description = tool.description.toLowerCase()
5558
let score = 0
@@ -80,6 +83,47 @@ export function projectIntegrationCatalog(
8083
}
8184
}
8285

86+
/** Specific service identities win over credential families; resolution never adds callable tools. */
87+
function resolveServiceFilter(
88+
tools: ToolSchema[],
89+
service: string | undefined
90+
): ReadonlySet<string> | undefined {
91+
const normalized = service?.trim().toLowerCase()
92+
if (!normalized) return undefined
93+
if (
94+
normalized.startsWith('mcp:') ||
95+
tools.some((tool) => tool.service?.toLowerCase() === normalized)
96+
) {
97+
return new Set([normalized])
98+
}
99+
100+
const named = INTEGRATION_METADATA.filter((integration) =>
101+
[
102+
integration.type,
103+
stripVersionSuffix(integration.type),
104+
integration.slug,
105+
integration.name,
106+
].some((name) => name.toLowerCase() === normalized)
107+
)
108+
const integrations = named.length ? named : getIntegrationsForCredentialProvider(normalized)
109+
const services = new Set(
110+
integrations.map((integration) => stripVersionSuffix(integration.type).toLowerCase())
111+
)
112+
/** Callable preview integrations may not yet appear in the public metadata. */
113+
if (!named.length) {
114+
for (const tool of tools) {
115+
if (tool.service && tool.oauth?.provider.toLowerCase() === normalized) {
116+
services.add(tool.service.toLowerCase())
117+
}
118+
}
119+
}
120+
if (services.size) return services
121+
throw new OrchestrationError(
122+
'validation',
123+
'Unknown integration service. Use a service ID, name, or credential provider from discovery, or retry without the service filter.'
124+
)
125+
}
126+
83127
/** Catalog access rechecks current grants; worker state carries scope and provenance, never schemas. */
84128
const catalogUseCase = defineAuthorizedChatUseCase({
85129
operation: readIntegrationCatalogOperation,

0 commit comments

Comments
 (0)