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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.unmock('@/lib/mothership/request/http')
vi.mock('@/lib/mothership/chat/payload', () => mothershipChatPayloadMock)
vi.mock('@/lib/mothership/mcp-tools', () => ({ buildTaggedMcpToolSchemas: vi.fn() }))
vi.mock('@/lib/mothership/mcp-tools', () => ({ buildMcpToolSchemas: vi.fn() }))
vi.mock('@/lib/mcp/application/use-cases', () => mcpUseCasesMock)
vi.mock('@/lib/workspaces/application/context', () => workspaceContextMock)
vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock)
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/mothership/chat/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export async function loadChatFileAttachmentsNamed(
* MCP server ids tagged (`/name`) on a chat's live messages, in first-tagged
* transcript order. Reads only the `mcp` entries of each message's `contexts`
* instead of materializing the whole transcript, and skips soft-deleted
* messages so a removed turn no longer enables its servers.
* messages so a removed turn no longer tags its servers.
*/
export async function loadChatMcpServerIds(chatId: string): Promise<string[]> {
const rows = await db
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/mothership/chat/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ interface BuildPayloadParams {
provider?: string
contexts?: Array<{ type: string; content: string; tag?: string; path?: string }>
/**
* MCP servers enabled for this chat — every server tagged on this or any
* earlier turn. Servers never tagged in the chat stay unavailable.
* MCP servers tagged on this or any earlier turn of the chat. Every usable
* workspace server is discoverable without a tag; tagged ones rank first.
*/
mcpServerIds?: string[]
fileAttachments?: Array<{ id: string; key: string; size: number; [key: string]: unknown }>
Expand Down
15 changes: 8 additions & 7 deletions apps/sim/lib/mothership/chat/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,14 +508,15 @@ function normalizeContexts(contexts: UnifiedChatRequest['contexts']) {
}

/**
* An MCP server tagged with `/name` stays enabled for the rest of the chat, not
* just the turn it was tagged on. Persisted user messages already carry their
* `mcp` contexts, so the transcript is the source of truth — enablement survives
* reloads and reopened chats with no extra state to keep in sync. There is
* deliberately no off switch: history is append-only.
* An MCP server tagged with `/name` stays tagged for the rest of the chat, not
* just the turn it was tagged on. Every usable workspace server is discoverable
* without a tag; a tag marks the servers the user means, whose operations the
* integration catalog ranks first and lists completely. Persisted user messages
* already carry their `mcp` contexts, so the transcript is the source of truth —
* tags survive 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 ids scope the
* request's integration catalog, and the agent discovers these servers'
* Only the ids travel forward, not the contexts themselves. The agent discovers
* 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
Expand Down
6 changes: 4 additions & 2 deletions apps/sim/lib/mothership/chat/process-contents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
MAX_TABLE_SELECTION_PREVIEW_LENGTH,
MAX_TABLE_SELECTION_ROWS,
} from '@/lib/mothership/chat/selection-context'
import { buildTaggedMcpToolSchemas } from '@/lib/mothership/mcp-tools'
import { buildMcpToolSchemas } from '@/lib/mothership/mcp-tools'
import {
getBlock as registryGetBlock,
getBlockRegistry as registryGetBlockRegistry,
Expand Down Expand Up @@ -635,7 +635,9 @@ describe('processContextsServer - MCP contexts', () => {
},
])
expect(discoverServerTools).not.toHaveBeenCalled()
const { tools: catalog } = await buildTaggedMcpToolSchemas('user-1', 'ws-1', ['mcp-server-1'])
const { tools: catalog } = await buildMcpToolSchemas('user-1', 'ws-1', {
complete: ['mcp-server-1'],
})
expect(discoverServerTools).toHaveBeenCalledTimes(1)
expect(discoverServerTools).toHaveBeenCalledWith({
principal: expect.objectContaining({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { projectIntegrationCatalog } from '@/lib/mothership/integrations/applica
/** Registration assertions must exercise the executable registry, not the global empty mock. */
vi.unmock('@/tools/registry')
vi.mock('@/lib/mothership/chat/payload', () => mothershipChatPayloadMock)
vi.mock('@/lib/mothership/mcp-tools', () => ({ buildTaggedMcpToolSchemas: vi.fn() }))
vi.mock('@/lib/mothership/mcp-tools', () => ({ buildMcpToolSchemas: vi.fn() }))
vi.mock('@/lib/mcp/application/use-cases', () => mcpUseCasesMock)

const tools: ToolSchema[] = getExposedIntegrationTools().map((tool) => ({
Expand Down
125 changes: 121 additions & 4 deletions apps/sim/lib/mothership/integrations/application/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
mothershipWorkspaceTargetMockFns,
} from '@sim/testing/mocks/mothership-workspace-target.mock'
import {
MockMcpToolsNotAllowedError,
permissionCheckMock,
permissionCheckMockFns,
} from '@sim/testing/mocks/permission-check.mock'
Expand All @@ -22,7 +23,7 @@ import {
workspaceContextMock,
workspaceContextMockFns,
} from '@sim/testing/mocks/workspace-context.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
createTrustedCopilotPrincipal,
Expand Down Expand Up @@ -72,6 +73,9 @@ const tools = [
input_schema: { type: 'object' },
},
]
function usableServer(id: string, overrides: Record<string, unknown> = {}) {
return { id, enabled: true, credentialGroupId: null, ...overrides }
}
function discoveredServerIds() {
return mocks.discover.mock.calls.map(([args]) => args.input.serverId)
}
Expand All @@ -96,7 +100,7 @@ beforeEach(() => {
mocks.discover.mockReset()
mocks.discover.mockResolvedValue({ tools: [] })
mocks.mcpPermission.mockResolvedValue(undefined)
mocks.listServers.mockResolvedValue({ servers: [{ id: 'mcp-abc' }] })
mocks.listServers.mockResolvedValue({ servers: [usableServer('mcp-abc')] })
})
describe('integration catalog projection', () => {
it.each(['google_calendar', 'google-calendar', 'Google Calendar', ' GOOGLE-CALENDAR '])(
Expand Down Expand Up @@ -384,7 +388,6 @@ it('ranks query tokens across operation names, descriptions, and service while p
it('filters organization enabled servers to the authorized target before broad MCP discovery', async () => {
queueChat('agent')
mocks.target.mockResolvedValue({ workspaceId: 'workspace-1' })
mocks.listServers.mockResolvedValue({ servers: [{ id: 'mcp-abc' }] })
await readIntegrationCatalog.execute({
principal: principal(),
input: {
Expand Down Expand Up @@ -438,7 +441,7 @@ describe('tagged MCP servers that cannot be discovered', () => {
}
beforeEach(() => {
mocks.target.mockResolvedValue({ workspaceId: 'workspace-1' })
mocks.listServers.mockResolvedValue({ servers: serverIds.map((id) => ({ id })) })
mocks.listServers.mockResolvedValue({ servers: serverIds.map((id) => usableServer(id)) })
mocks.discover.mockImplementation(async ({ input: { serverId } }) => {
const failure = failures[serverId]
if (failure) return failure()
Expand Down Expand Up @@ -467,3 +470,117 @@ describe('tagged MCP servers that cannot be discovered', () => {
await expect(catalog({ toolId: `${serverId}-send` })).rejects.toThrow(message)
})
})

describe('untagged workspace MCP servers', () => {
function catalog(
request: Partial<IntegrationCatalogRequest>,
mode: IntegrationCatalogRequest['mode'] = 'agent'
) {
queueChat(mode)
return readIntegrationCatalog.execute({
principal: principal(),
input: { ...input, mode, workspaceId: 'workspace-1', ...request },
})
}
function toolIds(result: { operations: Array<{ toolId: string }> }) {
return result.operations.map((operation) => operation.toolId)
}
beforeEach(() => {
mocks.target.mockResolvedValue({ workspaceId: 'workspace-1' })
mocks.listServers.mockResolvedValue({
servers: ['mcp-docs', 'mcp-crm'].map((id) => usableServer(id)),
})
mocks.discover.mockImplementation(async ({ input: { serverId } }) => ({
tools: [{ serverId, name: 'lookup', description: 'Look up a record', inputSchema: {} }],
}))
})
afterEach(() => {
vi.useRealTimers()
})

it.each(['agent', 'plan'] as const)(
'discovers every usable server without a tag in %s mode, from the discovery cache',
async (mode) => {
const result = await catalog({ query: 'lookup' }, mode)
expect(toolIds(result)).toEqual(['mcp-crm-lookup', 'mcp-docs-lookup'])
expect(mocks.discover).toHaveBeenCalledWith({
principal: expect.objectContaining({ workspaceId: 'workspace-1', subjectUserId: 'actor' }),
input: { workspaceId: 'workspace-1', serverId: 'mcp-docs' },
})
}
)

it('keeps Search free of MCP even with usable workspace servers', async () => {
const result = await catalog({}, 'assistant')
expect(toolIds(result)).toEqual(['gmail_send', 'slack_send'])
expect(mocks.listServers).not.toHaveBeenCalled()
expect(mocks.discover).not.toHaveBeenCalled()
})

it('never discovers disabled, deleted, or Credential Group servers', async () => {
mocks.listServers.mockResolvedValue({
servers: [
usableServer('mcp-docs'),
usableServer('mcp-off', { enabled: false }),
usableServer('mcp-group', { credentialGroupId: 'group-1' }),
],
})
const result = await catalog({ mcpServerIds: ['mcp-deleted'], query: 'lookup' })
expect(toolIds(result)).toEqual(['mcp-docs-lookup'])
expect(discoveredServerIds()).toEqual(['mcp-docs'])
})

it('keeps a workflow selection scoped to the operations it selected', async () => {
const result = await catalog({ mcpToolIds: ['mcp-docs-lookup'] })
expect(toolIds(result)).toEqual(['gmail_send', 'mcp-docs-lookup', 'slack_send'])
expect(discoveredServerIds()).toEqual(['mcp-docs'])
expect(mocks.listServers).not.toHaveBeenCalled()
})

it('ranks a tagged server ahead of an equally relevant untagged one', async () => {
const result = await catalog({ mcpServerIds: ['mcp-docs'], query: 'lookup' })
expect(toolIds(result)).toEqual(['mcp-docs-lookup', 'mcp-crm-lookup'])
})

it('skips an untagged server that does not answer in time without failing the query', async () => {
vi.useFakeTimers()
mocks.discover.mockImplementation(async ({ input: { serverId } }) =>
serverId === 'mcp-crm'
? new Promise(() => {})
: {
tools: [{ serverId, name: 'lookup', description: 'Look up a record', inputSchema: {} }],
}
)
const pending = catalog({ query: 'lookup' })
await vi.advanceTimersByTimeAsync(3_000)
expect(toolIds(await pending)).toEqual(['mcp-docs-lookup'])
})

it('lists native operations when a permission group disables MCP', async () => {
mocks.mcpPermission.mockRejectedValue(new MockMcpToolsNotAllowedError())
const result = await catalog({ mcpServerIds: ['mcp-docs'] })
expect(toolIds(result)).toEqual(['gmail_send', 'slack_send'])
expect(mocks.discover).not.toHaveBeenCalled()
await expect(catalog({ service: 'mcp:mcp-docs' })).rejects.toThrow('MCP tools are not allowed')
})

it('resolves an untagged server operation by exact ID for execution', async () => {
const result = await catalog({ toolId: 'mcp-crm-lookup' })
expect(result.operations).toEqual([
{
toolId: 'mcp-crm-lookup',
service: 'mcp:mcp-crm',
description: 'Look up a record',
inputSchema: {},
},
])
expect(mocks.discover).toHaveBeenCalledTimes(1)
expect(mocks.discover).toHaveBeenCalledWith(
expect.objectContaining({
input: { workspaceId: 'workspace-1', serverId: 'mcp-crm', requireComplete: true },
})
)
mocks.discover.mockRejectedValue(new Error('connect ECONNREFUSED'))
await expect(catalog({ toolId: 'mcp-crm-lookup' })).rejects.toThrow('connect ECONNREFUSED')
})
})
Loading
Loading