diff --git a/README.md b/README.md index 317aa02b9d..7afbda6469 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,21 @@ Copilot is a Sim-managed service. To use Copilot on a self-hosted instance: - Go to https://sim.ai → Settings → Copilot and generate a Copilot API key - Set `COPILOT_API_KEY` environment variable in your self-hosted apps/sim/.env file to that value +## Hosted MCP Servers + +Sim can now author and deploy Model Context Protocol servers without leaving the workspace: + +1. Scaffold a starter project locally (Reddit search + arXiv summaries included) with + ```bash + simstudio mcp init my-research-server + ``` +2. Push the code to your repo or automation pipeline, then open `/workspace//mcp` to create a hosted + project, track versions, and trigger deployments. +3. Each deployment spins up a managed MCP endpoint, stores credentials in your workspace, and immediately + appears inside the workflow tool picker. + +> **Note:** Set `HOSTED_MCP_BASE_URL` in your `.env` to route hosted deployments through your own reverse +> proxy or edge network. ## Tech Stack - **Framework**: [Next.js](https://nextjs.org/) (App Router) @@ -214,3 +229,4 @@ We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTI This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.

Made with ❤️ by the Sim Team

+ diff --git a/apps/sim/app/.env.example b/apps/sim/app/.env.example index 0f9db4fe66..a553c1b4d8 100644 --- a/apps/sim/app/.env.example +++ b/apps/sim/app/.env.example @@ -10,6 +10,7 @@ BETTER_AUTH_URL=http://localhost:3000 # NextJS (Required) NEXT_PUBLIC_APP_URL=http://localhost:3000 +HOSTED_MCP_BASE_URL=http://localhost:8787 # Security (Required) ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables @@ -20,4 +21,4 @@ INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to gen # If left commented out, emails will be logged to console instead # Local AI Models (Optional) -# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models \ No newline at end of file +# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models diff --git a/apps/sim/app/api/mcp/projects/[projectId]/deployments/[deploymentId]/route.ts b/apps/sim/app/api/mcp/projects/[projectId]/deployments/[deploymentId]/route.ts new file mode 100644 index 0000000000..39da62b402 --- /dev/null +++ b/apps/sim/app/api/mcp/projects/[projectId]/deployments/[deploymentId]/route.ts @@ -0,0 +1,105 @@ +import type { NextRequest } from 'next/server' +import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { + getProjectIdFromRequest, + getDeploymentIdFromRequest, +} from '@/lib/mcp/request-utils' +import { + getMcpServerDeployment, + updateMcpServerDeployment, +} from '@/lib/mcp/deployment-service' +import { createLogger } from '@/lib/logs/console/logger' +import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' + +const logger = createLogger('McpProjectDeploymentDetailsAPI') +const STATUS_VALUES = new Set(['pending', 'deploying', 'active', 'failed', 'decommissioned']) + +export const dynamic = 'force-dynamic' + +function getIds(request: NextRequest) { + return { + projectId: getProjectIdFromRequest(request), + deploymentId: getDeploymentIdFromRequest(request), + } +} + +export const GET = withMcpAuth('read')(async (request: NextRequest, context) => { + const { projectId, deploymentId } = getIds(request) + if (!projectId || !deploymentId) { + return createMcpErrorResponse( + new Error('Missing projectId or deploymentId'), + 'Missing parameters', + 400 + ) + } + + try { + const deployment = await getMcpServerDeployment(context.workspaceId, projectId, deploymentId) + if (!deployment) { + return createMcpErrorResponse(new Error('Deployment not found'), 'Deployment not found', 404) + } + return createMcpSuccessResponse({ deployment }) + } catch (error) { + logger.error(`[${context.requestId}] Failed to fetch deployment ${deploymentId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to fetch deployment'), + 'Failed to fetch deployment', + 500 + ) + } +}) + +export const PATCH = withMcpAuth('write')(async (request: NextRequest, context) => { + const { projectId, deploymentId } = getIds(request) + if (!projectId || !deploymentId) { + return createMcpErrorResponse( + new Error('Missing projectId or deploymentId'), + 'Missing parameters', + 400 + ) + } + + try { + const body = getParsedBody(request) || (await request.json()) + const updates: Record = {} + + if (body.status && STATUS_VALUES.has(body.status)) { + updates.status = body.status + } + + if ('endpointUrl' in body) { + updates.endpointUrl = body.endpointUrl ?? null + } + + if ('logsUrl' in body) { + updates.logsUrl = body.logsUrl ?? null + } + + if ('serverId' in body) { + updates.serverId = body.serverId ?? null + } + + if ('rolledBackAt' in body) { + updates.rolledBackAt = body.rolledBackAt ? new Date(body.rolledBackAt) : null + } + + if (Object.keys(updates).length === 0) { + return createMcpErrorResponse(new Error('No valid fields to update'), 'No updates', 400) + } + + const deployment = await updateMcpServerDeployment( + context.workspaceId, + projectId, + deploymentId, + updates + ) + return createMcpSuccessResponse({ deployment }) + } catch (error) { + logger.error(`[${context.requestId}] Failed to update deployment ${deploymentId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to update deployment'), + error instanceof Error ? error.message : 'Failed to update deployment', + 500 + ) + } +}) diff --git a/apps/sim/app/api/mcp/projects/[projectId]/deployments/route.ts b/apps/sim/app/api/mcp/projects/[projectId]/deployments/route.ts new file mode 100644 index 0000000000..9b7a5985c4 --- /dev/null +++ b/apps/sim/app/api/mcp/projects/[projectId]/deployments/route.ts @@ -0,0 +1,73 @@ +import type { NextRequest } from 'next/server' +import { tasks } from '@trigger.dev/sdk' +import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { getProjectIdFromRequest } from '@/lib/mcp/request-utils' +import { + createMcpServerDeployment, + listMcpServerDeployments, +} from '@/lib/mcp/deployment-service' +import { createLogger } from '@/lib/logs/console/logger' +import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' + +const logger = createLogger('McpProjectDeploymentsAPI') +export const dynamic = 'force-dynamic' + +export const GET = withMcpAuth('read')(async (request: NextRequest, context) => { + const projectId = getProjectIdFromRequest(request) + if (!projectId) { + return createMcpErrorResponse(new Error('Project ID missing from URL'), 'Missing projectId', 400) + } + + try { + const deployments = await listMcpServerDeployments(context.workspaceId, projectId) + return createMcpSuccessResponse({ deployments }) + } catch (error) { + logger.error(`[${context.requestId}] Failed to list deployments for ${projectId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to list deployments'), + 'Failed to list deployments', + 500 + ) + } +}) + +export const POST = withMcpAuth('write')(async (request: NextRequest, context) => { + const projectId = getProjectIdFromRequest(request) + if (!projectId) { + return createMcpErrorResponse(new Error('Project ID missing from URL'), 'Missing projectId', 400) + } + + try { + const body = getParsedBody(request) || (await request.json()) + if (!body?.versionId) { + return createMcpErrorResponse(new Error('versionId is required'), 'Missing versionId', 400) + } + + const deployment = await createMcpServerDeployment({ + workspaceId: context.workspaceId, + projectId, + versionId: body.versionId, + environment: body.environment, + region: body.region, + serverId: body.serverId, + deployedBy: context.userId, + }) + + await tasks.trigger('mcp-server-deploy', { + deploymentId: deployment.id, + projectId, + versionId: body.versionId, + workspaceId: context.workspaceId, + userId: context.userId, + }) + + return createMcpSuccessResponse({ deployment }, 201) + } catch (error) { + logger.error(`[${context.requestId}] Failed to create deployment for ${projectId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to create deployment'), + error instanceof Error ? error.message : 'Failed to create deployment', + 500 + ) + } +}) diff --git a/apps/sim/app/api/mcp/projects/[projectId]/route.ts b/apps/sim/app/api/mcp/projects/[projectId]/route.ts new file mode 100644 index 0000000000..feb4fca63a --- /dev/null +++ b/apps/sim/app/api/mcp/projects/[projectId]/route.ts @@ -0,0 +1,140 @@ +import type { NextRequest } from 'next/server' +import { + archiveMcpServerProject, + getMcpServerProject, + updateMcpServerProject, +} from '@/lib/mcp/project-service' +import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { createLogger } from '@/lib/logs/console/logger' +import { getProjectIdFromRequest } from '@/lib/mcp/request-utils' +import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' + +const logger = createLogger('McpProjectDetailsAPI') +const VISIBILITY_VALUES = new Set(['private', 'workspace', 'public']) +const SOURCE_TYPE_VALUES = new Set(['inline', 'repo', 'package']) +const STATUS_VALUES = new Set(['draft', 'building', 'deploying', 'active', 'failed', 'archived']) + +export const dynamic = 'force-dynamic' + +export const GET = withMcpAuth('read')(async (request: NextRequest, context) => { + const { workspaceId, requestId } = context + const projectId = getProjectIdFromRequest(request) + + try { + if (!projectId) { + return createMcpErrorResponse(new Error('Project ID missing from URL'), 'Missing projectId', 400) + } + + const project = await getMcpServerProject(workspaceId, projectId) + if (!project) { + return createMcpErrorResponse(new Error('Project not found'), 'Project not found', 404) + } + + return createMcpSuccessResponse({ project }) + } catch (error) { + logger.error(`[${requestId}] Failed to fetch MCP project ${projectId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to fetch project'), + 'Failed to fetch project', + 500 + ) + } +}) + +export const PATCH = withMcpAuth('write')(async (request: NextRequest, context) => { + const { workspaceId, requestId } = context + const projectId = getProjectIdFromRequest(request) + + try { + if (!projectId) { + return createMcpErrorResponse(new Error('Project ID missing from URL'), 'Missing projectId', 400) + } + + const body = getParsedBody(request) || (await request.json()) + const updates: Record = {} + + if (typeof body.name === 'string') { + updates.name = body.name + } + + if ('description' in body) { + updates.description = body.description ?? null + } + + if (body.visibility && VISIBILITY_VALUES.has(body.visibility)) { + updates.visibility = body.visibility + } + + if (body.runtime) { + updates.runtime = body.runtime + } + + if (body.entryPoint) { + updates.entryPoint = body.entryPoint + } + + if ('template' in body) { + updates.template = body.template ?? null + } + + if (body.sourceType && SOURCE_TYPE_VALUES.has(body.sourceType)) { + updates.sourceType = body.sourceType + } + + if ('repositoryUrl' in body) { + updates.repositoryUrl = body.repositoryUrl ?? null + } + + if ('repositoryBranch' in body) { + updates.repositoryBranch = body.repositoryBranch ?? null + } + + if (body.environmentVariables && typeof body.environmentVariables === 'object') { + updates.environmentVariables = body.environmentVariables + } + + if (body.metadata && typeof body.metadata === 'object') { + updates.metadata = body.metadata + } + + if (body.status && STATUS_VALUES.has(body.status)) { + updates.status = body.status + } + + if (Object.keys(updates).length === 0) { + return createMcpErrorResponse(new Error('No valid fields to update'), 'No updates', 400) + } + + const project = await updateMcpServerProject(workspaceId, projectId, updates) + return createMcpSuccessResponse({ project }) + } catch (error) { + logger.error(`[${requestId}] Failed to update MCP project ${projectId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to update project'), + error instanceof Error ? error.message : 'Failed to update project', + 500 + ) + } +}) + +export const DELETE = withMcpAuth('admin')(async (request: NextRequest, context) => { + const { workspaceId, requestId } = context + const projectId = getProjectIdFromRequest(request) + + try { + if (!projectId) { + return createMcpErrorResponse(new Error('Project ID missing from URL'), 'Missing projectId', 400) + } + + await archiveMcpServerProject(workspaceId, projectId) + logger.info(`[${requestId}] Archived MCP project ${projectId}`) + return createMcpSuccessResponse({ projectId }) + } catch (error) { + logger.error(`[${requestId}] Failed to archive MCP project ${projectId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to archive project'), + error instanceof Error ? error.message : 'Failed to archive project', + 500 + ) + } +}) diff --git a/apps/sim/app/api/mcp/projects/[projectId]/tokens/route.ts b/apps/sim/app/api/mcp/projects/[projectId]/tokens/route.ts new file mode 100644 index 0000000000..2359a5a246 --- /dev/null +++ b/apps/sim/app/api/mcp/projects/[projectId]/tokens/route.ts @@ -0,0 +1,95 @@ +import type { NextRequest } from 'next/server' +import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { getProjectIdFromRequest } from '@/lib/mcp/request-utils' +import { + issueMcpServerToken, + listMcpServerTokens, + revokeMcpServerToken, +} from '@/lib/mcp/token-service' +import { createLogger } from '@/lib/logs/console/logger' +import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' + +const logger = createLogger('McpProjectTokensAPI') +const SCOPE_VALUES = new Set(['deploy', 'runtime', 'logs']) + +export const dynamic = 'force-dynamic' + +export const GET = withMcpAuth('read')(async (request: NextRequest, context) => { + const projectId = getProjectIdFromRequest(request) + if (!projectId) { + return createMcpErrorResponse(new Error('Project ID missing from URL'), 'Missing projectId', 400) + } + + try { + const tokens = await listMcpServerTokens(context.workspaceId, projectId) + return createMcpSuccessResponse({ tokens }) + } catch (error) { + logger.error(`[${context.requestId}] Failed to list tokens for ${projectId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to list tokens'), + 'Failed to list tokens', + 500 + ) + } +}) + +export const POST = withMcpAuth('write')(async (request: NextRequest, context) => { + const projectId = getProjectIdFromRequest(request) + if (!projectId) { + return createMcpErrorResponse(new Error('Project ID missing from URL'), 'Missing projectId', 400) + } + + try { + const body = getParsedBody(request) || (await request.json()) + if (!body?.name) { + return createMcpErrorResponse(new Error('Token name is required'), 'Missing name', 400) + } + + const scope = SCOPE_VALUES.has(body.scope) ? body.scope : undefined + const expiresAt = body.expiresAt ? new Date(body.expiresAt) : undefined + + const result = await issueMcpServerToken({ + workspaceId: context.workspaceId, + projectId, + name: body.name, + scope, + expiresAt, + createdBy: context.userId, + }) + + return createMcpSuccessResponse({ token: result.token, record: result.record }, 201) + } catch (error) { + logger.error(`[${context.requestId}] Failed to issue token for ${projectId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to issue token'), + error instanceof Error ? error.message : 'Failed to issue token', + 500 + ) + } +}) + +export const DELETE = withMcpAuth('admin')(async (request: NextRequest, context) => { + const projectId = getProjectIdFromRequest(request) + if (!projectId) { + return createMcpErrorResponse(new Error('Project ID missing from URL'), 'Missing projectId', 400) + } + + const { searchParams } = request.nextUrl + const tokenId = searchParams.get('tokenId') + + if (!tokenId) { + return createMcpErrorResponse(new Error('tokenId is required'), 'Missing tokenId', 400) + } + + try { + await revokeMcpServerToken(context.workspaceId, projectId, tokenId) + return createMcpSuccessResponse({ tokenId }) + } catch (error) { + logger.error(`[${context.requestId}] Failed to revoke token ${tokenId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to revoke token'), + error instanceof Error ? error.message : 'Failed to revoke token', + 500 + ) + } +}) diff --git a/apps/sim/app/api/mcp/projects/[projectId]/versions/[versionId]/route.ts b/apps/sim/app/api/mcp/projects/[projectId]/versions/[versionId]/route.ts new file mode 100644 index 0000000000..de9e13740b --- /dev/null +++ b/apps/sim/app/api/mcp/projects/[projectId]/versions/[versionId]/route.ts @@ -0,0 +1,116 @@ +import type { NextRequest } from 'next/server' +import { + getMcpServerVersion, + updateMcpServerVersion, +} from '@/lib/mcp/version-service' +import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { + getProjectIdFromRequest, + getVersionIdFromRequest, +} from '@/lib/mcp/request-utils' +import { createLogger } from '@/lib/logs/console/logger' +import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' + +const logger = createLogger('McpProjectVersionDetailsAPI') +const STATUS_VALUES = new Set(['queued', 'building', 'ready', 'failed', 'deprecated']) + +export const dynamic = 'force-dynamic' + +function getIds(request: NextRequest) { + return { + projectId: getProjectIdFromRequest(request), + versionId: getVersionIdFromRequest(request), + } +} + +export const GET = withMcpAuth('read')(async (request: NextRequest, context) => { + const { projectId, versionId } = getIds(request) + + if (!projectId || !versionId) { + return createMcpErrorResponse( + new Error('Missing projectId or versionId'), + 'Missing parameters', + 400 + ) + } + + try { + const version = await getMcpServerVersion(context.workspaceId, projectId, versionId) + if (!version) { + return createMcpErrorResponse(new Error('Version not found'), 'Version not found', 404) + } + + return createMcpSuccessResponse({ version }) + } catch (error) { + logger.error(`[${context.requestId}] Failed to fetch version ${versionId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to fetch version'), + 'Failed to fetch version', + 500 + ) + } +}) + +export const PATCH = withMcpAuth('write')(async (request: NextRequest, context) => { + const { projectId, versionId } = getIds(request) + + if (!projectId || !versionId) { + return createMcpErrorResponse( + new Error('Missing projectId or versionId'), + 'Missing parameters', + 400 + ) + } + + try { + const body = getParsedBody(request) || (await request.json()) + const updates: Record = {} + + if (body.status && STATUS_VALUES.has(body.status)) { + updates.status = body.status + } + + if ('artifactUrl' in body) { + updates.artifactUrl = body.artifactUrl ?? null + } + + if ('runtimeMetadata' in body) { + updates.runtimeMetadata = body.runtimeMetadata ?? {} + } + + if ('buildLogsUrl' in body) { + updates.buildLogsUrl = body.buildLogsUrl ?? null + } + + if ('changelog' in body) { + updates.changelog = body.changelog ?? null + } + + if ('promotedBy' in body) { + updates.promotedBy = body.promotedBy ?? null + } + + if ('promotedAt' in body) { + updates.promotedAt = body.promotedAt ? new Date(body.promotedAt) : null + } + + if (Object.keys(updates).length === 0) { + return createMcpErrorResponse(new Error('No valid fields to update'), 'No updates', 400) + } + + const version = await updateMcpServerVersion( + context.workspaceId, + projectId, + versionId, + updates + ) + return createMcpSuccessResponse({ version }) + } catch (error) { + logger.error(`[${context.requestId}] Failed to update version ${versionId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to update version'), + error instanceof Error ? error.message : 'Failed to update version', + 500 + ) + } +}) diff --git a/apps/sim/app/api/mcp/projects/[projectId]/versions/route.ts b/apps/sim/app/api/mcp/projects/[projectId]/versions/route.ts new file mode 100644 index 0000000000..114ab87db2 --- /dev/null +++ b/apps/sim/app/api/mcp/projects/[projectId]/versions/route.ts @@ -0,0 +1,61 @@ +import type { NextRequest } from 'next/server' +import { createMcpServerVersion, listMcpServerVersions } from '@/lib/mcp/version-service' +import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { getProjectIdFromRequest } from '@/lib/mcp/request-utils' +import { createLogger } from '@/lib/logs/console/logger' +import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' + +const logger = createLogger('McpProjectVersionsAPI') +export const dynamic = 'force-dynamic' + +export const GET = withMcpAuth('read')(async (request: NextRequest, context) => { + const projectId = getProjectIdFromRequest(request) + + if (!projectId) { + return createMcpErrorResponse(new Error('Project ID missing from URL'), 'Missing projectId', 400) + } + + try { + const versions = await listMcpServerVersions(context.workspaceId, projectId) + return createMcpSuccessResponse({ versions }) + } catch (error) { + logger.error(`[${context.requestId}] Failed to list versions for ${projectId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to list versions'), + 'Failed to list versions', + 500 + ) + } +}) + +export const POST = withMcpAuth('write')(async (request: NextRequest, context) => { + const projectId = getProjectIdFromRequest(request) + + if (!projectId) { + return createMcpErrorResponse(new Error('Project ID missing from URL'), 'Missing projectId', 400) + } + + try { + const body = getParsedBody(request) || (await request.json()) + const version = await createMcpServerVersion({ + workspaceId: context.workspaceId, + projectId, + sourceHash: body.sourceHash, + manifest: body.manifest, + buildConfig: body.buildConfig, + artifactUrl: body.artifactUrl, + runtimeMetadata: body.runtimeMetadata, + changelog: body.changelog, + buildLogsUrl: body.buildLogsUrl, + }) + + return createMcpSuccessResponse({ version }, 201) + } catch (error) { + logger.error(`[${context.requestId}] Failed to create MCP version for ${projectId}`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to create version'), + error instanceof Error ? error.message : 'Failed to create version', + 500 + ) + } +}) diff --git a/apps/sim/app/api/mcp/projects/route.ts b/apps/sim/app/api/mcp/projects/route.ts new file mode 100644 index 0000000000..f3cba887a3 --- /dev/null +++ b/apps/sim/app/api/mcp/projects/route.ts @@ -0,0 +1,78 @@ +import type { NextRequest } from 'next/server' +import { createMcpServerProject, listMcpServerProjects } from '@/lib/mcp/project-service' +import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { createLogger } from '@/lib/logs/console/logger' +import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' + +const logger = createLogger('McpProjectsAPI') +export const dynamic = 'force-dynamic' + +const VISIBILITY_VALUES = new Set(['private', 'workspace', 'public']) +const SOURCE_TYPE_VALUES = new Set(['inline', 'repo', 'package']) + +/** + * GET - List all MCP server projects for a workspace + */ +export const GET = withMcpAuth('read')(async (_request, { workspaceId, requestId }) => { + try { + logger.info(`[${requestId}] Listing MCP server projects for workspace ${workspaceId}`) + const projects = await listMcpServerProjects(workspaceId) + return createMcpSuccessResponse({ projects }) + } catch (error) { + logger.error(`[${requestId}] Failed to list MCP server projects`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to list MCP server projects'), + 'Failed to list MCP server projects', + 500 + ) + } +}) + +/** + * POST - Create a new MCP server project + */ +export const POST = withMcpAuth('write')(async (request: NextRequest, authContext) => { + const { userId, workspaceId, requestId } = authContext + + try { + const body = getParsedBody(request) || (await request.json()) + + if (!body?.name) { + return createMcpErrorResponse(new Error('Project name is required'), 'Missing name', 400) + } + + const visibility = VISIBILITY_VALUES.has(body.visibility) + ? body.visibility + : undefined + const sourceType = SOURCE_TYPE_VALUES.has(body.sourceType) + ? body.sourceType + : undefined + + const project = await createMcpServerProject({ + workspaceId, + createdBy: userId, + name: body.name, + slug: body.slug, + description: body.description, + visibility, + runtime: body.runtime, + entryPoint: body.entryPoint, + template: body.template, + sourceType, + repositoryUrl: body.repositoryUrl, + repositoryBranch: body.repositoryBranch, + environmentVariables: body.environmentVariables, + metadata: body.metadata, + }) + + logger.info(`[${requestId}] Created MCP server project ${project.id}`) + return createMcpSuccessResponse({ project }, 201) + } catch (error) { + logger.error(`[${requestId}] Failed to create MCP server project`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to create MCP server project'), + error instanceof Error ? error.message : 'Failed to create MCP server project', + 500 + ) + } +}) diff --git a/apps/sim/app/workspace/[workspaceId]/mcp/components/mcp-project-manager.tsx b/apps/sim/app/workspace/[workspaceId]/mcp/components/mcp-project-manager.tsx new file mode 100644 index 0000000000..8c2b1db395 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/mcp/components/mcp-project-manager.tsx @@ -0,0 +1,205 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState, useTransition } from 'react' +import { Loader2, RefreshCcw, Rocket } from 'lucide-react' +import type { McpServerProject } from '@/lib/mcp/types' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { cn } from '@/lib/utils' + +interface McpProjectManagerProps { + workspaceId: string +} + +interface CreateProjectState { + name: string + description: string +} + +const defaultState: CreateProjectState = { + name: '', + description: '', +} + +export function McpProjectManager({ workspaceId }: McpProjectManagerProps) { + const [projects, setProjects] = useState([]) + const [formState, setFormState] = useState(defaultState) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isPending, startTransition] = useTransition() + + const fetchProjects = useCallback(async () => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch(`/api/mcp/projects?workspaceId=${workspaceId}`, { + cache: 'no-store', + }) + + if (!response.ok) { + throw new Error(`Failed to load projects (${response.status})`) + } + + const payload = await response.json() + const nextProjects = + payload?.data?.projects ?? payload?.projects ?? payload?.data ?? [] + setProjects(nextProjects as McpServerProject[]) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load MCP projects') + } finally { + setIsLoading(false) + } + }, [workspaceId]) + + useEffect(() => { + fetchProjects().catch(() => { + // Already handled inside fetchProjects + }) + }, [fetchProjects]) + + const canSubmit = useMemo(() => formState.name.trim().length > 2, [formState.name]) + + const handleCreateProject = () => { + setError(null) + startTransition(async () => { + try { + const response = await fetch('/api/mcp/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...formState, + workspaceId, + runtime: 'node', + entryPoint: 'index.ts', + }), + }) + + if (!response.ok) { + const payload = await response.json().catch(() => ({})) + throw new Error(payload.error || 'Failed to create project') + } + + setFormState(defaultState) + await fetchProjects() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create project') + } + }) + } + + return ( +
+ + + New hosted server + + Start with a project name and optional description. You can define manifests, custom + tools, and deployments after the project is created. + + + +
+ + setFormState((prev) => ({ ...prev, name: event.target.value }))} + disabled={isPending} + /> +
+
+ +