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
47 changes: 47 additions & 0 deletions apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,51 @@ describe('MCP Serve Route', () => {
expect(headers['X-API-Key']).toBeUndefined()
expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1')
})

describe('initialize protocol version negotiation', () => {
async function callInitialize(protocolVersion?: string) {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'server-1',
name: 'Public Server',
workspaceId: 'ws-1',
isPublic: true,
createdBy: 'owner-1',
},
])
const params: Record<string, unknown> = {
capabilities: {},
clientInfo: { name: 'test', version: '1.0.0' },
}
if (protocolVersion !== undefined) params.protocolVersion = protocolVersion
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
method: 'POST',
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params }),
})
const res = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
return res.json() as Promise<{ result: { protocolVersion: string } }>
}

it('echoes a supported client protocolVersion (2025-06-18)', async () => {
const body = await callInitialize('2025-06-18')
expect(body.result.protocolVersion).toBe('2025-06-18')
})

it('echoes a supported client protocolVersion (2024-11-05)', async () => {
const body = await callInitialize('2024-11-05')
expect(body.result.protocolVersion).toBe('2024-11-05')
})

it('falls back to SDK latest when client requests unknown version', async () => {
const { LATEST_PROTOCOL_VERSION } = await import('@modelcontextprotocol/sdk/types.js')
const body = await callInitialize('2099-01-01')
expect(body.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION)
})

it('falls back to SDK latest when client omits protocolVersion', async () => {
const { LATEST_PROTOCOL_VERSION } = await import('@modelcontextprotocol/sdk/types.js')
const body = await callInitialize(undefined)
expect(body.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION)
})
})
})
15 changes: 14 additions & 1 deletion apps/sim/app/api/mcp/serve/[serverId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import {
type JSONRPCError,
type JSONRPCMessage,
type JSONRPCResultResponse,
LATEST_PROTOCOL_VERSION,
type ListToolsResult,
type RequestId,
SUPPORTED_PROTOCOL_VERSIONS,
type Tool,
} from '@modelcontextprotocol/sdk/types.js'
import { db } from '@sim/db'
Expand All @@ -36,6 +38,17 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('WorkflowMcpServeAPI')

function negotiateProtocolVersion(rpcParams: unknown): string {
const requested =
rpcParams && typeof rpcParams === 'object' && 'protocolVersion' in rpcParams
? (rpcParams as { protocolVersion?: unknown }).protocolVersion
: undefined
if (typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
return requested
}
return LATEST_PROTOCOL_VERSION
}

export const dynamic = 'force-dynamic'

interface RouteParams {
Expand Down Expand Up @@ -214,7 +227,7 @@ export const POST = withRouteHandler(
switch (method) {
case 'initialize': {
const result: InitializeResult = {
protocolVersion: '2024-11-05',
protocolVersion: negotiateProtocolVersion(rpcParams),
capabilities: { tools: {} },
serverInfo: { name: server.name, version: '1.0.0' },
}
Expand Down
24 changes: 3 additions & 21 deletions apps/sim/hooks/queries/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,7 @@ export const mcpKeys = {

export type { McpServer }

/**
* Input for creating/updating an MCP server (distinct from McpServerConfig in types.ts)
*/
/** Wire shape for create/update; distinct from runtime McpServerConfig. */
export interface McpServerInput {
name: string
transport: McpTransport
Expand Down Expand Up @@ -265,11 +263,7 @@ export function useCreateMcpServer() {
})
}

/**
* Result of `useStartMcpOauth`. When `popup` is set, the caller should wait
* for it to close (or for the `mcp-oauth` postMessage) before clearing any
* "connecting" UI state.
*/
/** On `redirect`, the caller must wait for `popup.closed` or the `mcp-oauth` postMessage. */
export type StartMcpOauthMutationResult =
| { status: 'redirect'; popup: Window }
| { status: 'already_authorized' }
Expand Down Expand Up @@ -464,13 +458,7 @@ const sseConnections: Map<string, SseEntry> =
((globalThis as Record<string, unknown>)[SSE_KEY] as Map<string, SseEntry>) ??
((globalThis as Record<string, unknown>)[SSE_KEY] = new Map<string, SseEntry>())

/**
* Subscribe to MCP tool-change SSE events for a workspace.
* On each `tools_changed` event, invalidates the relevant React Query caches
* so the UI refreshes automatically.
*
* Invalidates both external MCP server keys and workflow MCP server keys.
*/
/** Subscribes to `tools_changed` SSE events and invalidates the affected query keys. */
export function useMcpToolsEvents(workspaceId: string) {
const queryClient = useQueryClient()

Expand Down Expand Up @@ -598,17 +586,11 @@ export function useMcpServerTest() {
}
}

/**
* Fetch allowed MCP domains (admin-configured allowlist)
*/
async function fetchAllowedMcpDomains(signal?: AbortSignal): Promise<string[] | null> {
const data = await requestJson(getAllowedMcpDomainsContract, { signal })
return data.allowedMcpDomains ?? null
}

/**
* Hook to fetch allowed MCP domains
*/
export function useAllowedMcpDomains() {
return useQuery<string[] | null>({
queryKey: mcpKeys.allowedDomains(),
Expand Down
59 changes: 5 additions & 54 deletions apps/sim/lib/mcp/client.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
/**
* MCP (Model Context Protocol) Client
*
* Implements the client side of MCP protocol with support for:
* - Streamable HTTP transport (MCP 2025-06-18)
* - Tool execution and discovery
* - Session management and protocol version negotiation
* - Custom security/consent layer
*/

import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import {
LATEST_PROTOCOL_VERSION,
type ListToolsResult,
SUPPORTED_PROTOCOL_VERSIONS,
type Tool,
ToolListChangedNotificationSchema,
} from '@modelcontextprotocol/sdk/types.js'
Expand Down Expand Up @@ -50,12 +42,6 @@ export class McpClient {
private authProvider?: McpClientOptions['authProvider']
private isConnected = false

private static readonly SUPPORTED_VERSIONS = [
'2025-06-18', // Latest stable with elicitation and OAuth 2.1
'2025-03-26', // Streamable HTTP support
'2024-11-05', // Initial stable release
]

constructor(options: McpClientOptions) {
this.config = options.config
this.securityPolicy = options.securityPolicy ?? {
Expand Down Expand Up @@ -135,9 +121,6 @@ export class McpClient {
}
}

/**
* Disconnect from MCP server
*/
async disconnect(): Promise<void> {
logger.info(`Disconnecting from MCP server: ${this.config.name}`)

Expand All @@ -152,16 +135,10 @@ export class McpClient {
logger.info(`Disconnected from MCP server: ${this.config.name}`)
}

/**
* Get current connection status
*/
getStatus(): McpConnectionStatus {
return { ...this.connectionStatus }
}

/**
* List all available tools from the server
*/
async listTools(): Promise<McpTool[]> {
if (!this.isConnected) {
throw new McpConnectionError('Not connected to server', this.config.name)
Expand Down Expand Up @@ -190,9 +167,6 @@ export class McpClient {
}
}

/**
* Execute a tool on the MCP server
*/
async callTool(toolCall: McpToolCall): Promise<McpToolResult> {
if (!this.isConnected) {
throw new McpConnectionError('Not connected to server', this.config.name)
Expand Down Expand Up @@ -237,10 +211,6 @@ export class McpClient {
}
}

/**
* Ping the server to check if it's still alive and responsive
* Per MCP spec: servers should respond to ping requests
*/
async ping(): Promise<{ _meta?: Record<string, any> }> {
if (!this.isConnected) {
throw new McpConnectionError('Not connected to server', this.config.name)
Expand All @@ -257,18 +227,11 @@ export class McpClient {
}
}

/**
* Check if the server declared `capabilities.tools.listChanged: true` during initialization.
*/
hasListChangedCapability(): boolean {
return !!this.client.getServerCapabilities()?.tools?.listChanged
}

/**
* Register a callback to be invoked when the underlying transport closes.
* Used by the connection manager for reconnection logic.
* Chains with the SDK's internal onclose handler so it still performs its cleanup.
*/
/** Chains with the SDK's internal onclose handler so its cleanup still runs. */
onClose(callback: () => void): void {
const existingHandler = this.transport.onclose
this.transport.onclose = () => {
Expand All @@ -277,26 +240,17 @@ export class McpClient {
}
}

/**
* Get server configuration
*/
getConfig(): McpServerConfig {
return { ...this.config }
}

/**
* Get version information for this client
*/
static getVersionInfo(): McpVersionInfo {
return {
supported: [...McpClient.SUPPORTED_VERSIONS],
preferred: McpClient.SUPPORTED_VERSIONS[0],
supported: [...SUPPORTED_PROTOCOL_VERSIONS],
preferred: LATEST_PROTOCOL_VERSION,
}
}

/**
* Get the negotiated protocol version for this connection
*/
getNegotiatedVersion(): string | undefined {
const serverVersion = this.client.getServerVersion()
return typeof serverVersion === 'string' ? serverVersion : undefined
Expand All @@ -306,9 +260,6 @@ export class McpClient {
return this.transport.sessionId
}

/**
* Request user consent for tool execution
*/
async requestConsent(consentRequest: McpConsentRequest): Promise<McpConsentResponse> {
if (!this.securityPolicy.requireConsent) {
return { granted: true, auditId: `audit-${Date.now()}` }
Expand Down
Loading
Loading