mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers (#4441)
* feat(mcp): OAuth 2.1 support for outbound MCP servers
* fix(mcp): tighten OAuth refresh race and session-error detection
Re-load the OAuth row inside withMcpOauthRefreshLock so concurrent
callers observe predecessor-written tokens instead of a stale snapshot
loaded before lock acquisition. Without this, the second caller's
provider held a rotated-out refresh token and the SDK tripped
invalid_grant, forcing reauthorization.
Switch isSessionError to match the SDK's typed StreamableHTTPError
(code 404/400) instead of substring-checking arbitrary error messages,
removing false positives on URLs that happen to contain those digits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(mcp): tighten OAuth callback contract and registration metadata
- Validate callback query params via mcpOauthCallbackContract instead of
raw searchParams.get, matching the rest of the MCP route surface.
- Drop non-RFC-7591 application_type field from dynamic client registration
to avoid rejection by strict authorization servers.
- Collapse the pre-lock OAuth row load in createClient — the row is now
loaded exclusively inside withMcpOauthRefreshLock, removing a redundant
query and a stale-snapshot path.
* fix(mcp): narrow workspaceId before async closure in OAuth createClient
* fix(mcp): return authType from create-server endpoint
The POST /api/mcp/servers handler omitted authType from the success
response, so useCreateMcpServer always saw data.data.authType as
undefined and never triggered the OAuth popup after creating an
OAuth-protected server. Thread authType through performCreateMcpServer
into the response so the client can decide whether to auto-start OAuth.
* fix(mcp): mirror server null normalization in optimistic oauthClientId update
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(mcp): revert optimistic oauthClientId to undefined to match McpServer type
The response contract preprocesses null → undefined, so McpServer.oauthClientId
is string | undefined. Using null broke type checking.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(mcp): tighten OAuth probe signal and clear stale popup interval
- probe: only classify as OAuth on resource_metadata or scope params.
Bare `Bearer error="invalid_token"` is generic and used by API-key servers,
so it must not auto-flip the auth type to OAuth.
- popup hook: clear any existing close-watcher interval before overwriting
when startOauthForServer is invoked twice for the same serverId.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(mcp): normalize empty-string oauthClientId at route boundary
Orchestration already converts falsy → null via `|| null` (server-lifecycle.ts),
so the DB was never receiving an empty string. Tightening the route layer to
match the same convention keeps the boundary contract consistent and avoids
relying on downstream normalization.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(canvas): expand MCP tool params into per-row labels on block tile
The MCP Tool block on the workflow canvas previously crammed every selected-
tool parameter into a stringified blob under the `Tool` row. Now, when a tool
is selected, the tile reads the cached `_toolSchema` and emits one labeled
SubBlockRow per parameter (matching the Exa block's per-param layout). Labels
reuse `formatParameterLabel` for parity with the editor panel; values pass
through the existing `getDisplayValue` so booleans/numbers/arrays render
identically to other blocks. Deterministic tile height counts expanded rows
so the tile sizes correctly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(logs): show MCP icon and strip prefix in trace tool spans
Tool spans for MCP calls were rendering the raw id (e.g.
`mcp-f908f259-planetscale_list_organizations`) with the default blank-
square icon. Now they read just the tool name and render the MCP block's
icon and bgColor, matching how workflow-execute tools render.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(logs): lift near-black trace icon backgrounds for dark-mode contrast
Block bgColors below a small luminance threshold (e.g. the MCP block's
#181C1E) rendered nearly invisible against the dark-mode surface
(--bg: #1b1b1b). Adds a tiny adjustBgForContrast helper that floors each
RGB channel at 0x33 only when luminance is below 30,000, leaving every
branded color above that band untouched. Applied to both the trace tree
row and the detail pane.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(logs): fall back to neutral gray for near-black trace icon bgs
#333333 was still too close to the dark-mode surface to read. For bgs
below the luminance threshold (e.g. the MCP block's #181C1E) we now fall
back to DEFAULT_BLOCK_COLOR (#6b7280) — the same neutral the renderer
uses for blocks with no distinct identity. Clearly visible in both
themes; brighter brand colors still pass through.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(db): drop 0209_mcp_oauth migration ahead of staging merge
Staging shipped 0209_smiling_fixer; the MCP OAuth migration will be
regenerated on top of staging as 0210.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(db): regenerate MCP OAuth migration as 0210
Re-runs drizzle-kit generate on top of staging's 0209_smiling_fixer.
Same schema (mcp_server_oauth table + mcp_servers.auth_type / oauth_*
columns) as the dropped 0209_mcp_oauth.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(audit): bump route baseline 748 → 749 after staging merge
The post-merge route count is 749 (this branch's OAuth start/callback
plus staging's new route). I had set the baseline to 748 in the merge
conflict resolution — bumping to match reality so the strict audit
passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: remove source-command skill files committed by accident
These were untracked-then-accidentally-staged in 05c4bc19e via a wide
`git add -A`. They aren't part of this PR's scope.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import { auth as mcpAuth } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServers } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
assertSafeOauthServerUrl,
|
||||
clearState,
|
||||
clearVerifier,
|
||||
loadOauthRowByState,
|
||||
loadPreregisteredClient,
|
||||
type McpOauthCallbackReason,
|
||||
SimMcpOauthProvider,
|
||||
} from '@/lib/mcp/oauth'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
|
||||
const logger = createLogger('McpOauthCallbackAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function jsonLiteral(value: string | undefined): string {
|
||||
if (value === undefined) return 'undefined'
|
||||
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e')
|
||||
}
|
||||
|
||||
function htmlClose(
|
||||
message: string,
|
||||
ok: boolean,
|
||||
reason: McpOauthCallbackReason,
|
||||
serverId?: string
|
||||
): NextResponse {
|
||||
const safeMessage = escapeHtml(message)
|
||||
const title = ok ? 'Connected' : 'Connection failed'
|
||||
const body = `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body style="font-family: system-ui; padding: 24px"><p>${safeMessage}</p><script>
|
||||
try { window.opener && window.opener.postMessage({ type: 'mcp-oauth', ok: ${ok ? 'true' : 'false'}, serverId: ${jsonLiteral(serverId)}, reason: ${jsonLiteral(reason)} }, window.location.origin) } catch (e) {}
|
||||
setTimeout(function () { window.close() }, 800)
|
||||
</script></body></html>`
|
||||
return new NextResponse(body, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
})
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const parsed = await parseRequest(mcpOauthCallbackContract, request, {})
|
||||
if (!parsed.success) {
|
||||
return htmlClose('Malformed authorization callback.', false, 'missing_params')
|
||||
}
|
||||
const { state, code, error: errorParam } = parsed.data.query
|
||||
|
||||
const initialRow = state ? await loadOauthRowByState(state).catch(() => null) : null
|
||||
const stateRowServerId = initialRow?.mcpServerId
|
||||
|
||||
if (errorParam) {
|
||||
logger.warn(`MCP OAuth callback received error: ${errorParam}`)
|
||||
if (initialRow) await clearState(initialRow.id).catch(() => {})
|
||||
return htmlClose(
|
||||
`Authorization failed: ${errorParam}`,
|
||||
false,
|
||||
'provider_error',
|
||||
stateRowServerId
|
||||
)
|
||||
}
|
||||
if (!state || !code) {
|
||||
return htmlClose(
|
||||
'Missing state or code in callback URL.',
|
||||
false,
|
||||
'missing_params',
|
||||
stateRowServerId
|
||||
)
|
||||
}
|
||||
|
||||
let serverId: string | undefined
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return htmlClose(
|
||||
'You must be signed in to complete authorization.',
|
||||
false,
|
||||
'unauthenticated',
|
||||
stateRowServerId
|
||||
)
|
||||
}
|
||||
|
||||
const row = initialRow
|
||||
if (!row) {
|
||||
return htmlClose('Invalid or expired authorization state.', false, 'invalid_state')
|
||||
}
|
||||
serverId = row.mcpServerId
|
||||
|
||||
if (session.user.id !== row.userId) {
|
||||
return htmlClose(
|
||||
'You must be signed in as the same user that initiated the flow.',
|
||||
false,
|
||||
'user_mismatch',
|
||||
serverId
|
||||
)
|
||||
}
|
||||
|
||||
const [server] = await db
|
||||
.select({ id: mcpServers.id, url: mcpServers.url, workspaceId: mcpServers.workspaceId })
|
||||
.from(mcpServers)
|
||||
.where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt)))
|
||||
.limit(1)
|
||||
if (!server || !server.url) {
|
||||
return htmlClose('Server no longer exists.', false, 'server_gone', serverId)
|
||||
}
|
||||
if (server.workspaceId !== row.workspaceId) {
|
||||
return htmlClose(
|
||||
'Workspace mismatch on authorization callback.',
|
||||
false,
|
||||
'invalid_state',
|
||||
serverId
|
||||
)
|
||||
}
|
||||
try {
|
||||
assertSafeOauthServerUrl(server.url)
|
||||
} catch {
|
||||
return htmlClose(
|
||||
'MCP OAuth requires https (or http://localhost for development).',
|
||||
false,
|
||||
'insecure_url',
|
||||
serverId
|
||||
)
|
||||
}
|
||||
|
||||
// Burn state before token exchange so a replayed callback cannot reuse it.
|
||||
await clearState(row.id)
|
||||
|
||||
const preregistered = await loadPreregisteredClient(server.id)
|
||||
const provider = new SimMcpOauthProvider({ row, preregistered })
|
||||
let result: Awaited<ReturnType<typeof mcpAuth>>
|
||||
try {
|
||||
result = await mcpAuth(provider, {
|
||||
serverUrl: server.url,
|
||||
authorizationCode: code,
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error('Token exchange failed during MCP OAuth callback', e)
|
||||
return htmlClose(
|
||||
'Token exchange failed. Please try again.',
|
||||
false,
|
||||
'token_exchange_failed',
|
||||
server.id
|
||||
)
|
||||
} finally {
|
||||
await clearVerifier(row.id)
|
||||
}
|
||||
|
||||
if (result !== 'AUTHORIZED') {
|
||||
return htmlClose('Authorization did not complete.', false, 'token_exchange_failed', server.id)
|
||||
}
|
||||
|
||||
try {
|
||||
await mcpService.clearCache(server.workspaceId)
|
||||
await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId)
|
||||
} catch (e) {
|
||||
logger.warn('Post-auth tools refresh failed', toError(e).message)
|
||||
}
|
||||
|
||||
return htmlClose('Connected. You can close this window.', true, 'authorized', server.id)
|
||||
} catch (error) {
|
||||
logger.error('MCP OAuth callback failed', error)
|
||||
return htmlClose('Authorization failed. Please try again.', false, 'unknown', serverId)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
dbChainMock,
|
||||
dbChainMockFns,
|
||||
hybridAuthMock,
|
||||
hybridAuthMockFns,
|
||||
McpOauthRedirectRequiredMock,
|
||||
mcpOauthMock,
|
||||
mcpOauthMockFns,
|
||||
permissionsMock,
|
||||
permissionsMockFns,
|
||||
resetDbChainMock,
|
||||
schemaMock,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockMcpAuth } = vi.hoisted(() => ({
|
||||
mockMcpAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
vi.mock('@sim/db/schema', () => schemaMock)
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
isNull: vi.fn(),
|
||||
}))
|
||||
vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
|
||||
auth: mockMcpAuth,
|
||||
}))
|
||||
vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
|
||||
|
||||
import { GET } from './route'
|
||||
|
||||
describe('MCP OAuth start route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-2',
|
||||
userName: 'User Two',
|
||||
userEmail: 'user2@example.com',
|
||||
authType: 'session',
|
||||
})
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
dbChainMockFns.limit.mockResolvedValue([
|
||||
{
|
||||
id: 'server-1',
|
||||
name: 'Exa',
|
||||
url: 'https://mcp.exa.ai/mcp',
|
||||
workspaceId: 'workspace-1',
|
||||
authType: 'oauth',
|
||||
deletedAt: null,
|
||||
},
|
||||
])
|
||||
mcpOauthMockFns.mockGetOrCreateOauthRow.mockResolvedValue({
|
||||
id: 'oauth-row-1',
|
||||
mcpServerId: 'server-1',
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
clientInformation: null,
|
||||
tokens: null,
|
||||
codeVerifier: null,
|
||||
state: null,
|
||||
stateCreatedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined)
|
||||
mockMcpAuth.mockRejectedValue(new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize'))
|
||||
})
|
||||
|
||||
it('requires workspace write permission via MCP auth middleware', async () => {
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
|
||||
)
|
||||
|
||||
await GET(request)
|
||||
|
||||
expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith(
|
||||
'user-2',
|
||||
'workspace',
|
||||
'workspace-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a workspace-scoped OAuth row and stamps the latest authorizing user', async () => {
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
|
||||
)
|
||||
|
||||
const response = await GET(request)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toEqual({
|
||||
status: 'redirect',
|
||||
authorizationUrl: 'https://mcp.exa.ai/authorize',
|
||||
})
|
||||
expect(mcpOauthMockFns.mockGetOrCreateOauthRow).toHaveBeenCalledWith({
|
||||
mcpServerId: 'server-1',
|
||||
userId: 'user-2',
|
||||
workspaceId: 'workspace-1',
|
||||
})
|
||||
expect(mcpOauthMockFns.mockSetOauthRowUser).toHaveBeenCalledWith('oauth-row-1', 'user-2')
|
||||
})
|
||||
|
||||
it('rejects a second user starting OAuth while another authorization is active', async () => {
|
||||
mcpOauthMockFns.mockGetOrCreateOauthRow.mockResolvedValueOnce({
|
||||
id: 'oauth-row-1',
|
||||
mcpServerId: 'server-1',
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
clientInformation: null,
|
||||
tokens: null,
|
||||
codeVerifier: null,
|
||||
state: 'hashed-active-state',
|
||||
stateCreatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
|
||||
)
|
||||
|
||||
const response = await GET(request)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(body.error).toBe('OAuth authorization already in progress for this server')
|
||||
expect(mockMcpAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { auth as mcpAuth } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServers } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { startMcpOauthContract } from '@/lib/api/contracts/mcp'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import {
|
||||
assertSafeOauthServerUrl,
|
||||
getOrCreateOauthRow,
|
||||
loadPreregisteredClient,
|
||||
McpOauthInsecureUrlError,
|
||||
McpOauthRedirectRequired,
|
||||
SimMcpOauthProvider,
|
||||
setOauthRowUser,
|
||||
} from '@/lib/mcp/oauth'
|
||||
import { createMcpErrorResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('McpOauthStartAPI')
|
||||
const OAUTH_START_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withMcpAuth('write')(async (request: NextRequest, { userId, workspaceId }) => {
|
||||
try {
|
||||
const parsed = await parseRequest(startMcpOauthContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { serverId } = parsed.data.query
|
||||
|
||||
const [server] = await db
|
||||
.select()
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
eq(mcpServers.id, serverId),
|
||||
eq(mcpServers.workspaceId, workspaceId),
|
||||
isNull(mcpServers.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
if (server.authType !== 'oauth') {
|
||||
return createMcpErrorResponse(
|
||||
new Error(`Server authType is "${server.authType}", not oauth`),
|
||||
'Server is not configured for OAuth',
|
||||
400
|
||||
)
|
||||
}
|
||||
if (!server.url) {
|
||||
return createMcpErrorResponse(new Error('Server has no URL'), 'Missing server URL', 400)
|
||||
}
|
||||
try {
|
||||
assertSafeOauthServerUrl(server.url)
|
||||
} catch (e) {
|
||||
if (e instanceof McpOauthInsecureUrlError) {
|
||||
return createMcpErrorResponse(
|
||||
e,
|
||||
'MCP OAuth requires https (or http://localhost for development)',
|
||||
400
|
||||
)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
const row = await getOrCreateOauthRow({
|
||||
mcpServerId: server.id,
|
||||
userId,
|
||||
workspaceId,
|
||||
})
|
||||
const hasActiveFlow =
|
||||
!!row.state &&
|
||||
!!row.stateCreatedAt &&
|
||||
row.stateCreatedAt.getTime() > Date.now() - OAUTH_START_TTL_MS
|
||||
if (hasActiveFlow && row.userId && row.userId !== userId) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('OAuth authorization already in progress'),
|
||||
'OAuth authorization already in progress for this server',
|
||||
409
|
||||
)
|
||||
}
|
||||
if (row.userId !== userId) {
|
||||
await setOauthRowUser(row.id, userId)
|
||||
row.userId = userId
|
||||
}
|
||||
const preregistered = await loadPreregisteredClient(server.id)
|
||||
const provider = new SimMcpOauthProvider({ row, preregistered })
|
||||
|
||||
try {
|
||||
const result = await mcpAuth(provider, { serverUrl: server.url })
|
||||
if (result === 'AUTHORIZED') {
|
||||
return NextResponse.json({ status: 'already_authorized' })
|
||||
}
|
||||
return createMcpErrorResponse(
|
||||
new Error('Provider did not capture redirect URL'),
|
||||
'Failed to start OAuth flow',
|
||||
500
|
||||
)
|
||||
} catch (e) {
|
||||
if (e instanceof McpOauthRedirectRequired) {
|
||||
logger.info(`OAuth redirect for server ${serverId}`)
|
||||
return NextResponse.json({
|
||||
status: 'redirect',
|
||||
authorizationUrl: e.authorizationUrl,
|
||||
})
|
||||
}
|
||||
throw e
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error starting MCP OAuth flow:', error)
|
||||
return createMcpErrorResponse(toError(error), 'Failed to start OAuth flow', 500)
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -45,23 +45,25 @@ export const PATCH = withRouteHandler(
|
||||
}
|
||||
)
|
||||
|
||||
// Remove workspaceId from body to prevent it from being updated
|
||||
const { workspaceId: _, ...updateData } = body
|
||||
|
||||
const result = await performUpdateMcpServer({
|
||||
workspaceId,
|
||||
userId,
|
||||
actorName: userName,
|
||||
actorEmail: userEmail,
|
||||
serverId,
|
||||
name: updateData.name,
|
||||
description: updateData.description,
|
||||
transport: updateData.transport,
|
||||
url: updateData.url,
|
||||
headers: updateData.headers,
|
||||
timeout: updateData.timeout,
|
||||
retries: updateData.retries,
|
||||
enabled: updateData.enabled,
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
transport: body.transport,
|
||||
url: body.url,
|
||||
headers: body.headers,
|
||||
timeout: body.timeout,
|
||||
retries: body.retries,
|
||||
enabled: body.enabled,
|
||||
authType: body.authType,
|
||||
oauthClientId: body.oauthClientId || null,
|
||||
oauthClientIdProvided: body.oauthClientId !== undefined,
|
||||
oauthClientSecret: body.oauthClientSecret,
|
||||
oauthClientSecretProvided: body.oauthClientSecret !== undefined,
|
||||
request,
|
||||
})
|
||||
if (!result.success || !result.server) {
|
||||
@@ -75,7 +77,10 @@ export const PATCH = withRouteHandler(
|
||||
|
||||
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({ server: updatedServer })
|
||||
const { oauthClientSecret: _secret, ...rest } = updatedServer
|
||||
return createMcpSuccessResponse({
|
||||
server: { ...rest, hasOauthClientSecret: !!_secret },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating MCP server:`, error)
|
||||
return createMcpErrorResponse(toError(error), 'Failed to update MCP server', 500)
|
||||
|
||||
@@ -27,11 +27,16 @@ export const GET = withRouteHandler(
|
||||
try {
|
||||
logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`)
|
||||
|
||||
const servers = await db
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mcpServers)
|
||||
.where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)))
|
||||
|
||||
const servers = rows.map(({ oauthClientSecret: _secret, ...rest }) => ({
|
||||
...rest,
|
||||
hasOauthClientSecret: !!_secret,
|
||||
}))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}`
|
||||
)
|
||||
@@ -45,13 +50,6 @@ export const GET = withRouteHandler(
|
||||
|
||||
/**
|
||||
* POST - Register a new MCP server for the workspace (requires write permission)
|
||||
*
|
||||
* Uses deterministic server IDs based on URL hash to ensure that re-adding
|
||||
* the same server produces the same ID. This prevents "server not found" errors
|
||||
* when workflows reference the old server ID after delete/re-add cycles.
|
||||
*
|
||||
* If a server with the same ID already exists (same URL in same workspace),
|
||||
* it will be updated instead of creating a duplicate.
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
withMcpAuth('write')(
|
||||
@@ -96,6 +94,11 @@ export const POST = withRouteHandler(
|
||||
retries: body.retries,
|
||||
enabled: body.enabled,
|
||||
source,
|
||||
authType: body.authType,
|
||||
oauthClientId: body.oauthClientId || null,
|
||||
oauthClientIdProvided: body.oauthClientId !== undefined,
|
||||
oauthClientSecret: body.oauthClientSecret,
|
||||
oauthClientSecretProvided: body.oauthClientSecret !== undefined,
|
||||
request,
|
||||
})
|
||||
if (!result.success || !result.serverId) {
|
||||
@@ -112,8 +115,8 @@ export const POST = withRouteHandler(
|
||||
|
||||
return createMcpSuccessResponse(
|
||||
result.updated
|
||||
? { serverId: result.serverId, updated: true }
|
||||
: { serverId: result.serverId },
|
||||
? { serverId: result.serverId, updated: true, authType: result.authType }
|
||||
: { serverId: result.serverId, authType: result.authType },
|
||||
result.updated ? 200 : 201
|
||||
)
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { mcpToolDiscoveryQuerySchema, refreshMcpToolsBodySchema } from '@/lib/api/contracts/mcp'
|
||||
@@ -5,7 +6,7 @@ import { validationErrorResponse } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import type { McpToolDiscoveryResponse } from '@/lib/mcp/types'
|
||||
import { McpOauthAuthorizationRequiredError, type McpToolDiscoveryResponse } from '@/lib/mcp/types'
|
||||
import { categorizeError, createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('McpToolDiscoveryAPI')
|
||||
@@ -46,6 +47,12 @@ export const GET = withRouteHandler(
|
||||
)
|
||||
return createMcpSuccessResponse(responseData)
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof McpOauthAuthorizationRequiredError ||
|
||||
error instanceof UnauthorizedError
|
||||
) {
|
||||
return createMcpErrorResponse(error, 'OAuth re-authorization required', 401)
|
||||
}
|
||||
logger.error(`[${requestId}] Error discovering MCP tools:`, error)
|
||||
const { message, status } = categorizeError(error)
|
||||
return createMcpErrorResponse(new Error(message), 'Failed to discover MCP tools', status)
|
||||
@@ -100,6 +107,12 @@ export const POST = withRouteHandler(
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof McpOauthAuthorizationRequiredError ||
|
||||
error instanceof UnauthorizedError
|
||||
) {
|
||||
return createMcpErrorResponse(error, 'OAuth re-authorization required', 401)
|
||||
}
|
||||
logger.error(`[${requestId}] Error refreshing tool discovery:`, error)
|
||||
const { message, status } = categorizeError(error)
|
||||
return createMcpErrorResponse(new Error(message), 'Failed to refresh tool discovery', status)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { mcpToolExecutionBodySchema } from '@/lib/api/contracts/mcp'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
|
||||
import { getExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
@@ -7,8 +9,14 @@ import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import type { McpTool, McpToolCall, McpToolResult } from '@/lib/mcp/types'
|
||||
import {
|
||||
McpOauthAuthorizationRequiredError,
|
||||
type McpTool,
|
||||
type McpToolCall,
|
||||
type McpToolResult,
|
||||
} from '@/lib/mcp/types'
|
||||
import { categorizeError, createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import {
|
||||
assertPermissionsAllowed,
|
||||
@@ -43,6 +51,7 @@ function hasType(prop: unknown): prop is SchemaProperty {
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||
let serverId: string | undefined
|
||||
try {
|
||||
const rawBody = getParsedBody(request) ?? (await request.json())
|
||||
const parsedBody = mcpToolExecutionBodySchema.safeParse(rawBody)
|
||||
@@ -63,7 +72,8 @@ export const POST = withRouteHandler(
|
||||
userId: userId,
|
||||
})
|
||||
|
||||
const { serverId, toolName, arguments: rawArgs } = body
|
||||
const { toolName, arguments: rawArgs } = body
|
||||
serverId = body.serverId
|
||||
const args = rawArgs || {}
|
||||
|
||||
try {
|
||||
@@ -101,7 +111,8 @@ export const POST = withRouteHandler(
|
||||
|
||||
if (tool.inputSchema?.properties) {
|
||||
for (const [paramName, paramSchema] of Object.entries(tool.inputSchema.properties)) {
|
||||
const schema = paramSchema as any
|
||||
const schema = hasType(paramSchema) ? paramSchema : null
|
||||
if (!schema) continue
|
||||
const value = args[paramName]
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
@@ -185,12 +196,18 @@ export const POST = withRouteHandler(
|
||||
extraHeaders[SIM_VIA_HEADER] = simViaHeader
|
||||
}
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
const result = await Promise.race([
|
||||
mcpService.executeTool(userId, serverId, toolCall, workspaceId, extraHeaders),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Tool execution timeout')), executionTimeout)
|
||||
),
|
||||
])
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(
|
||||
() => reject(new Error('Tool execution timeout')),
|
||||
executionTimeout
|
||||
)
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
|
||||
})
|
||||
|
||||
const transformedResult = transformToolResult(result)
|
||||
|
||||
@@ -218,6 +235,27 @@ export const POST = withRouteHandler(
|
||||
|
||||
return createMcpSuccessResponse(transformedResult)
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof McpOauthAuthorizationRequiredError ||
|
||||
error instanceof McpOauthRedirectRequired ||
|
||||
error instanceof UnauthorizedError
|
||||
) {
|
||||
const errorServerId =
|
||||
error instanceof McpOauthAuthorizationRequiredError ? error.serverId : serverId
|
||||
logger.warn(`[${requestId}] OAuth re-authorization required for MCP tool execution`, {
|
||||
serverId: errorServerId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'OAuth re-authorization required',
|
||||
code: 'reauth_required',
|
||||
serverId: errorServerId,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error executing MCP tool:`, error)
|
||||
|
||||
const { message, status } = categorizeError(error)
|
||||
|
||||
+28
-2
@@ -32,6 +32,7 @@ import {
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import {
|
||||
DEFAULT_BLOCK_COLOR,
|
||||
formatCostAmount,
|
||||
formatTokenCount,
|
||||
formatTps,
|
||||
@@ -120,6 +121,21 @@ function iconColorClass(bgColor: string): string {
|
||||
return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white'
|
||||
}
|
||||
|
||||
/**
|
||||
* Near-black bgColors disappear against the dark-mode surface (--bg: #1b1b1b).
|
||||
* Below the luminance threshold we fall back to the neutral block color used
|
||||
* for blocks with no distinct identity; everything brighter passes through.
|
||||
*/
|
||||
function adjustBgForContrast(bgColor: string): string {
|
||||
const hex = bgColor.replace('#', '')
|
||||
if (hex.length !== 6) return bgColor
|
||||
const r = Number.parseInt(hex.slice(0, 2), 16)
|
||||
const g = Number.parseInt(hex.slice(2, 4), 16)
|
||||
const b = Number.parseInt(hex.slice(4, 6), 16)
|
||||
if (r * 299 + g * 587 + b * 114 < 30_000) return DEFAULT_BLOCK_COLOR
|
||||
return bgColor
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the visible (expanded) span tree into a linear list for keyboard
|
||||
* navigation, carrying depth, the chain of parent ids for indent drawing, and
|
||||
@@ -268,7 +284,12 @@ const TraceTreeRow = memo(function TraceTreeRow({
|
||||
const duration = span.duration || endMs - startMs
|
||||
const isRootWorkflow = depth === 0 && span.type?.toLowerCase() === 'workflow'
|
||||
const hasError = isRootWorkflow ? hasUnhandledErrorInTree(span) : hasErrorInTree(span)
|
||||
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name, span.provider)
|
||||
const { icon: BlockIcon, bgColor: rawBgColor } = getBlockIconAndColor(
|
||||
span.type,
|
||||
span.name,
|
||||
span.provider
|
||||
)
|
||||
const bgColor = adjustBgForContrast(rawBgColor)
|
||||
const nameMatches = !!matchQuery && spanMatchesQuery(span, matchQuery)
|
||||
|
||||
const offsetMs = runStartMs > 0 ? Math.max(0, startMs - runStartMs) : 0
|
||||
@@ -651,7 +672,12 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
|
||||
}
|
||||
|
||||
const duration = span.duration || parseTime(span.endTime) - parseTime(span.startTime)
|
||||
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name, span.provider)
|
||||
const { icon: BlockIcon, bgColor: rawBgColor } = getBlockIconAndColor(
|
||||
span.type,
|
||||
span.name,
|
||||
span.provider
|
||||
)
|
||||
const bgColor = adjustBgForContrast(rawBgColor)
|
||||
const isRootWorkflow = span.type?.toLowerCase() === 'workflow'
|
||||
const hasError = isRootWorkflow ? hasUnhandledErrorInTree(span) : hasErrorInTree(span)
|
||||
const isDirectError = span.status === 'error'
|
||||
|
||||
@@ -8,6 +8,20 @@ import { getBlock, getBlockByToolName } from '@/blocks'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
import { normalizeToolId } from '@/tools/normalize'
|
||||
|
||||
/**
|
||||
* Extracts the bare tool name from an MCP tool id of the form
|
||||
* `mcp-{serverId}-{toolName}`. Returns null when the id is not MCP-shaped.
|
||||
* Kept local to avoid importing from `@/lib/mcp/utils`, which pulls in
|
||||
* `next/server` and breaks client bundles.
|
||||
*/
|
||||
function tryParseMcpToolName(toolId: string): string | null {
|
||||
if (!toolId.startsWith('mcp-')) return null
|
||||
const parts = toolId.split('-')
|
||||
if (parts.length < 3) return null
|
||||
const toolName = parts.slice(2).join('-')
|
||||
return toolName.length > 0 ? toolName : null
|
||||
}
|
||||
|
||||
export const DEFAULT_BLOCK_COLOR = '#6b7280'
|
||||
|
||||
export interface BlockIconAndColor {
|
||||
@@ -41,6 +55,10 @@ export function getBlockIconAndColor(
|
||||
): BlockIconAndColor {
|
||||
const lowerType = type.toLowerCase()
|
||||
if (lowerType === 'tool' && toolName) {
|
||||
if (tryParseMcpToolName(toolName)) {
|
||||
const mcpBlock = getBlock('mcp')
|
||||
if (mcpBlock) return { icon: mcpBlock.icon, bgColor: mcpBlock.bgColor }
|
||||
}
|
||||
const normalized = normalizeToolId(toolName)
|
||||
if (normalized === 'load_skill') return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' }
|
||||
const toolBlock = getBlockByToolName(normalized)
|
||||
@@ -90,7 +108,11 @@ export function formatTps(
|
||||
}
|
||||
|
||||
export function getDisplayName(span: TraceSpan): string {
|
||||
if (span.type?.toLowerCase() === 'tool') return normalizeToolId(span.name)
|
||||
if (span.type?.toLowerCase() === 'tool') {
|
||||
const mcpToolName = tryParseMcpToolName(span.name)
|
||||
if (mcpToolName) return mcpToolName
|
||||
return normalizeToolId(span.name)
|
||||
}
|
||||
return span.name
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ interface FormFieldProps {
|
||||
export function FormField({ label, children, optional }: FormFieldProps) {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Label className='w-[100px] shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||
<Label className='w-[116px] shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{label}
|
||||
{optional && (
|
||||
<span className='ml-1 font-normal text-[var(--text-muted)] text-xs'>(optional)</span>
|
||||
|
||||
+216
-120
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Input as EmcnInput,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
SecretInput,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -37,6 +39,9 @@ interface McpServerFormData {
|
||||
url?: string
|
||||
timeout?: number
|
||||
headers?: HeaderEntry[]
|
||||
oauthClientId?: string
|
||||
oauthClientSecret?: string
|
||||
hasOauthClientSecret?: boolean
|
||||
}
|
||||
|
||||
export interface McpServerFormConfig {
|
||||
@@ -45,6 +50,8 @@ export interface McpServerFormConfig {
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
timeout: number
|
||||
oauthClientId?: string
|
||||
oauthClientSecret?: string
|
||||
}
|
||||
|
||||
export interface McpServerFormModalProps {
|
||||
@@ -324,6 +331,9 @@ export function McpServerFormModal({
|
||||
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
|
||||
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [oauthClientSecretTouched, setOauthClientSecretTouched] = useState(false)
|
||||
|
||||
const [prevOpen, setPrevOpen] = useState(false)
|
||||
if (open && !prevOpen) {
|
||||
const data = initialData ?? DEFAULT_FORM_DATA
|
||||
@@ -339,6 +349,8 @@ export function McpServerFormModal({
|
||||
setActiveHeaderIndex(null)
|
||||
setUrlScrollLeft(0)
|
||||
setHeaderScrollLeft({})
|
||||
setShowAdvanced(!!(data.oauthClientId || data.oauthClientSecret || data.hasOauthClientSecret))
|
||||
setOauthClientSecretTouched(false)
|
||||
}
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open)
|
||||
@@ -352,76 +364,72 @@ export function McpServerFormModal({
|
||||
}
|
||||
}, [open, clearTestResult])
|
||||
|
||||
const resetEnvVarState = useCallback(() => {
|
||||
const resetEnvVarState = () => {
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
}, [])
|
||||
}
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(field: InputFieldType, value: string, headerIndex?: number) => {
|
||||
const input = document.activeElement as HTMLInputElement
|
||||
const pos = input?.selectionStart || 0
|
||||
setCursorPosition(pos)
|
||||
const handleInputChange = (field: InputFieldType, value: string, headerIndex?: number) => {
|
||||
const input = document.activeElement as HTMLInputElement
|
||||
const pos = input?.selectionStart || 0
|
||||
setCursorPosition(pos)
|
||||
|
||||
if (testResult) clearTestResult()
|
||||
if (submitError) setSubmitError(null)
|
||||
if (testResult) clearTestResult()
|
||||
if (submitError) setSubmitError(null)
|
||||
|
||||
const envVarTrigger = checkEnvVarTrigger(value, pos)
|
||||
setShowEnvVars(envVarTrigger.show)
|
||||
setEnvSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
|
||||
const envVarTrigger = checkEnvVarTrigger(value, pos)
|
||||
setShowEnvVars(envVarTrigger.show)
|
||||
setEnvSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
|
||||
|
||||
if (envVarTrigger.show) {
|
||||
setActiveInputField(field)
|
||||
setActiveHeaderIndex(headerIndex ?? null)
|
||||
} else {
|
||||
resetEnvVarState()
|
||||
}
|
||||
|
||||
if (field === 'url') {
|
||||
setFormData((prev) => ({ ...prev, url: value }))
|
||||
} else if (headerIndex !== undefined) {
|
||||
const headerField = field === 'header-key' ? 'key' : 'value'
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
headers: updateHeadersArray(prev.headers || [], headerIndex, headerField, value),
|
||||
}))
|
||||
}
|
||||
},
|
||||
[testResult, clearTestResult, submitError, resetEnvVarState]
|
||||
)
|
||||
|
||||
const handleEnvVarSelect = useCallback(
|
||||
(newValue: string) => {
|
||||
if (activeInputField === 'url') {
|
||||
setFormData((prev) => ({ ...prev, url: newValue }))
|
||||
} else if (activeHeaderIndex !== null) {
|
||||
const field = activeInputField === 'header-key' ? 'key' : 'value'
|
||||
const processedValue = field === 'key' ? newValue.replace(/[{}]/g, '') : newValue
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
headers: updateHeadersArray(prev.headers || [], activeHeaderIndex, field, processedValue),
|
||||
}))
|
||||
}
|
||||
if (envVarTrigger.show) {
|
||||
setActiveInputField(field)
|
||||
setActiveHeaderIndex(headerIndex ?? null)
|
||||
} else {
|
||||
resetEnvVarState()
|
||||
},
|
||||
[activeInputField, activeHeaderIndex, resetEnvVarState]
|
||||
)
|
||||
}
|
||||
|
||||
const handleHeaderScroll = useCallback((key: string, sl: number) => {
|
||||
if (field === 'url') {
|
||||
setFormData((prev) => ({ ...prev, url: value }))
|
||||
} else if (headerIndex !== undefined) {
|
||||
const headerField = field === 'header-key' ? 'key' : 'value'
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
headers: updateHeadersArray(prev.headers || [], headerIndex, headerField, value),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnvVarSelect = (newValue: string) => {
|
||||
if (activeInputField === 'url') {
|
||||
setFormData((prev) => ({ ...prev, url: newValue }))
|
||||
} else if (activeHeaderIndex !== null) {
|
||||
const field = activeInputField === 'header-key' ? 'key' : 'value'
|
||||
const processedValue = field === 'key' ? newValue.replace(/[{}]/g, '') : newValue
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
headers: updateHeadersArray(prev.headers || [], activeHeaderIndex, field, processedValue),
|
||||
}))
|
||||
}
|
||||
resetEnvVarState()
|
||||
}
|
||||
|
||||
const handleHeaderScroll = (key: string, sl: number) => {
|
||||
setHeaderScrollLeft((prev) => ({ ...prev, [key]: sl }))
|
||||
}, [])
|
||||
}
|
||||
|
||||
const isDomainBlocked =
|
||||
!!formData.url?.trim() && !isDomainAllowed(formData.url, allowedMcpDomains)
|
||||
const isFormValid = !!(formData.name.trim() && formData.url?.trim())
|
||||
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
const computeHasChanges = (): boolean => {
|
||||
if (mode === 'add') return true
|
||||
if (formData.name !== originalData.name) return true
|
||||
if (formData.url !== originalData.url) return true
|
||||
if (formData.transport !== originalData.transport) return true
|
||||
if ((formData.oauthClientId || '') !== (originalData.oauthClientId || '')) return true
|
||||
if (oauthClientSecretTouched) return true
|
||||
const currentHeaders = formData.headers || []
|
||||
const origHeaders = originalData.headers || []
|
||||
if (currentHeaders.length !== origHeaders.length) return true
|
||||
@@ -433,49 +441,49 @@ export function McpServerFormModal({
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [mode, formData, originalData])
|
||||
}
|
||||
const hasChanges = computeHasChanges()
|
||||
|
||||
const parseJsonConfig = useCallback(
|
||||
(json: string): { name: string; url: string; headers: Record<string, string> } | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
const parseJsonConfig = (
|
||||
json: string
|
||||
): { name: string; url: string; headers: Record<string, string> } | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
|
||||
if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
|
||||
const entries = Object.entries(parsed.mcpServers)
|
||||
if (entries.length === 0) {
|
||||
setJsonError('No servers found in mcpServers')
|
||||
return null
|
||||
}
|
||||
if (entries.length > 1) {
|
||||
setJsonError(
|
||||
`Only the first server ("${entries[0][0]}") will be imported. Paste each config separately to add others.`
|
||||
)
|
||||
}
|
||||
const [name, config] = entries[0] as [string, Record<string, unknown>]
|
||||
if (!config.url || typeof config.url !== 'string') {
|
||||
setJsonError('Server config must include a "url" field')
|
||||
return null
|
||||
}
|
||||
if (entries.length <= 1) setJsonError(null)
|
||||
return { name, url: config.url, headers: extractStringHeaders(config.headers) }
|
||||
if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
|
||||
const entries = Object.entries(parsed.mcpServers)
|
||||
if (entries.length === 0) {
|
||||
setJsonError('No servers found in mcpServers')
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsed.url && typeof parsed.url === 'string') {
|
||||
setJsonError(null)
|
||||
return { name: '', url: parsed.url, headers: extractStringHeaders(parsed.headers) }
|
||||
if (entries.length > 1) {
|
||||
setJsonError(
|
||||
`Only the first server ("${entries[0][0]}") will be imported. Paste each config separately to add others.`
|
||||
)
|
||||
}
|
||||
|
||||
setJsonError('JSON must contain "mcpServers" or a "url" field')
|
||||
return null
|
||||
} catch {
|
||||
setJsonError('Invalid JSON')
|
||||
return null
|
||||
const [name, config] = entries[0] as [string, Record<string, unknown>]
|
||||
if (!config.url || typeof config.url !== 'string') {
|
||||
setJsonError('Server config must include a "url" field')
|
||||
return null
|
||||
}
|
||||
if (entries.length <= 1) setJsonError(null)
|
||||
return { name, url: config.url, headers: extractStringHeaders(config.headers) }
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (parsed.url && typeof parsed.url === 'string') {
|
||||
setJsonError(null)
|
||||
return { name: '', url: parsed.url, headers: extractStringHeaders(parsed.headers) }
|
||||
}
|
||||
|
||||
setJsonError('JSON must contain "mcpServers" or a "url" field')
|
||||
return null
|
||||
} catch {
|
||||
setJsonError('Invalid JSON')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!isFormValid) return
|
||||
|
||||
await testConnection({
|
||||
@@ -486,15 +494,20 @@ export function McpServerFormModal({
|
||||
timeout: formData.timeout,
|
||||
workspaceId,
|
||||
})
|
||||
}, [formData, isFormValid, testConnection, workspaceId])
|
||||
}
|
||||
|
||||
const handleSubmitForm = useCallback(async () => {
|
||||
const handleSubmitForm = async () => {
|
||||
if (!isFormValid || isDomainBlocked) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setSubmitError(null)
|
||||
try {
|
||||
const headers = headersToRecord(formData.headers)
|
||||
const oauthClientId = formData.oauthClientId?.trim()
|
||||
const oauthClientSecret = formData.oauthClientSecret?.trim()
|
||||
const originalClientId = (originalData.oauthClientId || '').trim()
|
||||
const oauthClientIdChanged = (oauthClientId || '') !== originalClientId
|
||||
|
||||
const connectionResult = await testConnection({
|
||||
name: formData.name,
|
||||
transport: formData.transport,
|
||||
@@ -505,10 +518,18 @@ export function McpServerFormModal({
|
||||
})
|
||||
|
||||
if (!connectionResult.success) {
|
||||
setSubmitError(
|
||||
connectionResult.error || 'Connection test failed. Please check the URL and try again.'
|
||||
)
|
||||
return
|
||||
const errorText = (connectionResult.error || '').toLowerCase()
|
||||
const looksLikeAuthRequired =
|
||||
/\b401\b/.test(errorText) ||
|
||||
errorText.includes('unauthorized') ||
|
||||
errorText.includes('oauth') ||
|
||||
errorText.includes('authentication')
|
||||
if (!looksLikeAuthRequired) {
|
||||
setSubmitError(
|
||||
connectionResult.error || 'Connection test failed. Please check the URL and try again.'
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await onSubmit({
|
||||
@@ -517,19 +538,30 @@ export function McpServerFormModal({
|
||||
url: formData.url!,
|
||||
headers,
|
||||
timeout: formData.timeout || 30000,
|
||||
oauthClientId:
|
||||
mode === 'edit'
|
||||
? oauthClientIdChanged
|
||||
? (oauthClientId ?? '')
|
||||
: undefined
|
||||
: oauthClientId || undefined,
|
||||
oauthClientSecret:
|
||||
mode === 'edit'
|
||||
? oauthClientSecretTouched
|
||||
? (oauthClientSecret ?? '')
|
||||
: undefined
|
||||
: oauthClientSecret || undefined,
|
||||
})
|
||||
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Failed to save server')
|
||||
setSubmitError(message)
|
||||
setSubmitError(getErrorMessage(error, 'Failed to save server'))
|
||||
logger.error('Failed to save MCP server:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [formData, isFormValid, isDomainBlocked, testConnection, workspaceId, onSubmit, onOpenChange])
|
||||
}
|
||||
|
||||
const handleSubmitJson = useCallback(async () => {
|
||||
const handleSubmitJson = async () => {
|
||||
const config = parseJsonConfig(jsonInput)
|
||||
if (!config) return
|
||||
|
||||
@@ -572,21 +604,12 @@ export function McpServerFormModal({
|
||||
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Failed to save server')
|
||||
setSubmitError(message)
|
||||
setSubmitError(getErrorMessage(error, 'Failed to save server'))
|
||||
logger.error('Failed to save MCP server from JSON:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [
|
||||
jsonInput,
|
||||
parseJsonConfig,
|
||||
allowedMcpDomains,
|
||||
testConnection,
|
||||
workspaceId,
|
||||
onSubmit,
|
||||
onOpenChange,
|
||||
])
|
||||
}
|
||||
|
||||
const isSubmitDisabled =
|
||||
isSubmitting || !isFormValid || isDomainBlocked || (mode === 'edit' && !hasChanges)
|
||||
@@ -596,9 +619,9 @@ export function McpServerFormModal({
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalHeader>{title}</ModalHeader>
|
||||
<ModalBody>
|
||||
<ModalContent size='lg' className='max-h-[82vh]'>
|
||||
<ModalHeader className='border-[var(--border)] border-b pb-3'>{title}</ModalHeader>
|
||||
<ModalBody className='min-h-0 px-4 pt-4 pb-4'>
|
||||
<ModalDescription className='sr-only'>
|
||||
Configure an MCP server by entering the server URL and optional headers, or paste a JSON
|
||||
configuration.
|
||||
@@ -614,12 +637,28 @@ export function McpServerFormModal({
|
||||
if (testResult) clearTestResult()
|
||||
if (submitError) setSubmitError(null)
|
||||
}}
|
||||
className='min-h-[200px] font-mono text-small'
|
||||
className='min-h-[280px] font-mono text-small leading-5'
|
||||
/>
|
||||
{jsonError && <p className='text-[var(--text-error)] text-caption'>{jsonError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
type='password'
|
||||
name='fakepasswordremembered'
|
||||
autoComplete='current-password'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<FormField label='Server Name'>
|
||||
<EmcnInput
|
||||
placeholder='e.g., My MCP Server'
|
||||
@@ -658,8 +697,7 @@ export function McpServerFormModal({
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-small'>Headers</span>
|
||||
<FormField label='Headers'>
|
||||
<div className='flex max-h-[140px] flex-col gap-2 overflow-y-auto'>
|
||||
{(formData.headers || []).map((header, index) => (
|
||||
<HeaderRow
|
||||
@@ -681,13 +719,71 @@ export function McpServerFormModal({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className='mt-1 gap-1 self-start px-0 py-0 text-small'
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<ChevronDown className='size-[14px]' />
|
||||
) : (
|
||||
<ChevronRight className='size-[14px]' />
|
||||
)}
|
||||
Advanced settings
|
||||
</Button>
|
||||
{showAdvanced && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<FormField label='Client ID'>
|
||||
<EmcnInput
|
||||
placeholder='OAuth Client ID (optional)'
|
||||
value={formData.oauthClientId || ''}
|
||||
name='mcp_oauth_client_id'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
onChange={(e) => {
|
||||
if (testResult) clearTestResult()
|
||||
if (submitError) setSubmitError(null)
|
||||
setFormData((prev) => ({ ...prev, oauthClientId: e.target.value }))
|
||||
}}
|
||||
className='h-9'
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label='Client Secret'>
|
||||
<SecretInput
|
||||
placeholder='OAuth Client Secret (optional)'
|
||||
value={formData.oauthClientSecret || ''}
|
||||
name='mcp_oauth_client_secret'
|
||||
autoComplete='new-password'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
onChange={(value) => {
|
||||
if (testResult) clearTestResult()
|
||||
if (submitError) setSubmitError(null)
|
||||
setOauthClientSecretTouched(value.length > 0)
|
||||
setFormData((prev) => ({ ...prev, oauthClientSecret: value }))
|
||||
}}
|
||||
className='h-9'
|
||||
/>
|
||||
</FormField>
|
||||
<p className='text-[var(--text-tertiary)] text-caption'>
|
||||
Only needed for servers that don't support automatic client registration.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<ModalFooter className='flex-col items-stretch gap-2'>
|
||||
{submitError && (
|
||||
<p className='mb-2 w-full text-[var(--text-error)] text-small'>{submitError}</p>
|
||||
<p className='w-full text-[var(--text-error)] text-small'>{submitError}</p>
|
||||
)}
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
@@ -716,7 +812,7 @@ export function McpServerFormModal({
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button variant='ghost' onClick={() => onOpenChange(false)}>
|
||||
<Button variant='default' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{formMode === 'json' ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { ChevronDown, Plus, Search } from 'lucide-react'
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type McpToolIssue,
|
||||
} from '@/lib/mcp/tool-validation'
|
||||
import type { McpTransport } from '@/lib/mcp/types'
|
||||
import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup'
|
||||
import {
|
||||
type McpServer,
|
||||
type McpTool,
|
||||
@@ -102,7 +103,10 @@ function ServerListItem({
|
||||
<span className='text-[var(--text-secondary)] text-sm'>({transportLabel})</span>
|
||||
</div>
|
||||
<p
|
||||
className={`truncate text-sm ${isError ? 'text-red-500 dark:text-red-400' : 'text-[var(--text-muted)]'}`}
|
||||
className={cn(
|
||||
'truncate text-sm',
|
||||
isError ? 'text-[var(--text-error)]' : 'text-[var(--text-muted)]'
|
||||
)}
|
||||
>
|
||||
{isRefreshing
|
||||
? 'Refreshing...'
|
||||
@@ -123,14 +127,29 @@ function ServerListItem({
|
||||
)
|
||||
}
|
||||
|
||||
function buildEditInitialData(server: McpServer) {
|
||||
const entries: { key: string; value: string }[] = server.headers
|
||||
? Object.entries(server.headers).map(([key, value]) => ({ key, value }))
|
||||
: []
|
||||
if (entries.length === 0) entries.push({ key: '', value: '' })
|
||||
const last = entries[entries.length - 1]
|
||||
if (last.key !== '' || last.value !== '') entries.push({ key: '', value: '' })
|
||||
|
||||
return {
|
||||
name: server.name || '',
|
||||
transport: (server.transport as McpTransport) || 'streamable-http',
|
||||
url: server.url || '',
|
||||
timeout: 30000,
|
||||
headers: entries,
|
||||
oauthClientId: server.oauthClientId || undefined,
|
||||
hasOauthClientSecret: server.hasOauthClientSecret === true,
|
||||
}
|
||||
}
|
||||
|
||||
interface MCPProps {
|
||||
initialServerId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Settings component for managing Model Context Protocol servers.
|
||||
* Handles server CRUD operations, connection testing, and environment variable integration.
|
||||
*/
|
||||
export function MCP({ initialServerId }: MCPProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -147,7 +166,8 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
isFetching: toolsFetching,
|
||||
} = useMcpToolsQuery(workspaceId)
|
||||
const { data: storedTools = [], refetch: refetchStoredTools } = useStoredMcpTools(workspaceId)
|
||||
const forceRefreshTools = useForceRefreshMcpTools()
|
||||
const forceRefreshToolsMutation = useForceRefreshMcpTools()
|
||||
const forceRefreshTools = forceRefreshToolsMutation.mutate
|
||||
const createServerMutation = useCreateMcpServer()
|
||||
const deleteServerMutation = useDeleteMcpServer()
|
||||
const refreshServerMutation = useRefreshMcpServer()
|
||||
@@ -156,23 +176,16 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
|
||||
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [editInitialData, setEditInitialData] = useState<
|
||||
| {
|
||||
name: string
|
||||
transport: McpTransport
|
||||
url?: string
|
||||
timeout?: number
|
||||
headers?: { key: string; value: string }[]
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
const [editingServerId, setEditingServerId] = useState<string | null>(null)
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [deletingServers, setDeletingServers] = useState<Set<string>>(() => new Set())
|
||||
const { connectingServers: connectingOauthServers, startOauthForServer } = useMcpOauthPopup({
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [serverToDelete, setServerToDelete] = useState<{ id: string; name: string } | null>(null)
|
||||
const [serverToDeleteId, setServerToDeleteId] = useState<string | null>(null)
|
||||
const showDeleteDialog = serverToDeleteId !== null
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId ?? null)
|
||||
|
||||
@@ -185,28 +198,23 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [refreshingServers, setRefreshingServers] = useState<
|
||||
Record<string, { status: 'refreshing' | 'refreshed'; workflowsUpdated?: number }>
|
||||
>({})
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const handleRemoveServer = useCallback((serverId: string, serverName: string) => {
|
||||
setServerToDelete({ id: serverId, name: serverName })
|
||||
setShowDeleteDialog(true)
|
||||
}, [])
|
||||
const handleRemoveServer = (serverId: string) => {
|
||||
setServerToDeleteId(serverId)
|
||||
}
|
||||
|
||||
const confirmDeleteServer = useCallback(async () => {
|
||||
if (!serverToDelete) return
|
||||
const confirmDeleteServer = async () => {
|
||||
if (!serverToDeleteId) return
|
||||
|
||||
setShowDeleteDialog(false)
|
||||
const { id: serverId, name: serverName } = serverToDelete
|
||||
setServerToDelete(null)
|
||||
const serverId = serverToDeleteId
|
||||
setServerToDeleteId(null)
|
||||
|
||||
setDeletingServers((prev) => new Set(prev).add(serverId))
|
||||
|
||||
try {
|
||||
await deleteServerMutation.mutateAsync({ workspaceId, serverId })
|
||||
logger.info(`Removed MCP server: ${serverName}`)
|
||||
logger.info(`Removed MCP server: ${serverId}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove MCP server:', error)
|
||||
} finally {
|
||||
@@ -216,43 +224,36 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
}, [serverToDelete, deleteServerMutation, workspaceId])
|
||||
}
|
||||
|
||||
const toolsByServer = useMemo(() => {
|
||||
return (mcpToolsData || []).reduce(
|
||||
(acc, tool) => {
|
||||
if (!tool?.serverId) return acc
|
||||
if (!acc[tool.serverId]) {
|
||||
acc[tool.serverId] = []
|
||||
}
|
||||
acc[tool.serverId].push(tool)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, typeof mcpToolsData>
|
||||
)
|
||||
}, [mcpToolsData])
|
||||
|
||||
const filteredServers = useMemo(() => {
|
||||
return (servers || []).filter((server) =>
|
||||
server.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}, [servers, searchTerm])
|
||||
|
||||
const handleViewDetails = useCallback(
|
||||
(serverId: string) => {
|
||||
setSelectedServerId(serverId)
|
||||
forceRefreshTools(workspaceId)
|
||||
refetchStoredTools()
|
||||
const toolsByServer = (mcpToolsData || []).reduce(
|
||||
(acc, tool) => {
|
||||
if (!tool?.serverId) return acc
|
||||
if (!acc[tool.serverId]) {
|
||||
acc[tool.serverId] = []
|
||||
}
|
||||
acc[tool.serverId].push(tool)
|
||||
return acc
|
||||
},
|
||||
[workspaceId, forceRefreshTools, refetchStoredTools]
|
||||
{} as Record<string, typeof mcpToolsData>
|
||||
)
|
||||
|
||||
const handleBackToList = useCallback(() => {
|
||||
const filteredServers = (servers || []).filter((server) =>
|
||||
server.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
const handleViewDetails = (serverId: string) => {
|
||||
setSelectedServerId(serverId)
|
||||
forceRefreshTools(workspaceId)
|
||||
refetchStoredTools()
|
||||
}
|
||||
|
||||
const handleBackToList = () => {
|
||||
setSelectedServerId(null)
|
||||
setExpandedTools(new Set())
|
||||
}, [])
|
||||
}
|
||||
|
||||
const toggleToolExpanded = useCallback((toolName: string) => {
|
||||
const toggleToolExpanded = (toolName: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(toolName)) {
|
||||
@@ -262,131 +263,109 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
const handleRefreshServer = useCallback(
|
||||
async (serverId: string) => {
|
||||
try {
|
||||
setRefreshingServers((prev) => ({ ...prev, [serverId]: { status: 'refreshing' } }))
|
||||
const result = await refreshServerMutation.mutateAsync({ workspaceId, serverId })
|
||||
logger.info(
|
||||
`Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}`
|
||||
)
|
||||
const handleRefreshServer = async (serverId: string) => {
|
||||
try {
|
||||
const result = await refreshServerMutation.mutateAsync({ workspaceId, serverId })
|
||||
logger.info(
|
||||
`Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}`
|
||||
)
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
|
||||
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)
|
||||
try {
|
||||
const { data: workflowData } = await requestJson(getWorkflowStateContract, {
|
||||
params: { id: activeWorkflowId },
|
||||
})
|
||||
if (workflowData?.state?.blocks) {
|
||||
useSubBlockStore
|
||||
.getState()
|
||||
.initializeFromWorkflow(
|
||||
activeWorkflowId,
|
||||
workflowData.state.blocks as Record<string, BlockState>
|
||||
)
|
||||
}
|
||||
} catch (reloadError) {
|
||||
logger.warn('Failed to reload workflow subblock values:', reloadError)
|
||||
}
|
||||
}
|
||||
|
||||
setRefreshingServers((prev) => ({
|
||||
...prev,
|
||||
[serverId]: { status: 'refreshed', workflowsUpdated: result.workflowsUpdated },
|
||||
}))
|
||||
setTimeout(() => {
|
||||
setRefreshingServers((prev) => {
|
||||
const newState = { ...prev }
|
||||
delete newState[serverId]
|
||||
return newState
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
|
||||
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)
|
||||
try {
|
||||
const { data: workflowData } = await requestJson(getWorkflowStateContract, {
|
||||
params: { id: activeWorkflowId },
|
||||
})
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh MCP server:', error)
|
||||
setRefreshingServers((prev) => {
|
||||
const newState = { ...prev }
|
||||
delete newState[serverId]
|
||||
return newState
|
||||
})
|
||||
if (workflowData?.state?.blocks) {
|
||||
useSubBlockStore
|
||||
.getState()
|
||||
.initializeFromWorkflow(
|
||||
activeWorkflowId,
|
||||
workflowData.state.blocks as Record<string, BlockState>
|
||||
)
|
||||
}
|
||||
} catch (reloadError) {
|
||||
logger.warn('Failed to reload workflow subblock values:', reloadError)
|
||||
}
|
||||
}
|
||||
},
|
||||
[refreshServerMutation, workspaceId]
|
||||
)
|
||||
|
||||
const handleOpenEditModal = useCallback((server: McpServer) => {
|
||||
const headers: { key: string; value: string }[] = server.headers
|
||||
? Object.entries(server.headers).map(([key, value]) => ({ key, value }))
|
||||
: [{ key: '', value: '' }]
|
||||
if (headers.length === 0) headers.push({ key: '', value: '' })
|
||||
|
||||
const lastHeader = headers[headers.length - 1]
|
||||
if (lastHeader.key !== '' || lastHeader.value !== '') {
|
||||
headers.push({ key: '', value: '' })
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh MCP server:', error)
|
||||
}
|
||||
}
|
||||
|
||||
setEditInitialData({
|
||||
name: server.name || '',
|
||||
transport: (server.transport as McpTransport) || 'streamable-http',
|
||||
url: server.url || '',
|
||||
timeout: 30000,
|
||||
headers,
|
||||
})
|
||||
setShowEditModal(true)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!refreshServerMutation.isSuccess) return
|
||||
const timeout = window.setTimeout(() => refreshServerMutation.reset(), 3000)
|
||||
return () => window.clearTimeout(timeout)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation object is unstable; isSuccess flag is the trigger
|
||||
}, [refreshServerMutation.isSuccess])
|
||||
|
||||
const selectedServer = useMemo(() => {
|
||||
const refreshingServerId = refreshServerMutation.isPending
|
||||
? refreshServerMutation.variables?.serverId
|
||||
: null
|
||||
const refreshedServerId = refreshServerMutation.isSuccess
|
||||
? refreshServerMutation.variables?.serverId
|
||||
: null
|
||||
const refreshedWorkflowsUpdated = refreshServerMutation.data?.workflowsUpdated
|
||||
|
||||
const editingServer = editingServerId
|
||||
? (servers.find((s) => s.id === editingServerId) as McpServer | undefined)
|
||||
: undefined
|
||||
const editInitialData = editingServer ? buildEditInitialData(editingServer) : undefined
|
||||
|
||||
const selectedServer = (() => {
|
||||
if (!selectedServerId) return null
|
||||
const server = servers.find((s) => s.id === selectedServerId) as McpServer | undefined
|
||||
if (!server) return null
|
||||
const serverTools = (toolsByServer[selectedServerId] || []) as McpTool[]
|
||||
return { server, tools: serverTools }
|
||||
}, [selectedServerId, servers, toolsByServer])
|
||||
})()
|
||||
|
||||
const getStoredToolIssues = useCallback(
|
||||
(serverId: string, toolName: string): { issue: McpToolIssue; workflowName: string }[] => {
|
||||
const relevantStoredTools = storedTools.filter(
|
||||
(st) => st.serverId === serverId && st.toolName === toolName
|
||||
const getStoredToolIssues = (
|
||||
serverId: string,
|
||||
toolName: string
|
||||
): { issue: McpToolIssue; workflowName: string }[] => {
|
||||
const relevantStoredTools = storedTools.filter(
|
||||
(st) => st.serverId === serverId && st.toolName === toolName
|
||||
)
|
||||
|
||||
const serverStates = servers.map((s) => ({
|
||||
id: s.id,
|
||||
url: s.url,
|
||||
connectionStatus: s.connectionStatus,
|
||||
lastError: s.lastError || undefined,
|
||||
}))
|
||||
|
||||
const discoveredTools = mcpToolsData.map((t) => ({
|
||||
serverId: t.serverId,
|
||||
name: t.name,
|
||||
inputSchema: t.inputSchema,
|
||||
}))
|
||||
|
||||
const issues: { issue: McpToolIssue; workflowName: string }[] = []
|
||||
|
||||
for (const storedTool of relevantStoredTools) {
|
||||
const issue = getMcpToolIssue(
|
||||
{
|
||||
serverId: storedTool.serverId,
|
||||
serverUrl: storedTool.serverUrl,
|
||||
toolName: storedTool.toolName,
|
||||
schema: storedTool.schema,
|
||||
},
|
||||
serverStates,
|
||||
discoveredTools
|
||||
)
|
||||
|
||||
const serverStates = servers.map((s) => ({
|
||||
id: s.id,
|
||||
url: s.url,
|
||||
connectionStatus: s.connectionStatus,
|
||||
lastError: s.lastError || undefined,
|
||||
}))
|
||||
|
||||
const discoveredTools = mcpToolsData.map((t) => ({
|
||||
serverId: t.serverId,
|
||||
name: t.name,
|
||||
inputSchema: t.inputSchema,
|
||||
}))
|
||||
|
||||
const issues: { issue: McpToolIssue; workflowName: string }[] = []
|
||||
|
||||
for (const storedTool of relevantStoredTools) {
|
||||
const issue = getMcpToolIssue(
|
||||
{
|
||||
serverId: storedTool.serverId,
|
||||
serverUrl: storedTool.serverUrl,
|
||||
toolName: storedTool.toolName,
|
||||
schema: storedTool.schema,
|
||||
},
|
||||
serverStates,
|
||||
discoveredTools
|
||||
)
|
||||
|
||||
if (issue) {
|
||||
issues.push({ issue, workflowName: storedTool.workflowName })
|
||||
}
|
||||
if (issue) {
|
||||
issues.push({ issue, workflowName: storedTool.workflowName })
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
},
|
||||
[storedTools, servers, mcpToolsData]
|
||||
)
|
||||
return issues
|
||||
}
|
||||
|
||||
const error = toolsError || serversError
|
||||
const hasServers = servers && servers.length > 0
|
||||
@@ -422,12 +401,32 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
{server.connectionStatus === 'error' && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='font-medium text-[var(--text-primary)] text-sm'>Status</span>
|
||||
<p className='text-base text-red-500 dark:text-red-400'>
|
||||
<p className='text-[var(--text-error)] text-base'>
|
||||
{server.lastError || 'Unable to connect'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{server.authType === 'oauth' && server.connectionStatus !== 'connected' && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
Authentication
|
||||
</span>
|
||||
<div>
|
||||
<Button
|
||||
variant='primary'
|
||||
size='sm'
|
||||
disabled={connectingOauthServers.has(server.id)}
|
||||
onClick={async () => {
|
||||
await startOauthForServer(server.id)
|
||||
}}
|
||||
>
|
||||
{connectingOauthServers.has(server.id) ? 'Connecting…' : 'Connect with OAuth'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
Tools ({tools.length})
|
||||
@@ -450,11 +449,12 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
key={tool.name}
|
||||
className='overflow-hidden rounded-md border bg-[var(--surface-3)]'
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => hasParams && toggleToolExpanded(tool.name)}
|
||||
className={cn(
|
||||
'flex w-full items-start justify-between px-2.5 py-2 text-left',
|
||||
'flex h-auto w-full items-start justify-between rounded-none px-2.5 py-2 text-left text-sm',
|
||||
hasParams && 'cursor-pointer hover-hover:bg-[var(--surface-4)]'
|
||||
)}
|
||||
disabled={!hasParams}
|
||||
@@ -491,12 +491,12 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
{hasParams && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mt-0.5 h-[14px] w-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
|
||||
'mt-0.5 size-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
|
||||
isExpanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{isExpanded && hasParams && (
|
||||
<div className='border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 py-2'>
|
||||
@@ -563,25 +563,27 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
<Button
|
||||
onClick={() => handleRefreshServer(server.id)}
|
||||
variant='default'
|
||||
disabled={!!refreshingServers[server.id]}
|
||||
disabled={refreshingServerId === server.id || refreshedServerId === server.id}
|
||||
>
|
||||
{refreshingServers[server.id]?.status === 'refreshing'
|
||||
{refreshingServerId === server.id
|
||||
? 'Refreshing...'
|
||||
: refreshingServers[server.id]?.status === 'refreshed'
|
||||
? refreshingServers[server.id].workflowsUpdated
|
||||
? `Synced (${refreshingServers[server.id].workflowsUpdated} workflow${refreshingServers[server.id].workflowsUpdated === 1 ? '' : 's'})`
|
||||
: refreshedServerId === server.id
|
||||
? refreshedWorkflowsUpdated
|
||||
? `Synced (${refreshedWorkflowsUpdated} workflow${refreshedWorkflowsUpdated === 1 ? '' : 's'})`
|
||||
: 'Refreshed'
|
||||
: 'Refresh Tools'}
|
||||
</Button>
|
||||
<Button onClick={() => handleOpenEditModal(server)} variant='default'>
|
||||
<Button onClick={() => setEditingServerId(server.id)} variant='default'>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<McpServerFormModal
|
||||
open={showEditModal}
|
||||
onOpenChange={setShowEditModal}
|
||||
open={editingServerId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingServerId(null)
|
||||
}}
|
||||
mode='edit'
|
||||
initialData={editInitialData}
|
||||
onSubmit={async (config) => {
|
||||
@@ -620,7 +622,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddModal(true)} variant='primary' disabled={serversLoading}>
|
||||
<Plus className='mr-1.5 size-[13px]' />
|
||||
<Plus className='mr-1.5 size-[14px]' />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
@@ -628,7 +630,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-2'>
|
||||
<p className='text-[var(--error)] text-xs leading-tight dark:text-[var(--error)]'>
|
||||
<p className='text-[var(--text-error)] text-xs leading-tight'>
|
||||
{getErrorMessage(error, 'Failed to load MCP servers')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -656,8 +658,8 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
tools={tools}
|
||||
isDeleting={deletingServers.has(server.id)}
|
||||
isLoadingTools={isLoadingTools}
|
||||
isRefreshing={refreshingServers[server.id]?.status === 'refreshing'}
|
||||
onRemove={() => handleRemoveServer(server.id, server.name || 'this server')}
|
||||
isRefreshing={refreshingServerId === server.id}
|
||||
onRemove={() => handleRemoveServer(server.id)}
|
||||
onViewDetails={() => handleViewDetails(server.id)}
|
||||
/>
|
||||
)
|
||||
@@ -677,28 +679,38 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
onOpenChange={setShowAddModal}
|
||||
mode='add'
|
||||
onSubmit={async (config) => {
|
||||
await createServerMutation.mutateAsync({
|
||||
const result = await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
config: { ...config, enabled: true },
|
||||
})
|
||||
if (result.authType === 'oauth') {
|
||||
await startOauthForServer(result.serverId)
|
||||
}
|
||||
}}
|
||||
workspaceId={workspaceId}
|
||||
availableEnvVars={availableEnvVars}
|
||||
allowedMcpDomains={allowedMcpDomains}
|
||||
/>
|
||||
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<Modal
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setServerToDeleteId(null)
|
||||
}}
|
||||
>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<ModalDescription className='text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{serverToDelete?.name}</span>
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{servers.find((s) => s.id === serverToDeleteId)?.name || 'this server'}
|
||||
</span>
|
||||
? This action cannot be undone.
|
||||
</ModalDescription>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setShowDeleteDialog(false)}>
|
||||
<Button variant='default' onClick={() => setServerToDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={confirmDeleteServer}>
|
||||
|
||||
+10
-2
@@ -51,6 +51,7 @@ import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workf
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
|
||||
import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup'
|
||||
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
|
||||
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
|
||||
import {
|
||||
@@ -514,10 +515,11 @@ export const ToolInput = memo(function ToolInput({
|
||||
|
||||
const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId)
|
||||
const { data: storedMcpTools = [] } = useStoredMcpTools(workspaceId)
|
||||
const forceRefreshMcpTools = useForceRefreshMcpTools()
|
||||
const forceRefreshMcpTools = useForceRefreshMcpTools().mutate
|
||||
useMcpToolsEvents(workspaceId)
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
const createMcpServer = useCreateMcpServer()
|
||||
const { startOauthForServer } = useMcpOauthPopup({ workspaceId })
|
||||
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
const mcpDataLoading = mcpLoading || mcpServersLoading
|
||||
@@ -2114,7 +2116,13 @@ export const ToolInput = memo(function ToolInput({
|
||||
onOpenChange={setMcpModalOpen}
|
||||
mode='add'
|
||||
onSubmit={async (config) => {
|
||||
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
|
||||
const result = await createMcpServer.mutateAsync({
|
||||
workspaceId,
|
||||
config: { ...config, enabled: true },
|
||||
})
|
||||
if (result.authType === 'oauth') {
|
||||
await startOauthForServer(result.serverId)
|
||||
}
|
||||
}}
|
||||
workspaceId={workspaceId}
|
||||
availableEnvVars={availableEnvVars}
|
||||
|
||||
+48
-6
@@ -56,6 +56,7 @@ import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'
|
||||
import { formatParameterLabel } from '@/tools/params'
|
||||
|
||||
const logger = createLogger('WorkflowBlock')
|
||||
|
||||
@@ -1136,6 +1137,28 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
return getRouterRows(id, topologySubBlocks.routes?.value)
|
||||
}, [type, topologySubBlocks, id])
|
||||
|
||||
/**
|
||||
* Total rendered row count. `mcp-dynamic-args` expands one row per parameter
|
||||
* in the cached tool schema, so we count those properties instead of 1.
|
||||
*/
|
||||
const totalRenderedRowCount = useMemo(() => {
|
||||
let count = 0
|
||||
for (const row of subBlockRows) {
|
||||
for (const subBlock of row) {
|
||||
if (subBlock.type === 'mcp-dynamic-args') {
|
||||
const schema = subBlockState._toolSchema?.value as
|
||||
| { properties?: Record<string, unknown> }
|
||||
| undefined
|
||||
const properties = schema?.properties
|
||||
count += properties && typeof properties === 'object' ? Object.keys(properties).length : 0
|
||||
} else {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}, [subBlockRows, subBlockState])
|
||||
|
||||
/**
|
||||
* Compute and publish deterministic layout metrics for workflow blocks.
|
||||
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
|
||||
@@ -1147,7 +1170,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
blockType: type,
|
||||
category: config.category,
|
||||
displayTriggerMode,
|
||||
visibleSubBlockCount: subBlockRows.reduce((acc, row) => acc + row.length, 0),
|
||||
visibleSubBlockCount: totalRenderedRowCount,
|
||||
conditionRowCount: conditionRows.length,
|
||||
routerRowCount: routerRows.length,
|
||||
})
|
||||
@@ -1156,7 +1179,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
type,
|
||||
config.category,
|
||||
displayTriggerMode,
|
||||
subBlockRows.reduce((acc, row) => acc + row.length, 0),
|
||||
totalRenderedRowCount,
|
||||
conditionRows.length,
|
||||
routerRows.length,
|
||||
horizontalHandles,
|
||||
@@ -1378,9 +1401,28 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
</>
|
||||
) : (
|
||||
subBlockRows.map((row, rowIndex) =>
|
||||
row.map((subBlock) => {
|
||||
row.flatMap((subBlock) => {
|
||||
const rawValue = subBlockState[subBlock.id]?.value
|
||||
return (
|
||||
if (subBlock.type === 'mcp-dynamic-args') {
|
||||
const schema = subBlockState._toolSchema?.value as
|
||||
| { properties?: Record<string, unknown> }
|
||||
| undefined
|
||||
const properties = schema?.properties
|
||||
if (properties && typeof properties === 'object') {
|
||||
const args = (
|
||||
rawValue && typeof rawValue === 'object' ? rawValue : {}
|
||||
) as Record<string, unknown>
|
||||
return Object.keys(properties).map((paramName) => (
|
||||
<SubBlockRow
|
||||
key={`${subBlock.id}-${paramName}-${rowIndex}`}
|
||||
title={formatParameterLabel(paramName)}
|
||||
value={getDisplayValue(args[paramName])}
|
||||
/>
|
||||
))
|
||||
}
|
||||
return []
|
||||
}
|
||||
return [
|
||||
<SubBlockRow
|
||||
key={`${subBlock.id}-${rowIndex}`}
|
||||
title={subBlock.title ?? subBlock.id}
|
||||
@@ -1394,8 +1436,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
displayAdvancedOptions={effectiveAdvanced}
|
||||
canonicalIndex={canonicalIndex}
|
||||
canonicalModeOverrides={canonicalModeOverrides}
|
||||
/>
|
||||
)
|
||||
/>,
|
||||
]
|
||||
})
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '@/components/emcn'
|
||||
import type { McpOauthCallbackMessage, McpOauthCallbackReason } from '@/lib/mcp/oauth'
|
||||
import { mcpKeys, useStartMcpOauth } from '@/hooks/queries/mcp'
|
||||
|
||||
const logger = createLogger('useMcpOauthPopup')
|
||||
|
||||
function reasonToMessage(reason: McpOauthCallbackReason | undefined): string {
|
||||
switch (reason) {
|
||||
case 'provider_error':
|
||||
return 'The authorization server returned an error. Please try again.'
|
||||
case 'invalid_state':
|
||||
return 'Authorization expired. Please try again.'
|
||||
case 'user_mismatch':
|
||||
return 'You must complete authorization as the same user who started it.'
|
||||
case 'server_gone':
|
||||
return 'This MCP server no longer exists.'
|
||||
case 'insecure_url':
|
||||
return 'MCP OAuth requires https.'
|
||||
case 'token_exchange_failed':
|
||||
return 'Failed to complete token exchange with the authorization server.'
|
||||
case 'unauthenticated':
|
||||
return 'Please sign in and try again.'
|
||||
case 'missing_params':
|
||||
return 'The authorization callback was missing required parameters.'
|
||||
default:
|
||||
return 'Authorization failed. Please try again.'
|
||||
}
|
||||
}
|
||||
|
||||
interface UseMcpOauthPopupProps {
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { mutateAsync: startOauth } = useStartMcpOauth()
|
||||
|
||||
const [connectingServers, setConnectingServers] = useState<Set<string>>(() => new Set())
|
||||
const popupIntervalsRef = useRef<Map<string, number>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
const intervals = popupIntervalsRef.current
|
||||
return () => {
|
||||
for (const id of intervals.values()) window.clearInterval(id)
|
||||
intervals.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function onMessage(event: MessageEvent) {
|
||||
if (event.origin !== window.location.origin) return
|
||||
const data = event.data as Partial<McpOauthCallbackMessage> | null
|
||||
if (data?.type !== 'mcp-oauth') return
|
||||
if (data.serverId) {
|
||||
const serverId = data.serverId
|
||||
const interval = popupIntervalsRef.current.get(serverId)
|
||||
if (interval !== undefined) {
|
||||
window.clearInterval(interval)
|
||||
popupIntervalsRef.current.delete(serverId)
|
||||
}
|
||||
setConnectingServers((prev) => {
|
||||
if (!prev.has(serverId)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(serverId)
|
||||
return next
|
||||
})
|
||||
} else if (!data.ok) {
|
||||
// Early callback failures (missing params, invalid state) post back
|
||||
// without a serverId, so we can't target a specific row — clear all
|
||||
// in-flight popups instead of leaving the UI stuck on "Connecting…".
|
||||
for (const id of popupIntervalsRef.current.values()) window.clearInterval(id)
|
||||
popupIntervalsRef.current.clear()
|
||||
setConnectingServers((prev) => (prev.size === 0 ? prev : new Set()))
|
||||
}
|
||||
if (data.ok) {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) })
|
||||
toast.success('Server authorized')
|
||||
} else {
|
||||
toast.error(reasonToMessage(data.reason))
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', onMessage)
|
||||
return () => window.removeEventListener('message', onMessage)
|
||||
}, [queryClient, workspaceId])
|
||||
|
||||
const startOauthForServer = useCallback(
|
||||
async (serverId: string) => {
|
||||
setConnectingServers((prev) => new Set(prev).add(serverId))
|
||||
const clear = () => {
|
||||
const existing = popupIntervalsRef.current.get(serverId)
|
||||
if (existing !== undefined) {
|
||||
window.clearInterval(existing)
|
||||
popupIntervalsRef.current.delete(serverId)
|
||||
}
|
||||
setConnectingServers((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
try {
|
||||
const result = await startOauth({ serverId, workspaceId })
|
||||
if (result.status === 'already_authorized') {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
const { popup } = result
|
||||
const existing = popupIntervalsRef.current.get(serverId)
|
||||
if (existing !== undefined) window.clearInterval(existing)
|
||||
const interval = window.setInterval(() => {
|
||||
if (popup.closed) clear()
|
||||
}, 500)
|
||||
popupIntervalsRef.current.set(serverId, interval)
|
||||
} catch (e) {
|
||||
clear()
|
||||
logger.error('Failed to start MCP OAuth', e)
|
||||
toast.error(toError(e).message || 'Failed to start authorization')
|
||||
}
|
||||
},
|
||||
[startOauth, workspaceId]
|
||||
)
|
||||
|
||||
return { connectingServers, startOauthForServer }
|
||||
}
|
||||
@@ -5,26 +5,27 @@
|
||||
* using TanStack Query for optimal caching and performance
|
||||
*/
|
||||
|
||||
import type React from 'react'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { McpIcon } from '@/components/icons'
|
||||
import { createMcpToolId } from '@/lib/mcp/shared'
|
||||
import type { McpToolSchema } from '@/lib/mcp/types'
|
||||
import { mcpKeys, useMcpToolsQuery } from '@/hooks/queries/mcp'
|
||||
|
||||
const logger = createLogger('useMcpTools')
|
||||
|
||||
interface McpToolForUI {
|
||||
export interface McpToolForUI {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
type: 'mcp'
|
||||
inputSchema: any
|
||||
inputSchema: McpToolSchema
|
||||
bgColor: string
|
||||
icon: React.ComponentType<any>
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
}
|
||||
|
||||
export interface UseMcpToolsResult {
|
||||
@@ -64,7 +65,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
|
||||
logger.info('Refreshing MCP tools', { forceRefresh, workspaceId })
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: mcpKeys.tools(workspaceId),
|
||||
queryKey: mcpKeys.toolsList(workspaceId),
|
||||
refetchType: forceRefresh ? 'active' : 'all',
|
||||
})
|
||||
},
|
||||
|
||||
@@ -16,22 +16,27 @@ import {
|
||||
type McpServerTestResult,
|
||||
type RefreshMcpServerResult,
|
||||
refreshMcpServerContract,
|
||||
startMcpOauthContract,
|
||||
testMcpServerConnectionContract,
|
||||
updateMcpServerContract,
|
||||
} from '@/lib/api/contracts/mcp'
|
||||
import { isLoopbackHostname } from '@/lib/core/utils/urls'
|
||||
import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/shared'
|
||||
import type { McpTool, McpTransport, StoredMcpTool } from '@/lib/mcp/types'
|
||||
import type { McpServerStatusConfig, McpTool, McpTransport, StoredMcpTool } from '@/lib/mcp/types'
|
||||
import { workflowMcpServerKeys } from '@/hooks/queries/workflow-mcp-servers'
|
||||
|
||||
const logger = createLogger('McpQueries')
|
||||
|
||||
export type { McpTool, StoredMcpTool }
|
||||
export type { McpServerStatusConfig, McpTool, StoredMcpTool }
|
||||
|
||||
export const mcpKeys = {
|
||||
all: ['mcp'] as const,
|
||||
servers: (workspaceId: string) => [...mcpKeys.all, 'servers', workspaceId] as const,
|
||||
tools: (workspaceId: string) => [...mcpKeys.all, 'tools', workspaceId] as const,
|
||||
storedTools: (workspaceId: string) => [...mcpKeys.all, 'stored', workspaceId] as const,
|
||||
servers: () => [...mcpKeys.all, 'servers'] as const,
|
||||
serversList: (workspaceId?: string) => [...mcpKeys.servers(), workspaceId ?? ''] as const,
|
||||
tools: () => [...mcpKeys.all, 'tools'] as const,
|
||||
toolsList: (workspaceId?: string) => [...mcpKeys.tools(), workspaceId ?? ''] as const,
|
||||
storedTools: () => [...mcpKeys.all, 'storedTools'] as const,
|
||||
storedToolsList: (workspaceId?: string) => [...mcpKeys.storedTools(), workspaceId ?? ''] as const,
|
||||
allowedDomains: () => [...mcpKeys.all, 'allowedDomains'] as const,
|
||||
}
|
||||
|
||||
@@ -40,13 +45,15 @@ export type { McpServer }
|
||||
/**
|
||||
* Input for creating/updating an MCP server (distinct from McpServerConfig in types.ts)
|
||||
*/
|
||||
interface McpServerInput {
|
||||
export interface McpServerInput {
|
||||
name: string
|
||||
transport: McpTransport
|
||||
url?: string
|
||||
timeout: number
|
||||
headers?: Record<string, string>
|
||||
enabled: boolean
|
||||
oauthClientId?: string
|
||||
oauthClientSecret?: string
|
||||
}
|
||||
|
||||
async function fetchMcpServers(workspaceId: string, signal?: AbortSignal): Promise<McpServer[]> {
|
||||
@@ -66,7 +73,7 @@ async function fetchMcpServers(workspaceId: string, signal?: AbortSignal): Promi
|
||||
|
||||
export function useMcpServers(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.servers(workspaceId),
|
||||
queryKey: mcpKeys.serversList(workspaceId),
|
||||
queryFn: ({ signal }) => fetchMcpServers(workspaceId, signal),
|
||||
enabled: !!workspaceId,
|
||||
retry: false,
|
||||
@@ -96,7 +103,7 @@ async function fetchMcpTools(
|
||||
|
||||
export function useMcpToolsQuery(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.tools(workspaceId),
|
||||
queryKey: mcpKeys.toolsList(workspaceId),
|
||||
queryFn: ({ signal }) => fetchMcpTools(workspaceId, false, signal),
|
||||
enabled: !!workspaceId,
|
||||
retry: false,
|
||||
@@ -108,11 +115,14 @@ export function useMcpToolsQuery(workspaceId: string) {
|
||||
export function useForceRefreshMcpTools() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return async (workspaceId: string) => {
|
||||
const freshTools = await fetchMcpTools(workspaceId, true)
|
||||
queryClient.setQueryData(mcpKeys.tools(workspaceId), freshTools)
|
||||
return freshTools
|
||||
}
|
||||
return useMutation({
|
||||
mutationFn: (workspaceId: string) => fetchMcpTools(workspaceId, true),
|
||||
onSettled: (_data, _error, workspaceId) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface CreateMcpServerParams {
|
||||
@@ -138,6 +148,7 @@ export function useCreateMcpServer() {
|
||||
|
||||
const serverId = data.data.serverId
|
||||
const wasUpdated = data.data.updated === true
|
||||
const authType = data.data.authType
|
||||
|
||||
logger.info(
|
||||
wasUpdated
|
||||
@@ -145,52 +156,61 @@ export function useCreateMcpServer() {
|
||||
: `Created MCP server: ${config.name} (ID: ${serverId})`
|
||||
)
|
||||
|
||||
const { oauthClientSecret: _omitSecret, ...safeServerData } = serverData
|
||||
return {
|
||||
...serverData,
|
||||
...safeServerData,
|
||||
id: serverId,
|
||||
connectionStatus: 'connected' as const,
|
||||
connectionStatus: authType === 'oauth' ? ('disconnected' as const) : ('connected' as const),
|
||||
serverId,
|
||||
updated: wasUpdated,
|
||||
authType,
|
||||
}
|
||||
},
|
||||
onSuccess: async (data, variables) => {
|
||||
const freshTools = await fetchMcpTools(variables.workspaceId, true)
|
||||
|
||||
const previousServers = queryClient.getQueryData<McpServer[]>(
|
||||
mcpKeys.servers(variables.workspaceId)
|
||||
)
|
||||
if (previousServers) {
|
||||
const newServer: McpServer = {
|
||||
id: data.id,
|
||||
workspaceId: variables.workspaceId,
|
||||
name: variables.config.name,
|
||||
transport: variables.config.transport,
|
||||
url: variables.config.url,
|
||||
timeout: variables.config.timeout || 30000,
|
||||
headers: variables.config.headers,
|
||||
enabled: variables.config.enabled,
|
||||
connectionStatus: 'connected',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const serverExists = previousServers.some((s) => s.id === data.id)
|
||||
queryClient.setQueryData<McpServer[]>(
|
||||
mcpKeys.servers(variables.workspaceId),
|
||||
serverExists
|
||||
? previousServers.map((s) => (s.id === data.id ? { ...s, ...newServer } : s))
|
||||
: [...previousServers, newServer]
|
||||
)
|
||||
}
|
||||
|
||||
queryClient.setQueryData(mcpKeys.tools(variables.workspaceId), freshTools)
|
||||
},
|
||||
onSettled: (_, __, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export type StartMcpOauthMutationResult =
|
||||
| { status: 'redirect'; popup: Window }
|
||||
| { status: 'already_authorized' }
|
||||
|
||||
export function useStartMcpOauth() {
|
||||
return useMutation<StartMcpOauthMutationResult, Error, { serverId: string; workspaceId: string }>(
|
||||
{
|
||||
mutationFn: async ({ serverId, workspaceId }) => {
|
||||
const result = await requestJson(startMcpOauthContract, {
|
||||
query: { serverId, workspaceId },
|
||||
})
|
||||
if (result.status === 'already_authorized') return { status: 'already_authorized' }
|
||||
|
||||
const parsedUrl = new URL(result.authorizationUrl)
|
||||
const isLoopbackHttp =
|
||||
parsedUrl.protocol === 'http:' && isLoopbackHostname(parsedUrl.hostname)
|
||||
if (parsedUrl.protocol !== 'https:' && !isLoopbackHttp) {
|
||||
throw new Error('Authorization URL must use HTTPS')
|
||||
}
|
||||
const popup = window.open(
|
||||
result.authorizationUrl,
|
||||
`mcp-oauth-${serverId}`,
|
||||
'width=560,height=720,resizable=yes,scrollbars=yes'
|
||||
)
|
||||
if (!popup) {
|
||||
throw new Error('Popup blocked. Please allow popups for this site and retry.')
|
||||
}
|
||||
return { status: 'redirect', popup }
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
interface DeleteMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
@@ -208,9 +228,10 @@ export function useDeleteMcpServer() {
|
||||
logger.info(`Deleted MCP server: ${serverId} from workspace: ${workspaceId}`)
|
||||
return data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -242,16 +263,23 @@ export function useUpdateMcpServer() {
|
||||
return data.data.server
|
||||
},
|
||||
onMutate: async ({ workspaceId, serverId, updates }) => {
|
||||
await queryClient.cancelQueries({ queryKey: mcpKeys.servers(workspaceId) })
|
||||
await queryClient.cancelQueries({ queryKey: mcpKeys.serversList(workspaceId) })
|
||||
|
||||
const previousServers = queryClient.getQueryData<McpServer[]>(mcpKeys.servers(workspaceId))
|
||||
const previousServers = queryClient.getQueryData<McpServer[]>(
|
||||
mcpKeys.serversList(workspaceId)
|
||||
)
|
||||
|
||||
if (previousServers) {
|
||||
const { oauthClientSecret: _omitSecret, oauthClientId, ...rest } = updates
|
||||
const safeUpdates: Partial<McpServer> = { ...rest }
|
||||
if (oauthClientId !== undefined) {
|
||||
safeUpdates.oauthClientId = oauthClientId || undefined
|
||||
}
|
||||
queryClient.setQueryData<McpServer[]>(
|
||||
mcpKeys.servers(workspaceId),
|
||||
mcpKeys.serversList(workspaceId),
|
||||
previousServers.map((server) =>
|
||||
server.id === serverId
|
||||
? { ...server, ...updates, updatedAt: new Date().toISOString() }
|
||||
? { ...server, ...safeUpdates, updatedAt: new Date().toISOString() }
|
||||
: server
|
||||
)
|
||||
)
|
||||
@@ -261,12 +289,15 @@ export function useUpdateMcpServer() {
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
if (context?.previousServers) {
|
||||
queryClient.setQueryData(mcpKeys.servers(variables.workspaceId), context.previousServers)
|
||||
queryClient.setQueryData(
|
||||
mcpKeys.serversList(variables.workspaceId),
|
||||
context.previousServers
|
||||
)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -294,11 +325,10 @@ export function useRefreshMcpServer() {
|
||||
logger.info(`Refreshed MCP server: ${serverId}`)
|
||||
return data.data
|
||||
},
|
||||
onSuccess: async (_data, variables) => {
|
||||
const freshTools = await fetchMcpTools(variables.workspaceId, true)
|
||||
queryClient.setQueryData(mcpKeys.tools(variables.workspaceId), freshTools)
|
||||
await queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
|
||||
await queryClient.refetchQueries({ queryKey: mcpKeys.storedTools(variables.workspaceId) })
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -316,7 +346,7 @@ async function fetchStoredMcpTools(
|
||||
|
||||
export function useStoredMcpTools(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.storedTools(workspaceId),
|
||||
queryKey: mcpKeys.storedToolsList(workspaceId),
|
||||
queryFn: ({ signal }) => fetchStoredMcpTools(workspaceId, signal),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 60 * 1000,
|
||||
@@ -350,9 +380,9 @@ export function useMcpToolsEvents(workspaceId: string) {
|
||||
if (!workspaceId) return
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedTools(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.toolsList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: workflowMcpServerKeys.all })
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ const optionalHeadersFromNullableSchema = z.preprocess(
|
||||
|
||||
export const mcpTransportSchema = z.enum(['streamable-http'])
|
||||
|
||||
export const mcpAuthTypeSchema = z.enum(['none', 'headers', 'oauth'])
|
||||
|
||||
export const mcpServerStatusConfigSchema = z
|
||||
.object({
|
||||
consecutiveFailures: z.number().default(0),
|
||||
@@ -88,6 +90,7 @@ export const mcpServerSchema = z
|
||||
name: z.string(),
|
||||
description: optionalStringFromNullableSchema,
|
||||
transport: mcpTransportSchema,
|
||||
authType: mcpAuthTypeSchema.optional(),
|
||||
url: optionalStringFromNullableSchema,
|
||||
timeout: optionalNumberFromNullableSchema,
|
||||
retries: optionalNumberFromNullableSchema,
|
||||
@@ -105,6 +108,8 @@ export const mcpServerSchema = z
|
||||
createdAt: dateStringSchema,
|
||||
updatedAt: dateStringSchema,
|
||||
deletedAt: optionalDateStringFromNullableSchema,
|
||||
oauthClientId: optionalStringFromNullableSchema,
|
||||
hasOauthClientSecret: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
export type McpServer = z.output<typeof mcpServerSchema>
|
||||
@@ -123,12 +128,15 @@ export const createMcpServerBodySchema = z
|
||||
description: z.string().optional(),
|
||||
transport: mcpTransportSchema,
|
||||
url: z.string().optional(),
|
||||
authType: mcpAuthTypeSchema.optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
timeout: z.number().optional(),
|
||||
retries: z.number().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
source: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
oauthClientId: z.string().nullable().optional(),
|
||||
oauthClientSecret: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -178,6 +186,21 @@ export const mcpToolExecutionBodySchema = z
|
||||
.passthrough()
|
||||
export type McpToolExecutionBody = z.input<typeof mcpToolExecutionBodySchema>
|
||||
|
||||
export const mcpToolResultSchema = z
|
||||
.object({
|
||||
content: z.array(z.unknown()).optional(),
|
||||
isError: z.boolean().optional(),
|
||||
structuredContent: z.unknown().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const mcpToolExecutionResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: mcpToolResultSchema.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
export type McpToolExecutionResult = z.output<typeof mcpToolExecutionResultSchema>
|
||||
|
||||
export const mcpJsonRpcRequestSchema = z
|
||||
.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
@@ -283,6 +306,7 @@ export const createMcpServerContract = defineRouteContract({
|
||||
z.object({
|
||||
serverId: z.string(),
|
||||
updated: z.boolean().optional(),
|
||||
authType: mcpAuthTypeSchema.optional(),
|
||||
})
|
||||
),
|
||||
},
|
||||
@@ -391,6 +415,59 @@ export const testMcpServerConnectionContract = defineRouteContract({
|
||||
},
|
||||
})
|
||||
|
||||
export const executeMcpToolContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/mcp/tools/execute',
|
||||
body: mcpToolExecutionBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: mcpSuccessResponseSchema(mcpToolExecutionResultSchema),
|
||||
},
|
||||
})
|
||||
export type ExecuteMcpToolResponse = ContractJsonResponse<typeof executeMcpToolContract>
|
||||
|
||||
export const startMcpOauthQuerySchema = z.object({
|
||||
serverId: z.string().min(1, 'serverId is required'),
|
||||
workspaceId: z.string().min(1, 'workspaceId is required'),
|
||||
})
|
||||
|
||||
export const startMcpOauthResultSchema = z.discriminatedUnion('status', [
|
||||
z.object({ status: z.literal('redirect'), authorizationUrl: z.string().url() }),
|
||||
z.object({ status: z.literal('already_authorized') }),
|
||||
])
|
||||
export type StartMcpOauthResult = z.output<typeof startMcpOauthResultSchema>
|
||||
|
||||
export const startMcpOauthContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/mcp/oauth/start',
|
||||
query: startMcpOauthQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: startMcpOauthResultSchema,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Provider can return any subset depending on the outcome:
|
||||
* - success: `state` + `code`
|
||||
* - provider error: `error` + optional `error_description` + optional `state`
|
||||
* - malformed callback: nothing
|
||||
* All fields are optional so the route can render an HTML error page itself.
|
||||
*/
|
||||
export const mcpOauthCallbackQuerySchema = z.object({
|
||||
state: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
error_description: z.string().optional(),
|
||||
})
|
||||
|
||||
export const mcpOauthCallbackContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/mcp/oauth/callback',
|
||||
query: mcpOauthCallbackQuerySchema,
|
||||
response: { mode: 'text' },
|
||||
})
|
||||
|
||||
export const getAllowedMcpDomainsContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/settings/allowed-mcp-domains',
|
||||
|
||||
@@ -103,7 +103,16 @@ export function getEmailDomain(): string {
|
||||
|
||||
const DEFAULT_SOCKET_URL = 'http://localhost:3002'
|
||||
const DEFAULT_OLLAMA_URL = 'http://localhost:11434'
|
||||
const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
|
||||
export const LOCALHOST_HOSTNAMES: ReadonlySet<string> = new Set([
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'[::1]',
|
||||
'::1',
|
||||
])
|
||||
|
||||
export function isLoopbackHostname(hostname: string): boolean {
|
||||
return LOCALHOST_HOSTNAMES.has(hostname)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a comma-separated list of origins (e.g. from a `TRUSTED_ORIGINS` env
|
||||
|
||||
@@ -39,8 +39,9 @@ vi.mock('@/lib/core/execution-limits', () => ({
|
||||
getMaxExecutionTimeout: vi.fn().mockReturnValue(30000),
|
||||
}))
|
||||
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import { McpClient } from './client'
|
||||
import type { McpServerConfig } from './types'
|
||||
import type { McpClientOptions, McpServerConfig } from './types'
|
||||
|
||||
function createConfig(): McpServerConfig {
|
||||
return {
|
||||
@@ -54,6 +55,7 @@ function createConfig(): McpServerConfig {
|
||||
describe('McpClient notification handler', () => {
|
||||
beforeEach(() => {
|
||||
capturedNotificationHandler = null
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fires onToolsChanged when a notification arrives while connected', async () => {
|
||||
@@ -103,4 +105,25 @@ describe('McpClient notification handler', () => {
|
||||
|
||||
expect(capturedNotificationHandler).toBeNull()
|
||||
})
|
||||
|
||||
it('passes configured headers for OAuth transports as well as header auth transports', () => {
|
||||
const authProvider = {} as unknown as NonNullable<McpClientOptions['authProvider']>
|
||||
new McpClient({
|
||||
config: {
|
||||
...createConfig(),
|
||||
authType: 'oauth',
|
||||
headers: { 'X-Sim-Via': 'workflow' },
|
||||
},
|
||||
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
|
||||
authProvider,
|
||||
})
|
||||
|
||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||
new URL('https://test.example.com/mcp'),
|
||||
{
|
||||
authProvider,
|
||||
requestInit: { headers: { 'X-Sim-Via': 'workflow' } },
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* - 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 {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
|
||||
import { createMcpPinnedFetch } from '@/lib/mcp/pinned-fetch'
|
||||
import {
|
||||
type McpClientOptions,
|
||||
@@ -44,6 +46,7 @@ export class McpClient {
|
||||
private connectionStatus: McpConnectionStatus
|
||||
private securityPolicy: McpSecurityPolicy
|
||||
private onToolsChanged?: McpToolsChangedCallback
|
||||
private authProvider?: McpClientOptions['authProvider']
|
||||
private isConnected = false
|
||||
|
||||
private static readonly SUPPORTED_VERSIONS = [
|
||||
@@ -60,6 +63,7 @@ export class McpClient {
|
||||
maxToolExecutionsPerHour: 1000,
|
||||
}
|
||||
this.onToolsChanged = options.onToolsChanged
|
||||
this.authProvider = options.authProvider
|
||||
const resolvedIP = options.resolvedIP
|
||||
|
||||
this.connectionStatus = { connected: false }
|
||||
@@ -68,10 +72,13 @@ export class McpClient {
|
||||
throw new McpError('URL required for Streamable HTTP transport')
|
||||
}
|
||||
|
||||
if (this.config.authType === 'oauth' && this.authProvider == null) {
|
||||
throw new McpError('OAuth MCP server requires an authProvider')
|
||||
}
|
||||
const useOauth = this.config.authType === 'oauth'
|
||||
this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), {
|
||||
requestInit: {
|
||||
headers: this.config.headers,
|
||||
},
|
||||
authProvider: useOauth ? this.authProvider : undefined,
|
||||
requestInit: { headers: this.config.headers },
|
||||
...(resolvedIP ? { fetch: createMcpPinnedFetch(resolvedIP) } : {}),
|
||||
})
|
||||
|
||||
@@ -115,9 +122,13 @@ export class McpClient {
|
||||
protocolVersion: serverVersion,
|
||||
})
|
||||
} catch (error) {
|
||||
this.isConnected = false
|
||||
if (error instanceof McpOauthRedirectRequired || error instanceof UnauthorizedError) {
|
||||
this.connectionStatus.lastError = undefined
|
||||
throw error
|
||||
}
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error')
|
||||
this.connectionStatus.lastError = errorMessage
|
||||
this.isConnected = false
|
||||
logger.error(`Failed to connect to MCP server ${this.config.name}:`, error)
|
||||
throw new McpConnectionError(errorMessage, this.config.name)
|
||||
}
|
||||
|
||||
@@ -28,13 +28,17 @@ function serverConfig(id: string, name = `Server ${id}`) {
|
||||
}
|
||||
}
|
||||
|
||||
const { MockMcpClientConstructor, mockOnToolsChanged, mockPublishToolsChanged } = vi.hoisted(
|
||||
() => ({
|
||||
MockMcpClientConstructor: vi.fn(),
|
||||
mockOnToolsChanged: vi.fn(() => vi.fn()),
|
||||
mockPublishToolsChanged: vi.fn(),
|
||||
})
|
||||
)
|
||||
const {
|
||||
MockMcpClientConstructor,
|
||||
mockOnToolsChanged,
|
||||
mockPublishToolsChanged,
|
||||
mockGetOrCreateOauthRow,
|
||||
} = vi.hoisted(() => ({
|
||||
MockMcpClientConstructor: vi.fn(),
|
||||
mockOnToolsChanged: vi.fn(() => vi.fn()),
|
||||
mockPublishToolsChanged: vi.fn(),
|
||||
mockGetOrCreateOauthRow: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({ isTest: false }))
|
||||
vi.mock('@/lib/mcp/pubsub', () => ({
|
||||
@@ -46,6 +50,11 @@ vi.mock('@/lib/mcp/pubsub', () => ({
|
||||
vi.mock('@/lib/mcp/client', () => ({
|
||||
McpClient: MockMcpClientConstructor,
|
||||
}))
|
||||
vi.mock('@/lib/mcp/oauth', () => ({
|
||||
getOrCreateOauthRow: mockGetOrCreateOauthRow,
|
||||
loadPreregisteredClient: vi.fn(),
|
||||
SimMcpOauthProvider: vi.fn().mockImplementation((value) => value),
|
||||
}))
|
||||
|
||||
import { McpConnectionManager } from '@/lib/mcp/connection-manager'
|
||||
|
||||
@@ -54,6 +63,17 @@ describe('McpConnectionManager', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetOrCreateOauthRow.mockResolvedValue({
|
||||
id: 'oauth-row-1',
|
||||
mcpServerId: 'server-oauth',
|
||||
userId: 'authorizer-1',
|
||||
workspaceId: 'ws-1',
|
||||
clientInformation: null,
|
||||
tokens: { access_token: 'workspace-token', token_type: 'Bearer' },
|
||||
codeVerifier: null,
|
||||
state: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -98,6 +118,37 @@ describe('McpConnectionManager', () => {
|
||||
expect(r2.supportsListChanged).toBe(false)
|
||||
})
|
||||
|
||||
it('shares OAuth managed connections across workspace users for the same server', async () => {
|
||||
const instances: MockMcpClient[] = []
|
||||
|
||||
MockMcpClientConstructor.mockImplementation(() => {
|
||||
const instance: MockMcpClient = {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
hasListChangedCapability: vi.fn().mockReturnValue(true),
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
instances.push(instance)
|
||||
return instance
|
||||
})
|
||||
|
||||
const mgr = createFreshManager()
|
||||
const config = { ...serverConfig('server-oauth'), authType: 'oauth' as const }
|
||||
|
||||
const r1 = await mgr.connect(config, 'user-1', 'ws-1')
|
||||
const r2 = await mgr.connect(config, 'user-2', 'ws-1')
|
||||
|
||||
expect(instances).toHaveLength(1)
|
||||
expect(r1.supportsListChanged).toBe(true)
|
||||
expect(r2.supportsListChanged).toBe(true)
|
||||
expect(mockGetOrCreateOauthRow).toHaveBeenCalledTimes(1)
|
||||
expect(mockGetOrCreateOauthRow).toHaveBeenCalledWith({
|
||||
mcpServerId: 'server-oauth',
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows a new connect() after a previous one completes', async () => {
|
||||
const instances: MockMcpClient[] = []
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isTest } from '@/lib/core/config/feature-flags'
|
||||
import { McpClient } from '@/lib/mcp/client'
|
||||
import { getOrCreateOauthRow, loadPreregisteredClient, SimMcpOauthProvider } from '@/lib/mcp/oauth'
|
||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||
import type {
|
||||
ManagedConnectionState,
|
||||
McpClientOptions,
|
||||
McpServerConfig,
|
||||
McpToolsChangedCallback,
|
||||
ToolsChangedEvent,
|
||||
@@ -31,6 +33,15 @@ const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
type ToolsChangedListener = (event: ToolsChangedEvent) => void
|
||||
|
||||
/**
|
||||
* Cache key for managed connections.
|
||||
* MCP servers are workspace-owned, so OAuth/header/no-auth connections are
|
||||
* keyed by server and share the same workspace-scoped server credentials.
|
||||
*/
|
||||
function connectionKey(config: McpServerConfig): string {
|
||||
return config.id
|
||||
}
|
||||
|
||||
export class McpConnectionManager {
|
||||
private connections = new Map<string, McpClient>()
|
||||
private states = new Map<string, ManagedConnectionState>()
|
||||
@@ -79,11 +90,11 @@ export class McpConnectionManager {
|
||||
return { supportsListChanged: false }
|
||||
}
|
||||
|
||||
const serverId = config.id
|
||||
const key = connectionKey(config)
|
||||
|
||||
if (this.connections.has(serverId) || this.connectingServers.has(serverId)) {
|
||||
if (this.connections.has(key) || this.connectingServers.has(key)) {
|
||||
logger.info(`[${config.name}] Already has a managed connection or is connecting, skipping`)
|
||||
const state = this.states.get(serverId)
|
||||
const state = this.states.get(key)
|
||||
return { supportsListChanged: state?.supportsListChanged ?? false }
|
||||
}
|
||||
|
||||
@@ -92,11 +103,28 @@ export class McpConnectionManager {
|
||||
return { supportsListChanged: false }
|
||||
}
|
||||
|
||||
this.connectingServers.add(serverId)
|
||||
this.connectingServers.add(key)
|
||||
|
||||
try {
|
||||
const onToolsChanged: McpToolsChangedCallback = (sid) => {
|
||||
this.handleToolsChanged(sid)
|
||||
const onToolsChanged: McpToolsChangedCallback = () => {
|
||||
this.handleToolsChanged(key)
|
||||
}
|
||||
|
||||
let authProvider: McpClientOptions['authProvider']
|
||||
if (config.authType === 'oauth') {
|
||||
const row = await getOrCreateOauthRow({
|
||||
mcpServerId: config.id,
|
||||
userId,
|
||||
workspaceId,
|
||||
})
|
||||
if (!row.tokens) {
|
||||
logger.info(
|
||||
`[${config.name}] OAuth server has no workspace tokens — skipping persistent connection until authorized`
|
||||
)
|
||||
return { supportsListChanged: false }
|
||||
}
|
||||
const preregistered = await loadPreregisteredClient(config.id)
|
||||
authProvider = new SimMcpOauthProvider({ row, preregistered })
|
||||
}
|
||||
|
||||
const client = new McpClient({
|
||||
@@ -108,6 +136,7 @@ export class McpConnectionManager {
|
||||
},
|
||||
onToolsChanged,
|
||||
resolvedIP: resolvedIP ?? undefined,
|
||||
authProvider,
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -127,11 +156,11 @@ export class McpConnectionManager {
|
||||
return { supportsListChanged: false }
|
||||
}
|
||||
|
||||
this.clearReconnectTimer(serverId)
|
||||
this.clearReconnectTimer(key)
|
||||
|
||||
this.connections.set(serverId, client)
|
||||
this.states.set(serverId, {
|
||||
serverId,
|
||||
this.connections.set(key, client)
|
||||
this.states.set(key, {
|
||||
serverId: config.id,
|
||||
serverName: config.name,
|
||||
workspaceId,
|
||||
userId,
|
||||
@@ -150,42 +179,49 @@ export class McpConnectionManager {
|
||||
logger.info(`[${config.name}] Persistent connection established (listChanged supported)`)
|
||||
return { supportsListChanged: true }
|
||||
} finally {
|
||||
this.connectingServers.delete(serverId)
|
||||
this.connectingServers.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a managed connection.
|
||||
* Disconnect a managed connection by internal cache key.
|
||||
*/
|
||||
async disconnect(serverId: string): Promise<void> {
|
||||
this.clearReconnectTimer(serverId)
|
||||
private async disconnectByKey(key: string): Promise<void> {
|
||||
this.clearReconnectTimer(key)
|
||||
|
||||
const client = this.connections.get(serverId)
|
||||
const client = this.connections.get(key)
|
||||
if (client) {
|
||||
try {
|
||||
await client.disconnect()
|
||||
} catch (error) {
|
||||
logger.warn(`Error disconnecting managed client ${serverId}:`, error)
|
||||
logger.warn(`Error disconnecting managed client ${key}:`, error)
|
||||
}
|
||||
this.connections.delete(serverId)
|
||||
this.connections.delete(key)
|
||||
}
|
||||
|
||||
this.states.delete(serverId)
|
||||
logger.info(`Managed connection removed: ${serverId}`)
|
||||
this.states.delete(key)
|
||||
logger.info(`Managed connection removed: ${key}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the managed connection for the given server.
|
||||
*/
|
||||
async disconnectServer(serverId: string): Promise<void> {
|
||||
const keys: string[] = []
|
||||
for (const [key, state] of this.states) {
|
||||
if (state.serverId === serverId) keys.push(key)
|
||||
}
|
||||
await Promise.all(keys.map((key) => this.disconnectByKey(key)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a managed connection exists for the given server.
|
||||
*/
|
||||
hasConnection(serverId: string): boolean {
|
||||
return this.connections.has(serverId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection state for a server.
|
||||
*/
|
||||
getState(serverId: string): ManagedConnectionState | undefined {
|
||||
return this.states.get(serverId)
|
||||
for (const state of this.states.values()) {
|
||||
if (state.serverId === serverId) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,14 +285,14 @@ export class McpConnectionManager {
|
||||
* Handle a tools/list_changed notification from an external MCP server.
|
||||
* Publishes to pub/sub so all processes are notified.
|
||||
*/
|
||||
private handleToolsChanged(serverId: string): void {
|
||||
const state = this.states.get(serverId)
|
||||
private handleToolsChanged(key: string): void {
|
||||
const state = this.states.get(key)
|
||||
if (!state) return
|
||||
|
||||
state.lastActivity = Date.now()
|
||||
|
||||
const event: ToolsChangedEvent = {
|
||||
serverId,
|
||||
serverId: state.serverId,
|
||||
serverName: state.serverName,
|
||||
workspaceId: state.workspaceId,
|
||||
timestamp: Date.now(),
|
||||
@@ -268,13 +304,13 @@ export class McpConnectionManager {
|
||||
}
|
||||
|
||||
private handleDisconnect(config: McpServerConfig, userId: string, workspaceId: string): void {
|
||||
const serverId = config.id
|
||||
const state = this.states.get(serverId)
|
||||
const key = connectionKey(config)
|
||||
const state = this.states.get(key)
|
||||
|
||||
if (!state || this.disposed) return
|
||||
|
||||
state.connected = false
|
||||
this.connections.delete(serverId)
|
||||
this.connections.delete(key)
|
||||
|
||||
logger.warn(`[${config.name}] Persistent connection lost, scheduling reconnect`)
|
||||
|
||||
@@ -282,8 +318,8 @@ export class McpConnectionManager {
|
||||
}
|
||||
|
||||
private scheduleReconnect(config: McpServerConfig, userId: string, workspaceId: string): void {
|
||||
const serverId = config.id
|
||||
const state = this.states.get(serverId)
|
||||
const key = connectionKey(config)
|
||||
const state = this.states.get(key)
|
||||
|
||||
if (!state || this.disposed) return
|
||||
|
||||
@@ -291,7 +327,7 @@ export class McpConnectionManager {
|
||||
logger.error(
|
||||
`[${config.name}] Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached — giving up`
|
||||
)
|
||||
this.states.delete(serverId)
|
||||
this.states.delete(key)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -302,14 +338,14 @@ export class McpConnectionManager {
|
||||
`[${config.name}] Reconnecting in ${delay}ms (attempt ${state.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`
|
||||
)
|
||||
|
||||
this.clearReconnectTimer(serverId)
|
||||
this.clearReconnectTimer(key)
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
this.reconnectTimers.delete(serverId)
|
||||
this.reconnectTimers.delete(key)
|
||||
|
||||
if (this.disposed) return
|
||||
|
||||
const currentState = this.states.get(serverId)
|
||||
const currentState = this.states.get(key)
|
||||
if (currentState?.connected) {
|
||||
logger.info(
|
||||
`[${config.name}] Connection already re-established externally, skipping reconnect`
|
||||
@@ -318,8 +354,8 @@ export class McpConnectionManager {
|
||||
}
|
||||
|
||||
const attempts = state.reconnectAttempts
|
||||
this.connections.delete(serverId)
|
||||
this.states.delete(serverId)
|
||||
this.connections.delete(key)
|
||||
this.states.delete(key)
|
||||
|
||||
try {
|
||||
const result = await this.connect(config, userId, workspaceId)
|
||||
@@ -336,14 +372,14 @@ export class McpConnectionManager {
|
||||
}
|
||||
}, delay)
|
||||
|
||||
this.reconnectTimers.set(serverId, timer)
|
||||
this.reconnectTimers.set(key, timer)
|
||||
}
|
||||
|
||||
private clearReconnectTimer(serverId: string): void {
|
||||
const timer = this.reconnectTimers.get(serverId)
|
||||
private clearReconnectTimer(key: string): void {
|
||||
const timer = this.reconnectTimers.get(key)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
this.reconnectTimers.delete(serverId)
|
||||
this.reconnectTimers.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,8 +392,9 @@ export class McpConnectionManager {
|
||||
workspaceId: string,
|
||||
reconnectAttempts: number
|
||||
): void {
|
||||
if (!this.states.has(config.id)) {
|
||||
this.states.set(config.id, {
|
||||
const key = connectionKey(config)
|
||||
if (!this.states.has(key)) {
|
||||
this.states.set(key, {
|
||||
serverId: config.id,
|
||||
serverName: config.name,
|
||||
workspaceId,
|
||||
@@ -375,12 +412,12 @@ export class McpConnectionManager {
|
||||
|
||||
this.idleCheckTimer = setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [serverId, state] of this.states) {
|
||||
for (const [key, state] of this.states) {
|
||||
if (now - state.lastActivity > IDLE_TIMEOUT_MS) {
|
||||
logger.info(
|
||||
`[${state.serverName}] Idle timeout reached, disconnecting managed connection`
|
||||
)
|
||||
this.disconnect(serverId)
|
||||
this.disconnectByKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const logger = createLogger('McpAuthMiddleware')
|
||||
|
||||
export type McpPermissionLevel = 'read' | 'write' | 'admin'
|
||||
|
||||
interface McpAuthContext {
|
||||
export interface McpAuthContext {
|
||||
userId: string
|
||||
userName?: string | null
|
||||
userEmail?: string | null
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Reasons surfaced from the OAuth callback popup back to the parent window via
|
||||
* `window.opener.postMessage`. Consumed by the popup hook to render user-facing
|
||||
* status messages and by the callback route to discriminate failure modes.
|
||||
*/
|
||||
export type McpOauthCallbackReason =
|
||||
| 'authorized'
|
||||
| 'provider_error'
|
||||
| 'missing_params'
|
||||
| 'unauthenticated'
|
||||
| 'invalid_state'
|
||||
| 'user_mismatch'
|
||||
| 'server_gone'
|
||||
| 'insecure_url'
|
||||
| 'token_exchange_failed'
|
||||
| 'unknown'
|
||||
|
||||
export interface McpOauthCallbackMessage {
|
||||
type: 'mcp-oauth'
|
||||
ok: boolean
|
||||
serverId?: string
|
||||
reason?: McpOauthCallbackReason
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
|
||||
interface OauthCredsDiffParams {
|
||||
incomingClientId: string | null | undefined
|
||||
incomingClientIdProvided: boolean
|
||||
incomingClientSecret: string | null | undefined
|
||||
incomingClientSecretProvided: boolean
|
||||
currentClientId: string | null | undefined
|
||||
currentEncryptedClientSecret: string | null | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether OAuth client credentials on an MCP server row have changed.
|
||||
* Decrypt failure (corrupted ciphertext, rotated key) is treated as a change so
|
||||
* admins can overwrite an unusable stored secret instead of getting a 500.
|
||||
*/
|
||||
export async function oauthCredsChanged(params: OauthCredsDiffParams): Promise<boolean> {
|
||||
const clientIdChanged =
|
||||
params.incomingClientIdProvided &&
|
||||
(params.incomingClientId || null) !== (params.currentClientId ?? null)
|
||||
|
||||
let clientSecretChanged = false
|
||||
if (params.incomingClientSecretProvided) {
|
||||
if (!params.incomingClientSecret) {
|
||||
clientSecretChanged = params.currentEncryptedClientSecret != null
|
||||
} else if (!params.currentEncryptedClientSecret) {
|
||||
clientSecretChanged = true
|
||||
} else {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(params.currentEncryptedClientSecret)
|
||||
clientSecretChanged = decrypted !== params.incomingClientSecret
|
||||
} catch {
|
||||
clientSecretChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clientIdChanged || clientSecretChanged
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type {
|
||||
McpOauthCallbackMessage,
|
||||
McpOauthCallbackReason,
|
||||
} from './callback-reasons'
|
||||
export { oauthCredsChanged } from './creds-diff'
|
||||
export { detectMcpAuthType } from './probe'
|
||||
export {
|
||||
loadPreregisteredClient,
|
||||
McpOauthRedirectRequired,
|
||||
type PreregisteredClient,
|
||||
SimMcpOauthProvider,
|
||||
} from './provider'
|
||||
export { revokeMcpOauthTokens } from './revoke'
|
||||
export {
|
||||
clearClient,
|
||||
clearState,
|
||||
clearTokens,
|
||||
clearVerifier,
|
||||
getOrCreateOauthRow,
|
||||
loadOauthRow,
|
||||
loadOauthRowByState,
|
||||
type McpOauthRow,
|
||||
saveClientInformation,
|
||||
saveCodeVerifier,
|
||||
saveState,
|
||||
saveTokens,
|
||||
setOauthRowUser,
|
||||
withMcpOauthRefreshLock,
|
||||
} from './storage'
|
||||
export { assertSafeOauthServerUrl, McpOauthInsecureUrlError } from './url-validation'
|
||||
@@ -0,0 +1,92 @@
|
||||
import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isLoopbackHostname } from '@/lib/core/utils/urls'
|
||||
import type { McpAuthType } from '@/lib/mcp/types'
|
||||
|
||||
const logger = createLogger('McpOauthProbe')
|
||||
|
||||
const PROBE_TIMEOUT_MS = 5000
|
||||
|
||||
export async function detectMcpAuthType(url: string): Promise<McpAuthType> {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return 'headers'
|
||||
}
|
||||
const isLoopbackHttp = parsed.protocol === 'http:' && isLoopbackHostname(parsed.hostname)
|
||||
if (parsed.protocol !== 'https:' && !isLoopbackHttp) {
|
||||
return 'headers'
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'sim-platform-probe', version: '1.0.0' },
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const sessionId = res.headers.get('mcp-session-id')
|
||||
if (sessionId) {
|
||||
void closeMcpSession(url, sessionId)
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
const params = extractWWWAuthenticateParams(res)
|
||||
// Per RFC 9728, an OAuth-protected resource signals OAuth via
|
||||
// `resource_metadata=...` in WWW-Authenticate. `scope=...` is also an
|
||||
// OAuth-specific hint. A bare `error="invalid_token"` is generic Bearer
|
||||
// and used by plain API-key servers too, so it must not classify as OAuth.
|
||||
if (params.resourceMetadataUrl || params.scope) {
|
||||
return 'oauth'
|
||||
}
|
||||
return 'headers'
|
||||
}
|
||||
|
||||
if (res.ok) return 'none'
|
||||
return 'headers'
|
||||
} catch (e) {
|
||||
logger.warn(`Probe failed for ${url}`, e)
|
||||
return 'headers'
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort DELETE to release the streamable-HTTP session the probe just
|
||||
* allocated. Failures are ignored — the session will expire on the server side.
|
||||
*/
|
||||
async function closeMcpSession(url: string, sessionId: string): Promise<void> {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Mcp-Session-Id': sessionId },
|
||||
signal: controller.signal,
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
} catch {
|
||||
// Ignore — best-effort cleanup
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import type {
|
||||
OAuthClientInformationMixed,
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServers } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
clearClient,
|
||||
clearState,
|
||||
clearTokens,
|
||||
clearVerifier,
|
||||
type McpOauthRow,
|
||||
saveClientInformation as saveClientInformationDb,
|
||||
saveCodeVerifier as saveCodeVerifierDb,
|
||||
saveState,
|
||||
saveTokens as saveTokensDb,
|
||||
} from '@/lib/mcp/oauth/storage'
|
||||
|
||||
const logger = createLogger('SimMcpOauthProvider')
|
||||
|
||||
export class McpOauthRedirectRequired extends Error {
|
||||
constructor(public readonly authorizationUrl: string) {
|
||||
super('MCP OAuth redirect required')
|
||||
this.name = 'McpOauthRedirectRequired'
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreregisteredClient {
|
||||
clientId: string
|
||||
clientSecret?: string
|
||||
}
|
||||
|
||||
interface SimMcpOauthProviderInit {
|
||||
row: McpOauthRow
|
||||
scope?: string
|
||||
/**
|
||||
* Optional user-supplied client credentials. When provided, the SDK skips
|
||||
* Dynamic Client Registration and uses these for the auth/token exchange.
|
||||
*/
|
||||
preregistered?: PreregisteredClient
|
||||
}
|
||||
|
||||
export class SimMcpOauthProvider implements OAuthClientProvider {
|
||||
private row: McpOauthRow
|
||||
private readonly scope?: string
|
||||
private readonly preregistered?: PreregisteredClient
|
||||
|
||||
constructor({ row, scope, preregistered }: SimMcpOauthProviderInit) {
|
||||
this.row = row
|
||||
this.scope = scope
|
||||
this.preregistered = preregistered
|
||||
}
|
||||
|
||||
get redirectUrl(): string {
|
||||
return `${getBaseUrl().replace(/\/$/, '')}/api/mcp/oauth/callback`
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
const meta: OAuthClientMetadata = {
|
||||
client_name: 'Sim',
|
||||
redirect_uris: [this.redirectUrl],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: this.preregistered?.clientSecret ? 'client_secret_post' : 'none',
|
||||
}
|
||||
if (this.scope) meta.scope = this.scope
|
||||
return meta
|
||||
}
|
||||
|
||||
async state(): Promise<string> {
|
||||
const state = generateId()
|
||||
await saveState(this.row.id, state)
|
||||
return state
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformationMixed | undefined {
|
||||
if (this.row.clientInformation) return this.row.clientInformation
|
||||
if (this.preregistered) {
|
||||
return {
|
||||
client_id: this.preregistered.clientId,
|
||||
client_secret: this.preregistered.clientSecret,
|
||||
redirect_uris: [this.redirectUrl],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: this.preregistered.clientSecret ? 'client_secret_post' : 'none',
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async saveClientInformation(info: OAuthClientInformationMixed): Promise<void> {
|
||||
if (this.preregistered) return
|
||||
await saveClientInformationDb(this.row.id, info)
|
||||
this.row.clientInformation = info
|
||||
}
|
||||
|
||||
tokens(): OAuthTokens | undefined {
|
||||
return this.row.tokens ?? undefined
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
await saveTokensDb(this.row.id, tokens)
|
||||
this.row.tokens = tokens
|
||||
}
|
||||
|
||||
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||
throw new McpOauthRedirectRequired(authorizationUrl.toString())
|
||||
}
|
||||
|
||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||
await saveCodeVerifierDb(this.row.id, codeVerifier)
|
||||
this.row.codeVerifier = codeVerifier
|
||||
}
|
||||
|
||||
async codeVerifier(): Promise<string> {
|
||||
if (!this.row.codeVerifier) {
|
||||
throw new Error('No PKCE code verifier saved for this MCP OAuth session')
|
||||
}
|
||||
return this.row.codeVerifier
|
||||
}
|
||||
|
||||
async invalidateCredentials(
|
||||
scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'
|
||||
): Promise<void> {
|
||||
if (scope === 'all' || scope === 'client') {
|
||||
await clearClient(this.row.id)
|
||||
this.row.clientInformation = null
|
||||
}
|
||||
if (scope === 'all' || scope === 'tokens') {
|
||||
await clearTokens(this.row.id)
|
||||
this.row.tokens = null
|
||||
}
|
||||
if (scope === 'all' || scope === 'verifier') {
|
||||
await clearVerifier(this.row.id)
|
||||
await clearState(this.row.id)
|
||||
this.row.codeVerifier = null
|
||||
}
|
||||
}
|
||||
|
||||
get rowId(): string {
|
||||
return this.row.id
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPreregisteredClient(
|
||||
serverId: string
|
||||
): Promise<PreregisteredClient | undefined> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
clientId: mcpServers.oauthClientId,
|
||||
clientSecret: mcpServers.oauthClientSecret,
|
||||
})
|
||||
.from(mcpServers)
|
||||
.where(eq(mcpServers.id, serverId))
|
||||
.limit(1)
|
||||
if (!row?.clientId) return undefined
|
||||
let clientSecret: string | undefined
|
||||
if (row.clientSecret) {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(row.clientSecret)
|
||||
clientSecret = decrypted
|
||||
} catch (error) {
|
||||
logger.error('Failed to decrypt preregistered MCP OAuth client secret', {
|
||||
serverId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
throw new Error('Failed to decrypt preregistered MCP OAuth client secret')
|
||||
}
|
||||
}
|
||||
return { clientId: row.clientId, clientSecret }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { discoverOAuthServerInfo } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServers } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { loadOauthRow } from '@/lib/mcp/oauth/storage'
|
||||
|
||||
const logger = createLogger('McpOauthRevoke')
|
||||
const REVOKE_TIMEOUT_MS = 5000
|
||||
|
||||
/**
|
||||
* Best-effort RFC 7009 revocation of tokens at the authorization server.
|
||||
* Never throws — revocation is advisory and must not block disconnect/delete flows.
|
||||
*/
|
||||
export async function revokeMcpOauthTokens(mcpServerId: string): Promise<void> {
|
||||
try {
|
||||
const row = await loadOauthRow({ mcpServerId })
|
||||
if (!row?.tokens) return
|
||||
|
||||
const [server] = await db
|
||||
.select({
|
||||
url: mcpServers.url,
|
||||
oauthClientId: mcpServers.oauthClientId,
|
||||
oauthClientSecret: mcpServers.oauthClientSecret,
|
||||
})
|
||||
.from(mcpServers)
|
||||
.where(eq(mcpServers.id, mcpServerId))
|
||||
.limit(1)
|
||||
if (!server?.url) return
|
||||
|
||||
const info = await discoverOAuthServerInfo(server.url).catch(() => undefined)
|
||||
const metadata = info?.authorizationServerMetadata as
|
||||
| (Record<string, unknown> & { revocation_endpoint?: string })
|
||||
| undefined
|
||||
const revocationEndpoint = metadata?.revocation_endpoint
|
||||
if (!revocationEndpoint) return
|
||||
|
||||
const clientInfo = row.clientInformation
|
||||
const clientId = clientInfo?.client_id ?? server.oauthClientId ?? undefined
|
||||
if (!clientId) return
|
||||
|
||||
let clientSecret = clientInfo?.client_secret
|
||||
if (!clientSecret && server.oauthClientSecret) {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(server.oauthClientSecret)
|
||||
clientSecret = decrypted
|
||||
} catch {
|
||||
clientSecret = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const tokensToRevoke: Array<{ token: string; hint: 'refresh_token' | 'access_token' }> = []
|
||||
if (row.tokens.refresh_token) {
|
||||
tokensToRevoke.push({ token: row.tokens.refresh_token, hint: 'refresh_token' })
|
||||
}
|
||||
if (row.tokens.access_token) {
|
||||
tokensToRevoke.push({ token: row.tokens.access_token, hint: 'access_token' })
|
||||
}
|
||||
|
||||
for (const { token, hint } of tokensToRevoke) {
|
||||
await postRevoke(revocationEndpoint, token, hint, clientId, clientSecret)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Token revocation failed for server ${mcpServerId}`, {
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function postRevoke(
|
||||
endpoint: string,
|
||||
token: string,
|
||||
hint: 'refresh_token' | 'access_token',
|
||||
clientId: string,
|
||||
clientSecret: string | undefined
|
||||
): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), REVOKE_TIMEOUT_MS)
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
}
|
||||
const params = new URLSearchParams({ token, token_type_hint: hint })
|
||||
if (clientSecret) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`
|
||||
} else {
|
||||
params.set('client_id', clientId)
|
||||
}
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: params.toString(),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
logger.info(`Revocation returned ${res.status} for ${hint}; treating as best-effort`)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
dbChainMock,
|
||||
dbChainMockFns,
|
||||
encryptionMock,
|
||||
encryptionMockFns,
|
||||
resetDbChainMock,
|
||||
schemaMock,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
vi.mock('@sim/db/schema', () => schemaMock)
|
||||
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
|
||||
|
||||
import { getOrCreateOauthRow, loadOauthRow, setOauthRowUser } from './storage'
|
||||
|
||||
describe('MCP OAuth storage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: '{}' })
|
||||
encryptionMockFns.mockEncryptSecret.mockResolvedValue({
|
||||
encrypted: 'encrypted',
|
||||
iv: 'iv',
|
||||
})
|
||||
})
|
||||
|
||||
it('loads OAuth state by MCP server, independent of the requesting user', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'oauth-row-1',
|
||||
mcpServerId: 'server-1',
|
||||
userId: 'authorizer-1',
|
||||
workspaceId: 'workspace-1',
|
||||
clientInformation: null,
|
||||
tokens: null,
|
||||
codeVerifier: null,
|
||||
state: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
])
|
||||
|
||||
const row = await loadOauthRow({ mcpServerId: 'server-1' })
|
||||
|
||||
expect(row).toMatchObject({
|
||||
id: 'oauth-row-1',
|
||||
mcpServerId: 'server-1',
|
||||
userId: 'authorizer-1',
|
||||
workspaceId: 'workspace-1',
|
||||
})
|
||||
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses the existing workspace OAuth row instead of creating a per-user row', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'oauth-row-1',
|
||||
mcpServerId: 'server-1',
|
||||
userId: 'authorizer-1',
|
||||
workspaceId: 'workspace-1',
|
||||
clientInformation: null,
|
||||
tokens: null,
|
||||
codeVerifier: null,
|
||||
state: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
])
|
||||
|
||||
const row = await getOrCreateOauthRow({
|
||||
mcpServerId: 'server-1',
|
||||
userId: 'different-user',
|
||||
workspaceId: 'workspace-1',
|
||||
})
|
||||
|
||||
expect(row.id).toBe('oauth-row-1')
|
||||
expect(row.userId).toBe('authorizer-1')
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records the latest authorizing user without changing row ownership', async () => {
|
||||
await setOauthRowUser('oauth-row-1', 'user-2')
|
||||
|
||||
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-2',
|
||||
updatedAt: expect.any(Date),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type {
|
||||
OAuthClientInformationMixed,
|
||||
OAuthTokens,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServerOauth } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, gt } from 'drizzle-orm'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||
|
||||
const logger = createLogger('McpOauthStorage')
|
||||
|
||||
function hashState(state: string): string {
|
||||
return createHash('sha256').update(state).digest('hex')
|
||||
}
|
||||
|
||||
const STATE_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
export interface McpOauthRow {
|
||||
id: string
|
||||
mcpServerId: string
|
||||
userId: string | null
|
||||
workspaceId: string
|
||||
clientInformation: OAuthClientInformationMixed | null
|
||||
tokens: OAuthTokens | null
|
||||
codeVerifier: string | null
|
||||
state: string | null
|
||||
stateCreatedAt: Date | null
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
async function encryptTokens(tokens: OAuthTokens): Promise<string> {
|
||||
const { encrypted } = await encryptSecret(JSON.stringify(tokens))
|
||||
return encrypted
|
||||
}
|
||||
|
||||
async function encryptClientInformation(info: OAuthClientInformationMixed): Promise<string> {
|
||||
const { encrypted } = await encryptSecret(JSON.stringify(info))
|
||||
return encrypted
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `null` and clears the column when decryption fails (e.g. key rotation)
|
||||
* so the next call triggers a fresh OAuth flow instead of a 500.
|
||||
*/
|
||||
async function safeDecrypt<T>(
|
||||
rowId: string,
|
||||
column: 'tokens' | 'clientInformation' | 'codeVerifier',
|
||||
encrypted: string,
|
||||
decode: (decrypted: string) => T
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(encrypted)
|
||||
return decode(decrypted)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to decrypt ${column} for OAuth row ${rowId}; clearing column`, {
|
||||
error: toError(error).message,
|
||||
})
|
||||
await db
|
||||
.update(mcpServerOauth)
|
||||
.set({ [column]: null, updatedAt: new Date() })
|
||||
.where(eq(mcpServerOauth.id, rowId))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrCreateOauthRow(params: {
|
||||
mcpServerId: string
|
||||
userId: string
|
||||
workspaceId: string
|
||||
}): Promise<McpOauthRow> {
|
||||
const existing = await loadOauthRow(params)
|
||||
if (existing) return existing
|
||||
|
||||
const id = generateId()
|
||||
try {
|
||||
await db.insert(mcpServerOauth).values({
|
||||
id,
|
||||
mcpServerId: params.mcpServerId,
|
||||
userId: params.userId,
|
||||
workspaceId: params.workspaceId,
|
||||
})
|
||||
} catch (error) {
|
||||
const winner = await loadOauthRow(params)
|
||||
if (winner) return winner
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
mcpServerId: params.mcpServerId,
|
||||
userId: params.userId,
|
||||
workspaceId: params.workspaceId,
|
||||
clientInformation: null,
|
||||
tokens: null,
|
||||
codeVerifier: null,
|
||||
state: null,
|
||||
stateCreatedAt: null,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
type RawOauthRow = typeof mcpServerOauth.$inferSelect
|
||||
|
||||
async function mapOauthRow(row: RawOauthRow): Promise<McpOauthRow> {
|
||||
return {
|
||||
id: row.id,
|
||||
mcpServerId: row.mcpServerId,
|
||||
userId: row.userId,
|
||||
workspaceId: row.workspaceId,
|
||||
clientInformation: row.clientInformation
|
||||
? await safeDecrypt(
|
||||
row.id,
|
||||
'clientInformation',
|
||||
row.clientInformation,
|
||||
(d) => JSON.parse(d) as OAuthClientInformationMixed
|
||||
)
|
||||
: null,
|
||||
tokens: row.tokens
|
||||
? await safeDecrypt(row.id, 'tokens', row.tokens, (d) => JSON.parse(d) as OAuthTokens)
|
||||
: null,
|
||||
codeVerifier: row.codeVerifier
|
||||
? await safeDecrypt(row.id, 'codeVerifier', row.codeVerifier, (d) => d)
|
||||
: null,
|
||||
state: row.state,
|
||||
stateCreatedAt: row.stateCreatedAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadOauthRow(params: { mcpServerId: string }): Promise<McpOauthRow | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(mcpServerOauth)
|
||||
.where(eq(mcpServerOauth.mcpServerId, params.mcpServerId))
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
return mapOauthRow(row)
|
||||
}
|
||||
|
||||
export async function setOauthRowUser(rowId: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.update(mcpServerOauth)
|
||||
.set({ userId, updatedAt: new Date() })
|
||||
.where(eq(mcpServerOauth.id, rowId))
|
||||
}
|
||||
|
||||
export async function loadOauthRowByState(state: string): Promise<McpOauthRow | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(mcpServerOauth)
|
||||
.where(
|
||||
and(
|
||||
eq(mcpServerOauth.state, hashState(state)),
|
||||
gt(mcpServerOauth.stateCreatedAt, new Date(Date.now() - STATE_TTL_MS))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
return mapOauthRow(row)
|
||||
}
|
||||
|
||||
export async function saveClientInformation(
|
||||
rowId: string,
|
||||
info: OAuthClientInformationMixed
|
||||
): Promise<void> {
|
||||
const encrypted = await encryptClientInformation(info)
|
||||
await db
|
||||
.update(mcpServerOauth)
|
||||
.set({ clientInformation: encrypted, updatedAt: new Date() })
|
||||
.where(eq(mcpServerOauth.id, rowId))
|
||||
}
|
||||
|
||||
export async function saveTokens(rowId: string, tokens: OAuthTokens): Promise<void> {
|
||||
const encrypted = await encryptTokens(tokens)
|
||||
await db
|
||||
.update(mcpServerOauth)
|
||||
.set({ tokens: encrypted, lastRefreshedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(mcpServerOauth.id, rowId))
|
||||
}
|
||||
|
||||
export async function saveCodeVerifier(rowId: string, verifier: string): Promise<void> {
|
||||
const { encrypted } = await encryptSecret(verifier)
|
||||
await db
|
||||
.update(mcpServerOauth)
|
||||
.set({ codeVerifier: encrypted, updatedAt: new Date() })
|
||||
.where(eq(mcpServerOauth.id, rowId))
|
||||
}
|
||||
|
||||
export async function saveState(rowId: string, state: string): Promise<void> {
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(mcpServerOauth)
|
||||
.set({ state: hashState(state), stateCreatedAt: now, updatedAt: now })
|
||||
.where(eq(mcpServerOauth.id, rowId))
|
||||
}
|
||||
|
||||
export async function clearTokens(rowId: string): Promise<void> {
|
||||
await db
|
||||
.update(mcpServerOauth)
|
||||
.set({ tokens: null, updatedAt: new Date() })
|
||||
.where(eq(mcpServerOauth.id, rowId))
|
||||
}
|
||||
|
||||
export async function clearClient(rowId: string): Promise<void> {
|
||||
await db
|
||||
.update(mcpServerOauth)
|
||||
.set({ clientInformation: null, updatedAt: new Date() })
|
||||
.where(eq(mcpServerOauth.id, rowId))
|
||||
}
|
||||
|
||||
export async function clearVerifier(rowId: string): Promise<void> {
|
||||
await db
|
||||
.update(mcpServerOauth)
|
||||
.set({ codeVerifier: null, updatedAt: new Date() })
|
||||
.where(eq(mcpServerOauth.id, rowId))
|
||||
}
|
||||
|
||||
export async function clearState(rowId: string): Promise<void> {
|
||||
await db
|
||||
.update(mcpServerOauth)
|
||||
.set({ state: null, stateCreatedAt: null, updatedAt: new Date() })
|
||||
.where(eq(mcpServerOauth.id, rowId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-process serialization for an OAuth row. Refresh tokens rotate (RFC 6749 §6,
|
||||
* MCP §2.3.3), so two concurrent refreshes against the same row would race and one
|
||||
* would receive `invalid_grant`, wiping the credentials. We serialize SDK calls
|
||||
* that may trigger a refresh on a per-row basis.
|
||||
*/
|
||||
const refreshLocks = new Map<string, Promise<unknown>>()
|
||||
|
||||
export async function withMcpOauthRefreshLock<T>(rowId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = refreshLocks.get(rowId) ?? Promise.resolve()
|
||||
// Wait for the predecessor to settle (success or failure), discard its
|
||||
// value/error, then run fn. Each caller awaits its own fn's outcome — errors
|
||||
// do not propagate across callers in the chain.
|
||||
const next = prev.catch(() => undefined).then(() => fn())
|
||||
refreshLocks.set(rowId, next)
|
||||
const cleanup = () => {
|
||||
if (refreshLocks.get(rowId) === next) refreshLocks.delete(rowId)
|
||||
}
|
||||
next.then(cleanup, cleanup)
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { isLoopbackHostname } from '@/lib/core/utils/urls'
|
||||
|
||||
export class McpOauthInsecureUrlError extends Error {
|
||||
constructor(url: string) {
|
||||
super(`MCP OAuth requires https for non-loopback hosts: ${url}`)
|
||||
this.name = 'McpOauthInsecureUrlError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP spec §2.1 and RFC 8252 §7.3: OAuth flows must run over https, with
|
||||
* http allowed only for loopback addresses during local development.
|
||||
*/
|
||||
export function assertSafeOauthServerUrl(rawUrl: string | null | undefined): URL {
|
||||
if (!rawUrl) throw new McpOauthInsecureUrlError(String(rawUrl))
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(rawUrl)
|
||||
} catch {
|
||||
throw new McpOauthInsecureUrlError(rawUrl)
|
||||
}
|
||||
if (parsed.protocol === 'https:') return parsed
|
||||
if (parsed.protocol === 'http:' && isLoopbackHostname(parsed.hostname)) return parsed
|
||||
throw new McpOauthInsecureUrlError(rawUrl)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db, mcpServers } from '@sim/db'
|
||||
import { mcpServerOauth } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import {
|
||||
McpDnsResolutionError,
|
||||
McpDomainNotAllowedError,
|
||||
@@ -11,7 +13,9 @@ import {
|
||||
validateMcpDomain,
|
||||
validateMcpServerSsrf,
|
||||
} from '@/lib/mcp/domain-check'
|
||||
import { detectMcpAuthType, oauthCredsChanged, revokeMcpOauthTokens } from '@/lib/mcp/oauth'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import type { McpAuthType } from '@/lib/mcp/types'
|
||||
import { generateMcpServerId } from '@/lib/mcp/utils'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
@@ -39,6 +43,11 @@ export interface PerformCreateMcpServerParams extends ActorMetadata {
|
||||
retries?: number
|
||||
enabled?: boolean
|
||||
source?: string
|
||||
authType?: McpAuthType
|
||||
oauthClientId?: string | null
|
||||
oauthClientIdProvided?: boolean
|
||||
oauthClientSecret?: string | null
|
||||
oauthClientSecretProvided?: boolean
|
||||
}
|
||||
|
||||
export interface PerformUpdateMcpServerParams extends ActorMetadata {
|
||||
@@ -53,6 +62,11 @@ export interface PerformUpdateMcpServerParams extends ActorMetadata {
|
||||
timeout?: number
|
||||
retries?: number
|
||||
enabled?: boolean
|
||||
authType?: McpAuthType
|
||||
oauthClientId?: string | null
|
||||
oauthClientIdProvided?: boolean
|
||||
oauthClientSecret?: string | null
|
||||
oauthClientSecretProvided?: boolean
|
||||
}
|
||||
|
||||
export interface PerformDeleteMcpServerParams extends ActorMetadata {
|
||||
@@ -69,6 +83,7 @@ export interface PerformMcpServerResult {
|
||||
serverId?: string
|
||||
server?: typeof mcpServers.$inferSelect
|
||||
updated?: boolean
|
||||
authType?: McpAuthType
|
||||
}
|
||||
|
||||
async function validateMcpServerUrl(url: string): Promise<PerformMcpServerResult | null> {
|
||||
@@ -99,34 +114,92 @@ export async function performCreateMcpServer(
|
||||
const enabled = params.enabled !== false
|
||||
const serverId = params.url ? generateMcpServerId(params.workspaceId, params.url) : generateId()
|
||||
|
||||
const oauthClientSecretEncrypted = params.oauthClientSecret
|
||||
? (await encryptSecret(params.oauthClientSecret)).encrypted
|
||||
: null
|
||||
const oauthClientId = params.oauthClientId || null
|
||||
const hasHeaders = params.headers && Object.keys(params.headers).length > 0
|
||||
|
||||
try {
|
||||
const [existingServer] = await db
|
||||
.select({ id: mcpServers.id, deletedAt: mcpServers.deletedAt })
|
||||
.select({
|
||||
id: mcpServers.id,
|
||||
deletedAt: mcpServers.deletedAt,
|
||||
url: mcpServers.url,
|
||||
authType: mcpServers.authType,
|
||||
oauthClientId: mcpServers.oauthClientId,
|
||||
oauthClientSecret: mcpServers.oauthClientSecret,
|
||||
})
|
||||
.from(mcpServers)
|
||||
.where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, params.workspaceId)))
|
||||
.limit(1)
|
||||
|
||||
const urlChanged = existingServer ? existingServer.url !== params.url : true
|
||||
|
||||
let resolvedAuthType: McpAuthType = params.authType ?? 'headers'
|
||||
if (!params.authType) {
|
||||
if (existingServer && !urlChanged) {
|
||||
resolvedAuthType = (existingServer.authType ?? 'headers') as McpAuthType
|
||||
} else if (params.url && !hasHeaders) {
|
||||
try {
|
||||
resolvedAuthType = await detectMcpAuthType(params.url)
|
||||
} catch (e) {
|
||||
logger.warn('Probe failed, defaulting to headers', { url: params.url, error: e })
|
||||
resolvedAuthType = 'headers'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (params.oauthClientId) resolvedAuthType = 'oauth'
|
||||
|
||||
if (existingServer) {
|
||||
await db
|
||||
.update(mcpServers)
|
||||
.set({
|
||||
const credsChanged = await oauthCredsChanged({
|
||||
incomingClientId: oauthClientId,
|
||||
incomingClientIdProvided: params.oauthClientIdProvided ?? false,
|
||||
incomingClientSecret: params.oauthClientSecret,
|
||||
incomingClientSecretProvided: params.oauthClientSecretProvided ?? false,
|
||||
currentClientId: existingServer.oauthClientId,
|
||||
currentEncryptedClientSecret: existingServer.oauthClientSecret,
|
||||
})
|
||||
const isRevival = existingServer.deletedAt !== null
|
||||
const shouldClearOauth = urlChanged || credsChanged || isRevival
|
||||
|
||||
if (shouldClearOauth) await revokeMcpOauthTokens(serverId)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (shouldClearOauth) {
|
||||
await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, serverId))
|
||||
}
|
||||
const updateValues: Record<string, unknown> = {
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
transport,
|
||||
url: params.url,
|
||||
authType: resolvedAuthType,
|
||||
headers: params.headers || {},
|
||||
timeout,
|
||||
retries,
|
||||
enabled,
|
||||
connectionStatus: 'connected',
|
||||
lastConnected: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
})
|
||||
.where(eq(mcpServers.id, serverId))
|
||||
}
|
||||
if (resolvedAuthType === 'oauth') {
|
||||
if (shouldClearOauth) {
|
||||
updateValues.connectionStatus = 'disconnected'
|
||||
updateValues.lastConnected = null
|
||||
}
|
||||
} else {
|
||||
updateValues.connectionStatus = 'connected'
|
||||
updateValues.lastConnected = new Date()
|
||||
}
|
||||
if (params.oauthClientIdProvided) updateValues.oauthClientId = oauthClientId
|
||||
if (params.oauthClientSecretProvided) {
|
||||
updateValues.oauthClientSecret = oauthClientSecretEncrypted
|
||||
}
|
||||
await tx.update(mcpServers).set(updateValues).where(eq(mcpServers.id, serverId))
|
||||
})
|
||||
|
||||
await mcpService.clearCache(params.workspaceId)
|
||||
return { success: true, serverId, updated: true }
|
||||
return { success: true, serverId, updated: true, authType: resolvedAuthType }
|
||||
}
|
||||
|
||||
await db.insert(mcpServers).values({
|
||||
@@ -137,12 +210,15 @@ export async function performCreateMcpServer(
|
||||
description: params.description,
|
||||
transport,
|
||||
url: params.url,
|
||||
authType: resolvedAuthType,
|
||||
oauthClientId,
|
||||
oauthClientSecret: oauthClientSecretEncrypted,
|
||||
headers: params.headers || {},
|
||||
timeout,
|
||||
retries,
|
||||
enabled,
|
||||
connectionStatus: 'connected',
|
||||
lastConnected: new Date(),
|
||||
connectionStatus: resolvedAuthType === 'oauth' ? 'disconnected' : 'connected',
|
||||
lastConnected: resolvedAuthType === 'oauth' ? null : new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -193,7 +269,7 @@ export async function performCreateMcpServer(
|
||||
request: params.request,
|
||||
})
|
||||
|
||||
return { success: true, serverId, updated: false }
|
||||
return { success: true, serverId, updated: false, authType: resolvedAuthType }
|
||||
} catch (error) {
|
||||
logger.error('Failed to create MCP server', { error })
|
||||
return { success: false, error: 'Failed to register MCP server', errorCode: 'internal' }
|
||||
@@ -208,6 +284,11 @@ export async function performUpdateMcpServer(
|
||||
if (validation) return validation
|
||||
}
|
||||
|
||||
const oauthClientSecretEncrypted =
|
||||
params.oauthClientSecretProvided && params.oauthClientSecret
|
||||
? (await encryptSecret(params.oauthClientSecret)).encrypted
|
||||
: null
|
||||
|
||||
const updateData: Partial<typeof mcpServers.$inferInsert> = { updatedAt: new Date() }
|
||||
if (params.name !== undefined) updateData.name = params.name
|
||||
if (params.description !== undefined) updateData.description = params.description
|
||||
@@ -217,10 +298,20 @@ export async function performUpdateMcpServer(
|
||||
if (params.timeout !== undefined) updateData.timeout = params.timeout
|
||||
if (params.retries !== undefined) updateData.retries = params.retries
|
||||
if (params.enabled !== undefined) updateData.enabled = params.enabled
|
||||
if (params.authType !== undefined) updateData.authType = params.authType
|
||||
if (params.oauthClientIdProvided) updateData.oauthClientId = params.oauthClientId || null
|
||||
if (params.oauthClientSecretProvided) {
|
||||
updateData.oauthClientSecret = oauthClientSecretEncrypted
|
||||
}
|
||||
|
||||
try {
|
||||
const [currentServer] = await db
|
||||
.select({ url: mcpServers.url })
|
||||
.select({
|
||||
url: mcpServers.url,
|
||||
authType: mcpServers.authType,
|
||||
oauthClientId: mcpServers.oauthClientId,
|
||||
oauthClientSecret: mcpServers.oauthClientSecret,
|
||||
})
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
@@ -231,22 +322,60 @@ export async function performUpdateMcpServer(
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const [server] = await db
|
||||
.update(mcpServers)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(mcpServers.id, params.serverId),
|
||||
eq(mcpServers.workspaceId, params.workspaceId),
|
||||
isNull(mcpServers.deletedAt)
|
||||
if (!currentServer) return { success: false, error: 'Server not found', errorCode: 'not_found' }
|
||||
|
||||
if (
|
||||
params.oauthClientId &&
|
||||
currentServer.authType !== 'oauth' &&
|
||||
updateData.authType === undefined
|
||||
) {
|
||||
updateData.authType = 'oauth'
|
||||
}
|
||||
|
||||
const urlChanged = params.url !== undefined && currentServer.url !== params.url
|
||||
const credsChanged = await oauthCredsChanged({
|
||||
incomingClientId: params.oauthClientId,
|
||||
incomingClientIdProvided: params.oauthClientIdProvided ?? false,
|
||||
incomingClientSecret: params.oauthClientSecret,
|
||||
incomingClientSecretProvided: params.oauthClientSecretProvided ?? false,
|
||||
currentClientId: currentServer.oauthClientId,
|
||||
currentEncryptedClientSecret: currentServer.oauthClientSecret,
|
||||
})
|
||||
const shouldClearOauth = urlChanged || credsChanged
|
||||
const resolvedAuthType = (updateData.authType ?? currentServer.authType) as McpAuthType
|
||||
if (shouldClearOauth && resolvedAuthType === 'oauth') {
|
||||
updateData.connectionStatus = 'disconnected'
|
||||
updateData.lastConnected = null
|
||||
}
|
||||
|
||||
if (shouldClearOauth) await revokeMcpOauthTokens(params.serverId)
|
||||
|
||||
const server = await db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
.update(mcpServers)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(mcpServers.id, params.serverId),
|
||||
eq(mcpServers.workspaceId, params.workspaceId),
|
||||
isNull(mcpServers.deletedAt)
|
||||
)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
.returning()
|
||||
|
||||
if (!updated) return null
|
||||
|
||||
if (shouldClearOauth) {
|
||||
await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, params.serverId))
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' }
|
||||
|
||||
const shouldClearCache =
|
||||
(params.url !== undefined && currentServer?.url !== params.url) ||
|
||||
urlChanged ||
|
||||
credsChanged ||
|
||||
params.enabled !== undefined ||
|
||||
params.headers !== undefined ||
|
||||
params.timeout !== undefined ||
|
||||
@@ -284,6 +413,7 @@ export async function performDeleteMcpServer(
|
||||
params: PerformDeleteMcpServerParams
|
||||
): Promise<PerformMcpServerResult> {
|
||||
try {
|
||||
await revokeMcpOauthTokens(params.serverId)
|
||||
const [server] = await db
|
||||
.delete(mcpServers)
|
||||
.where(
|
||||
|
||||
+112
-29
@@ -2,10 +2,12 @@
|
||||
* MCP Service - Clean stateless service for MCP operations
|
||||
*/
|
||||
|
||||
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServers } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { isTest } from '@/lib/core/config/feature-flags'
|
||||
@@ -17,20 +19,27 @@ import {
|
||||
validateMcpDomain,
|
||||
validateMcpServerSsrf,
|
||||
} from '@/lib/mcp/domain-check'
|
||||
import {
|
||||
getOrCreateOauthRow,
|
||||
loadPreregisteredClient,
|
||||
SimMcpOauthProvider,
|
||||
withMcpOauthRefreshLock,
|
||||
} from '@/lib/mcp/oauth'
|
||||
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
|
||||
import {
|
||||
createMcpCacheAdapter,
|
||||
getMcpCacheType,
|
||||
type McpCacheStorageAdapter,
|
||||
} from '@/lib/mcp/storage'
|
||||
import type {
|
||||
McpServerConfig,
|
||||
McpServerStatusConfig,
|
||||
McpServerSummary,
|
||||
McpTool,
|
||||
McpToolCall,
|
||||
McpToolResult,
|
||||
McpTransport,
|
||||
import {
|
||||
McpOauthAuthorizationRequiredError,
|
||||
type McpServerConfig,
|
||||
type McpServerStatusConfig,
|
||||
type McpServerSummary,
|
||||
type McpTool,
|
||||
type McpToolCall,
|
||||
type McpToolResult,
|
||||
type McpTransport,
|
||||
} from '@/lib/mcp/types'
|
||||
import { MCP_CONSTANTS } from '@/lib/mcp/utils'
|
||||
|
||||
@@ -112,6 +121,8 @@ class McpService {
|
||||
description: server.description || undefined,
|
||||
transport: 'streamable-http' as const,
|
||||
url: server.url || undefined,
|
||||
authType: (server.authType as McpServerConfig['authType']) ?? 'headers',
|
||||
workspaceId: server.workspaceId,
|
||||
headers: (server.headers as Record<string, string>) || {},
|
||||
timeout: server.timeout || 30000,
|
||||
retries: server.retries || 3,
|
||||
@@ -143,6 +154,8 @@ class McpService {
|
||||
description: server.description || undefined,
|
||||
transport: server.transport as McpTransport,
|
||||
url: server.url || undefined,
|
||||
authType: (server.authType as McpServerConfig['authType']) ?? 'headers',
|
||||
workspaceId: server.workspaceId,
|
||||
headers: (server.headers as Record<string, string>) || {},
|
||||
timeout: server.timeout || 30000,
|
||||
retries: server.retries || 3,
|
||||
@@ -158,7 +171,8 @@ class McpService {
|
||||
*/
|
||||
private async createClient(
|
||||
config: McpServerConfig,
|
||||
resolvedIP: string | null
|
||||
resolvedIP: string | null,
|
||||
userId?: string
|
||||
): Promise<McpClient> {
|
||||
const securityPolicy = {
|
||||
requireConsent: true,
|
||||
@@ -167,13 +181,46 @@ class McpService {
|
||||
allowedOrigins: config.url ? [new URL(config.url).origin] : undefined,
|
||||
}
|
||||
|
||||
const client = new McpClient({
|
||||
config,
|
||||
securityPolicy,
|
||||
resolvedIP: resolvedIP ?? undefined,
|
||||
if (config.authType !== 'oauth') {
|
||||
const client = new McpClient({
|
||||
config,
|
||||
securityPolicy,
|
||||
resolvedIP: resolvedIP ?? undefined,
|
||||
})
|
||||
await client.connect()
|
||||
return client
|
||||
}
|
||||
|
||||
if (!userId || !config.workspaceId) {
|
||||
throw new Error('OAuth MCP server requires both userId and workspaceId')
|
||||
}
|
||||
const workspaceId = config.workspaceId
|
||||
|
||||
// Load the row inside the refresh lock so concurrent callers observe tokens
|
||||
// written by a predecessor refresh, rather than a stale snapshot. Without
|
||||
// this, the second caller's provider would hold a rotated-out refresh token
|
||||
// and the SDK would trip `invalid_grant`. The lock is keyed on serverId
|
||||
// since the row is per-server.
|
||||
return withMcpOauthRefreshLock(config.id, async () => {
|
||||
const row = await getOrCreateOauthRow({
|
||||
mcpServerId: config.id,
|
||||
userId,
|
||||
workspaceId,
|
||||
})
|
||||
if (!row.tokens) {
|
||||
throw new McpOauthAuthorizationRequiredError(config.id, config.name)
|
||||
}
|
||||
const preregistered = await loadPreregisteredClient(config.id)
|
||||
const authProvider = new SimMcpOauthProvider({ row, preregistered })
|
||||
const client = new McpClient({
|
||||
config,
|
||||
securityPolicy,
|
||||
authProvider,
|
||||
resolvedIP: resolvedIP ?? undefined,
|
||||
})
|
||||
await client.connect()
|
||||
return client
|
||||
})
|
||||
await client.connect()
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,7 +256,7 @@ class McpService {
|
||||
if (extraHeaders && Object.keys(extraHeaders).length > 0) {
|
||||
resolvedConfig.headers = { ...resolvedConfig.headers, ...extraHeaders }
|
||||
}
|
||||
const client = await this.createClient(resolvedConfig, resolvedIP)
|
||||
const client = await this.createClient(resolvedConfig, resolvedIP, userId)
|
||||
|
||||
try {
|
||||
const result = await client.callTool(toolCall)
|
||||
@@ -235,17 +282,16 @@ class McpService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error indicates a session-related issue that might be resolved by retry
|
||||
* Detects an expired or unknown `Mcp-Session-Id` so the caller can retry.
|
||||
* Per MCP spec, the server returns HTTP 404 for an unknown session id and
|
||||
* may return 400 when the session header is malformed; the SDK surfaces
|
||||
* both as `StreamableHTTPError` with a typed numeric `code` field.
|
||||
*/
|
||||
private isSessionError(error: unknown): boolean {
|
||||
const message = toError(error).message
|
||||
const lowerMessage = message.toLowerCase()
|
||||
return (
|
||||
lowerMessage.includes('session') ||
|
||||
lowerMessage.includes('400') ||
|
||||
lowerMessage.includes('404') ||
|
||||
lowerMessage.includes('no valid session')
|
||||
)
|
||||
if (error instanceof StreamableHTTPError) {
|
||||
return error.code === 404 || error.code === 400
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,7 +410,7 @@ class McpService {
|
||||
userId,
|
||||
workspaceId
|
||||
)
|
||||
const client = await this.createClient(resolvedConfig, resolvedIP)
|
||||
const client = await this.createClient(resolvedConfig, resolvedIP, userId)
|
||||
try {
|
||||
const tools = await client.listTools()
|
||||
logger.debug(
|
||||
@@ -393,6 +439,27 @@ class McpService {
|
||||
result.value.tools.length
|
||||
)
|
||||
)
|
||||
} else if (
|
||||
result.reason instanceof McpOauthAuthorizationRequiredError ||
|
||||
result.reason instanceof UnauthorizedError
|
||||
) {
|
||||
// Force 'disconnected' so the settings UI surfaces the re-auth button
|
||||
// instead of a stale 'connected' state when refresh has expired.
|
||||
logger.info(`[${requestId}] Skipping server ${server.name}: OAuth authorization pending`)
|
||||
statusUpdates.push(
|
||||
db
|
||||
.update(mcpServers)
|
||||
.set({
|
||||
connectionStatus: 'disconnected',
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(mcpServers.id, server.id!))
|
||||
.then(() => undefined)
|
||||
.catch((err) => {
|
||||
logger.warn(`[${requestId}] Failed to mark server ${server.id} disconnected:`, err)
|
||||
})
|
||||
)
|
||||
} else {
|
||||
failedCount++
|
||||
const errorMessage = getErrorMessage(result.reason, 'Unknown error')
|
||||
@@ -472,7 +539,7 @@ class McpService {
|
||||
userId,
|
||||
workspaceId
|
||||
)
|
||||
const client = await this.createClient(resolvedConfig, resolvedIP)
|
||||
const client = await this.createClient(resolvedConfig, resolvedIP, userId)
|
||||
|
||||
try {
|
||||
const tools = await client.listTools()
|
||||
@@ -516,7 +583,7 @@ class McpService {
|
||||
userId,
|
||||
workspaceId
|
||||
)
|
||||
const client = await this.createClient(resolvedConfig, resolvedIP)
|
||||
const client = await this.createClient(resolvedConfig, resolvedIP, userId)
|
||||
const tools = await client.listTools()
|
||||
await client.disconnect()
|
||||
|
||||
@@ -531,6 +598,22 @@ class McpService {
|
||||
error: undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof McpOauthAuthorizationRequiredError ||
|
||||
error instanceof UnauthorizedError
|
||||
) {
|
||||
summaries.push({
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
url: config.url,
|
||||
transport: config.transport,
|
||||
status: 'disconnected',
|
||||
toolCount: 0,
|
||||
lastSeen: undefined,
|
||||
error: undefined,
|
||||
})
|
||||
continue
|
||||
}
|
||||
summaries.push({
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
|
||||
export type McpTransport = 'streamable-http'
|
||||
|
||||
/**
|
||||
* Auth mode for an outbound MCP server connection.
|
||||
* - `none` — server requires no auth.
|
||||
* - `headers` — static header map (legacy / API-token / bearer).
|
||||
* - `oauth` — OAuth 2.1 + PKCE via the SDK's authProvider, persisted per workspace server.
|
||||
*/
|
||||
export type McpAuthType = 'none' | 'headers' | 'oauth'
|
||||
|
||||
export interface McpServerStatusConfig {
|
||||
consecutiveFailures: number
|
||||
lastSuccessfulDiscovery: string | null
|
||||
@@ -15,6 +23,13 @@ export interface McpServerConfig {
|
||||
description?: string
|
||||
transport: McpTransport
|
||||
url?: string
|
||||
authType?: McpAuthType
|
||||
/**
|
||||
* Required when `authType === 'oauth'` — identifies whose stored tokens
|
||||
* to use when establishing the connection. Omit for header / none auth.
|
||||
*/
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
headers?: Record<string, string>
|
||||
timeout?: number
|
||||
retries?: number
|
||||
@@ -136,6 +151,22 @@ export class McpConnectionError extends McpError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when an OAuth-protected MCP server is reachable but the current
|
||||
* user has not yet authorized Sim. This is a benign "pending" state, not a
|
||||
* connection failure — callers should surface a re-auth prompt rather than
|
||||
* marking the server as errored.
|
||||
*/
|
||||
export class McpOauthAuthorizationRequiredError extends McpError {
|
||||
constructor(
|
||||
public readonly serverId: string,
|
||||
serverName: string
|
||||
) {
|
||||
super(`OAuth authorization required for "${serverName}"`)
|
||||
this.name = 'McpOauthAuthorizationRequiredError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface McpServerSummary {
|
||||
id: string
|
||||
name: string
|
||||
@@ -169,6 +200,13 @@ export interface McpClientOptions {
|
||||
* just validated the URL via `validateMcpServerSsrf`.
|
||||
*/
|
||||
resolvedIP?: string
|
||||
/**
|
||||
* SDK-compatible OAuth client provider. When provided, the underlying
|
||||
* StreamableHTTPClientTransport delegates token discovery, refresh, and
|
||||
* 401 recovery to it. Should be supplied for `authType === 'oauth'`
|
||||
* server configs.
|
||||
*/
|
||||
authProvider?: import('@modelcontextprotocol/sdk/client/auth.js').OAuthClientProvider
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { McpToolSchema } from './types'
|
||||
* Extended property definition for workflow tool schemas.
|
||||
* More specific than the generic McpToolSchema properties.
|
||||
*/
|
||||
interface McpToolProperty {
|
||||
export interface McpToolProperty {
|
||||
[key: string]: unknown
|
||||
type: string
|
||||
description?: string
|
||||
@@ -24,7 +24,7 @@ export interface McpToolInputSchema extends McpToolSchema {
|
||||
properties: Record<string, McpToolProperty>
|
||||
}
|
||||
|
||||
interface McpToolDefinition {
|
||||
export interface McpToolDefinition {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: McpToolInputSchema
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE "mcp_server_oauth" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"mcp_server_id" text NOT NULL,
|
||||
"user_id" text,
|
||||
"workspace_id" text NOT NULL,
|
||||
"client_information" text,
|
||||
"tokens" text,
|
||||
"code_verifier" text,
|
||||
"state" text,
|
||||
"state_created_at" timestamp,
|
||||
"last_refreshed_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "mcp_servers" ADD COLUMN "auth_type" text DEFAULT 'headers' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "mcp_servers" ADD COLUMN "oauth_client_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "mcp_servers" ADD COLUMN "oauth_client_secret" text;--> statement-breakpoint
|
||||
ALTER TABLE "mcp_server_oauth" ADD CONSTRAINT "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk" FOREIGN KEY ("mcp_server_id") REFERENCES "public"."mcp_servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "mcp_server_oauth" ADD CONSTRAINT "mcp_server_oauth_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "mcp_server_oauth" ADD CONSTRAINT "mcp_server_oauth_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "mcp_server_oauth_server_unique" ON "mcp_server_oauth" USING btree ("mcp_server_id");--> statement-breakpoint
|
||||
CREATE INDEX "mcp_server_oauth_state_idx" ON "mcp_server_oauth" USING btree ("state");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1464,6 +1464,13 @@
|
||||
"when": 1779246572978,
|
||||
"tag": "0209_smiling_fixer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 210,
|
||||
"version": "7",
|
||||
"when": 1779299303134,
|
||||
"tag": "0210_mcp_oauth",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2211,6 +2211,14 @@ export const mcpServers = pgTable(
|
||||
transport: text('transport').notNull(),
|
||||
url: text('url'),
|
||||
|
||||
authType: text('auth_type').notNull().default('headers'),
|
||||
/**
|
||||
* Optional pre-registered OAuth credentials for servers that don't
|
||||
* support Dynamic Client Registration (RFC 7591). When set, these
|
||||
* shortcut the SDK's DCR step. `oauthClientSecret` is encrypted.
|
||||
*/
|
||||
oauthClientId: text('oauth_client_id'),
|
||||
oauthClientSecret: text('oauth_client_secret'),
|
||||
headers: json('headers').default('{}'),
|
||||
timeout: integer('timeout').default(30000),
|
||||
retries: integer('retries').default(3),
|
||||
@@ -2246,6 +2254,60 @@ export const mcpServers = pgTable(
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Workspace-scoped OAuth state for an outbound MCP server.
|
||||
*
|
||||
* Holds the SDK-managed OAuth artifacts needed to drive the standard MCP
|
||||
* OAuth 2.1 + PKCE + dynamic-client-registration flow against a remote MCP
|
||||
* server. One row per MCP server; workspace members share the authorized
|
||||
* connection just like they share the MCP server definition.
|
||||
*/
|
||||
export const mcpServerOauth = pgTable(
|
||||
'mcp_server_oauth',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
mcpServerId: text('mcp_server_id')
|
||||
.notNull()
|
||||
.references(() => mcpServers.id, { onDelete: 'cascade' }),
|
||||
/** Last workspace user who initiated/completed authorization. */
|
||||
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
|
||||
workspaceId: text('workspace_id')
|
||||
.notNull()
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
|
||||
/**
|
||||
* Encrypted JSON of the RFC 7591 dynamic client registration result.
|
||||
* Encrypted because some authorization servers may issue a client_secret
|
||||
* even for clients advertising `token_endpoint_auth_method: 'none'`.
|
||||
*/
|
||||
clientInformation: text('client_information'),
|
||||
|
||||
/** Encrypted JSON of the OAuth tokens (access + refresh). */
|
||||
tokens: text('tokens'),
|
||||
|
||||
/** PKCE verifier held only between /authorize and /callback. */
|
||||
codeVerifier: text('code_verifier'),
|
||||
|
||||
/** Opaque state mint to correlate the callback. */
|
||||
state: text('state'),
|
||||
|
||||
/**
|
||||
* When `state` was minted. Used to expire the active-flow window and the
|
||||
* state replay window independently of `updatedAt`, which is touched by
|
||||
* token refreshes and other writes.
|
||||
*/
|
||||
stateCreatedAt: timestamp('state_created_at'),
|
||||
|
||||
lastRefreshedAt: timestamp('last_refreshed_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
serverUnique: uniqueIndex('mcp_server_oauth_server_unique').on(table.mcpServerId),
|
||||
stateIdx: index('mcp_server_oauth_state_idx').on(table.state),
|
||||
})
|
||||
)
|
||||
|
||||
// SSO Provider table
|
||||
export const ssoProvider = pgTable(
|
||||
'sso_provider',
|
||||
|
||||
@@ -84,6 +84,13 @@ export {
|
||||
loggingSessionMock,
|
||||
loggingSessionMockFns,
|
||||
} from './logging-session.mock'
|
||||
// MCP OAuth mocks (for @/lib/mcp/oauth)
|
||||
export {
|
||||
McpOauthInsecureUrlErrorMock,
|
||||
McpOauthRedirectRequiredMock,
|
||||
mcpOauthMock,
|
||||
mcpOauthMockFns,
|
||||
} from './mcp-oauth.mock'
|
||||
// Permission mocks
|
||||
export { permissionsMock, permissionsMockFns } from './permissions.mock'
|
||||
// PostHog server mocks (for @/lib/posthog/server)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Controllable mock functions for `@/lib/mcp/oauth`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { mcpOauthMockFns } from '@sim/testing'
|
||||
*
|
||||
* mcpOauthMockFns.mockGetOrCreateOauthRow.mockResolvedValue({ id: 'oauth-row-1', ... })
|
||||
* ```
|
||||
*/
|
||||
export const mcpOauthMockFns = {
|
||||
mockAssertSafeOauthServerUrl: vi.fn(),
|
||||
mockGetOrCreateOauthRow: vi.fn(),
|
||||
mockLoadOauthRow: vi.fn(),
|
||||
mockLoadOauthRowByState: vi.fn(),
|
||||
mockLoadPreregisteredClient: vi.fn(),
|
||||
mockSetOauthRowUser: vi.fn(),
|
||||
mockSaveClientInformation: vi.fn(),
|
||||
mockSaveTokens: vi.fn(),
|
||||
mockSaveCodeVerifier: vi.fn(),
|
||||
mockSaveState: vi.fn(),
|
||||
mockClearTokens: vi.fn(),
|
||||
mockClearClient: vi.fn(),
|
||||
mockClearVerifier: vi.fn(),
|
||||
mockClearState: vi.fn(),
|
||||
mockRevokeMcpOauthTokens: vi.fn(),
|
||||
mockWithMcpOauthRefreshLock: vi.fn(async (_rowId: string, fn: () => Promise<unknown>) => fn()),
|
||||
}
|
||||
|
||||
export class McpOauthRedirectRequiredMock extends Error {
|
||||
constructor(public readonly authorizationUrl: string) {
|
||||
super('MCP OAuth redirect required')
|
||||
this.name = 'McpOauthRedirectRequiredMock'
|
||||
}
|
||||
}
|
||||
|
||||
export class McpOauthInsecureUrlErrorMock extends Error {
|
||||
constructor(public readonly url: string) {
|
||||
super(`Insecure MCP OAuth server URL: ${url}`)
|
||||
this.name = 'McpOauthInsecureUrlErrorMock'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static mock module for `@/lib/mcp/oauth`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
|
||||
* ```
|
||||
*/
|
||||
export const mcpOauthMock = {
|
||||
assertSafeOauthServerUrl: mcpOauthMockFns.mockAssertSafeOauthServerUrl,
|
||||
getOrCreateOauthRow: mcpOauthMockFns.mockGetOrCreateOauthRow,
|
||||
loadOauthRow: mcpOauthMockFns.mockLoadOauthRow,
|
||||
loadOauthRowByState: mcpOauthMockFns.mockLoadOauthRowByState,
|
||||
loadPreregisteredClient: mcpOauthMockFns.mockLoadPreregisteredClient,
|
||||
setOauthRowUser: mcpOauthMockFns.mockSetOauthRowUser,
|
||||
saveClientInformation: mcpOauthMockFns.mockSaveClientInformation,
|
||||
saveTokens: mcpOauthMockFns.mockSaveTokens,
|
||||
saveCodeVerifier: mcpOauthMockFns.mockSaveCodeVerifier,
|
||||
saveState: mcpOauthMockFns.mockSaveState,
|
||||
clearTokens: mcpOauthMockFns.mockClearTokens,
|
||||
clearClient: mcpOauthMockFns.mockClearClient,
|
||||
clearVerifier: mcpOauthMockFns.mockClearVerifier,
|
||||
clearState: mcpOauthMockFns.mockClearState,
|
||||
revokeMcpOauthTokens: mcpOauthMockFns.mockRevokeMcpOauthTokens,
|
||||
withMcpOauthRefreshLock: mcpOauthMockFns.mockWithMcpOauthRefreshLock,
|
||||
McpOauthRedirectRequired: McpOauthRedirectRequiredMock,
|
||||
McpOauthInsecureUrlError: McpOauthInsecureUrlErrorMock,
|
||||
SimMcpOauthProvider: vi.fn().mockImplementation((value) => value),
|
||||
}
|
||||
@@ -870,6 +870,19 @@ export const schemaMock = {
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
},
|
||||
mcpServerOauth: {
|
||||
id: 'id',
|
||||
mcpServerId: 'mcpServerId',
|
||||
userId: 'userId',
|
||||
workspaceId: 'workspaceId',
|
||||
clientInformation: 'clientInformation',
|
||||
tokens: 'tokens',
|
||||
codeVerifier: 'codeVerifier',
|
||||
state: 'state',
|
||||
lastRefreshedAt: 'lastRefreshedAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
},
|
||||
ssoProvider: {
|
||||
id: 'id',
|
||||
issuer: 'issuer',
|
||||
|
||||
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
|
||||
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
|
||||
|
||||
const BASELINE = {
|
||||
totalRoutes: 747,
|
||||
zodRoutes: 747,
|
||||
totalRoutes: 749,
|
||||
zodRoutes: 749,
|
||||
nonZodRoutes: 0,
|
||||
} as const
|
||||
|
||||
@@ -79,6 +79,10 @@ const INDIRECT_ZOD_ROUTES = new Set([
|
||||
// MCP routes that take only auth context (no client-supplied params/query/body).
|
||||
'apps/sim/app/api/mcp/discover/route.ts',
|
||||
'apps/sim/app/api/mcp/tools/stored/route.ts',
|
||||
// MCP OAuth callback is the provider redirect target — the response is HTML
|
||||
// that closes the popup, so the JSON-mode contract framework doesn't fit.
|
||||
// Validation is enforced via state lookup + session-vs-row userId match.
|
||||
'apps/sim/app/api/mcp/oauth/callback/route.ts',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user