|
1 | 1 | import { type FastifyInstance } from 'fastify'; |
| 2 | +import { z } from 'zod'; |
| 3 | +import { zodToJsonSchema } from 'zod-to-json-schema'; |
| 4 | +import { requireGlobalAdmin } from '../../../middleware/roleMiddleware'; |
| 5 | +import { McpCatalogService } from '../../../services/mcpCatalogService'; |
| 6 | +import { getDb } from '../../../db'; |
| 7 | + |
| 8 | +// Request schema for creating global MCP servers |
| 9 | +const createGlobalServerRequestSchema = z.object({ |
| 10 | + // Required fields |
| 11 | + name: z.string().min(1, 'Name is required').max(255, 'Name must be 255 characters or less'), |
| 12 | + description: z.string().min(1, 'Description is required'), |
| 13 | + language: z.string().min(1, 'Language is required'), |
| 14 | + runtime: z.string().min(1, 'Runtime is required'), |
| 15 | + installation_methods: z.array(z.object({ |
| 16 | + type: z.string().min(1, 'Installation method type is required'), |
| 17 | + command: z.string().optional(), |
| 18 | + image: z.string().optional(), |
| 19 | + description: z.string().optional() |
| 20 | + })).min(1, 'At least one installation method is required'), |
| 21 | + tools: z.array(z.object({ |
| 22 | + name: z.string().min(1, 'Tool name is required'), |
| 23 | + description: z.string().min(1, 'Tool description is required') |
| 24 | + })).min(1, 'At least one tool is required'), |
| 25 | + |
| 26 | + // Optional fields |
| 27 | + long_description: z.string().optional(), |
| 28 | + github_url: z.string().url('Invalid GitHub URL').optional(), |
| 29 | + git_branch: z.string().default('main'), |
| 30 | + homepage_url: z.string().url('Invalid homepage URL').optional(), |
| 31 | + runtime_min_version: z.string().optional(), |
| 32 | + resources: z.array(z.object({ |
| 33 | + type: z.string().min(1, 'Resource type is required'), |
| 34 | + description: z.string().min(1, 'Resource description is required') |
| 35 | + })).optional(), |
| 36 | + prompts: z.array(z.object({ |
| 37 | + name: z.string().min(1, 'Prompt name is required'), |
| 38 | + description: z.string().min(1, 'Prompt description is required') |
| 39 | + })).optional(), |
| 40 | + author_name: z.string().optional(), |
| 41 | + author_contact: z.string().optional(), |
| 42 | + organization: z.string().optional(), |
| 43 | + license: z.string().optional(), |
| 44 | + default_config: z.record(z.any()).optional(), |
| 45 | + environment_variables: z.array(z.object({ |
| 46 | + name: z.string().min(1, 'Environment variable name is required'), |
| 47 | + description: z.string().min(1, 'Environment variable description is required'), |
| 48 | + required: z.boolean().default(false), |
| 49 | + default_value: z.string().optional() |
| 50 | + })).optional(), |
| 51 | + dependencies: z.record(z.any()).optional(), |
| 52 | + category_id: z.string().optional(), |
| 53 | + tags: z.array(z.string()).optional(), |
| 54 | + featured: z.boolean().default(false) |
| 55 | +}); |
| 56 | + |
| 57 | +// Response schema for successful creation |
| 58 | +const createGlobalServerResponseSchema = z.object({ |
| 59 | + success: z.boolean(), |
| 60 | + data: z.object({ |
| 61 | + id: z.string(), |
| 62 | + name: z.string(), |
| 63 | + slug: z.string(), |
| 64 | + description: z.string(), |
| 65 | + long_description: z.string().nullable(), |
| 66 | + github_url: z.string().nullable(), |
| 67 | + git_branch: z.string().nullable(), |
| 68 | + homepage_url: z.string().nullable(), |
| 69 | + language: z.string(), |
| 70 | + runtime: z.string(), |
| 71 | + runtime_min_version: z.string().nullable(), |
| 72 | + installation_methods: z.array(z.any()), |
| 73 | + tools: z.array(z.any()), |
| 74 | + resources: z.array(z.any()).nullable(), |
| 75 | + prompts: z.array(z.any()).nullable(), |
| 76 | + visibility: z.enum(['global', 'team']), |
| 77 | + owner_team_id: z.string().nullable(), |
| 78 | + created_by: z.string(), |
| 79 | + author_name: z.string().nullable(), |
| 80 | + author_contact: z.string().nullable(), |
| 81 | + organization: z.string().nullable(), |
| 82 | + license: z.string().nullable(), |
| 83 | + default_config: z.record(z.any()).nullable(), |
| 84 | + environment_variables: z.array(z.any()).nullable(), |
| 85 | + dependencies: z.record(z.any()).nullable(), |
| 86 | + category_id: z.string().nullable(), |
| 87 | + tags: z.array(z.string()).nullable(), |
| 88 | + status: z.enum(['active', 'deprecated', 'maintenance']), |
| 89 | + featured: z.boolean(), |
| 90 | + created_at: z.string(), |
| 91 | + updated_at: z.string(), |
| 92 | + last_sync_at: z.string().nullable() |
| 93 | + }) |
| 94 | +}); |
| 95 | + |
| 96 | +// Error response schema |
| 97 | +const errorResponseSchema = z.object({ |
| 98 | + success: z.boolean().default(false), |
| 99 | + error: z.string(), |
| 100 | + details: z.any().optional() |
| 101 | +}); |
2 | 102 |
|
3 | 103 | export default async function createGlobalServer(server: FastifyInstance) { |
4 | 104 | server.post('/mcp/servers/global', { |
| 105 | + preValidation: requireGlobalAdmin(), |
5 | 106 | schema: { |
6 | 107 | tags: ['MCP Servers'], |
7 | | - summary: 'Create global MCP server (Admin only)', |
8 | | - description: 'Create a new global MCP server - requires global admin permissions. Will require Content-Type: application/json header when sending request body once implemented.' |
| 108 | + summary: 'Create global MCP server (Global Admin only)', |
| 109 | + description: 'Create a new global MCP server - requires global admin permissions. Global servers are visible to all users. Requires Content-Type: application/json header when sending request body.', |
| 110 | + security: [{ cookieAuth: [] }], |
| 111 | + requestBody: { |
| 112 | + required: true, |
| 113 | + content: { |
| 114 | + 'application/json': { |
| 115 | + schema: zodToJsonSchema(createGlobalServerRequestSchema, { |
| 116 | + $refStrategy: 'none', |
| 117 | + target: 'openApi3' |
| 118 | + }) |
| 119 | + } |
| 120 | + } |
| 121 | + }, |
| 122 | + response: { |
| 123 | + 201: zodToJsonSchema(createGlobalServerResponseSchema, { |
| 124 | + $refStrategy: 'none', |
| 125 | + target: 'openApi3' |
| 126 | + }), |
| 127 | + 400: zodToJsonSchema(errorResponseSchema.describe('Bad Request - Invalid input or missing Content-Type header'), { |
| 128 | + $refStrategy: 'none', |
| 129 | + target: 'openApi3' |
| 130 | + }), |
| 131 | + 401: zodToJsonSchema(errorResponseSchema.describe('Unauthorized - Authentication required'), { |
| 132 | + $refStrategy: 'none', |
| 133 | + target: 'openApi3' |
| 134 | + }), |
| 135 | + 403: zodToJsonSchema(errorResponseSchema.describe('Forbidden - Global admin permissions required'), { |
| 136 | + $refStrategy: 'none', |
| 137 | + target: 'openApi3' |
| 138 | + }), |
| 139 | + 409: zodToJsonSchema(errorResponseSchema.describe('Conflict - Server name already exists'), { |
| 140 | + $refStrategy: 'none', |
| 141 | + target: 'openApi3' |
| 142 | + }), |
| 143 | + 500: zodToJsonSchema(errorResponseSchema.describe('Internal Server Error'), { |
| 144 | + $refStrategy: 'none', |
| 145 | + target: 'openApi3' |
| 146 | + }) |
| 147 | + } |
9 | 148 | } |
10 | 149 | }, async (request, reply) => { |
11 | | - return reply.status(501).send({ |
12 | | - success: false, |
13 | | - error: 'Not implemented yet' |
14 | | - }); |
| 150 | + const requestData = request.body as z.infer<typeof createGlobalServerRequestSchema>; |
| 151 | + |
| 152 | + request.log.info({ |
| 153 | + operation: 'create_global_mcp_server', |
| 154 | + userId: request.user?.id, |
| 155 | + serverName: requestData.name, |
| 156 | + language: requestData.language, |
| 157 | + runtime: requestData.runtime, |
| 158 | + featured: requestData.featured |
| 159 | + }, 'Creating global MCP server'); |
| 160 | + |
| 161 | + try { |
| 162 | + const db = getDb(); |
| 163 | + const mcpService = new McpCatalogService(db, request.log); |
| 164 | + |
| 165 | + // Force global visibility and no team ownership for global servers |
| 166 | + const serverData = { |
| 167 | + ...requestData, |
| 168 | + visibility: 'global' as const |
| 169 | + }; |
| 170 | + |
| 171 | + const newServer = await mcpService.createServer( |
| 172 | + request.user!.id, |
| 173 | + 'global_admin', // We know user is global admin due to middleware |
| 174 | + null, // No team for global servers |
| 175 | + serverData |
| 176 | + ); |
| 177 | + |
| 178 | + request.log.info({ |
| 179 | + operation: 'create_global_mcp_server', |
| 180 | + userId: request.user?.id, |
| 181 | + serverId: newServer.id, |
| 182 | + serverSlug: newServer.slug, |
| 183 | + serverName: newServer.name, |
| 184 | + featured: newServer.featured |
| 185 | + }, 'Global MCP server created successfully'); |
| 186 | + |
| 187 | + // Parse JSON fields for response with proper null checks and error handling |
| 188 | + try { |
| 189 | + request.log.debug({ |
| 190 | + operation: 'create_global_mcp_server', |
| 191 | + userId: request.user?.id, |
| 192 | + serverId: newServer.id, |
| 193 | + rawServerData: { |
| 194 | + installation_methods: newServer.installation_methods, |
| 195 | + tools: newServer.tools, |
| 196 | + resources: newServer.resources, |
| 197 | + prompts: newServer.prompts, |
| 198 | + default_config: newServer.default_config, |
| 199 | + environment_variables: newServer.environment_variables, |
| 200 | + dependencies: newServer.dependencies, |
| 201 | + tags: newServer.tags |
| 202 | + } |
| 203 | + }, 'About to parse JSON fields for response'); |
| 204 | + |
| 205 | + const responseData = { |
| 206 | + id: newServer.id, |
| 207 | + name: newServer.name, |
| 208 | + slug: newServer.slug, |
| 209 | + description: newServer.description, |
| 210 | + long_description: newServer.long_description || null, |
| 211 | + github_url: newServer.github_url || null, |
| 212 | + git_branch: newServer.git_branch || null, |
| 213 | + homepage_url: newServer.homepage_url || null, |
| 214 | + language: newServer.language, |
| 215 | + runtime: newServer.runtime, |
| 216 | + runtime_min_version: newServer.runtime_min_version || null, |
| 217 | + installation_methods: newServer.installation_methods ? JSON.parse(newServer.installation_methods) : [], |
| 218 | + tools: newServer.tools ? JSON.parse(newServer.tools) : [], |
| 219 | + resources: newServer.resources ? JSON.parse(newServer.resources) : null, |
| 220 | + prompts: newServer.prompts ? JSON.parse(newServer.prompts) : null, |
| 221 | + visibility: newServer.visibility, |
| 222 | + owner_team_id: newServer.owner_team_id || null, |
| 223 | + created_by: newServer.created_by, |
| 224 | + author_name: newServer.author_name || null, |
| 225 | + author_contact: newServer.author_contact || null, |
| 226 | + organization: newServer.organization || null, |
| 227 | + license: newServer.license || null, |
| 228 | + default_config: newServer.default_config ? JSON.parse(newServer.default_config) : null, |
| 229 | + environment_variables: newServer.environment_variables ? JSON.parse(newServer.environment_variables) : null, |
| 230 | + dependencies: newServer.dependencies ? JSON.parse(newServer.dependencies) : null, |
| 231 | + category_id: newServer.category_id || null, |
| 232 | + tags: newServer.tags ? JSON.parse(newServer.tags) : null, |
| 233 | + status: newServer.status, |
| 234 | + featured: newServer.featured, |
| 235 | + created_at: newServer.created_at.toISOString(), |
| 236 | + updated_at: newServer.updated_at.toISOString(), |
| 237 | + last_sync_at: newServer.last_sync_at?.toISOString() || null |
| 238 | + }; |
| 239 | + |
| 240 | + request.log.debug({ |
| 241 | + operation: 'create_global_mcp_server', |
| 242 | + userId: request.user?.id, |
| 243 | + serverId: newServer.id |
| 244 | + }, 'JSON parsing completed, about to send response'); |
| 245 | + |
| 246 | + const response = { |
| 247 | + success: true, |
| 248 | + data: responseData |
| 249 | + }; |
| 250 | + |
| 251 | + request.log.debug({ |
| 252 | + operation: 'create_global_mcp_server', |
| 253 | + userId: request.user?.id, |
| 254 | + serverId: newServer.id |
| 255 | + }, 'Sending 201 response'); |
| 256 | + |
| 257 | + return reply.status(201).send(response); |
| 258 | + } catch (jsonError) { |
| 259 | + request.log.error({ |
| 260 | + operation: 'create_global_mcp_server', |
| 261 | + userId: request.user?.id, |
| 262 | + serverId: newServer.id, |
| 263 | + jsonError, |
| 264 | + serverData: { |
| 265 | + installation_methods: newServer.installation_methods, |
| 266 | + tools: newServer.tools, |
| 267 | + resources: newServer.resources, |
| 268 | + prompts: newServer.prompts, |
| 269 | + default_config: newServer.default_config, |
| 270 | + environment_variables: newServer.environment_variables, |
| 271 | + dependencies: newServer.dependencies, |
| 272 | + tags: newServer.tags |
| 273 | + } |
| 274 | + }, 'Failed to parse JSON fields in response'); |
| 275 | + |
| 276 | + return reply.status(500).send({ |
| 277 | + success: false, |
| 278 | + error: 'Failed to format server response' |
| 279 | + }); |
| 280 | + } |
| 281 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 282 | + } catch (error: any) { |
| 283 | + request.log.error({ |
| 284 | + operation: 'create_global_mcp_server', |
| 285 | + userId: request.user?.id, |
| 286 | + serverName: requestData.name, |
| 287 | + error |
| 288 | + }, 'Failed to create global MCP server'); |
| 289 | + |
| 290 | + // Handle specific error cases |
| 291 | + if (error.message?.includes('UNIQUE constraint failed') || |
| 292 | + error.message?.includes('already exists') || |
| 293 | + error.message?.includes('duplicate')) { |
| 294 | + return reply.status(409).send({ |
| 295 | + success: false, |
| 296 | + error: 'Server name already exists' |
| 297 | + }); |
| 298 | + } |
| 299 | + |
| 300 | + if (error.message?.includes('Only global administrators')) { |
| 301 | + return reply.status(403).send({ |
| 302 | + success: false, |
| 303 | + error: 'Global admin permissions required' |
| 304 | + }); |
| 305 | + } |
| 306 | + |
| 307 | + return reply.status(500).send({ |
| 308 | + success: false, |
| 309 | + error: 'Failed to create global MCP server' |
| 310 | + }); |
| 311 | + } |
15 | 312 | }); |
16 | 313 | } |
0 commit comments