chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
|
||||
import {
|
||||
type McpApiResponse,
|
||||
McpConnectionError,
|
||||
McpOauthAuthorizationRequiredError,
|
||||
} from '@/lib/mcp/types'
|
||||
import { isMcpTool, MCP } from '@/executor/constants'
|
||||
|
||||
export const MCP_CONSTANTS = {
|
||||
EXECUTION_TIMEOUT: DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
CACHE_TIMEOUT: 5 * 60 * 1000,
|
||||
DEFAULT_RETRIES: 3,
|
||||
DEFAULT_CONNECTION_TIMEOUT: 30000,
|
||||
MAX_CACHE_SIZE: 1000,
|
||||
MAX_CONSECUTIVE_FAILURES: 3,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Core MCP tool parameter keys that are metadata, not user-entered test values.
|
||||
* These should be preserved when cleaning up params during schema updates.
|
||||
*/
|
||||
export const MCP_TOOL_CORE_PARAMS = new Set(['serverId', 'serverUrl', 'toolName', 'serverName'])
|
||||
|
||||
/**
|
||||
* Sanitizes a string by removing invisible Unicode characters that cause HTTP header errors.
|
||||
* Handles characters like U+2028 (Line Separator) that can be introduced via copy-paste.
|
||||
*/
|
||||
export function sanitizeForHttp(value: string): string {
|
||||
return value
|
||||
.replace(/[\u2028\u2029\u200B-\u200D\uFEFF]/g, '')
|
||||
.replace(/[\x00-\x1F\x7F]/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes all header key-value pairs for HTTP usage.
|
||||
*/
|
||||
export function sanitizeHeaders(
|
||||
headers: Record<string, string> | undefined
|
||||
): Record<string, string> | undefined {
|
||||
if (!headers) return headers
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers)
|
||||
.map(([key, value]) => [sanitizeForHttp(key), sanitizeForHttp(value)])
|
||||
.filter(([key, value]) => key !== '' && value !== '')
|
||||
)
|
||||
}
|
||||
|
||||
export const MCP_CLIENT_CONSTANTS = {
|
||||
CLIENT_TIMEOUT: DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
AUTO_REFRESH_INTERVAL: 5 * 60 * 1000,
|
||||
LIST_TOOLS_TIMEOUT_MS: 10_000,
|
||||
FAILURE_CACHE_TTL_MS: 120_000,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Create standardized MCP error response.
|
||||
* Always returns the defaultMessage to clients to prevent leaking internal error details.
|
||||
* Callers are responsible for logging the original error before calling this function.
|
||||
*/
|
||||
export function createMcpErrorResponse(
|
||||
_error: unknown,
|
||||
defaultMessage: string,
|
||||
status = 500
|
||||
): NextResponse {
|
||||
const response: McpApiResponse = {
|
||||
success: false,
|
||||
error: defaultMessage,
|
||||
}
|
||||
|
||||
return NextResponse.json(response, { status })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standardized MCP success response
|
||||
*/
|
||||
export function createMcpSuccessResponse<T>(data: T, status = 200): NextResponse {
|
||||
const response: McpApiResponse<T> = {
|
||||
success: true,
|
||||
data,
|
||||
}
|
||||
|
||||
return NextResponse.json(response, { status })
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps MCP orchestration error codes to safe HTTP statuses.
|
||||
*/
|
||||
export function mcpOrchestrationStatus(errorCode: string | undefined): number {
|
||||
if (errorCode === 'validation') return 400
|
||||
if (errorCode === 'forbidden') return 403
|
||||
if (errorCode === 'not_found') return 404
|
||||
if (errorCode === 'conflict') return 409
|
||||
if (errorCode === 'bad_gateway') return 502
|
||||
return 500
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate string parameter
|
||||
* Consolidates parameter validation logic found across routes
|
||||
*/
|
||||
export function validateStringParam(
|
||||
value: unknown,
|
||||
paramName: string
|
||||
): { isValid: true } | { isValid: false; error: string } {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} is required and must be a string`,
|
||||
}
|
||||
}
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required fields in request body
|
||||
*/
|
||||
export function validateRequiredFields(
|
||||
body: Record<string, unknown>,
|
||||
requiredFields: string[]
|
||||
): { isValid: true } | { isValid: false; error: string } {
|
||||
const missingFields = requiredFields.filter((field) => !(field in body))
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Missing required fields: ${missingFields.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced error categorization for more specific HTTP status codes.
|
||||
* Returns safe, generic messages to prevent leaking internal details.
|
||||
*/
|
||||
export function categorizeError(error: unknown): { message: string; status: number } {
|
||||
if (!(error instanceof Error)) {
|
||||
return { message: 'Unknown error occurred', status: 500 }
|
||||
}
|
||||
|
||||
// Typed dispatch first — our own classes carry definitive intent.
|
||||
if (error instanceof McpOauthAuthorizationRequiredError || error instanceof UnauthorizedError) {
|
||||
return { message: 'Authentication required', status: 401 }
|
||||
}
|
||||
if (error instanceof McpConnectionError) {
|
||||
if (error.message.toLowerCase().includes('cooldown')) {
|
||||
return { message: 'Server temporarily unavailable', status: 503 }
|
||||
}
|
||||
return { message: 'Connection failed', status: 502 }
|
||||
}
|
||||
|
||||
// Fall back to substring matching for SDK / third-party errors we don't
|
||||
// own a typed class for.
|
||||
const msg = error.message.toLowerCase()
|
||||
|
||||
if (msg.includes('timeout')) {
|
||||
return { message: 'Request timed out', status: 408 }
|
||||
}
|
||||
if (msg.includes('cooldown')) {
|
||||
return { message: 'Server temporarily unavailable', status: 503 }
|
||||
}
|
||||
if (msg.includes('not found') || msg.includes('not accessible')) {
|
||||
return { message: 'Resource not found', status: 404 }
|
||||
}
|
||||
if (msg.includes('authentication') || msg.includes('unauthorized')) {
|
||||
return { message: 'Authentication required', status: 401 }
|
||||
}
|
||||
if (msg.includes('invalid') || msg.includes('missing required') || msg.includes('validation')) {
|
||||
return { message: 'Invalid request parameters', status: 400 }
|
||||
}
|
||||
return { message: 'Internal server error', status: 500 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standardized MCP tool ID from server ID and tool name
|
||||
*/
|
||||
export function createMcpToolId(serverId: string, toolName: string): string {
|
||||
const normalizedServerId = isMcpTool(serverId) ? serverId : `${MCP.TOOL_PREFIX}${serverId}`
|
||||
return `${normalizedServerId}-${toolName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse MCP tool ID to extract server ID and tool name
|
||||
*/
|
||||
export function parseMcpToolId(toolId: string): { serverId: string; toolName: string } {
|
||||
const parts = toolId.split('-')
|
||||
if (parts.length < 3 || parts[0] !== 'mcp') {
|
||||
throw new Error(`Invalid MCP tool ID format: ${toolId}. Expected: mcp-serverId-toolName`)
|
||||
}
|
||||
|
||||
const serverId = `${parts[0]}-${parts[1]}`
|
||||
const toolName = parts.slice(2).join('-')
|
||||
|
||||
return { serverId, toolName }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic MCP server ID based on workspace and URL.
|
||||
*
|
||||
* This ensures that re-adding the same MCP server (same URL in the same workspace)
|
||||
* produces the same ID, preventing "server not found" errors when workflows
|
||||
* reference the old server ID.
|
||||
*
|
||||
* The ID is a hash of: workspaceId + normalized URL
|
||||
* Format: mcp-<8 char hash>
|
||||
*/
|
||||
export function generateMcpServerId(workspaceId: string, url: string): string {
|
||||
const normalizedUrl = normalizeUrlForHashing(url)
|
||||
|
||||
const input = `${workspaceId}:${normalizedUrl}`
|
||||
const hash = simpleHash(input)
|
||||
|
||||
return `mcp-${hash}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize URL for consistent hashing.
|
||||
* - Converts to lowercase
|
||||
* - Removes trailing slashes
|
||||
* - Removes query parameters and fragments
|
||||
*/
|
||||
function normalizeUrlForHashing(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const normalized = `${parsed.origin}${parsed.pathname}`.toLowerCase().replace(/\/+$/, '')
|
||||
return normalized
|
||||
} catch {
|
||||
return url.toLowerCase().trim().replace(/\/+$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple deterministic hash function that produces an 8-character hex string.
|
||||
* Uses a variant of djb2 hash algorithm.
|
||||
*/
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) + hash + str.charCodeAt(i)
|
||||
hash = hash >>> 0
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0').slice(0, 8)
|
||||
}
|
||||
Reference in New Issue
Block a user