Skip to content

Commit dababea

Browse files
feat(mcp): add dynamic operations and Agent access controls (#7703)
* feat(mcp): add dynamic operations and workflow access controls * improvement(mcp): simplify server and operation selection * fix(mcp): preserve operation selection and discover managed connections * fix(mcp): scope operation provenance to MCP delegations * improvement(mcp): use literal tool IDs for Agent access controls * fix(mcp): migrate portable references with operation fields * fix(mcp): isolate shared query keys from discovery hooks
1 parent a50f02c commit dababea

114 files changed

Lines changed: 4524 additions & 820 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,16 +117,16 @@ Once MCP servers are configured, their tools become available within your agent
117117
/>
118118
</div>
119119

120-
4. Select individual tools, or choose **Use all N tools** to add every tool from that server
120+
4. Select individual tools, or choose **Configure operations access** for a dynamic server attachment
121121
5. The agent can now access these tools during execution
122122

123123
<Callout type="info">
124124
If you haven't configured a server yet, click **Add MCP Server** at the top of the dropdown to open the setup modal without leaving the block.
125125
</Callout>
126126

127-
## Standalone MCP Tool Block
127+
## Standalone MCP Block
128128

129-
For more granular control, you can use the dedicated MCP Tool block to execute specific MCP tools:
129+
Use the MCP block to discover operations or run one operation with explicit inputs:
130130

131131
<div className="flex justify-center">
132132
<Image
@@ -138,7 +138,34 @@ For more granular control, you can use the dedicated MCP Tool block to execute s
138138
/>
139139
</div>
140140

141-
The MCP Tool block runs one configured tool with parameters you set explicitly, and its output is readable by later blocks like any other.
141+
Choose an **Action**:
142+
143+
- **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.
144+
- **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.
145+
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.
149+
150+
### Operations access
151+
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:
155+
156+
| Mode | Behavior |
157+
| --- | --- |
158+
| **Only selected** | Allows only selected exact operation names. An empty selection allows nothing. Newly discovered names stay excluded. |
159+
| **All except selected** | Denies selected exact names. An empty selection allows everything otherwise permitted. Denied names stay saved if they temporarily disappear. |
160+
| **All permitted** | Allows every operation available to the authorized credential. |
161+
162+
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.
163+
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.
165+
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.
167+
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.
142169

143170
## When to Use MCP Tool vs Agent
144171

apps/docs/openapi-v2-workflows.json

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8201,6 +8201,69 @@
82018201
"const": "mcp-server-advanced",
82028202
"description": "Server-wide MCP binding discriminator."
82038203
},
8204+
"operationPolicy": {
8205+
"oneOf": [
8206+
{
8207+
"type": "object",
8208+
"properties": {
8209+
"mode": {
8210+
"type": "string",
8211+
"const": "all",
8212+
"description": "Allow all operations available to the authorized credential."
8213+
}
8214+
},
8215+
"required": ["mode"],
8216+
"additionalProperties": false
8217+
},
8218+
{
8219+
"type": "object",
8220+
"properties": {
8221+
"mode": {
8222+
"type": "string",
8223+
"const": "allow",
8224+
"description": "Allow only the selected exact operations."
8225+
},
8226+
"operations": {
8227+
"maxItems": 1000,
8228+
"type": "array",
8229+
"items": {
8230+
"type": "string",
8231+
"minLength": 1,
8232+
"maxLength": 256,
8233+
"description": "Exact MCP tool name on the resolved connection, without a Sim server prefix."
8234+
},
8235+
"description": "Allowed exact MCP tool names; an empty list grants no access."
8236+
}
8237+
},
8238+
"required": ["mode", "operations"],
8239+
"additionalProperties": false
8240+
},
8241+
{
8242+
"type": "object",
8243+
"properties": {
8244+
"mode": {
8245+
"type": "string",
8246+
"const": "deny",
8247+
"description": "Exclude the selected exact operations."
8248+
},
8249+
"operations": {
8250+
"maxItems": 1000,
8251+
"type": "array",
8252+
"items": {
8253+
"type": "string",
8254+
"minLength": 1,
8255+
"maxLength": 256,
8256+
"description": "Exact MCP tool name on the resolved connection, without a Sim server prefix."
8257+
},
8258+
"description": "Denied exact MCP tool names; an empty list allows otherwise permitted tools."
8259+
}
8260+
},
8261+
"required": ["mode", "operations"],
8262+
"additionalProperties": false
8263+
}
8264+
],
8265+
"description": "Saved workflow operation restrictions that can only narrow authorized credential access."
8266+
},
82048267
"params": {
82058268
"type": "object",
82068269
"properties": {
@@ -8213,7 +8276,7 @@
82138276
},
82148277
"required": ["serverId"],
82158278
"additionalProperties": false,
8216-
"description": "Server identity for discovering and invoking every available MCP tool."
8279+
"description": "Executable server or connection identity for authorized operation discovery and execution."
82178280
},
82188281
"usageControl": {
82198282
"type": "string",
@@ -8226,7 +8289,7 @@
82268289
"description": "Forward-compatible MCP server metadata preserved by the workflow editor."
82278290
},
82288291
"title": "Agent MCP server (advanced)",
8229-
"description": "All tools available to the executing subject from one MCP server.",
8292+
"description": "Dynamically discovered operations permitted by the authorized credential and saved block policy.",
82308293
"examples": [
82318294
{
82328295
"type": "mcp-server-advanced",

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

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ const {
3434
mockRunHeadlessCopilotLifecycle: vi.fn(),
3535
}))
3636

37+
vi.mock('@/lib/auth/internal', () => ({
38+
verifyInternalDelegationToken: vi.fn().mockResolvedValue({
39+
workflowId: 'workflow-1',
40+
executionId: 'execution-1',
41+
mcpBlockId: 'block-1',
42+
subjectUserId: 'user-1',
43+
}),
44+
}))
45+
vi.mock('@/lib/internal/principals/executor', () => ({
46+
createExecutorPrincipalFromExecutionContext: vi
47+
.fn()
48+
.mockResolvedValue({ workspaceId: 'workspace-1' }),
49+
}))
50+
3751
vi.mock('@/lib/core/security/encryption', () => ({
3852
decryptSecret: mockDecryptSecret,
3953
}))
@@ -112,6 +126,8 @@ describe('buildExecuteResponsePayload', () => {
112126

113127
describe('mothership private trace provenance transport', () => {
114128
const requestBody = {
129+
workflowId: 'workflow-1',
130+
executionId: 'execution-1',
115131
messages: [{ role: 'user', content: 'hello' }],
116132
workspaceId: 'workspace-1',
117133
userId: 'user-1',
@@ -177,7 +193,11 @@ describe('mothership private trace provenance transport', () => {
177193
createMockRequest(
178194
'POST',
179195
requestBody,
180-
{ Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' },
196+
{
197+
'X-Sim-Mcp-Delegation': 'signed-block',
198+
Authorization: 'Bearer internal',
199+
'x-sim-billing-attribution': 'billing',
200+
},
181201
'http://localhost:3000/api/mothership/execute'
182202
)
183203
)
@@ -204,7 +224,11 @@ describe('mothership private trace provenance transport', () => {
204224
createMockRequest(
205225
'POST',
206226
requestBody,
207-
{ Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' },
227+
{
228+
'X-Sim-Mcp-Delegation': 'signed-block',
229+
Authorization: 'Bearer internal',
230+
'x-sim-billing-attribution': 'billing',
231+
},
208232
'http://localhost:3000/api/mothership/execute'
209233
)
210234
)
@@ -226,7 +250,11 @@ describe('mothership private trace provenance transport', () => {
226250
messages: [{ role: 'user', content: 'secret-value __var_FOREIGN' }],
227251
contexts: [{ kind: 'docs', label: 'Docs' }],
228252
},
229-
{ Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' },
253+
{
254+
'X-Sim-Mcp-Delegation': 'signed-block',
255+
Authorization: 'Bearer internal',
256+
'x-sim-billing-attribution': 'billing',
257+
},
230258
'http://localhost:3000/api/mothership/execute'
231259
)
232260
)
@@ -274,13 +302,22 @@ describe('mothership private trace provenance transport', () => {
274302
},
275303
],
276304
},
277-
{ Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' },
305+
{
306+
'X-Sim-Mcp-Delegation': 'signed-block',
307+
Authorization: 'Bearer internal',
308+
'x-sim-billing-attribution': 'billing',
309+
},
278310
'http://localhost:3000/api/mothership/execute'
279311
)
280312
)
281313

282314
expect(response.status).toBe(200)
283-
expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith('user-1', 'workspace-1', ['123'])
315+
expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith(
316+
'user-1',
317+
'workspace-1',
318+
['123'],
319+
expect.objectContaining({ mcpBlockId: 'block-1' })
320+
)
284321
expect(mockProcessContextsServer).toHaveBeenCalledWith(
285322
[
286323
{
@@ -324,7 +361,11 @@ describe('mothership private trace provenance transport', () => {
324361
secretScope: 'selected',
325362
mountedSecrets: ['API_KEY'],
326363
},
327-
{ Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' },
364+
{
365+
'X-Sim-Mcp-Delegation': 'signed-block',
366+
Authorization: 'Bearer internal',
367+
'x-sim-billing-attribution': 'billing',
368+
},
328369
'http://localhost:3000/api/mothership/execute'
329370
)
330371
)
@@ -355,6 +396,7 @@ describe('mothership private trace provenance transport', () => {
355396
'POST',
356397
requestBody,
357398
{
399+
'X-Sim-Mcp-Delegation': 'signed-block',
358400
Authorization: 'Bearer internal',
359401
'x-sim-billing-attribution': 'billing',
360402
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
@@ -397,6 +439,7 @@ describe('mothership private trace provenance transport', () => {
397439
'POST',
398440
requestBody,
399441
{
442+
'X-Sim-Mcp-Delegation': 'signed-block',
400443
Authorization: 'Bearer internal',
401444
'x-sim-billing-attribution': 'billing',
402445
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
@@ -432,6 +475,7 @@ describe('mothership private trace provenance transport', () => {
432475
'POST',
433476
requestBody,
434477
{
478+
'X-Sim-Mcp-Delegation': 'signed-block',
435479
Authorization: 'Bearer internal',
436480
'x-sim-billing-attribution': 'billing',
437481
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
@@ -485,6 +529,7 @@ describe('mothership private trace provenance transport', () => {
485529
contexts: [{ kind: 'mcp', label: 'Docs', serverId: 'server-1' }],
486530
},
487531
{
532+
'X-Sim-Mcp-Delegation': 'signed-block',
488533
Authorization: 'Bearer internal',
489534
'x-sim-billing-attribution': 'billing',
490535
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
@@ -524,6 +569,7 @@ describe('mothership private trace provenance transport', () => {
524569
'POST',
525570
requestBody,
526571
{
572+
'X-Sim-Mcp-Delegation': 'signed-block',
527573
Authorization: 'Bearer internal',
528574
'x-sim-billing-attribution': 'billing',
529575
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
@@ -554,6 +600,7 @@ describe('mothership private trace provenance transport', () => {
554600
'POST',
555601
requestBody,
556602
{
603+
'X-Sim-Mcp-Delegation': 'signed-block',
557604
Authorization: 'Bearer internal',
558605
'x-sim-billing-attribution': 'billing',
559606
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server'
55
import { mothershipExecuteContract } from '@/lib/api/contracts/mothership-chats'
66
import { parseRequest } from '@/lib/api/server'
77
import { checkInternalAuth } from '@/lib/auth/hybrid'
8+
import { verifyInternalDelegationToken } from '@/lib/auth/internal'
89
import { requireBillingAttributionHeader } from '@/lib/billing/core/billing-attribution'
910
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload'
1011
import { processContextsServer } from '@/lib/copilot/chat/process-contents'
@@ -33,6 +34,8 @@ import {
3334
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
3435
requestsPrivateToolMetadata,
3536
} from '@/lib/execution/private-tool-metadata'
37+
import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor'
38+
import { MCP_SERVER_DELEGATION_AUDIENCE } from '@/lib/mcp/application/authorization'
3639
import {
3740
assertActiveWorkspaceAccess,
3841
isWorkspaceAccessDeniedError,
@@ -160,6 +163,29 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
160163
secretScope,
161164
mountedSecrets,
162165
} = validation.data.body
166+
const mcpDelegationToken = validation.data.headers['x-sim-mcp-delegation']
167+
if (!mcpDelegationToken) throw new Error('Mothership requires signed workflow provenance')
168+
const delegation = await verifyInternalDelegationToken(mcpDelegationToken)
169+
if (!delegation.mcpBlockId) throw new Error('Mothership requires signed block provenance')
170+
if (
171+
workflowId !== (delegation.currentWorkflow?.workflowId ?? delegation.workflowId) ||
172+
executionId !== delegation.executionId
173+
)
174+
throw new Error('Mothership workflow scope does not match signed provenance')
175+
const mcpContext = {
176+
userId: auth.userId,
177+
workflowId: workflowId ?? delegation.workflowId,
178+
workspaceId,
179+
executionId,
180+
executorDelegationOrigin: delegation,
181+
mcpBlockId: delegation.mcpBlockId,
182+
}
183+
const mcpPrincipal = await createExecutorPrincipalFromExecutionContext({
184+
context: mcpContext,
185+
audience: MCP_SERVER_DELEGATION_AUDIENCE,
186+
})
187+
if (mcpPrincipal.workspaceId !== workspaceId)
188+
throw new Error('MCP workspace scope does not match')
163189
const secretMountPolicy = normalizeSecretMountPolicy({ secretScope, mountedSecrets })
164190

165191
/**
@@ -221,8 +247,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
221247
const nonMcpAgentMentions = agentMentions?.filter((context) => context.kind !== 'mcp')
222248
const userPermission = workspaceAccess.permission
223249
const mothershipToolsPromise = Promise.allSettled([
224-
buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []),
225-
buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds),
250+
buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? [], mcpContext),
251+
buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds, mcpContext),
226252
]).then((results) => {
227253
const groups = results.map((result) => {
228254
if (result.status === 'rejected') throw result.reason
@@ -344,6 +370,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
344370
simRequestId: requestId,
345371
goRoute: '/api/mothership/execute',
346372
autoExecuteTools: true,
373+
mcpBlockId: delegation.mcpBlockId,
374+
executorDelegationOrigin: delegation,
347375
interactive: false,
348376
abortSignal: lifecycleAbortController.signal,
349377
billingAttribution,

0 commit comments

Comments
 (0)