diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 043856f..3ff34e4 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -83,7 +83,7 @@ "dependencies": [ "1" ], - "status": "pending", + "status": "done", "subtasks": [ { "id": 1, @@ -144,7 +144,8 @@ "testStrategy": "Verify migration files exist, migration helper tests pass, SECURITY_REVIEW.md covers all security checkpoints.", "parentId": "undefined" } - ] + ], + "updatedAt": "2026-02-21T04:10:25.433Z" }, { "id": "3", @@ -654,7 +655,7 @@ "description": "Merged into Task 3 subtasks", "details": null, "testStrategy": null, - "priority": "low", + "priority": null, "dependencies": [], "status": "cancelled", "subtasks": [], @@ -666,7 +667,7 @@ "description": "Merged into Task 5", "details": null, "testStrategy": null, - "priority": "low", + "priority": null, "dependencies": [], "status": "cancelled", "subtasks": [], @@ -678,7 +679,7 @@ "description": "Merged into Task 14", "details": null, "testStrategy": null, - "priority": "low", + "priority": null, "dependencies": [], "status": "cancelled", "subtasks": [], @@ -695,9 +696,8 @@ "6", "14" ], - "status": "cancelled", - "subtasks": [], - "updatedAt": "2026-02-21T03:23:02.572Z" + "status": "pending", + "subtasks": [] }, { "id": "23", @@ -710,9 +710,8 @@ "2", "5" ], - "status": "cancelled", - "subtasks": [], - "updatedAt": "2026-02-21T03:23:02.575Z" + "status": "pending", + "subtasks": [] }, { "id": "24", @@ -724,53 +723,18 @@ "dependencies": [ "1" ], - "status": "cancelled", - "subtasks": [], - "updatedAt": "2026-02-21T03:23:02.583Z" - }, - { - "id": 25, - "title": "Complete MCP Registry Client with Proxy, Metrics Exposure, and HTTP/CA Support", - "description": "Finalize the registry client implementation by adding HTTP proxy support, custom CA certificates for enterprise environments, and exposing SRE metrics via a dedicated metrics interface. The core client with strategy pattern, caching, deduplication, and ranking is already implemented.", - "details": "The registry client foundation already exists in src/cli/src/registry/ with:\n- RegistryClient class with search(), caching, metrics tracking\n- OfficialRegistrySource, GlamaRegistrySource, SmitheryRegistrySource\n- Deduplication by npm package/repo URL\n- Ranking by relevance, popularity, verified status\n- Zod validation of API responses\n- sanitizeString() for XSS prevention\n\nRemaining implementation:\n\n1. **HTTP Proxy & Custom CA Support** (src/cli/src/registry/http-agent.ts):\n```typescript\nimport { Agent } from 'undici';\nimport { ProxyAgent } from 'undici';\nimport fs from 'node:fs';\n\nexport function createHttpAgent(config: {\n httpProxy?: string;\n httpsProxy?: string;\n caPath?: string;\n}): Agent | ProxyAgent | undefined {\n const proxy = config.httpsProxy ?? config.httpProxy;\n if (proxy) {\n const ca = config.caPath ? fs.readFileSync(config.caPath) : undefined;\n return new ProxyAgent({ uri: proxy, connect: { ca } });\n }\n if (config.caPath) {\n const ca = fs.readFileSync(config.caPath);\n return new Agent({ connect: { ca } });\n }\n return undefined;\n}\n```\n\n2. **Update fetch calls** in each source to accept dispatcher option:\n```typescript\n// In retry.ts or each source\nconst agent = createHttpAgent(config);\nconst response = await fetch(url, { dispatcher: agent });\n```\n\n3. **Metrics Exposure Interface** (src/cli/src/registry/metrics.ts):\n```typescript\nexport interface RegistryMetrics {\n queryLatencyMs: { source: string; latencies: number[] }[];\n cacheHitRatio: number;\n cacheHits: number;\n cacheMisses: number;\n errorCounts: { source: string; count: number }[];\n}\n\nexport function collectMetrics(client: RegistryClient): RegistryMetrics {\n const cacheMetrics = client.getCacheMetrics();\n return {\n queryLatencyMs: Array.from(client.getQueryLatencies().entries())\n .map(([source, latencies]) => ({ source, latencies })),\n cacheHitRatio: cacheMetrics.ratio,\n cacheHits: cacheMetrics.hits,\n cacheMisses: cacheMetrics.misses,\n errorCounts: Array.from(client.getErrorCounts().entries())\n .map(([source, count]) => ({ source, count })),\n };\n}\n```\n\n4. **Update RegistryClientConfig** to include caPath:\n```typescript\nexport interface RegistryClientConfig {\n registries?: RegistryName[];\n cacheTTLMs?: number;\n smitheryApiKey?: string;\n httpProxy?: string;\n httpsProxy?: string;\n caPath?: string; // ADD THIS\n}\n```\n\n5. **Add data platform category filter** - update SearchOptions:\n```typescript\ncategory?: 'devops' | 'data-platform' | 'analytics' | 'communication' | 'development' | string;\n```\n\nTDD approach:\n- Write tests for createHttpAgent() with proxy, CA, and combined configs\n- Write tests for metrics collection interface\n- Write tests for category filtering in search results\n- All tests should be written BEFORE implementation", - "testStrategy": "1. Unit tests for http-agent.ts: verify ProxyAgent created with correct proxy URI, verify custom CA loaded from file path, verify combined proxy+CA configuration\n2. Unit tests for metrics.ts: verify collectMetrics() returns correct structure, verify latency arrays are captured per-source\n3. Integration test: mock HTTP server with self-signed cert, verify client connects with custom CA\n4. Test category filtering returns only servers matching category\n5. Run existing test suite to ensure no regressions: pnpm --filter @mcpctl/cli test", - "priority": "high", - "dependencies": [], - "status": "pending", - "subtasks": [] - }, - { - "id": 26, - "title": "Implement mcpctl discover Command with Interactive Mode", - "description": "Create the `mcpctl discover` CLI command that lets users search for MCP servers across all configured registries with rich filtering, table/JSON/YAML output, and an interactive browsing mode using inquirer.", - "details": "Create src/cli/src/commands/discover.ts with Commander.js:\n\n```typescript\nimport { Command } from 'commander';\nimport { RegistryClient, type SearchOptions, type RegistryServer } from '../registry/index.js';\nimport { getConfig } from '../config/index.js';\nimport inquirer from 'inquirer';\nimport chalk from 'chalk';\n\nexport function createDiscoverCommand(): Command {\n return new Command('discover')\n .description('Search for MCP servers across registries')\n .argument('', 'Search query (e.g., \"slack\", \"database\", \"terraform\")')\n .option('-c, --category ', 'Filter by category (devops, data-platform, analytics)')\n .option('-v, --verified', 'Only show verified servers')\n .option('-t, --transport ', 'Filter by transport (stdio, sse)', undefined)\n .option('-r, --registry ', 'Query specific registry (official, glama, smithery, all)', 'all')\n .option('-l, --limit ', 'Maximum results', '20')\n .option('-o, --output ', 'Output format (table, json, yaml)', 'table')\n .option('-i, --interactive', 'Interactive browsing mode')\n .action(async (query, options) => {\n const config = await getConfig();\n const client = new RegistryClient({\n smitheryApiKey: config.smitheryApiKey,\n httpProxy: config.httpProxy,\n httpsProxy: config.httpsProxy,\n caPath: config.caPath,\n });\n\n const searchOpts: SearchOptions = {\n query,\n limit: parseInt(options.limit, 10),\n verified: options.verified,\n transport: options.transport,\n category: options.category,\n registries: options.registry === 'all' \n ? undefined \n : [options.registry],\n };\n\n const results = await client.search(searchOpts);\n\n if (results.length === 0) {\n console.log('No servers found matching your query.');\n process.exitCode = 2;\n return;\n }\n\n if (options.interactive) {\n await runInteractiveMode(results);\n } else {\n outputResults(results, options.output);\n }\n });\n}\n\nfunction outputResults(results: RegistryServer[], format: string): void {\n switch (format) {\n case 'json':\n console.log(JSON.stringify(results, null, 2));\n break;\n case 'yaml':\n // Use yaml library\n import('yaml').then(yaml => console.log(yaml.stringify(results)));\n break;\n default:\n printTable(results);\n }\n}\n\nfunction printTable(results: RegistryServer[]): void {\n console.log('NAME'.padEnd(30) + 'DESCRIPTION'.padEnd(50) + 'PACKAGE'.padEnd(35) + 'TRANSPORT VERIFIED POPULARITY');\n console.log('-'.repeat(140));\n for (const s of results) {\n const pkg = s.packages.npm ?? s.packages.pypi ?? s.packages.docker ?? '-';\n const verified = s.verified ? chalk.green('āœ“') : '-';\n console.log(\n s.name.slice(0, 28).padEnd(30) +\n s.description.slice(0, 48).padEnd(50) +\n pkg.slice(0, 33).padEnd(35) +\n s.transport.padEnd(11) +\n verified.padEnd(10) +\n String(s.popularityScore)\n );\n }\n console.log(`\\nRun 'mcpctl install ' to set up a server.`);\n}\n\nasync function runInteractiveMode(results: RegistryServer[]): Promise {\n const { selected } = await inquirer.prompt([{\n type: 'list',\n name: 'selected',\n message: 'Select an MCP server to install:',\n choices: results.map(s => ({\n name: `${s.name} - ${s.description.slice(0, 60)}`,\n value: s,\n })),\n }]);\n\n const { action } = await inquirer.prompt([{\n type: 'list',\n name: 'action',\n message: `What would you like to do with ${selected.name}?`,\n choices: [\n { name: 'Install and configure', value: 'install' },\n { name: 'View details', value: 'details' },\n { name: 'Cancel', value: 'cancel' },\n ],\n }]);\n\n if (action === 'install') {\n // Invoke install command programmatically\n const { execSync } = await import('node:child_process');\n execSync(`mcpctl install ${selected.name}`, { stdio: 'inherit' });\n } else if (action === 'details') {\n console.log(JSON.stringify(selected, null, 2));\n }\n}\n```\n\nRegister command in src/cli/src/commands/index.ts.\n\nExit codes for scripting:\n- 0: Results found\n- 1: Error occurred\n- 2: No results found\n\nTDD: Write all tests BEFORE implementation:\n- Test command parsing with all options\n- Test table output formatting\n- Test JSON/YAML output\n- Test exit codes\n- Mock inquirer for interactive mode tests", - "testStrategy": "1. Unit tests (src/cli/tests/commands/discover.test.ts):\n - Test argument parsing: verify query is required, options have defaults\n - Test table output: mock RegistryClient, verify correct columns printed\n - Test JSON output: verify valid JSON with all fields\n - Test YAML output: verify valid YAML structure\n - Test --verified filter is passed to client\n - Test --registry parses correctly\n2. Integration tests:\n - Mock registry sources, run full discover command, verify output\n - Test exit code 2 when no results\n - Test exit code 1 on network error\n3. Interactive mode tests:\n - Mock inquirer responses, verify correct server selected\n - Verify install command invoked with correct name\n4. Run: pnpm --filter @mcpctl/cli test", - "priority": "high", - "dependencies": [ - 25 - ], - "status": "pending", - "subtasks": [] - }, - { - "id": 27, - "title": "Implement mcpctl install with LLM-Assisted Auto-Configuration", - "description": "Create the `mcpctl install ` command that uses a local LLM (Claude Code session, Ollama, or configured provider) to automatically analyze MCP server READMEs, generate envTemplate and setup guides, walk users through configuration, and register the server in mcpd.", - "details": "Create src/cli/src/commands/install.ts:\n\n```typescript\nimport { Command } from 'commander';\nimport { RegistryClient, type RegistryServer, type EnvVar } from '../registry/index.js';\nimport { getConfig } from '../config/index.js';\nimport { z } from 'zod';\nimport inquirer from 'inquirer';\n\n// Zod schema for validating LLM-generated envTemplate\nconst LLMEnvVarSchema = z.object({\n name: z.string().min(1),\n description: z.string(),\n isSecret: z.boolean(),\n setupUrl: z.string().url().optional(),\n defaultValue: z.string().optional(),\n});\n\nconst LLMConfigResponseSchema = z.object({\n envTemplate: z.array(LLMEnvVarSchema),\n setupGuide: z.array(z.string()),\n defaultProfiles: z.array(z.object({\n name: z.string(),\n permissions: z.array(z.string()),\n })).optional().default([]),\n});\n\nexport type LLMConfigResponse = z.infer;\n\nexport function createInstallCommand(): Command {\n return new Command('install')\n .description('Install and configure an MCP server')\n .argument('', 'Server name(s) from discover results')\n .option('--non-interactive', 'Use env vars for credentials (no prompts)')\n .option('--profile-name ', 'Name for the created profile')\n .option('--project ', 'Add to existing project after install')\n .option('--dry-run', 'Show configuration without applying')\n .option('--skip-llm', 'Skip LLM analysis, use registry metadata only')\n .action(async (servers, options) => {\n for (const serverName of servers) {\n await installServer(serverName, options);\n }\n });\n}\n\nasync function installServer(serverName: string, options: {\n nonInteractive?: boolean;\n profileName?: string;\n project?: string;\n dryRun?: boolean;\n skipLlm?: boolean;\n}): Promise {\n const config = await getConfig();\n const client = new RegistryClient(config);\n\n // Step 1: Fetch server metadata from registry\n console.log(`Searching for ${serverName}...`);\n const results = await client.search({ query: serverName, limit: 10 });\n const server = results.find(s => \n s.name.toLowerCase() === serverName.toLowerCase() ||\n s.packages.npm?.includes(serverName)\n );\n\n if (!server) {\n console.error(`Server \"${serverName}\" not found. Run 'mcpctl discover ${serverName}' to search.`);\n process.exitCode = 1;\n return;\n }\n\n console.log(`Found: ${server.name} (${server.packages.npm ?? server.packages.docker ?? 'N/A'})`);\n\n // Step 2: Determine envTemplate\n let envTemplate: EnvVar[] = server.envTemplate;\n let setupGuide: string[] = [];\n\n // Step 3: If envTemplate incomplete and LLM not skipped, use LLM\n if (envTemplate.length === 0 && !options.skipLlm && server.repositoryUrl) {\n console.log('Registry metadata incomplete. Analyzing README with LLM...');\n const llmConfig = await analyzeWithLLM(server.repositoryUrl, config);\n if (llmConfig) {\n envTemplate = llmConfig.envTemplate;\n setupGuide = llmConfig.setupGuide;\n }\n }\n\n // Step 4: Show setup guide if available\n if (setupGuide.length > 0) {\n console.log('\\nšŸ“‹ Setup Guide:');\n setupGuide.forEach((step, i) => console.log(` ${i + 1}. ${step}`));\n console.log('');\n }\n\n if (options.dryRun) {\n console.log('Dry run - would configure:');\n console.log(JSON.stringify({ server, envTemplate }, null, 2));\n return;\n }\n\n // Step 5: Collect credentials\n const credentials: Record = {};\n if (!options.nonInteractive) {\n for (const env of envTemplate) {\n const { value } = await inquirer.prompt([{\n type: env.isSecret ? 'password' : 'input',\n name: 'value',\n message: `${env.name}${env.description ? ` (${env.description})` : ''}:`,\n default: env.defaultValue,\n }]);\n credentials[env.name] = value;\n }\n } else {\n // Use environment variables\n for (const env of envTemplate) {\n credentials[env.name] = process.env[env.name] ?? env.defaultValue ?? '';\n }\n }\n\n // Step 6: Register with mcpd (mock for now until mcpd integration)\n console.log(`\\nRegistering ${server.name} with mcpd...`);\n // TODO: POST to mcpd /api/mcp-servers when mcpd is implemented\n // For now, write to local config\n await saveServerConfig(server, credentials, options.profileName ?? server.name);\n\n // Step 7: Add to project if specified\n if (options.project) {\n console.log(`Adding to project: ${options.project}`);\n // TODO: Call mcpd project API\n }\n\n console.log(`\\nāœ… ${server.name} installed successfully!`);\n console.log(`Run 'mcpctl get servers' to see installed servers.`);\n}\n\nasync function analyzeWithLLM(repoUrl: string, config: any): Promise {\n try {\n // Fetch README from GitHub\n const readmeUrl = convertToRawReadmeUrl(repoUrl);\n const response = await fetch(readmeUrl);\n if (!response.ok) {\n console.warn('Could not fetch README.');\n return null;\n }\n const readme = await response.text();\n\n // Sanitize README - prevent prompt injection\n const sanitizedReadme = sanitizeReadme(readme);\n\n // Use configured LLM provider (Ollama, OpenAI, etc. from Task 12)\n // For Claude Code integration, output prompt for user to paste\n const prompt = buildLLMPrompt(sanitizedReadme);\n \n // TODO: Integrate with actual LLM provider from Task 12\n // For now, attempt Ollama if configured\n const llmResponse = await callLLM(prompt, config);\n \n // Parse and validate response\n const parsed = JSON.parse(llmResponse);\n return LLMConfigResponseSchema.parse(parsed);\n } catch (error) {\n console.warn('LLM analysis failed, using registry metadata only.');\n return null;\n }\n}\n\nfunction buildLLMPrompt(readme: string): string {\n return `Analyze this MCP server README and extract configuration requirements.\n\nRETURN ONLY VALID JSON matching this schema:\n{\n \"envTemplate\": [{ \"name\": string, \"description\": string, \"isSecret\": boolean, \"setupUrl\"?: string }],\n \"setupGuide\": [\"Step 1...\", \"Step 2...\"],\n \"defaultProfiles\": [{ \"name\": string, \"permissions\": string[] }]\n}\n\nREADME content (trusted, from official repository):\n${readme.slice(0, 8000)}\n\nJSON output:`;\n}\n\nfunction sanitizeReadme(readme: string): string {\n // Remove potential prompt injection patterns\n return readme\n .replace(/ignore.*instructions/gi, '')\n .replace(/disregard.*above/gi, '')\n .replace(/system.*prompt/gi, '');\n}\n\nfunction convertToRawReadmeUrl(repoUrl: string): string {\n // Convert GitHub repo URL to raw README URL\n const match = repoUrl.match(/github\\.com\\/([^/]+)\\/([^/]+)/);\n if (match) {\n return `https://raw.githubusercontent.com/${match[1]}/${match[2]}/main/README.md`;\n }\n return repoUrl;\n}\n\nasync function callLLM(prompt: string, config: any): Promise {\n // Try Ollama first if available\n if (config.ollamaUrl) {\n const response = await fetch(`${config.ollamaUrl}/api/generate`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n model: config.ollamaModel ?? 'llama3',\n prompt,\n stream: false,\n }),\n });\n const data = await response.json();\n return data.response;\n }\n throw new Error('No LLM provider configured. Set OLLAMA_URL or use --skip-llm.');\n}\n\nasync function saveServerConfig(server: RegistryServer, credentials: Record, profileName: string): Promise {\n // Save to ~/.mcpctl/servers/.json\n const fs = await import('node:fs/promises');\n const path = await import('node:path');\n const os = await import('node:os');\n \n const configDir = path.join(os.homedir(), '.mcpctl', 'servers');\n await fs.mkdir(configDir, { recursive: true });\n \n await fs.writeFile(\n path.join(configDir, `${profileName}.json`),\n JSON.stringify({ server, credentials, createdAt: new Date().toISOString() }, null, 2)\n );\n}\n```\n\nSecurity considerations:\n- sanitizeReadme() removes prompt injection patterns\n- LLM responses validated against Zod schema before use\n- Never auto-execute commands suggested by LLM\n- Credentials stored in separate secure config (encrypted via Task 7.2)\n\nTDD: Write comprehensive tests BEFORE implementation.", - "testStrategy": "1. Unit tests (src/cli/tests/commands/install.test.ts):\n - Test server lookup from registry results\n - Test LLMConfigResponseSchema validates correct JSON\n - Test LLMConfigResponseSchema rejects invalid JSON\n - Test sanitizeReadme() removes injection patterns\n - Test buildLLMPrompt() generates valid prompt structure\n - Test convertToRawReadmeUrl() for various GitHub URL formats\n - Test --dry-run outputs config without saving\n - Test --non-interactive uses env vars\n - Test batch install: multiple servers processed sequentially\n2. Security tests:\n - Test sanitizeReadme blocks 'ignore all instructions'\n - Test LLM response with extra fields is safely parsed\n - Test credentials are not logged\n3. Integration tests:\n - Mock registry client and LLM endpoint\n - Full install flow with mocked dependencies\n - Verify server config file created with correct structure\n4. Run: pnpm --filter @mcpctl/cli test", - "priority": "high", - "dependencies": [ - 25, - 26 - ], "status": "pending", "subtasks": [] } ], "metadata": { - "created": "2026-02-21T03:25:05.784Z", - "updated": "2026-02-21T03:25:05.784Z", - "description": "Tasks for master context" + "version": "1.0.0", + "lastModified": "2026-02-21T04:10:25.433Z", + "taskCount": 24, + "completedCount": 2, + "tags": [ + "master" + ] } } } \ No newline at end of file diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma new file mode 100644 index 0000000..a445a89 --- /dev/null +++ b/src/db/prisma/schema.prisma @@ -0,0 +1,172 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ── Users ── + +model User { + id String @id @default(cuid()) + email String @unique + name String? + role Role @default(USER) + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + sessions Session[] + auditLogs AuditLog[] + projects Project[] + + @@index([email]) +} + +enum Role { + USER + ADMIN +} + +// ── Sessions ── + +model Session { + id String @id @default(cuid()) + token String @unique + userId String + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([token]) + @@index([userId]) + @@index([expiresAt]) +} + +// ── MCP Servers ── + +model McpServer { + id String @id @default(cuid()) + name String @unique + description String @default("") + packageName String? + dockerImage String? + transport Transport @default(STDIO) + repositoryUrl String? + envTemplate Json @default("[]") + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + profiles McpProfile[] + instances McpInstance[] + + @@index([name]) +} + +enum Transport { + STDIO + SSE + STREAMABLE_HTTP +} + +// ── MCP Profiles ── + +model McpProfile { + id String @id @default(cuid()) + name String + serverId String + permissions Json @default("[]") + envOverrides Json @default("{}") + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + projects ProjectMcpProfile[] + + @@unique([name, serverId]) + @@index([serverId]) +} + +// ── Projects ── + +model Project { + id String @id @default(cuid()) + name String @unique + description String @default("") + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + profiles ProjectMcpProfile[] + + @@index([name]) + @@index([ownerId]) +} + +// ── Project <-> Profile join table ── + +model ProjectMcpProfile { + id String @id @default(cuid()) + projectId String + profileId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + profile McpProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + + @@unique([projectId, profileId]) + @@index([projectId]) + @@index([profileId]) +} + +// ── MCP Instances (running containers) ── + +model McpInstance { + id String @id @default(cuid()) + serverId String + containerId String? + status InstanceStatus @default(STOPPED) + port Int? + metadata Json @default("{}") + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + + @@index([serverId]) + @@index([status]) +} + +enum InstanceStatus { + STARTING + RUNNING + STOPPING + STOPPED + ERROR +} + +// ── Audit Logs ── + +model AuditLog { + id String @id @default(cuid()) + userId String + action String + resource String + resourceId String? + details Json @default("{}") + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([action]) + @@index([resource]) + @@index([createdAt]) +} diff --git a/src/db/src/index.ts b/src/db/src/index.ts index 0140567..2312a36 100644 --- a/src/db/src/index.ts +++ b/src/db/src/index.ts @@ -1,2 +1,18 @@ // Database package - Prisma client and utilities -// Will be implemented in Task 2 +export { PrismaClient } from '@prisma/client'; +export type { + User, + Session, + McpServer, + McpProfile, + Project, + ProjectMcpProfile, + McpInstance, + AuditLog, + Role, + Transport, + InstanceStatus, +} from '@prisma/client'; + +export { seedMcpServers, defaultServers } from './seed/index.js'; +export type { SeedServer } from './seed/index.js'; diff --git a/src/db/src/seed/index.ts b/src/db/src/seed/index.ts new file mode 100644 index 0000000..8840535 --- /dev/null +++ b/src/db/src/seed/index.ts @@ -0,0 +1,131 @@ +import { PrismaClient } from '@prisma/client'; + +export interface SeedServer { + name: string; + description: string; + packageName: string; + transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP'; + repositoryUrl: string; + envTemplate: Array<{ + name: string; + description: string; + isSecret: boolean; + setupUrl?: string; + }>; +} + +export const defaultServers: SeedServer[] = [ + { + name: 'slack', + description: 'Slack MCP server for reading channels, messages, and user info', + packageName: '@anthropic/slack-mcp', + transport: 'STDIO', + repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack', + envTemplate: [ + { + name: 'SLACK_BOT_TOKEN', + description: 'Slack Bot User OAuth Token (xoxb-...)', + isSecret: true, + setupUrl: 'https://api.slack.com/apps', + }, + { + name: 'SLACK_TEAM_ID', + description: 'Slack Workspace Team ID', + isSecret: false, + }, + ], + }, + { + name: 'jira', + description: 'Jira MCP server for issues, projects, and boards', + packageName: '@anthropic/jira-mcp', + transport: 'STDIO', + repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/jira', + envTemplate: [ + { + name: 'JIRA_URL', + description: 'Jira instance URL (e.g., https://company.atlassian.net)', + isSecret: false, + }, + { + name: 'JIRA_EMAIL', + description: 'Jira account email', + isSecret: false, + }, + { + name: 'JIRA_API_TOKEN', + description: 'Jira API token', + isSecret: true, + setupUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens', + }, + ], + }, + { + name: 'github', + description: 'GitHub MCP server for repos, issues, PRs, and code search', + packageName: '@anthropic/github-mcp', + transport: 'STDIO', + repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github', + envTemplate: [ + { + name: 'GITHUB_TOKEN', + description: 'GitHub Personal Access Token', + isSecret: true, + setupUrl: 'https://github.com/settings/tokens', + }, + ], + }, + { + name: 'terraform', + description: 'Terraform MCP server for infrastructure documentation and state', + packageName: '@anthropic/terraform-mcp', + transport: 'STDIO', + repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/terraform', + envTemplate: [], + }, +]; + +export async function seedMcpServers( + prisma: PrismaClient, + servers: SeedServer[] = defaultServers, +): Promise { + let created = 0; + + for (const server of servers) { + await prisma.mcpServer.upsert({ + where: { name: server.name }, + update: { + description: server.description, + packageName: server.packageName, + transport: server.transport, + repositoryUrl: server.repositoryUrl, + envTemplate: server.envTemplate, + }, + create: { + name: server.name, + description: server.description, + packageName: server.packageName, + transport: server.transport, + repositoryUrl: server.repositoryUrl, + envTemplate: server.envTemplate, + }, + }); + created++; + } + + return created; +} + +// CLI entry point +if (import.meta.url === `file://${process.argv[1]}`) { + const prisma = new PrismaClient(); + seedMcpServers(prisma) + .then((count) => { + console.log(`Seeded ${count} MCP servers`); + return prisma.$disconnect(); + }) + .catch((e) => { + console.error(e); + return prisma.$disconnect().then(() => process.exit(1)); + }); +} diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts new file mode 100644 index 0000000..7ef2c06 --- /dev/null +++ b/src/db/tests/helpers.ts @@ -0,0 +1,58 @@ +import { PrismaClient } from '@prisma/client'; +import { execSync } from 'node:child_process'; + +const TEST_DATABASE_URL = process.env['DATABASE_URL'] ?? + 'postgresql://mcpctl:mcpctl_test@localhost:5433/mcpctl_test'; + +let prisma: PrismaClient | undefined; +let schemaReady = false; + +export function getTestClient(): PrismaClient { + if (!prisma) { + prisma = new PrismaClient({ + datasources: { db: { url: TEST_DATABASE_URL } }, + }); + } + return prisma; +} + +export async function setupTestDb(): Promise { + const client = getTestClient(); + + // Only push schema once per process (multiple test files share the worker) + if (!schemaReady) { + execSync('npx prisma db push --force-reset --skip-generate', { + cwd: new URL('..', import.meta.url).pathname, + env: { + ...process.env, + DATABASE_URL: TEST_DATABASE_URL, + // Consent required when Prisma detects AI agent context. + // This targets the ephemeral test database (tmpfs-backed, port 5433). + PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: 'yes', + }, + stdio: 'pipe', + }); + schemaReady = true; + } + + return client; +} + +export async function cleanupTestDb(): Promise { + if (prisma) { + await prisma.$disconnect(); + prisma = undefined; + } +} + +export async function clearAllTables(client: PrismaClient): Promise { + // Delete in order respecting foreign keys + await client.auditLog.deleteMany(); + await client.projectMcpProfile.deleteMany(); + await client.mcpInstance.deleteMany(); + await client.mcpProfile.deleteMany(); + await client.session.deleteMany(); + await client.project.deleteMany(); + await client.mcpServer.deleteMany(); + await client.user.deleteMany(); +} diff --git a/src/db/tests/models.test.ts b/src/db/tests/models.test.ts new file mode 100644 index 0000000..7e01abf --- /dev/null +++ b/src/db/tests/models.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { setupTestDb, cleanupTestDb, clearAllTables, getTestClient } from './helpers.js'; + +let prisma: PrismaClient; + +beforeAll(async () => { + prisma = await setupTestDb(); +}, 30_000); + +afterAll(async () => { + await cleanupTestDb(); +}); + +beforeEach(async () => { + await clearAllTables(prisma); +}); + +// ── Helper factories ── + +async function createUser(overrides: { email?: string; name?: string; role?: 'USER' | 'ADMIN' } = {}) { + return prisma.user.create({ + data: { + email: overrides.email ?? `test-${Date.now()}@example.com`, + name: overrides.name ?? 'Test User', + role: overrides.role ?? 'USER', + }, + }); +} + +async function createServer(overrides: { name?: string; transport?: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP' } = {}) { + return prisma.mcpServer.create({ + data: { + name: overrides.name ?? `server-${Date.now()}`, + description: 'Test server', + packageName: '@test/mcp-server', + transport: overrides.transport ?? 'STDIO', + }, + }); +} + +// ── User model ── + +describe('User', () => { + it('creates a user with defaults', async () => { + const user = await createUser(); + expect(user.id).toBeDefined(); + expect(user.role).toBe('USER'); + expect(user.version).toBe(1); + expect(user.createdAt).toBeInstanceOf(Date); + expect(user.updatedAt).toBeInstanceOf(Date); + }); + + it('enforces unique email', async () => { + await createUser({ email: 'dup@test.com' }); + await expect(createUser({ email: 'dup@test.com' })).rejects.toThrow(); + }); + + it('allows ADMIN role', async () => { + const admin = await createUser({ role: 'ADMIN' }); + expect(admin.role).toBe('ADMIN'); + }); + + it('updates updatedAt on change', async () => { + const user = await createUser(); + const original = user.updatedAt; + // Small delay to ensure different timestamp + await new Promise((r) => setTimeout(r, 50)); + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { name: 'Updated' }, + }); + expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(original.getTime()); + }); +}); + +// ── Session model ── + +describe('Session', () => { + it('creates a session linked to user', async () => { + const user = await createUser(); + const session = await prisma.session.create({ + data: { + token: 'test-token-123', + userId: user.id, + expiresAt: new Date(Date.now() + 86400_000), + }, + }); + expect(session.token).toBe('test-token-123'); + expect(session.userId).toBe(user.id); + }); + + it('enforces unique token', async () => { + const user = await createUser(); + const data = { + token: 'unique-token', + userId: user.id, + expiresAt: new Date(Date.now() + 86400_000), + }; + await prisma.session.create({ data }); + await expect(prisma.session.create({ data })).rejects.toThrow(); + }); + + it('cascades delete when user is deleted', async () => { + const user = await createUser(); + await prisma.session.create({ + data: { + token: 'cascade-token', + userId: user.id, + expiresAt: new Date(Date.now() + 86400_000), + }, + }); + await prisma.user.delete({ where: { id: user.id } }); + const sessions = await prisma.session.findMany({ where: { userId: user.id } }); + expect(sessions).toHaveLength(0); + }); +}); + +// ── McpServer model ── + +describe('McpServer', () => { + it('creates a server with defaults', async () => { + const server = await createServer(); + expect(server.transport).toBe('STDIO'); + expect(server.version).toBe(1); + expect(server.envTemplate).toEqual([]); + }); + + it('enforces unique name', async () => { + await createServer({ name: 'slack' }); + await expect(createServer({ name: 'slack' })).rejects.toThrow(); + }); + + it('stores envTemplate as JSON', async () => { + const server = await prisma.mcpServer.create({ + data: { + name: 'with-env', + envTemplate: [ + { name: 'API_KEY', description: 'Key', isSecret: true }, + ], + }, + }); + const envTemplate = server.envTemplate as Array<{ name: string }>; + expect(envTemplate).toHaveLength(1); + expect(envTemplate[0].name).toBe('API_KEY'); + }); + + it('supports SSE transport', async () => { + const server = await createServer({ transport: 'SSE' }); + expect(server.transport).toBe('SSE'); + }); +}); + +// ── McpProfile model ── + +describe('McpProfile', () => { + it('creates a profile linked to server', async () => { + const server = await createServer(); + const profile = await prisma.mcpProfile.create({ + data: { + name: 'readonly', + serverId: server.id, + permissions: ['read'], + }, + }); + expect(profile.name).toBe('readonly'); + expect(profile.serverId).toBe(server.id); + }); + + it('enforces unique name per server', async () => { + const server = await createServer(); + const data = { name: 'default', serverId: server.id }; + await prisma.mcpProfile.create({ data }); + await expect(prisma.mcpProfile.create({ data })).rejects.toThrow(); + }); + + it('allows same profile name on different servers', async () => { + const server1 = await createServer({ name: 'server-1' }); + const server2 = await createServer({ name: 'server-2' }); + await prisma.mcpProfile.create({ data: { name: 'default', serverId: server1.id } }); + const profile2 = await prisma.mcpProfile.create({ data: { name: 'default', serverId: server2.id } }); + expect(profile2.name).toBe('default'); + }); + + it('cascades delete when server is deleted', async () => { + const server = await createServer(); + await prisma.mcpProfile.create({ data: { name: 'test', serverId: server.id } }); + await prisma.mcpServer.delete({ where: { id: server.id } }); + const profiles = await prisma.mcpProfile.findMany({ where: { serverId: server.id } }); + expect(profiles).toHaveLength(0); + }); +}); + +// ── Project model ── + +describe('Project', () => { + it('creates a project with owner', async () => { + const user = await createUser(); + const project = await prisma.project.create({ + data: { name: 'weekly-reports', ownerId: user.id }, + }); + expect(project.name).toBe('weekly-reports'); + expect(project.ownerId).toBe(user.id); + }); + + it('enforces unique project name', async () => { + const user = await createUser(); + await prisma.project.create({ data: { name: 'dup', ownerId: user.id } }); + await expect( + prisma.project.create({ data: { name: 'dup', ownerId: user.id } }), + ).rejects.toThrow(); + }); + + it('cascades delete when owner is deleted', async () => { + const user = await createUser(); + await prisma.project.create({ data: { name: 'orphan', ownerId: user.id } }); + await prisma.user.delete({ where: { id: user.id } }); + const projects = await prisma.project.findMany({ where: { ownerId: user.id } }); + expect(projects).toHaveLength(0); + }); +}); + +// ── ProjectMcpProfile (join table) ── + +describe('ProjectMcpProfile', () => { + it('links project to profile', async () => { + const user = await createUser(); + const server = await createServer(); + const profile = await prisma.mcpProfile.create({ + data: { name: 'default', serverId: server.id }, + }); + const project = await prisma.project.create({ + data: { name: 'test-project', ownerId: user.id }, + }); + + const link = await prisma.projectMcpProfile.create({ + data: { projectId: project.id, profileId: profile.id }, + }); + expect(link.projectId).toBe(project.id); + expect(link.profileId).toBe(profile.id); + }); + + it('enforces unique project+profile combination', async () => { + const user = await createUser(); + const server = await createServer(); + const profile = await prisma.mcpProfile.create({ + data: { name: 'default', serverId: server.id }, + }); + const project = await prisma.project.create({ + data: { name: 'test-project', ownerId: user.id }, + }); + + const data = { projectId: project.id, profileId: profile.id }; + await prisma.projectMcpProfile.create({ data }); + await expect(prisma.projectMcpProfile.create({ data })).rejects.toThrow(); + }); + + it('loads profiles through project include', async () => { + const user = await createUser(); + const server = await createServer(); + const profile = await prisma.mcpProfile.create({ + data: { name: 'slack-ro', serverId: server.id }, + }); + const project = await prisma.project.create({ + data: { name: 'reports', ownerId: user.id }, + }); + await prisma.projectMcpProfile.create({ + data: { projectId: project.id, profileId: profile.id }, + }); + + const loaded = await prisma.project.findUnique({ + where: { id: project.id }, + include: { profiles: { include: { profile: true } } }, + }); + expect(loaded!.profiles).toHaveLength(1); + expect(loaded!.profiles[0].profile.name).toBe('slack-ro'); + }); +}); + +// ── McpInstance model ── + +describe('McpInstance', () => { + it('creates an instance linked to server', async () => { + const server = await createServer(); + const instance = await prisma.mcpInstance.create({ + data: { serverId: server.id }, + }); + expect(instance.status).toBe('STOPPED'); + expect(instance.serverId).toBe(server.id); + }); + + it('tracks instance status transitions', async () => { + const server = await createServer(); + const instance = await prisma.mcpInstance.create({ + data: { serverId: server.id, status: 'STARTING' }, + }); + const running = await prisma.mcpInstance.update({ + where: { id: instance.id }, + data: { status: 'RUNNING', containerId: 'abc123', port: 8080 }, + }); + expect(running.status).toBe('RUNNING'); + expect(running.containerId).toBe('abc123'); + expect(running.port).toBe(8080); + }); + + it('cascades delete when server is deleted', async () => { + const server = await createServer(); + await prisma.mcpInstance.create({ data: { serverId: server.id } }); + await prisma.mcpServer.delete({ where: { id: server.id } }); + const instances = await prisma.mcpInstance.findMany({ where: { serverId: server.id } }); + expect(instances).toHaveLength(0); + }); +}); + +// ── AuditLog model ── + +describe('AuditLog', () => { + it('creates an audit log entry', async () => { + const user = await createUser(); + const log = await prisma.auditLog.create({ + data: { + userId: user.id, + action: 'CREATE', + resource: 'McpServer', + resourceId: 'server-123', + details: { name: 'slack' }, + }, + }); + expect(log.action).toBe('CREATE'); + expect(log.resource).toBe('McpServer'); + expect(log.createdAt).toBeInstanceOf(Date); + }); + + it('supports querying by action and resource', async () => { + const user = await createUser(); + await prisma.auditLog.createMany({ + data: [ + { userId: user.id, action: 'CREATE', resource: 'McpServer' }, + { userId: user.id, action: 'UPDATE', resource: 'McpServer' }, + { userId: user.id, action: 'CREATE', resource: 'Project' }, + ], + }); + + const creates = await prisma.auditLog.findMany({ + where: { action: 'CREATE' }, + }); + expect(creates).toHaveLength(2); + + const serverLogs = await prisma.auditLog.findMany({ + where: { resource: 'McpServer' }, + }); + expect(serverLogs).toHaveLength(2); + }); + + it('cascades delete when user is deleted', async () => { + const user = await createUser(); + await prisma.auditLog.create({ + data: { userId: user.id, action: 'TEST', resource: 'Test' }, + }); + await prisma.user.delete({ where: { id: user.id } }); + const logs = await prisma.auditLog.findMany({ where: { userId: user.id } }); + expect(logs).toHaveLength(0); + }); +}); diff --git a/src/db/tests/seed.test.ts b/src/db/tests/seed.test.ts new file mode 100644 index 0000000..41ddbd5 --- /dev/null +++ b/src/db/tests/seed.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js'; +import { seedMcpServers, defaultServers } from '../src/seed/index.js'; + +let prisma: PrismaClient; + +beforeAll(async () => { + prisma = await setupTestDb(); +}, 30_000); + +afterAll(async () => { + await cleanupTestDb(); +}); + +beforeEach(async () => { + await clearAllTables(prisma); +}); + +describe('seedMcpServers', () => { + it('seeds all default servers', async () => { + const count = await seedMcpServers(prisma); + expect(count).toBe(defaultServers.length); + + const servers = await prisma.mcpServer.findMany({ orderBy: { name: 'asc' } }); + expect(servers).toHaveLength(defaultServers.length); + + const names = servers.map((s) => s.name); + expect(names).toContain('slack'); + expect(names).toContain('github'); + expect(names).toContain('jira'); + expect(names).toContain('terraform'); + }); + + it('is idempotent (upsert)', async () => { + await seedMcpServers(prisma); + const count = await seedMcpServers(prisma); + expect(count).toBe(defaultServers.length); + + const servers = await prisma.mcpServer.findMany(); + expect(servers).toHaveLength(defaultServers.length); + }); + + it('seeds envTemplate correctly', async () => { + await seedMcpServers(prisma); + const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } }); + const envTemplate = slack!.envTemplate as Array<{ name: string; isSecret: boolean }>; + expect(envTemplate).toHaveLength(2); + expect(envTemplate[0].name).toBe('SLACK_BOT_TOKEN'); + expect(envTemplate[0].isSecret).toBe(true); + }); + + it('accepts custom server list', async () => { + const custom = [ + { + name: 'custom-server', + description: 'Custom test server', + packageName: '@test/custom', + transport: 'STDIO' as const, + repositoryUrl: 'https://example.com', + envTemplate: [], + }, + ]; + const count = await seedMcpServers(prisma, custom); + expect(count).toBe(1); + + const servers = await prisma.mcpServer.findMany(); + expect(servers).toHaveLength(1); + expect(servers[0].name).toBe('custom-server'); + }); +}); diff --git a/src/db/vitest.config.ts b/src/db/vitest.config.ts index 8400038..9393b93 100644 --- a/src/db/vitest.config.ts +++ b/src/db/vitest.config.ts @@ -4,5 +4,7 @@ export default defineProject({ test: { name: 'db', include: ['tests/**/*.test.ts'], + // Test files share the same database — run sequentially + fileParallelism: false, }, });