Skip to content

Commit 465ea3c

Browse files
authored
feat(mcp): type mcp block replies from declared output schemas (#9466)
* feat(mcp): type mcp block replies from declared output schemas MCP servers may declare an outputSchema on a tool and return structuredContent, but discovery copied only name, description and inputSchema, so no surface showed a reply shape and the MCP block declared only content. Agents building workflows guessed field names, and a reference such as <mcp.structuredContent.x> failed validation even when the tool returned it. - Discovery keeps a declared outputSchema on McpTool; the tool cache carries it, and managed connections persist it in the existing mcp_tools jsonb snapshot (type only, no migration). - Surfaced on GET /api/v2/mcp-servers/{id}/tools (and so the CLI's mcp-servers tools list), mcp_list_operations, the editor tool list, and the integration catalog (synced contract, optional field). The v2 tool shape is strict, so keeping the field without declaring it would have turned discovery into a 500. - The MCP block gains a hidden, server-managed toolOutputSchema sub-block: a structure-only snapshot of the selected operation's schema, bound to the server and operation it was taken for. The editor writes it when an operation is picked; applyWorkflowOperations writes it for agent edits, through the same authorized discovery use cases, best effort and only when the snapshot is missing or stale. - With a matching snapshot, the block declares a typed structuredContent output beside content, so the run's reference check, lint (which now walks paths below structuredContent and names the declared fields) and the tag paths all read the declared shape, and workflows deps fills mock leaves from it. Without one, outputs are exactly as before. * fix(mcp): address review on mcp output schema snapshots - Read the MCP target with the block's saved canonical modes in lint, workflows deps and effective outputs, so a dormant basic/advanced member never selects or drops the snapshot. - Expand repeated bracket indices before the nested lint check, as the run resolves them. - Stop expanding a schema once its node budget is spent and leave that object open, instead of saving an untyped node per remaining field. - Keep every declared field name, constructor and __proto__ included. - Snapshot only blocks the edit changed, and discover best effort, so an unreachable server neither slows unrelated edits nor gets marked unhealthy. * fix(mcp): keep declared fields in deps whole reads and sample __proto__ fields safely workflows deps now adds an operation's declared fields wherever a consumer reads a structuredContent value whole, beside the paths it reads by name. Trigger mock payloads define sampled fields with Object.fromEntries, so a declared __proto__ field stays a field instead of replacing the sample's prototype. * fix(mcp): carry a whole structuredContent read through nested deps mocks A whole read now adds every declared field below it, through nested objects and array items, even where the consumer also reads a nested path by name.
1 parent 075c4ff commit 465ea3c

44 files changed

Lines changed: 1450 additions & 49 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/openapi-v2-resources.json‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15190,6 +15190,39 @@
1519015190
},
1519115191
"description": "JSON Schema for the tool's arguments, as reported by the server."
1519215192
},
15193+
"outputSchema": {
15194+
"type": "object",
15195+
"properties": {
15196+
"type": {
15197+
"type": "string",
15198+
"const": "object",
15199+
"description": "JSON Schema type of the structured result. MCP requires `object`."
15200+
},
15201+
"properties": {
15202+
"description": "Result field schemas keyed by field name.",
15203+
"type": "object",
15204+
"propertyNames": {
15205+
"type": "string"
15206+
},
15207+
"additionalProperties": {
15208+
"description": "Server-defined JSON Schema for one result field."
15209+
}
15210+
},
15211+
"required": {
15212+
"description": "Names of the fields every result carries.",
15213+
"type": "array",
15214+
"items": {
15215+
"type": "string",
15216+
"description": "Name of a field every result carries."
15217+
}
15218+
}
15219+
},
15220+
"required": ["type"],
15221+
"additionalProperties": {
15222+
"description": "Additional JSON Schema keyword reported by the server."
15223+
},
15224+
"description": "JSON Schema for the tool's `structuredContent`, as reported by the server. Absent when the tool declares none."
15225+
},
1519315226
"serverId": {
1519415227
"type": "string",
1519515228
"description": "Identifier of the MCP server exposing the tool."

‎apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,34 @@ describe('/api/v2/mcp-servers/[mcpServerId]/tools', () => {
232232
})
233233
})
234234

235+
/**
236+
* The v2 tool shape is strict, so a discovered field the contract does not
237+
* declare turns discovery into a 500 rather than being dropped. A declared
238+
* `outputSchema` is server-authored below its `object` wrapper, like
239+
* `inputSchema`, and passes through untouched.
240+
*/
241+
it('publishes the output schema a tool declares', async () => {
242+
const outputSchema = {
243+
type: 'object' as const,
244+
description: null,
245+
properties: { hits: { type: 'array', items: { type: 'string' } } },
246+
required: ['hits'],
247+
}
248+
mockDiscoverMcpServerToolsUseCase.mockResolvedValueOnce({
249+
tools: [
250+
{ ...TOOL, outputSchema },
251+
{ ...TOOL, name: 'ping' },
252+
],
253+
})
254+
255+
const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context })
256+
const body = await response.json()
257+
258+
expect(response.status).toBe(200)
259+
expect(body.data[0].outputSchema).toEqual(outputSchema)
260+
expect(body.data[1]).not.toHaveProperty('outputSchema')
261+
})
262+
235263
/**
236264
* `McpConnectionError` interpolates the server's display name into its
237265
* message, so selecting the 503 wording by searching that message for

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
import { useMemo } from 'react'
44
import { Combobox } from '@sim/emcn'
5+
import {
6+
createMcpToolOutputSnapshot,
7+
MCP_TOOL_OUTPUT_SCHEMA_SUBBLOCK,
8+
} from '@/lib/mcp/output-schema'
59
import { useWorkflowScope } from '@/app/workspace/[workspaceId]/providers/workflow-scope-provider'
610
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
711
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
@@ -35,6 +39,7 @@ export function McpToolSelector({
3539

3640
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
3741
const [, setSchemaCache] = useSubBlockValue(blockId, '_toolSchema')
42+
const [, setOutputSchemaSnapshot] = useSubBlockValue(blockId, MCP_TOOL_OUTPUT_SCHEMA_SUBBLOCK)
3843

3944
const { server: serverValue } = useMcpBlockConfig({ blockId, previewContextValues })
4045

@@ -68,6 +73,11 @@ export function McpToolSelector({
6873
const matchedTool = availableTools.find((tool) => tool.name === value)
6974
setStoreValue(matchedTool?.name ?? value)
7075
setSchemaCache(matchedTool?.inputSchema ?? null)
76+
setOutputSchemaSnapshot(
77+
matchedTool && typeof serverValue === 'string'
78+
? createMcpToolOutputSnapshot(serverValue, matchedTool.name, matchedTool.outputSchema)
79+
: null
80+
)
7181
}
7282

7383
const handleOpenChange = (isOpen: boolean) => {

‎apps/sim/blocks/blocks/mcp.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { McpIcon } from '@/components/icons'
2+
import { MCP_TOOL_OUTPUT_SCHEMA_SUBBLOCK } from '@/lib/mcp/output-schema'
23
import type { BlockConfig } from '@/blocks/types'
34
import { IntegrationType } from '@/blocks/types'
45
import type { ToolResponse } from '@/tools/types'
@@ -123,6 +124,15 @@ export const McpBlock: BlockConfig = {
123124
},
124125
dependsOn: ['tool'],
125126
},
127+
{
128+
id: MCP_TOOL_OUTPUT_SCHEMA_SUBBLOCK,
129+
title: 'Output schema',
130+
type: 'code',
131+
hidden: true,
132+
hideFromCopilot: true,
133+
description:
134+
"The selected operation's declared reply shape, which types the structuredContent output",
135+
},
126136
],
127137
tools: {
128138
access: ['mcp_run_operation', 'mcp_list_operations'],
@@ -157,7 +167,8 @@ export const McpBlock: BlockConfig = {
157167
serverId: { type: 'string', description: 'The supplied MCP server or connection ID' },
158168
operations: {
159169
type: 'json',
160-
description: 'Authorized operations (name, description, inputSchema)',
170+
description:
171+
'Authorized operations (name, description, inputSchema, outputSchema if declared)',
161172
},
162173
nextCursor: { type: 'string', description: 'Cursor for the next page, or null' },
163174
hasMore: { type: 'boolean', description: 'Whether more authorized operations are available' },

‎apps/sim/executor/utils/block-reference.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ function isOpaqueSchemaNode(value: unknown): boolean {
101101
return false
102102
}
103103

104-
function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]): boolean {
104+
/** Whether `pathParts` names a field the declared output schema has, the check a run makes. */
105+
export function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]): boolean {
105106
if (!schema || pathParts.length === 0) {
106107
return true
107108
}

‎apps/sim/hooks/mcp/use-mcp-tools.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface McpToolForUI {
2828
serverName: string
2929
type: 'mcp'
3030
inputSchema: McpToolSchema
31+
outputSchema?: McpToolSchema
3132
bgColor: string
3233
icon: ComponentType<SVGProps<SVGSVGElement>>
3334
}
@@ -55,6 +56,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
5556
serverName: tool.serverName,
5657
type: 'mcp' as const,
5758
inputSchema: tool.inputSchema,
59+
outputSchema: tool.outputSchema,
5860
bgColor: getManagedMcpConnectorBgColor(tool.managedConnectorId) ?? '#6366F1',
5961
icon: tool.managedConnectorId ? getManagedMcpConnectorIcon(tool.managedConnectorId) : McpIcon,
6062
}))

‎apps/sim/lib/api/contracts/mcp.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ export const mcpToolSchema = z.object({
9797
name: z.string(),
9898
description: z.string().optional(),
9999
inputSchema: mcpToolInputSchema,
100+
/** Server-authored below the `object` wrapper; passed through as discovered. */
101+
outputSchema: z
102+
.object({ type: z.literal('object') })
103+
.passthrough()
104+
.optional(),
100105
serverId: z.string(),
101106
canonicalServerId: z.string().optional(),
102107
serverName: z.string(),

‎apps/sim/lib/api/contracts/v2/mcp-servers.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,38 @@ const v2McpToolInputSchema = z
332332
.catchall(z.unknown().describe('Additional JSON Schema keyword reported by the server.'))
333333
.describe("JSON Schema for the tool's arguments, as reported by the server.")
334334

335+
/**
336+
* The shape of a tool's `structuredContent`, as the MCP server reports it.
337+
* Published open below the `object` wrapper for the reasons given for
338+
* {@link v2McpToolInputSchema}; the SDK's `ToolSchema.outputSchema` pins the same
339+
* three keys.
340+
*/
341+
const v2McpToolOutputSchema = z
342+
.object({
343+
type: z
344+
.literal('object')
345+
.describe('JSON Schema type of the structured result. MCP requires `object`.'),
346+
properties: z
347+
.record(z.string(), z.unknown().describe('Server-defined JSON Schema for one result field.'))
348+
.optional()
349+
.describe('Result field schemas keyed by field name.'),
350+
required: z
351+
.array(z.string().describe('Name of a field every result carries.'))
352+
.optional()
353+
.describe('Names of the fields every result carries.'),
354+
})
355+
.catchall(z.unknown().describe('Additional JSON Schema keyword reported by the server.'))
356+
.describe(
357+
"JSON Schema for the tool's `structuredContent`, as reported by the server. Absent when the tool declares none."
358+
)
359+
335360
/** One tool exposed by a registered MCP server. */
336361
export const v2McpToolSchema = z
337362
.object({
338363
name: z.string().describe('Tool name, as the MCP server reports it.'),
339364
description: z.string().optional().describe('Tool description reported by the server.'),
340365
inputSchema: v2McpToolInputSchema,
366+
outputSchema: v2McpToolOutputSchema.optional(),
341367
serverId: z.string().describe('Identifier of the MCP server exposing the tool.'),
342368
serverName: z.string().describe('Display name of the MCP server exposing the tool.'),
343369
})

‎apps/sim/lib/credential-groups/mcp-oauth.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@/lib/mcp/oauth'
1616
import { ManagedMcpOauthProvider } from '@/lib/mcp/oauth/managed-provider'
1717
import { mcpService } from '@/lib/mcp/service'
18+
import { toManagedMcpToolSnapshots } from '@/lib/mcp/tool-snapshot'
1819

1920
export async function startCredentialGroupMcpOAuth(
2021
context: CredentialGroupMcpOAuthContext,
@@ -118,11 +119,7 @@ export async function completeCredentialGroupMcpOAuth(
118119
mcpServerId: context.server.id,
119120
mcpServerName: context.server.name,
120121
tokens: grantedTokens,
121-
tools: tools.map((tool) => ({
122-
name: tool.name,
123-
...(tool.description ? { description: tool.description } : {}),
124-
inputSchema: tool.inputSchema,
125-
})),
122+
tools: toManagedMcpToolSnapshots(tools),
126123
})
127124
return { ...completion, mcpServerId: context.server.id }
128125
}

‎apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,33 @@ describe('discoverManagedMcpToolsUseCase', () => {
201201
}),
202202
])
203203
})
204+
it('persists a declared output schema with the snapshot a connection lists from', async () => {
205+
const outputSchema = { type: 'object' as const, properties: { hits: { type: 'array' } } }
206+
mocks.discoverTools.mockResolvedValueOnce([
207+
{
208+
name: 'search_transcripts',
209+
inputSchema: { type: 'object', properties: {} },
210+
outputSchema,
211+
serverId: context.mcpServerId,
212+
serverName: context.mcpServerName,
213+
},
214+
{
215+
name: 'ping',
216+
inputSchema: { type: 'object' },
217+
serverId: context.mcpServerId,
218+
serverName: context.mcpServerName,
219+
},
220+
])
221+
await discoverManagedMcpToolsUseCase.execute({
222+
principal,
223+
input: { workspaceId: context.workspaceId, credentialId: context.credentialId },
224+
})
225+
expect(hoisted.saveToolSnapshot.mock.calls[0]?.[1]).toEqual([
226+
{ name: 'search_transcripts', inputSchema: { type: 'object', properties: {} }, outputSchema },
227+
{ name: 'ping', inputSchema: { type: 'object' } },
228+
])
229+
})
230+
204231
it.each(['draft', 'deployment'] as const)(
205232
'catalog preserves managed executor authority and %s operation policy',
206233
async (mode) => {

0 commit comments

Comments
 (0)