Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/docs/openapi-v2-resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -15190,6 +15190,39 @@
},
"description": "JSON Schema for the tool's arguments, as reported by the server."
},
"outputSchema": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "object",
"description": "JSON Schema type of the structured result. MCP requires `object`."
},
"properties": {
"description": "Result field schemas keyed by field name.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"description": "Server-defined JSON Schema for one result field."
}
},
"required": {
"description": "Names of the fields every result carries.",
"type": "array",
"items": {
"type": "string",
"description": "Name of a field every result carries."
}
}
},
"required": ["type"],
"additionalProperties": {
"description": "Additional JSON Schema keyword reported by the server."
},
"description": "JSON Schema for the tool's `structuredContent`, as reported by the server. Absent when the tool declares none."
},
"serverId": {
"type": "string",
"description": "Identifier of the MCP server exposing the tool."
Expand Down
28 changes: 28 additions & 0 deletions apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,34 @@ describe('/api/v2/mcp-servers/[mcpServerId]/tools', () => {
})
})

/**
* The v2 tool shape is strict, so a discovered field the contract does not
* declare turns discovery into a 500 rather than being dropped. A declared
* `outputSchema` is server-authored below its `object` wrapper, like
* `inputSchema`, and passes through untouched.
*/
it('publishes the output schema a tool declares', async () => {
const outputSchema = {
type: 'object' as const,
description: null,
properties: { hits: { type: 'array', items: { type: 'string' } } },
required: ['hits'],
}
mockDiscoverMcpServerToolsUseCase.mockResolvedValueOnce({
tools: [
{ ...TOOL, outputSchema },
{ ...TOOL, name: 'ping' },
],
})

const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context })
const body = await response.json()

expect(response.status).toBe(200)
expect(body.data[0].outputSchema).toEqual(outputSchema)
expect(body.data[1]).not.toHaveProperty('outputSchema')
})

/**
* `McpConnectionError` interpolates the server's display name into its
* message, so selecting the 503 wording by searching that message for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

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

const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [, setSchemaCache] = useSubBlockValue(blockId, '_toolSchema')
const [, setOutputSchemaSnapshot] = useSubBlockValue(blockId, MCP_TOOL_OUTPUT_SCHEMA_SUBBLOCK)

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

Expand Down Expand Up @@ -68,6 +73,11 @@ export function McpToolSelector({
const matchedTool = availableTools.find((tool) => tool.name === value)
setStoreValue(matchedTool?.name ?? value)
setSchemaCache(matchedTool?.inputSchema ?? null)
setOutputSchemaSnapshot(
matchedTool && typeof serverValue === 'string'
? createMcpToolOutputSnapshot(serverValue, matchedTool.name, matchedTool.outputSchema)
: null
)
}

const handleOpenChange = (isOpen: boolean) => {
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/blocks/blocks/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { McpIcon } from '@/components/icons'
import { MCP_TOOL_OUTPUT_SCHEMA_SUBBLOCK } from '@/lib/mcp/output-schema'
import type { BlockConfig } from '@/blocks/types'
import { IntegrationType } from '@/blocks/types'
import type { ToolResponse } from '@/tools/types'
Expand Down Expand Up @@ -123,6 +124,15 @@ export const McpBlock: BlockConfig = {
},
dependsOn: ['tool'],
},
{
id: MCP_TOOL_OUTPUT_SCHEMA_SUBBLOCK,
title: 'Output schema',
type: 'code',
hidden: true,
hideFromCopilot: true,
description:
"The selected operation's declared reply shape, which types the structuredContent output",
},
],
tools: {
access: ['mcp_run_operation', 'mcp_list_operations'],
Expand Down Expand Up @@ -157,7 +167,8 @@ export const McpBlock: BlockConfig = {
serverId: { type: 'string', description: 'The supplied MCP server or connection ID' },
operations: {
type: 'json',
description: 'Authorized operations (name, description, inputSchema)',
description:
'Authorized operations (name, description, inputSchema, outputSchema if declared)',
},
nextCursor: { type: 'string', description: 'Cursor for the next page, or null' },
hasMore: { type: 'boolean', description: 'Whether more authorized operations are available' },
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/executor/utils/block-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ function isOpaqueSchemaNode(value: unknown): boolean {
return false
}

function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]): boolean {
/** Whether `pathParts` names a field the declared output schema has, the check a run makes. */
export function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]): boolean {
if (!schema || pathParts.length === 0) {
return true
}
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/hooks/mcp/use-mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface McpToolForUI {
serverName: string
type: 'mcp'
inputSchema: McpToolSchema
outputSchema?: McpToolSchema
bgColor: string
icon: ComponentType<SVGProps<SVGSVGElement>>
}
Expand Down Expand Up @@ -55,6 +56,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
serverName: tool.serverName,
type: 'mcp' as const,
inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema,
bgColor: getManagedMcpConnectorBgColor(tool.managedConnectorId) ?? '#6366F1',
icon: tool.managedConnectorId ? getManagedMcpConnectorIcon(tool.managedConnectorId) : McpIcon,
}))
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/api/contracts/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ export const mcpToolSchema = z.object({
name: z.string(),
description: z.string().optional(),
inputSchema: mcpToolInputSchema,
/** Server-authored below the `object` wrapper; passed through as discovered. */
outputSchema: z
.object({ type: z.literal('object') })
.passthrough()
.optional(),
serverId: z.string(),
canonicalServerId: z.string().optional(),
serverName: z.string(),
Expand Down
26 changes: 26 additions & 0 deletions apps/sim/lib/api/contracts/v2/mcp-servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,12 +332,38 @@ const v2McpToolInputSchema = z
.catchall(z.unknown().describe('Additional JSON Schema keyword reported by the server.'))
.describe("JSON Schema for the tool's arguments, as reported by the server.")

/**
* The shape of a tool's `structuredContent`, as the MCP server reports it.
* Published open below the `object` wrapper for the reasons given for
* {@link v2McpToolInputSchema}; the SDK's `ToolSchema.outputSchema` pins the same
* three keys.
*/
const v2McpToolOutputSchema = z
.object({
type: z
.literal('object')
.describe('JSON Schema type of the structured result. MCP requires `object`.'),
properties: z
.record(z.string(), z.unknown().describe('Server-defined JSON Schema for one result field.'))
.optional()
.describe('Result field schemas keyed by field name.'),
required: z
.array(z.string().describe('Name of a field every result carries.'))
.optional()
.describe('Names of the fields every result carries.'),
})
.catchall(z.unknown().describe('Additional JSON Schema keyword reported by the server.'))
.describe(
"JSON Schema for the tool's `structuredContent`, as reported by the server. Absent when the tool declares none."
)

/** One tool exposed by a registered MCP server. */
export const v2McpToolSchema = z
.object({
name: z.string().describe('Tool name, as the MCP server reports it.'),
description: z.string().optional().describe('Tool description reported by the server.'),
inputSchema: v2McpToolInputSchema,
outputSchema: v2McpToolOutputSchema.optional(),
serverId: z.string().describe('Identifier of the MCP server exposing the tool.'),
serverName: z.string().describe('Display name of the MCP server exposing the tool.'),
})
Expand Down
7 changes: 2 additions & 5 deletions apps/sim/lib/credential-groups/mcp-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@/lib/mcp/oauth'
import { ManagedMcpOauthProvider } from '@/lib/mcp/oauth/managed-provider'
import { mcpService } from '@/lib/mcp/service'
import { toManagedMcpToolSnapshots } from '@/lib/mcp/tool-snapshot'

export async function startCredentialGroupMcpOAuth(
context: CredentialGroupMcpOAuthContext,
Expand Down Expand Up @@ -118,11 +119,7 @@ export async function completeCredentialGroupMcpOAuth(
mcpServerId: context.server.id,
mcpServerName: context.server.name,
tokens: grantedTokens,
tools: tools.map((tool) => ({
name: tool.name,
...(tool.description ? { description: tool.description } : {}),
inputSchema: tool.inputSchema,
})),
tools: toManagedMcpToolSnapshots(tools),
})
return { ...completion, mcpServerId: context.server.id }
}
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,33 @@ describe('discoverManagedMcpToolsUseCase', () => {
}),
])
})
it('persists a declared output schema with the snapshot a connection lists from', async () => {
const outputSchema = { type: 'object' as const, properties: { hits: { type: 'array' } } }
mocks.discoverTools.mockResolvedValueOnce([
{
name: 'search_transcripts',
inputSchema: { type: 'object', properties: {} },
outputSchema,
serverId: context.mcpServerId,
serverName: context.mcpServerName,
},
{
name: 'ping',
inputSchema: { type: 'object' },
serverId: context.mcpServerId,
serverName: context.mcpServerName,
},
])
await discoverManagedMcpToolsUseCase.execute({
principal,
input: { workspaceId: context.workspaceId, credentialId: context.credentialId },
})
expect(hoisted.saveToolSnapshot.mock.calls[0]?.[1]).toEqual([
{ name: 'search_transcripts', inputSchema: { type: 'object', properties: {} }, outputSchema },
{ name: 'ping', inputSchema: { type: 'object' } },
])
})

it.each(['draft', 'deployment'] as const)(
'catalog preserves managed executor authority and %s operation policy',
async (mode) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { loadManagedMcpAuthProvider } from '@/lib/mcp/application/managed-auth-p
import { loadMcpOperationAccess } from '@/lib/mcp/application/operation-access'
import { mcpService } from '@/lib/mcp/service'
import { compileMcpToolSchema } from '@/lib/mcp/tool-schema'
import { toManagedMcpToolSnapshots } from '@/lib/mcp/tool-snapshot'
import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions'

export interface DiscoverManagedMcpToolsInput {
Expand Down Expand Up @@ -73,11 +74,7 @@ export const discoverManagedMcpToolsUseCase = defineAuthorizedWorkspaceUseCase({
)
await saveManagedMcpToolSnapshot(
runtime.credentialId,
tools.map((tool) => ({
name: tool.name,
...(tool.description ? { description: tool.description } : {}),
inputSchema: tool.inputSchema,
})),
toManagedMcpToolSnapshots(tools),
runtime.oauthConfigVersion,
runtime.grantedAt
)
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/credentials/managed-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ export async function persistManagedMcpCredential(params: {
mcpServerId: string
mcpServerName: string
tokens: OAuthTokens
tools: Array<{ name: string; description?: string; inputSchema: Record<string, unknown> }>
tools: ManagedMcpToolSnapshot[]
}): Promise<{
connectionId: string
created: boolean
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/lib/internal/mcp/list-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ describe('List MCP operations', () => {
})
})

it('publishes the reply schema an operation declares, and none for one that declares none', async () => {
const outputSchema = { type: 'object', properties: { ok: { type: 'boolean' } } }
discover.mockResolvedValue([
{ name: 'add_note', serverId: 'server-1', inputSchema: { type: 'object' }, outputSchema },
{ name: 'on_call', serverId: 'server-1', inputSchema: { type: 'object' } },
])
const response = await listMcpOperations({ ...call, input: { server: 'server-1' } })
const { output } = await response.json()
expect(output.operations).toEqual([
{ name: 'add_note', description: '', inputSchema: { type: 'object' }, outputSchema },
{ name: 'on_call', description: '', inputSchema: { type: 'object' } },
])
})

it('rejects an empty resolved server without selecting a credential', async () => {
await expect(listMcpOperations({ ...call, input: { server: '' } })).rejects.toThrow(
'MCP server is required'
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/internal/mcp/list-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const listMcpOperations: InternalToolOperationHandler = async (request) =
name: tool.name,
description: tool.description ?? '',
inputSchema: tool.inputSchema,
...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
}))
const hasMore = remaining.length > limit
return Response.json({
Expand Down
7 changes: 2 additions & 5 deletions apps/sim/lib/mcp/application/execute-managed-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
requireMcpOperationAccess,
} from '@/lib/mcp/application/operation-access'
import { mcpService } from '@/lib/mcp/service'
import { toManagedMcpToolSnapshots } from '@/lib/mcp/tool-snapshot'
import type { McpTool, McpToolCall, McpToolSchema } from '@/lib/mcp/types'
import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions'

Expand Down Expand Up @@ -100,11 +101,7 @@ export const executeManagedMcpToolUseCase = defineAuthorizedWorkspaceUseCase({
)
await saveManagedMcpToolSnapshot(
runtime.credentialId,
tools.map((tool) => ({
name: tool.name,
...(tool.description ? { description: tool.description } : {}),
inputSchema: tool.inputSchema,
})),
toManagedMcpToolSnapshots(tools),
runtime.oauthConfigVersion,
runtime.grantedAt
)
Expand Down
23 changes: 14 additions & 9 deletions apps/sim/lib/mcp/application/managed-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability'
import { resolveMcpWorkspaceContext } from '@/lib/mcp/application/context'
import { mcpServerOperations } from '@/lib/mcp/application/operations'
import { readMcpToolOutputSchema } from '@/lib/mcp/output-schema'
import type { McpToolSchema } from '@/lib/mcp/types'
import { requireResourcePolicy } from '@/lib/resource-policies/repository'

Expand Down Expand Up @@ -188,15 +189,19 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase
updatedAt: row.updatedAt.toISOString(),
})),
tools: rows.flatMap((row) => {
return row.tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: requireMcpToolSchema(tool.inputSchema),
serverId: row.id,
canonicalServerId: row.serverId,
serverName: `${row.serverName} — ${row.email}`,
managedConnectorId: row.managedConnectorId,
}))
return row.tools.map((tool) => {
const outputSchema = readMcpToolOutputSchema(tool.outputSchema)
return {
name: tool.name,
description: tool.description,
inputSchema: requireMcpToolSchema(tool.inputSchema),
...(outputSchema ? { outputSchema } : {}),
serverId: row.id,
canonicalServerId: row.serverId,
serverName: `${row.serverName} — ${row.email}`,
managedConnectorId: row.managedConnectorId,
}
})
}),
}
},
Expand Down
Loading
Loading