Skip to content

Commit 35f75b2

Browse files
improvement(mcp): use literal tool IDs for Agent access controls
1 parent 27f902e commit 35f75b2

17 files changed

Lines changed: 251 additions & 262 deletions

File tree

apps/docs/content/docs/agents/mcp.mdx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,15 @@ Choose an **Action**:
143143
- **List operations** discovers authorized operation names, descriptions, and input schemas without executing provider operations. Filter by name or description, set a page size from 1 to 100, and pass `nextCursor` into the next request while `hasMore` is true. An authorized list can be empty.
144144
- **Run operation** executes one exact operation name. Configured operations keep their generated argument fields. For an operation name resolved at runtime, supply a JSON arguments object; Sim validates it against the operation's discovered schema before execution.
145145

146-
Server, managed connection, operation name, and arguments can use upstream block references. If you supply a managed connection separately, it must belong to the selected canonical server and be authorized for the workflow's workspace.
146+
**MCP Server** takes one shared-server ID or managed-connection ID. Sim resolves a managed connection's parent server internally and verifies workspace and credential access. Both actions accept the same ID; List operations returns that ID as `serverId`, alongside the discovered `operations` and pagination metadata.
147+
148+
The standalone block's Basic fields select a configured connection and discovered operation. Advanced fields accept literal IDs/names or upstream references. A runtime server reference requires JSON arguments. Listing hides the operation and argument fields.
147149

148150
### Operations access
149151

150-
The standalone MCP block and **MCP Server (Advanced)** Agent attachment share three access modes:
152+
The **MCP Server (Advanced)** Agent attachment takes a server/connection ID or upstream reference in a plain input. Its **Tool IDs** field accepts exact MCP tool names, such as `search_docs`, entered directly. These are the names returned by List operations, without a Sim server prefix. Neither field uses a server or operation catalog picker.
153+
154+
Agent attachments have three access modes:
151155

152156
| Mode | Behavior |
153157
| --- | --- |
@@ -157,11 +161,11 @@ The standalone MCP block and **MCP Server (Advanced)** Agent attachment share th
157161

158162
New restricted configurations start with an empty explicit selection. Existing saved workflows retain their prior access through normalization, while current organization and credential authorization still apply.
159163

160-
Operation restrictions are saved in workflow state, on the MCP block or Agent attachment. An operation must be available to the authorized credential and permitted by the saved restriction.
164+
Operation restrictions are saved in workflow state on the Agent attachment. Tool IDs are literal configuration, not upstream references or model arguments. An operation must be available to the resolved, authorized connection and permitted by the saved restriction. The standalone block runs its explicitly specified operation and has no separate access policy.
161165

162-
Discovery filters the catalog shown to the editor and Agent. Execution checks the actual server, connection, and saved block restriction again before calling the provider. Missing or forbidden operations, unverifiable schemas, malformed arguments, and incorrect connection scopes fail the call. An Agent attachment with no permitted operations fails clearly.
166+
Discovery filters the tools exposed to the Agent. Execution checks the actual server, connection, and saved restriction again before calling the provider. Missing or forbidden operations, unverifiable schemas, malformed arguments, and incorrect connection scopes fail the call. An Agent attachment with no permitted operations fails clearly.
163167

164-
Policies match exact MCP tool names on a canonical server. They do not inspect operation arguments: allowing a generic `execute_sql` operation does not limit which SQL it can execute.
168+
Policies match exact, case-sensitive MCP tool names on whichever authorized connection resolves at runtime. They do not inspect operation arguments: allowing a generic `execute_sql` operation does not limit which SQL it can execute.
165169

166170
## When to Use MCP Tool vs Agent
167171

apps/docs/openapi-v2-workflows.json

Lines changed: 10 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8227,25 +8227,12 @@
82278227
"maxItems": 1000,
82288228
"type": "array",
82298229
"items": {
8230-
"type": "object",
8231-
"properties": {
8232-
"serverId": {
8233-
"type": "string",
8234-
"minLength": 1,
8235-
"maxLength": 256,
8236-
"description": "Canonical MCP server identity, independent of the selected credential."
8237-
},
8238-
"name": {
8239-
"type": "string",
8240-
"minLength": 1,
8241-
"maxLength": 256,
8242-
"description": "Exact operation name returned by MCP discovery."
8243-
}
8244-
},
8245-
"required": ["serverId", "name"],
8246-
"additionalProperties": false
8230+
"type": "string",
8231+
"minLength": 1,
8232+
"maxLength": 256,
8233+
"description": "Exact MCP tool name on the resolved connection, without a Sim server prefix."
82478234
},
8248-
"description": "Allowed server-scoped operation identities; an empty list grants no access."
8235+
"description": "Allowed exact MCP tool names; an empty list grants no access."
82498236
}
82508237
},
82518238
"required": ["mode", "operations"],
@@ -8263,25 +8250,12 @@
82638250
"maxItems": 1000,
82648251
"type": "array",
82658252
"items": {
8266-
"type": "object",
8267-
"properties": {
8268-
"serverId": {
8269-
"type": "string",
8270-
"minLength": 1,
8271-
"maxLength": 256,
8272-
"description": "Canonical MCP server identity, independent of the selected credential."
8273-
},
8274-
"name": {
8275-
"type": "string",
8276-
"minLength": 1,
8277-
"maxLength": 256,
8278-
"description": "Exact operation name returned by MCP discovery."
8279-
}
8280-
},
8281-
"required": ["serverId", "name"],
8282-
"additionalProperties": false
8253+
"type": "string",
8254+
"minLength": 1,
8255+
"maxLength": 256,
8256+
"description": "Exact MCP tool name on the resolved connection, without a Sim server prefix."
82838257
},
8284-
"description": "Denied server-scoped operation identities; an empty list allows otherwise permitted tools."
8258+
"description": "Denied exact MCP tool names; an empty list allows otherwise permitted tools."
82858259
}
82868260
},
82878261
"required": ["mode", "operations"],

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

Lines changed: 3 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type React from 'react'
22
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
33
import {
44
Badge,
5-
ChipCombobox,
65
Combobox,
76
type ComboboxOption,
87
type ComboboxOptionGroup,
@@ -18,7 +17,6 @@ import { createLogger } from '@sim/logger'
1817
import { useParams } from 'next/navigation'
1918
import { McpIcon, WorkflowIcon } from '@/components/icons'
2019
import { McpOperationPolicyEditor } from '@/components/mcp/operation-policy-editor'
21-
import { getMcpTargetOptions } from '@/components/mcp/target-options'
2220
import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons'
2321
import { MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared'
2422
import {
@@ -463,7 +461,7 @@ export const ToolInput = memo(function ToolInput({
463461
const shouldFetchCustomTools = !isPreview || hasReferenceOnlyCustomTools
464462
const { data: customTools = [] } = useCustomTools(shouldFetchCustomTools ? workspaceId : '')
465463

466-
const { mcpTools, isLoading: mcpLoading, error: mcpToolsError } = useMcpTools(workspaceId)
464+
const { mcpTools, isLoading: mcpLoading } = useMcpTools(workspaceId)
467465
const mcpToolNamesById = useMemo(() => {
468466
const names = new Map<string, string>()
469467
for (const t of mcpTools) {
@@ -472,11 +470,7 @@ export const ToolInput = memo(function ToolInput({
472470
return names
473471
}, [mcpTools])
474472

475-
const {
476-
data: mcpServers = [],
477-
isLoading: mcpServersLoading,
478-
error: mcpServersError,
479-
} = useMcpToolServers(workspaceId)
473+
const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpToolServers(workspaceId)
480474
const { data: storedMcpTools = [] } = useStoredMcpTools(workspaceId)
481475
const forceRefreshMcpTools = useForceRefreshMcpTools().mutate
482476
const { navigateToSettings } = useSettingsNavigation()
@@ -1549,7 +1543,7 @@ export const ToolInput = memo(function ToolInput({
15491543
isAdvancedMcpServer
15501544
? ADVANCED_MCP_SERVER_TOOL_SCHEMA
15511545
: (mcpToolSchema ?? undefined),
1552-
formatParameterLabel
1546+
isAdvancedMcpServer ? () => 'MCP Server' : formatParameterLabel
15531547
)
15541548
: (subBlocksResult?.subBlocks ?? []).filter(
15551549
(sb) =>
@@ -1817,21 +1811,7 @@ export const ToolInput = memo(function ToolInput({
18171811
{isAdvancedMcpServer && (
18181812
<McpOperationPolicyEditor
18191813
value={tool.operationPolicy}
1820-
operations={mcpTools
1821-
.filter(
1822-
(candidate) =>
1823-
!tool.params?.serverId ||
1824-
String(tool.params.serverId).includes('<') ||
1825-
String(tool.params.serverId).includes('{{') ||
1826-
candidate.serverId === tool.params.serverId
1827-
)
1828-
.map((candidate) => ({
1829-
...candidate,
1830-
serverId: candidate.canonicalServerId ?? candidate.serverId,
1831-
}))}
18321814
disabled={disabled || isPreview}
1833-
isLoading={mcpDataLoading}
1834-
error={mcpToolsError ?? mcpServersError?.message ?? null}
18351815
onChange={(operationPolicy) =>
18361816
setStoreValue(
18371817
selectedTools.map((candidate, index) =>
@@ -1874,28 +1854,6 @@ export const ToolInput = memo(function ToolInput({
18741854

18751855
{(() => {
18761856
const renderSubBlock = (sb: BlockSubBlockConfig): React.ReactNode => {
1877-
if (isAdvancedMcpServer) {
1878-
const options = getMcpTargetOptions(mcpServers)
1879-
const value = tool.params?.[sb.id] ?? ''
1880-
return (
1881-
<div key={sb.id} className='flex flex-col gap-[9px]'>
1882-
<span className='text-[var(--text-muted)] text-small'>MCP Server</span>
1883-
<ChipCombobox
1884-
options={options}
1885-
value={
1886-
options.find((option) => option.value === value)?.label ?? value
1887-
}
1888-
selectedValue={value}
1889-
editable
1890-
disabled={disabled || isPreview}
1891-
isLoading={mcpServersLoading}
1892-
error={mcpServersError?.message ?? null}
1893-
placeholder='Select or enter an upstream reference'
1894-
onChange={(next) => handleParamChange(toolIndex, sb.id, next)}
1895-
/>
1896-
</div>
1897-
)
1898-
}
18991857
const effectiveParamId = sb.id
19001858
const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
19011859
const canonicalGroup = canonicalId

apps/sim/components/mcp/index.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,2 @@
1-
export {
2-
type McpOperationChoice,
3-
McpOperationPolicyEditor,
4-
} from '@/components/mcp/operation-policy-editor'
1+
export { McpOperationPolicyEditor } from '@/components/mcp/operation-policy-editor'
52
export { getMcpTargetOptions } from '@/components/mcp/target-options'
Lines changed: 39 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,83 @@
11
/** @vitest-environment node */
22
import type { ComponentProps } from 'react'
3-
import type { ChipCombobox, ChipDropdown } from '@sim/emcn'
3+
import type { ChipDropdown, TagInput } from '@sim/emcn'
44
import { renderToStaticMarkup } from 'react-dom/server'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const { combobox, dropdown } = vi.hoisted(() => ({
8-
combobox: vi.fn<(props: ComponentProps<typeof ChipCombobox>) => null>(() => null),
7+
const { tagInput, dropdown } = vi.hoisted(() => ({
8+
tagInput: vi.fn<(props: ComponentProps<typeof TagInput>) => null>(() => null),
99
dropdown: vi.fn<(props: ComponentProps<typeof ChipDropdown>) => null>(() => null),
1010
}))
11-
vi.mock('@sim/emcn', () => ({
12-
ChipCombobox: combobox,
13-
ChipDropdown: dropdown,
14-
OverflowText: () => null,
15-
}))
11+
vi.mock('@sim/emcn', () => ({ TagInput: tagInput, ChipDropdown: dropdown }))
1612

1713
import { McpOperationPolicyEditor } from '@/components/mcp/operation-policy-editor'
1814

1915
describe('MCP operations access editor', () => {
2016
beforeEach(() => vi.clearAllMocks())
2117

22-
it('keeps absent deny entries while browsing the authorized catalog', () => {
18+
it('accepts exact names without a catalog and preserves absent deny entries', () => {
2319
const onChange = vi.fn()
2420
renderToStaticMarkup(
2521
<McpOperationPolicyEditor
26-
value={{
27-
mode: 'deny',
28-
operations: [{ serverId: 'server-1', name: 'temporarily_missing' }],
29-
}}
30-
operations={[{ serverId: 'server-1', name: 'read', description: 'Read documents' }]}
22+
value={{ mode: 'deny', operations: ['temporarily_missing'] }}
3123
onChange={onChange}
3224
/>
3325
)
34-
const props = combobox.mock.calls[0][0]
35-
expect(props.multiSelectValues).toEqual(['["server-1","temporarily_missing"]'])
36-
expect(props.options.map((option) => option.label)).toEqual(['read', 'temporarily_missing'])
37-
props.onMultiSelectChange?.(['["server-1","temporarily_missing"]', '["server-1","read"]'])
26+
const props = tagInput.mock.calls[0][0]
27+
expect(props.items).toEqual([{ value: 'temporarily_missing', isValid: true }])
28+
expect(props.onAdd('read')).toBe(true)
3829
expect(onChange).toHaveBeenCalledWith({
3930
mode: 'deny',
40-
operations: [
41-
{ serverId: 'server-1', name: 'temporarily_missing' },
42-
{ serverId: 'server-1', name: 'read' },
43-
],
31+
operations: ['temporarily_missing', 'read'],
4432
})
4533
})
4634

47-
it('starts explicit selection with no operations and allows clearing the selection', () => {
35+
it('adds multiple literal names in one update and deduplicates them', () => {
4836
const onChange = vi.fn()
4937
renderToStaticMarkup(
5038
<McpOperationPolicyEditor
51-
value={{ mode: 'allow', operations: [] }}
52-
operations={[{ serverId: 'server-1', name: 'read' }]}
39+
value={{ mode: 'allow', operations: ['read'] }}
5340
onChange={onChange}
5441
/>
5542
)
56-
expect(combobox.mock.calls[0][0].multiSelectValues).toEqual([])
57-
combobox.mock.calls[0][0].onMultiSelectChange?.([])
58-
expect(onChange).toHaveBeenCalledWith({ mode: 'allow', operations: [] })
43+
tagInput.mock.calls[0][0].onAddMany?.(['read', ' write ', 'write'])
44+
expect(onChange).toHaveBeenCalledWith({ mode: 'allow', operations: ['read', 'write'] })
5945
})
6046

61-
it('shows all three modes and honors readonly chrome', () => {
47+
it('allows removing the last entry without switching to unrestricted access', () => {
48+
const onChange = vi.fn()
6249
renderToStaticMarkup(
6350
<McpOperationPolicyEditor
64-
value={{ mode: 'all' }}
65-
operations={[]}
66-
onChange={vi.fn()}
67-
disabled
51+
value={{ mode: 'allow', operations: ['read'] }}
52+
onChange={onChange}
6853
/>
6954
)
55+
tagInput.mock.calls[0][0].onRemove('read', 0, true)
56+
expect(onChange).toHaveBeenCalledWith({ mode: 'allow', operations: [] })
57+
})
58+
59+
it.each(['<upstream.tool>', '{{tool}}', '', 'x'.repeat(257)])(
60+
'refuses invalid tool ID %s',
61+
(name) => {
62+
const onChange = vi.fn()
63+
renderToStaticMarkup(
64+
<McpOperationPolicyEditor value={{ mode: 'allow', operations: [] }} onChange={onChange} />
65+
)
66+
expect(tagInput.mock.calls[0][0].onAdd(name)).toBe(false)
67+
expect(onChange).not.toHaveBeenCalled()
68+
}
69+
)
70+
71+
it('shows all three modes and hides tool IDs for all permitted', () => {
72+
renderToStaticMarkup(
73+
<McpOperationPolicyEditor value={{ mode: 'all' }} onChange={vi.fn()} disabled />
74+
)
7075
expect(dropdown.mock.calls[0][0].options.map((option) => option.label)).toEqual([
7176
'Only selected',
7277
'All except selected',
7378
'All permitted',
7479
])
7580
expect(dropdown.mock.calls[0][0].disabled).toBe(true)
76-
expect(combobox).not.toHaveBeenCalled()
81+
expect(tagInput).not.toHaveBeenCalled()
7782
})
7883
})

0 commit comments

Comments
 (0)