Skip to content

Commit 31a8f8e

Browse files
committed
fix(tools): stop MCP pinned params from stating the server's identity
The MCP discovery path stripped only `toolName` from a tool entry's stored params, so `serverId` and `serverName` stayed in the values treated as pinned and were stated to the model — an internal server id reaching a provider, and two of the three field slots consumed before any real param. The cached path already stripped all three; the split now lives in one helper so the two cannot drift again. Pi withheld literal values for every tool whenever ANY input in the whole run resolved a secret, which is true of almost any real workflow — the feature was effectively off there. It now asks the registry the same per-input-path question the Agent block asks, so only the tool that actually carries a secret is withheld. A canonical group blocked by one half no longer leaks through the other: a `file-upload` basic half skipped without claiming its param, letting a `short-input` twin state a raw file reference. 78 groups have that shape. Widens the secret-name backstop for params named by a remote MCP schema rather than by Sim — `authorization`, `cookie`, `signature`, `connectionString`, `otp` and friends are not in the Sim-tuned `isPasswordParameter` list.
1 parent 53f4dde commit 31a8f8e

4 files changed

Lines changed: 113 additions & 18 deletions

File tree

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,19 @@ function isTransportTimeout(error: unknown): boolean {
211211
/**
212212
* Handler for Agent blocks that process LLM requests with optional tools.
213213
*/
214+
/**
215+
* Splits an MCP tool entry's stored params into the keys that identify the server and the values
216+
* the user pinned on the call.
217+
*
218+
* Both MCP paths must strip the same control keys: they name the server rather than the request,
219+
* and anything left in `userProvidedParams` is stated to the model as a pinned value. Keeping the
220+
* split in one place is what stops the two paths drifting apart.
221+
*/
222+
function splitMcpControlParams(params: Record<string, any> | undefined) {
223+
const { serverId, serverName, toolName, ...userProvidedParams } = params ?? {}
224+
return { serverId, serverName, toolName, userProvidedParams }
225+
}
226+
214227
export class AgentBlockHandler implements BlockHandler {
215228
canHandle(block: SerializedBlock): boolean {
216229
return block.metadata?.id === BlockType.AGENT
@@ -1130,7 +1143,9 @@ export class AgentBlockHandler implements BlockHandler {
11301143
projectedTool?: ToolInput,
11311144
toolIndex?: number
11321145
): Promise<any> {
1133-
const { serverId, toolName, serverName, ...userProvidedParams } = tool.params || {}
1146+
const { serverId, serverName, toolName, userProvidedParams } = splitMcpControlParams(
1147+
tool.params
1148+
)
11341149
const projectedSchema = projectedTool?.schema ?? tool.schema
11351150
if (projectedSchema !== undefined && !isPlainRecord(projectedSchema)) {
11361151
refuseResolvedSecretProjection({
@@ -1355,7 +1370,7 @@ export class AgentBlockHandler implements BlockHandler {
13551370
mcpTool: any,
13561371
serverId: string
13571372
): Promise<any> {
1358-
const { toolName, ...userProvidedParams } = tool.params || {}
1373+
const { toolName, userProvidedParams } = splitMcpControlParams(tool.params)
13591374
return this.buildMcpTool({
13601375
serverId,
13611376
toolName,

apps/sim/executor/handlers/pi/local/sim-tools.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -233,16 +233,24 @@ export async function buildSimToolSpecs(
233233
}
234234

235235
const providers = configuredTools.map(({ provider }) => provider)
236-
// Pi resolves secret provenance per tool CALL rather than per format, so at this point it cannot
237-
// say which individual tool carries one. Withhold literal values for the whole run when any input
238-
// resolved a secret — coarse, but it errs toward stating less.
239-
const withholdLiteralValues = Boolean(
240-
ctx.resolvedSecretTraceRegistry?.hasResolvedInputProjections()
241-
)
242-
if (withholdLiteralValues) {
243-
logger.debug('Withholding pinned literal values: an input in this run resolved a secret')
236+
237+
// Withhold a tool's literal values only when that tool's own params resolved a secret, asking
238+
// the registry the same per-input-path question the Agent block asks. A run-wide flag would be
239+
// safe but near-useless here: one `{{API_KEY}}` anywhere in a workflow would blank the literals
240+
// on every Pi tool for the whole run.
241+
const registry = ctx.resolvedSecretTraceRegistry
242+
const withheld = new Set<ProviderToolConfig>()
243+
if (registry) {
244+
for (const { provider, toolIndex } of configuredTools) {
245+
const provenance = registry.exportCommittedProvenanceForInputPaths([
246+
['tools', String(toolIndex), 'params'],
247+
])
248+
// An incomplete projection means the registry cannot vouch for the value; treat that the
249+
// same as carrying a secret.
250+
if (!provenance.complete || provenance.entries.length > 0) withheld.add(provider)
251+
}
244252
}
245-
await annotateToolPinnedParams(ctx, providers, () => withholdLiteralValues)
253+
await annotateToolPinnedParams(ctx, providers, (tool) => withheld.has(tool))
246254
assignProviderToolIdentities(providers)
247255
return configuredTools.map(({ provider, toolIndex }) =>
248256
buildSimToolSpec(ctx, inputTools, provider, toolIndex)

apps/sim/providers/tool-binding.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,51 @@ describe('collectToolPinnedFields', () => {
256256
expect(fields[0].title).toBe(`${'T'.repeat(40)}…`)
257257
})
258258

259+
it('keeps a canonical group blocked when only one half is unstateable', () => {
260+
// A `file-upload` basic half beside a `short-input` file-reference twin: the twin must not
261+
// state a raw reference (a presigned URL carries its credential) just because its sibling
262+
// was skipped.
263+
expect(
264+
collect({
265+
subBlocks: [
266+
sub({
267+
id: 'attachmentFiles',
268+
title: 'Attachments',
269+
type: 'file-upload',
270+
canonicalParamId: 'attachments',
271+
}),
272+
sub({
273+
id: 'attachments',
274+
title: 'Attachments',
275+
type: 'short-input',
276+
canonicalParamId: 'attachments',
277+
}),
278+
],
279+
resolvedResourceParams: { attachments: 'https://example.com/f?X-Amz-Signature=abc' },
280+
toolParams: toolParams('attachments'),
281+
})
282+
).toEqual([])
283+
})
284+
285+
it('keeps a canonical group blocked when only one half is a password field', () => {
286+
expect(
287+
collect({
288+
subBlocks: [
289+
sub({
290+
id: 'authBasic',
291+
title: 'Auth',
292+
type: 'short-input',
293+
password: true,
294+
canonicalParamId: 'auth',
295+
}),
296+
sub({ id: 'authAdvanced', title: 'Auth', type: 'short-input', canonicalParamId: 'auth' }),
297+
],
298+
resolvedResourceParams: { auth: 'hunter2' },
299+
toolParams: toolParams('auth'),
300+
})
301+
).toEqual([])
302+
})
303+
259304
it('drops a non-finite number', () => {
260305
expect(
261306
collect({
@@ -275,6 +320,22 @@ describe('collectPinnedFieldsFromParams', () => {
275320
])
276321
})
277322

323+
it('withholds secret-ish names a remote schema may use', () => {
324+
// Param names here are authored by the MCP server, not by Sim, so the Sim-tuned
325+
// `isPasswordParameter` list is not sufficient on its own.
326+
const remote = {
327+
authorization: 'Bearer abc',
328+
cookie: 'sid=1',
329+
signature: 'deadbeef',
330+
connectionString: 'postgres://u:p@h/db',
331+
otp: '123456',
332+
channel: 'general',
333+
}
334+
expect(collectPinnedFieldsFromParams(remote, sourceOptions)).toEqual([
335+
{ title: 'channel', value: 'general' },
336+
])
337+
})
338+
278339
it('withholds secrets and unstateable values', () => {
279340
expect(
280341
collectPinnedFieldsFromParams(

apps/sim/providers/tool-binding.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,14 @@ const UNSTATEABLE_SUBBLOCK_TYPES: ReadonlySet<string> = new Set([
5252
])
5353

5454
/**
55-
* Covers the one secret spelling `isPasswordParameter` misses — it tests for `password`, not
56-
* `passphrase`, and three blocks declare a `passphrase` field. Those all set `password: true` as
57-
* well, so this only matters for a field that forgets the flag.
55+
* Secret-ish names `isPasswordParameter` does not cover. It is tuned to Sim-authored param ids
56+
* (`password`, `apiKey`, `token`, `secret`, `key`, `credential`, …), but this module also states
57+
* params named by a REMOTE MCP schema, where these spellings are common and just as sensitive.
58+
* `passphrase` is the one that also occurs in Sim's own blocks — three declare it, all of them
59+
* with `password: true`, so that flag is the real guard and this is the backstop.
5860
*/
59-
const SUPPLEMENTAL_SECRET_PATTERN = /passphrase/i
61+
const SUPPLEMENTAL_SECRET_PATTERN =
62+
/passphrase|authorization|bearer|cookie|session|signature|connectionstring|dsn|webhookurl|\botp\b|\bpin\b/i
6063

6164
/**
6265
* Shape a configured value must have to be treated as a resolvable resource id.
@@ -186,10 +189,19 @@ export function collectToolPinnedFields(input: CollectToolPinnedFieldsInput): To
186189
// whole group rather than from whichever subblock is being scanned. Without this a resource
187190
// entered in advanced mode takes the literal path: a knowledge base id would be stated verbatim,
188191
// and a credential would be dropped entirely by the secret-name check below.
192+
// Decisions that must hold for a whole canonical group, not for whichever half is scanned first.
193+
// A group's advanced half is a plain `short-input`, so its kind has to come from the group — and
194+
// a group blocked by ANY half must stay blocked, or a `file-upload` basic half would skip without
195+
// claiming the param and let its `short-input` twin state a raw file reference.
189196
const kindByParamId = new Map<string, ResolvableKind>()
197+
const blockedParamIds = new Set<string>()
190198
for (const subBlock of subBlocks) {
199+
const paramId = subBlock.canonicalParamId ?? subBlock.id
191200
const kind = RESOURCE_SUBBLOCK_KINDS[subBlock.type]
192-
if (kind) kindByParamId.set(subBlock.canonicalParamId ?? subBlock.id, kind)
201+
if (kind) kindByParamId.set(paramId, kind)
202+
if (subBlock.password || subBlock.hidden || UNSTATEABLE_SUBBLOCK_TYPES.has(subBlock.type)) {
203+
blockedParamIds.add(paramId)
204+
}
193205
}
194206

195207
const fields: ToolPinnedField[] = []
@@ -200,8 +212,7 @@ export function collectToolPinnedFields(input: CollectToolPinnedFieldsInput): To
200212
if (seenParamIds.has(paramId)) continue
201213
if (selfDescribedParamId && paramId === selfDescribedParamId) continue
202214

203-
if (subBlock.password || subBlock.hidden) continue
204-
if (UNSTATEABLE_SUBBLOCK_TYPES.has(subBlock.type)) continue
215+
if (blockedParamIds.has(paramId)) continue
205216

206217
const kind = kindByParamId.get(paramId)
207218

0 commit comments

Comments
 (0)