Skip to content

Commit 0fbfe28

Browse files
committed
feat(mothership): support dynamic Sim Chat tool modes
1 parent e760f27 commit 0fbfe28

17 files changed

Lines changed: 213 additions & 62 deletions

File tree

‎apps/sim/app/api/mothership/execute/route.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ describe('mothership private trace provenance transport', () => {
424424
mcpTools: [
425425
{
426426
type: 'mcp',
427+
usageControl: 'force',
427428
params: { serverId: 'mcp-cg-123456789012345678901', toolName: 'search_transcripts' },
428429
},
429430
{
@@ -459,6 +460,7 @@ describe('mothership private trace provenance transport', () => {
459460
integrationCatalog: {
460461
mcpServerIds: ['123'],
461462
mcpToolIds: ['mcp-cg-123456789012345678901-search_transcripts'],
463+
requiredToolIds: ['mcp-cg-123456789012345678901-search_transcripts'],
462464
mcpExecution: {
463465
workflowId: 'workflow-1',
464466
executionId: 'execution-1',

‎apps/sim/app/api/mothership/execute/route.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
253253
const { serverId, toolName } = resolveMcpToolBinding(tool)
254254
return createMcpToolId(serverId, toolName)
255255
})
256+
const requiredToolIds = (mcpTools ?? [])
257+
.filter((tool) => tool.usageControl === 'force')
258+
.map((tool) => {
259+
const { serverId, toolName } = resolveMcpToolBinding(tool)
260+
return createMcpToolId(serverId, toolName)
261+
})
256262
const agentContexts = await processContextsServer(
257263
nonMcpAgentMentions,
258264
userId,
@@ -306,6 +312,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
306312
integrationCatalog: {
307313
mcpServerIds: taggedMcpServerIds,
308314
mcpToolIds: selectedMcpToolIds,
315+
...(requiredToolIds.length ? { requiredToolIds: [...new Set(requiredToolIds)] } : {}),
309316
mcpExecution: IntegrationCatalogMcpExecution.parse({
310317
workflowId: delegation.workflowId,
311318
executionId: delegation.executionId,

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -599,8 +599,9 @@ export const ToolInput = memo(function ToolInput({
599599
const modelValue = useSubBlockStore((state) => state.getValue(blockId, 'model'))
600600
const model = typeof modelValue === 'string' ? modelValue : ''
601601
const provider = model ? getProviderFromModel(model) : ''
602-
const supportsToolControl = provider ? supportsToolUsageControl(provider) : false
603-
const supportsForce = supportsForcedToolUse(model)
602+
const supportsToolControl =
603+
blockType === 'mothership' || (provider ? supportsToolUsageControl(provider) : false)
604+
const supportsForce = blockType === 'mothership' || supportsForcedToolUse(model)
604605

605606
const {
606607
filterBlocks,

‎apps/sim/executor/handlers/mothership/mothership-handler.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,6 +956,78 @@ describe('MothershipBlockHandler', () => {
956956
})
957957
})
958958

959+
it.each(['auto', 'force', 'none'])(
960+
'resolves variable MCP permission mode %s before discovery and execution',
961+
async (mode) => {
962+
fetchMock.mockResolvedValue(createJsonResponse({ content: 'done', toolCalls: [] }))
963+
block.canonicalModes = { '0:agentToolUsageControl': 'advanced' }
964+
await handler.execute(context, block, {
965+
prompt: 'Use my tools',
966+
tools: [
967+
{
968+
type: 'mcp',
969+
usageControl: 'auto',
970+
usageControlExpression: ` ${mode.toUpperCase()} `,
971+
params: { serverId: 'mcp-server-1', toolName: 'search' },
972+
},
973+
],
974+
})
975+
const body = JSON.parse(String(fetchMock.mock.calls[0][1].body))
976+
expect(body.mcpTools).toEqual(
977+
mode === 'none'
978+
? undefined
979+
: [
980+
{
981+
type: 'mcp',
982+
usageControl: mode,
983+
params: { serverId: 'mcp-server-1', toolName: 'search' },
984+
},
985+
]
986+
)
987+
}
988+
)
989+
990+
it('does not discover an advanced MCP server disabled by a variable', async () => {
991+
fetchMock.mockResolvedValue(createJsonResponse({ content: 'done', toolCalls: [] }))
992+
mockDiscoverMcpServerToolsAsExecutor.mockClear()
993+
block.canonicalModes = { '0:agentToolUsageControl': 'advanced' }
994+
await handler.execute(context, block, {
995+
prompt: 'No tools',
996+
tools: [
997+
{
998+
type: 'mcp-server-advanced',
999+
usageControl: 'force',
1000+
usageControlExpression: 'none',
1001+
params: { serverId: 'mcp-server-1' },
1002+
},
1003+
],
1004+
})
1005+
expect(mockDiscoverMcpServerToolsAsExecutor).not.toHaveBeenCalled()
1006+
expect(JSON.parse(String(fetchMock.mock.calls[0][1].body))).not.toHaveProperty('mcpTools')
1007+
})
1008+
1009+
it.each(['', 'sometimes', '<start.unresolved>', null])(
1010+
'rejects an invalid variable mode before making requests: %s',
1011+
async (mode) => {
1012+
block.canonicalModes = { '0:agentToolUsageControl': 'advanced' }
1013+
mockDiscoverMcpServerToolsAsExecutor.mockClear()
1014+
await expect(
1015+
handler.execute(context, block, {
1016+
prompt: 'Use tools',
1017+
tools: [
1018+
{
1019+
type: 'mcp-server-advanced',
1020+
usageControlExpression: mode,
1021+
params: { serverId: 'mcp-server-1' },
1022+
},
1023+
],
1024+
})
1025+
).rejects.toThrow('mode must resolve to Auto, Force, or None')
1026+
expect(fetchMock).not.toHaveBeenCalled()
1027+
expect(mockDiscoverMcpServerToolsAsExecutor).not.toHaveBeenCalled()
1028+
}
1029+
)
1030+
9591031
it('forwards only enabled MCP tools and selected skills', async () => {
9601032
mockGenerateId
9611033
.mockReturnValueOnce('chat-uuid')
@@ -1248,6 +1320,7 @@ describe('MothershipBlockHandler', () => {
12481320
expect(body.mcpTools).toEqual([
12491321
{
12501322
type: 'mcp',
1323+
usageControl: 'auto',
12511324
schema: {
12521325
type: 'object',
12531326
description: 'Search {{MCP_SCHEMA_DESCRIPTION}} for Box',
@@ -1681,6 +1754,7 @@ describe('MothershipBlockHandler', () => {
16811754
expect(body.mcpTools).toHaveLength(5)
16821755
expect(body.mcpTools[0]).toEqual({
16831756
type: 'mcp',
1757+
usageControl: 'auto',
16841758
params: { serverId: secret, toolName: 'search' },
16851759
})
16861760
expect(body.mcpTools[2].schema).toEqual({ type: 'string', enum: [secret] })

‎apps/sim/executor/handlers/mothership/mothership-handler.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
type RawFileInput,
3838
} from '@/lib/uploads/utils/file-utils'
3939
import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input'
40+
import { resolveAgentToolUsageControl } from '@/lib/workflows/tool-input/usage-control'
4041
import type { BlockOutput } from '@/blocks/types'
4142
import { normalizeFileInput } from '@/blocks/utils'
4243
import { BlockType } from '@/executor/constants'
@@ -890,13 +891,24 @@ export class MothershipBlockHandler implements BlockHandler {
890891
if (!prompt || typeof prompt !== 'string') {
891892
throw new Error('Prompt input is required')
892893
}
893-
const metadataInputPaths = selectMothershipMetadataModelInputPaths(inputs.tools, requestSkills)
894+
const requestTools = Array.isArray(inputs.tools)
895+
? inputs.tools.map((tool: unknown, index: number) => {
896+
if (!isPlainRecord(tool)) throw new Error(`Tool ${index + 1} must be a tool binding.`)
897+
const usageControl = resolveAgentToolUsageControl(tool, index, block.canonicalModes)
898+
if (!usageControl)
899+
throw new Error(
900+
`Tool ${index + 1} mode must resolve to Auto, Force, or None before Sim Chat can run.`
901+
)
902+
return { ...tool, usageControl }
903+
})
904+
: inputs.tools
905+
const metadataInputPaths = selectMothershipMetadataModelInputPaths(requestTools, requestSkills)
894906
if (ctx.resolvedSecretTraceRegistry) {
895907
assertMothershipStructuralInputsDoNotResolveSecrets(
896908
ctx.resolvedSecretTraceRegistry,
897909
metadataInputPaths.structuralInputPaths
898910
)
899-
assertMothershipToolSchemaProjectionsAreSafe(ctx.resolvedSecretTraceRegistry, inputs.tools)
911+
assertMothershipToolSchemaProjectionsAreSafe(ctx.resolvedSecretTraceRegistry, requestTools)
900912
}
901913
const modelInputPaths: ResolvedSecretInputPath[] = [
902914
['prompt'],
@@ -909,7 +921,7 @@ export class MothershipBlockHandler implements BlockHandler {
909921
]
910922
const modelInputProjection = projectResolvedModelInput(
911923
sourceRegistry,
912-
{ prompt, files: inputs.files, tools: inputs.tools, skills: requestSkills },
924+
{ prompt, files: inputs.files, tools: requestTools, skills: requestSkills },
913925
modelInputPaths
914926
)
915927
if (!modelInputProjection.complete || typeof modelInputProjection.value.prompt !== 'string') {

‎apps/sim/lib/mothership/agent-cli/curation.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ describe('curateBlockDetail', () => {
106106
type: 'array',
107107
items: { anyOf: expect.any(Array) },
108108
})
109+
expect(JSON.stringify(field?.valueSchema)).toContain('usageControlExpression')
110+
expect(JSON.stringify(field?.valueSchema)).toContain('force')
109111
expect(field?.toolBinding?.selectionMode).toBe(id === 'agent' ? 'explicit' : 'additive')
110112
expect(field?.toolBinding?.naming).toContain(
111113
id === 'agent' ? 'operations[operation].toolId' : 'call_integration_tool'

‎apps/sim/lib/mothership/agent-cli/tool-binding-hints.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function withToolBindingHints(detail: MothershipBlockDetail): MothershipB
3434
? 'Use the exact MCP toolName returned by discovery. Sim Chat discovers the callable toolId and invokes it through call_integration_tool. An attachment title/name cannot rename the operation.'
3535
: 'Integration operations use operations[operation].toolId from blocks get (not the UI toolName/title). Custom tools use custom_ followed by the saved definition title or inline title. MCP tools use a server-qualified ID derived from the discovered toolName. Duplicate bindings receive provider-generated suffixes. Do not override title, name, toolId or functionName when attaching a saved tool. Inline custom definitions require title = schema.function.name.',
3636
access: isMothership
37-
? 'Selections add MCP operations to available integration access; this is not an integration allowlist. Empty tools does not disable integrations. Fixed MCP arguments, force mode and variable permission mode are not supported by this block. Actual calls remain subject to execution permissions and MCP policies.'
37+
? 'Selections add MCP operations to available integration access; this is not an integration allowlist. Empty tools does not disable integrations. Fixed MCP arguments are not supported. Each selection supports usageControl (auto/force/none) or usageControlExpression resolving to those values. Send only the chosen property; Sim selects its mode. none removes the selection from discovery and execution; force requires an invocation of each selected operation before completion. These controls do not disable ambient integrations. Actual calls remain subject to execution permissions and MCP policies.'
3838
: 'This array defines the selected tools, subject to execution permissions. For integration, inline custom and individual MCP bindings, params fixes author-supplied values; remaining eligible parameters are model-supplied. Saved custom-tool references do not retain fixed arguments. auto lets the model choose, force requires a call, none disables the binding. Runtime helpers such as skill loading are separate. Labels do not enforce approvals or permissions.',
3939
},
4040
}

‎apps/sim/lib/mothership/generated/integration-catalog.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export type IntegrationCatalogMcpExecution = z.infer<typeof IntegrationCatalogMc
2828
export const IntegrationCatalogContext = z.object({
2929
mcpServerIds: z.array(z.string().min(1)).default([]),
3030
mcpToolIds: z.array(z.string().min(1)).optional(),
31+
/** Resolved workflow selections that must be invoked before completion; no schemas. */
32+
requiredToolIds: z.array(z.string().min(1)).optional(),
3133
mcpExecution: IntegrationCatalogMcpExecution.optional(),
3234
});
3335
export type IntegrationCatalogContext = z.infer<typeof IntegrationCatalogContext>;

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

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -283,26 +283,32 @@ describe('catalog authorization', () => {
283283
})
284284
})
285285

286-
it('exposes selected MCP operations without enabling sibling operations', async () => {
287-
queueChat('agent')
288-
mocks.target.mockResolvedValue({ workspaceId: 'workspace-1' })
289-
mocks.mcp.mockResolvedValue([
290-
{ name: 'mcp-abc-read', service: 'mcp:mcp-abc', description: 'Read', input_schema: {} },
291-
{ name: 'mcp-abc-write', service: 'mcp:mcp-abc', description: 'Write', input_schema: {} },
292-
])
293-
const result = await readIntegrationCatalog.execute({
294-
principal: principal(),
295-
input: {
296-
...input,
297-
mode: 'agent',
298-
workspaceId: 'workspace-1',
299-
mcpToolIds: ['mcp-abc-read'],
300-
service: 'mcp:mcp-abc',
301-
},
302-
})
303-
expect(mocks.mcp).toHaveBeenCalledWith('actor', 'workspace-1', ['mcp-abc'], undefined)
304-
expect(result.operations.map((operation) => operation.toolId)).toEqual(['mcp-abc-read'])
305-
})
286+
it.each([undefined, 'mcp-abc-write'])(
287+
'exposes selected MCP operations without enabling sibling operations (exact lookup: %s)',
288+
async (toolId) => {
289+
queueChat('agent')
290+
mocks.target.mockResolvedValue({ workspaceId: 'workspace-1' })
291+
mocks.mcp.mockResolvedValue([
292+
{ name: 'mcp-abc-read', service: 'mcp:mcp-abc', description: 'Read', input_schema: {} },
293+
{ name: 'mcp-abc-write', service: 'mcp:mcp-abc', description: 'Write', input_schema: {} },
294+
])
295+
const result = await readIntegrationCatalog.execute({
296+
principal: principal(),
297+
input: {
298+
...input,
299+
mode: 'agent',
300+
workspaceId: 'workspace-1',
301+
mcpToolIds: ['mcp-abc-read'],
302+
service: 'mcp:mcp-abc',
303+
...(toolId ? { toolId } : {}),
304+
},
305+
})
306+
expect(mocks.mcp).toHaveBeenCalledWith('actor', 'workspace-1', ['mcp-abc'], undefined)
307+
expect(result.operations.map((operation) => operation.toolId)).toEqual(
308+
toolId ? [] : ['mcp-abc-read']
309+
)
310+
}
311+
)
306312
it('keeps native discovery available in organization chats with tagged MCP servers', async () => {
307313
queueChat('agent')
308314
const result = await readIntegrationCatalog.execute({

‎apps/sim/lib/workflows/application/inspect-workflow-tools.test.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,14 +325,36 @@ describe('configured workflow tool inspection', () => {
325325
const result = await inspect()
326326
expect(result.selected[0]).toMatchObject({
327327
callableName: 'mcp-server-read',
328-
usageControl: 'auto',
328+
usageControl: 'force',
329329
})
330330
expect(result.selected[0].fixedArgumentNames).toBeUndefined()
331331
expect(result.selected[1].status).toBe('unavailable')
332332
expect(result.ambient?.total).toBe(2)
333333
expect(JSON.stringify(result)).not.toContain('never-return')
334334
})
335335

336+
it.each(['<start.mode>', 'none'])(
337+
'reports Sim Chat variable mode %s without discovering disabled or unresolved tools',
338+
async (usageControlExpression) => {
339+
save(
340+
'mothership',
341+
[
342+
{
343+
type: 'mcp',
344+
usageControl: 'force',
345+
usageControlExpression,
346+
params: { serverId: 'server', toolName: 'read' },
347+
},
348+
],
349+
{ data: { canonicalModes: { '0:agentToolUsageControl': 'advanced' } } }
350+
)
351+
expect((await inspect()).selected[0].status).toBe(
352+
usageControlExpression === 'none' ? 'disabled' : 'unresolved'
353+
)
354+
expect(mocks.mcp).not.toHaveBeenCalled()
355+
}
356+
)
357+
336358
it('does not discover tools from a disabled block', async () => {
337359
save('mothership', [{ type: 'mcp', params: { serverId: 'server', toolName: 'read' } }], {
338360
enabled: false,

0 commit comments

Comments
 (0)