Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions apps/sim/app/api/mothership/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,10 @@ import { createMockRequest } from '@sim/testing/mocks/request.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockBuildSelectedMcpToolSchemas,
mockBuildTaggedMcpToolSchemas,
mockComputeWorkspaceEntitlements,
mockProcessContextsServer,
mockRequestExplicitStreamAbort,
} = vi.hoisted(() => ({
mockBuildSelectedMcpToolSchemas: vi.fn(),
mockBuildTaggedMcpToolSchemas: vi.fn(),
mockComputeWorkspaceEntitlements: vi.fn(),
mockProcessContextsServer: vi.fn(),
mockRequestExplicitStreamAbort: vi.fn(),
Expand All @@ -53,11 +49,6 @@ vi.mock('@/lib/mothership/entitlements', () => ({
computeWorkspaceEntitlements: mockComputeWorkspaceEntitlements,
}))

vi.mock('@/lib/mothership/mcp-tools', () => ({
buildSelectedMcpToolSchemas: mockBuildSelectedMcpToolSchemas,
buildTaggedMcpToolSchemas: mockBuildTaggedMcpToolSchemas,
}))

vi.mock('@/lib/mothership/request/lifecycle/headless', () => mothershipHeadlessLifecycleMock)

vi.mock('@/lib/mothership/request/session/explicit-abort', () => ({
Expand Down Expand Up @@ -144,8 +135,6 @@ describe('mothership private trace provenance transport', () => {
decryptionFailures: [],
})
mockBuildIntegrationToolSchemas.mockResolvedValue([])
mockBuildSelectedMcpToolSchemas.mockResolvedValue([])
mockBuildTaggedMcpToolSchemas.mockResolvedValue([])
mockComputeWorkspaceEntitlements.mockResolvedValue([])
mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' })
mockProcessContextsServer.mockResolvedValue([])
Expand Down Expand Up @@ -484,9 +473,6 @@ describe('mothership private trace provenance transport', () => {
})

it('sends enabled MCP IDs without schemas or activating matching configured secrets', async () => {
mockBuildTaggedMcpToolSchemas.mockResolvedValueOnce([
{ name: 'mcp-docs', description: 'Uses secret-value' },
])
mockRunHeadlessCopilotLifecycle.mockImplementation(
async (payload: Record<string, unknown>, options: CopilotLifecycleOptions) => {
const registry =
Expand Down
8 changes: 0 additions & 8 deletions apps/sim/lib/mothership/chat/payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,6 @@ permissionGroupsResolveMockFns.mockGetUserPermissionConfigForOrganization.mockIm

vi.mock('@/lib/billing/plan-helpers', () => billingPlanHelpersMock)

vi.mock('@/lib/mothership/mcp-tools', () => ({
buildTaggedMcpToolSchemas: vi.fn(async () => []),
buildOrganizationTaggedMcpToolSchemas: vi.fn(async () => []),
}))

vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)

/** Denied-operation projection walks the block map only for blocks the mocked tool list never names. */
Expand Down Expand Up @@ -891,9 +886,7 @@ describe('desktop request capabilities', () => {

it('carries only enabled MCP IDs without eager catalog discovery while preserving desktop capabilities', async () => {
const { getBlockVisibilityForCopilot } = await import('@/lib/mothership/block-visibility')
const { buildOrganizationTaggedMcpToolSchemas } = await import('@/lib/mothership/mcp-tools')
vi.mocked(getBlockVisibilityForCopilot).mockClear()
vi.mocked(buildOrganizationTaggedMcpToolSchemas).mockClear()
const principal = createSessionPrincipal({ userId: 'user-org' })
const payload = await buildCopilotRequestPayload({
message: 'Use my tools',
Expand All @@ -909,7 +902,6 @@ it('carries only enabled MCP IDs without eager catalog discovery while preservin
terminalCapable: true,
})
expect(getBlockVisibilityForCopilot).not.toHaveBeenCalled()
expect(buildOrganizationTaggedMcpToolSchemas).not.toHaveBeenCalled()
expect(payload.integrationCatalog).toEqual({ mcpServerIds: ['server-a', 'server-b'] })
expect(payload).not.toHaveProperty('integrationTools')
expect(payload).not.toHaveProperty('mothershipTools')
Expand Down
11 changes: 6 additions & 5 deletions apps/sim/lib/mothership/chat/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,11 +514,12 @@ function normalizeContexts(contexts: UnifiedChatRequest['contexts']) {
* reloads and reopened chats with no extra state to keep in sync. There is
* deliberately no off switch: history is append-only.
*
* Only the ids travel forward, not the contexts themselves. The tools ride the
* tool array on every turn, so the model always sees their names and schemas;
* re-expanding the prompt listing each turn would just duplicate that. Keeping
* inherited servers out of the persisted contexts also keeps the `/name` chips
* on a sent message showing only what the user actually typed that turn.
* Only the ids travel forward, not the contexts themselves. The ids scope the
* request's integration catalog, and the agent discovers these servers'
* operations on demand, so re-expanding the prompt listing each turn adds
* nothing. Keeping inherited servers out of the persisted contexts also keeps
* the `/name` chips on a sent message showing only what the user actually typed
* that turn.
*/
function collectChatMcpServerIds(
chatMcpServerIds: string[],
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/mothership/chat/process-contents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ describe('processContextsServer - MCP contexts', () => {
},
])
expect(discoverServerTools).not.toHaveBeenCalled()
const catalog = await buildTaggedMcpToolSchemas('user-1', 'ws-1', ['mcp-server-1'])
const { tools: catalog } = await buildTaggedMcpToolSchemas('user-1', 'ws-1', ['mcp-server-1'])
expect(discoverServerTools).toHaveBeenCalledTimes(1)
expect(discoverServerTools).toHaveBeenCalledWith({
principal: expect.objectContaining({
Expand Down
97 changes: 85 additions & 12 deletions apps/sim/lib/mothership/integrations/application/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
mothershipWorkspaceTargetMock,
mothershipWorkspaceTargetMockFns,
} from '@sim/testing/mocks/mothership-workspace-target.mock'
import {
permissionCheckMock,
permissionCheckMockFns,
} from '@sim/testing/mocks/permission-check.mock'
import {
permissionGroupsResolveMock,
permissionGroupsResolveMockFns,
Expand All @@ -19,6 +23,7 @@ import {
workspaceContextMockFns,
} from '@sim/testing/mocks/workspace-context.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
createTrustedCopilotPrincipal,
createTrustedOrganizationCopilotPrincipal,
Expand All @@ -30,9 +35,11 @@ import {
readIntegrationCatalog,
} from '@/lib/mothership/integrations/application/catalog'

const hoisted = vi.hoisted(() => ({ mcp: vi.fn(), flag: vi.fn(async () => true) }))
const hoisted = vi.hoisted(() => ({ flag: vi.fn(async () => true) }))
const mocks = {
...hoisted,
discover: mcpUseCasesMockFns.mockDiscoverMcpServerToolsUseCase,
mcpPermission: permissionCheckMockFns.mockAssertPermissionsAllowed,
build: mothershipChatPayloadMockFns.mockBuildIntegrationToolSchemas,
banned: authBanMockFns.mockGetActivelyBannedUserIds,
target: mothershipWorkspaceTargetMockFns.mockResolveInvocationWorkspace,
Expand All @@ -42,7 +49,8 @@ const mocks = {
}
vi.mock('@/lib/mcp/application/use-cases', () => mcpUseCasesMock)
vi.mock('@/lib/mothership/chat/payload', () => mothershipChatPayloadMock)
vi.mock('@/lib/mothership/mcp-tools', () => ({ buildTaggedMcpToolSchemas: hoisted.mcp }))
vi.mock('@/lib/internal/mcp/discover-tools', () => ({ discoverMcpServerToolsAsExecutor: vi.fn() }))
vi.mock('@/ee/access-control/utils/permission-check', () => permissionCheckMock)
vi.mock('@/lib/mothership/application/workspace-target', () => mothershipWorkspaceTargetMock)
vi.mock('@/lib/workspaces/application/context', () => workspaceContextMock)
vi.mock('@/lib/auth/ban', () => authBanMock)
Expand All @@ -64,6 +72,9 @@ const tools = [
input_schema: { type: 'object' },
},
]
function discoveredServerIds() {
return mocks.discover.mock.calls.map(([args]) => args.input.serverId)
}
function principal() {
return createTrustedOrganizationCopilotPrincipal(
{ userId: 'actor', organizationId: 'org-1', chatId: 'chat-1', delegationId: 'catalog-1' },
Expand All @@ -82,7 +93,9 @@ beforeEach(() => {
mocks.banned.mockResolvedValue([])
mocks.config.mockResolvedValue(null)
mocks.build.mockResolvedValue([...tools])
mocks.mcp.mockResolvedValue([])
mocks.discover.mockReset()
mocks.discover.mockResolvedValue({ tools: [] })
mocks.mcpPermission.mockResolvedValue(undefined)
mocks.listServers.mockResolvedValue({ servers: [{ id: 'mcp-abc' }] })
})
describe('integration catalog projection', () => {
Expand Down Expand Up @@ -238,7 +251,9 @@ describe('catalog authorization', () => {

it('does not discover MCP operations in Search even with selected servers', async () => {
queueChat()
mocks.mcp.mockResolvedValue([{ ...tools[0], name: 'mcp-abc-send', service: 'mcp:mcp-abc' }])
mocks.discover.mockResolvedValue({
tools: [{ serverId: 'mcp-abc', name: 'send', description: 'Send', inputSchema: {} }],
})
const result = await readIntegrationCatalog.execute({
principal: principal(),
input: { ...input, service: 'mcp:mcp-abc', mcpServerIds: ['mcp-abc'] },
Expand Down Expand Up @@ -297,10 +312,12 @@ it.each([undefined, 'mcp-abc-write'])(
async (toolId) => {
queueChat('agent')
mocks.target.mockResolvedValue({ workspaceId: 'workspace-1' })
mocks.mcp.mockResolvedValue([
{ name: 'mcp-abc-read', service: 'mcp:mcp-abc', description: 'Read', input_schema: {} },
{ name: 'mcp-abc-write', service: 'mcp:mcp-abc', description: 'Write', input_schema: {} },
])
mocks.discover.mockResolvedValue({
tools: [
{ serverId: 'mcp-abc', name: 'read', description: 'Read', inputSchema: {} },
{ serverId: 'mcp-abc', name: 'write', description: 'Write', inputSchema: {} },
],
})
const result = await readIntegrationCatalog.execute({
principal: principal(),
input: {
Expand All @@ -312,7 +329,7 @@ it.each([undefined, 'mcp-abc-write'])(
...(toolId ? { toolId } : {}),
},
})
expect(mocks.mcp).toHaveBeenCalledWith('actor', 'workspace-1', ['mcp-abc'], undefined)
expect(discoveredServerIds()).toEqual(['mcp-abc'])
expect(result.operations.map((operation) => operation.toolId)).toEqual(
toolId ? [] : ['mcp-abc-read']
)
Expand All @@ -325,7 +342,7 @@ it('keeps native discovery available in organization chats with tagged MCP serve
input: { ...input, mode: 'agent', mcpServerIds: ['mcp-abc'], toolId: 'gmail_send' },
})
expect(result.operations.map((operation) => operation.toolId)).toEqual(['gmail_send'])
expect(mocks.mcp).not.toHaveBeenCalled()
expect(mocks.discover).not.toHaveBeenCalled()
})
it('requires an explicit workspace target for organization MCP discovery', async () => {
queueChat('agent')
Expand All @@ -335,7 +352,7 @@ it('requires an explicit workspace target for organization MCP discovery', async
input: { ...input, mode: 'agent', mcpServerIds: ['mcp-abc'], toolId: 'mcp-abc-read' },
})
).rejects.toThrow('explicit workspace ID')
expect(mocks.mcp).not.toHaveBeenCalled()
expect(mocks.discover).not.toHaveBeenCalled()
})

it('ranks query tokens across operation names, descriptions, and service while prioritizing exact IDs', () => {
Expand Down Expand Up @@ -381,7 +398,7 @@ it('filters organization enabled servers to the authorized target before broad M
principal: expect.objectContaining({ workspaceId: 'workspace-1', subjectUserId: 'actor' }),
input: { workspaceId: 'workspace-1' },
})
expect(mocks.mcp).toHaveBeenCalledWith('actor', 'workspace-1', ['mcp-abc'], undefined)
expect(discoveredServerIds()).toEqual(['mcp-abc'])
})

it('removes previously discoverable Search operations when the runtime flag turns off', async () => {
Expand All @@ -394,3 +411,59 @@ it('removes previously discoverable Search operations when the runtime flag turn
)
}
})

describe('tagged MCP servers that cannot be discovered', () => {
const failures: Record<string, () => Promise<never> | Promise<{ tools: never[] }>> = {
'mcp-deleted': async () => {
throw new OrchestrationError('not_found', 'MCP server not found')
},
'mcp-down': async () => {
throw new Error('connect ECONNREFUSED')
},
'mcp-empty': async () => ({ tools: [] }),
}
const serverIds = ['mcp-abc', ...Object.keys(failures)]
function catalog(request: Partial<IntegrationCatalogRequest>) {
queueChat('agent')
return readIntegrationCatalog.execute({
principal: principal(),
input: {
...input,
mode: 'agent',
workspaceId: 'workspace-1',
mcpServerIds: serverIds,
...request,
},
})
}
beforeEach(() => {
mocks.target.mockResolvedValue({ workspaceId: 'workspace-1' })
mocks.listServers.mockResolvedValue({ servers: serverIds.map((id) => ({ id })) })
mocks.discover.mockImplementation(async ({ input: { serverId } }) => {
const failure = failures[serverId]
if (failure) return failure()
return { tools: [{ serverId, name: 'send', description: 'Send a note', inputSchema: {} }] }
})
})

it.each([undefined, 'send'])(
'still lists native and healthy MCP operations (query: %s)',
async (query) => {
const result = await catalog(query ? { query } : {})
expect(result.operations.map((operation) => operation.toolId)).toEqual(
expect.arrayContaining(['gmail_send', 'slack_send', 'mcp-abc-send'])
)
expect(result.operations).toHaveLength(3)
expect(discoveredServerIds().sort()).toEqual([...serverIds].sort())
}
)

it.each([
['mcp-deleted', 'MCP server not found'],
['mcp-down', 'connect ECONNREFUSED'],
['mcp-empty', 'No permitted MCP operations are available for mcp-empty'],
])('fails a request that targets %s with its own error', async (serverId, message) => {
await expect(catalog({ service: `mcp:${serverId}` })).rejects.toThrow(message)
await expect(catalog({ toolId: `${serverId}-send` })).rejects.toThrow(message)
})
})
18 changes: 17 additions & 1 deletion apps/sim/lib/mothership/integrations/application/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
resolvePrincipalSubjectUserId,
} from '@sim/auth/principal'
import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata'
import { createLogger } from '@sim/logger'
import { stripVersionSuffix } from '@sim/utils/string'
import { defineWorkspaceOperation } from '@/lib/core/application'
import { defineOrganizationOperation } from '@/lib/core/application/organization-operation'
Expand All @@ -33,6 +34,8 @@ import type {
import { buildTaggedMcpToolSchemas } from '@/lib/mothership/mcp-tools'
import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context'

const logger = createLogger('IntegrationCatalog')

export const INTEGRATION_CATALOG_AUDIENCE = 'sim:integration-catalog'
export const readIntegrationCatalogOperation = defineWorkspaceOperation({
id: 'mothership.integrations.catalog',
Expand Down Expand Up @@ -227,12 +230,25 @@ const catalogUseCase = defineAuthorizedChatUseCase({
const executorContext = input.mcpExecution
? catalogExecutorContext(input, context.userId, workspaceId)
: undefined
const mcpTools = await buildTaggedMcpToolSchemas(
const { tools: mcpTools, failures } = await buildTaggedMcpToolSchemas(
context.userId,
workspaceId,
candidates,
executorContext
)
const targetFailure = failures.find(
Comment thread
waleedlatif1 marked this conversation as resolved.
({ serverId }) =>
input.service === `mcp:${serverId}` ||
(input.toolId?.startsWith(`${serverId}-`) &&
!mcpTools.some((tool) => tool.name === input.toolId))
)
if (targetFailure) throw targetFailure.error
for (const { serverId, error } of failures) {
logger.warn('Omitting an undiscoverable tagged MCP server from the integration catalog', {
serverId,
reason: error.message,
})
}
tools.push(
...mcpTools.filter(
(tool) => selectedIds.has(tool.name) || serverIds.has(tool.service?.slice(4) ?? '')
Expand Down
Loading