Skip to main content
Glama
REVERSE_MCP_SECURITY.mdโ€ข26.1 kB
# Reverse MCP Security Architecture **Document Version:** 1.0 **Created:** 2025-10-16 **Status:** Security Design Specification **Classification:** Internal - Security Sensitive --- ## Security Philosophy **Defense in Depth**: Multiple independent security layers so that if one fails, others still protect the system. **Fail Secure**: All security checks must pass. Any failure results in denied access, not degraded security. **Principle of Least Privilege**: Remote teams can only access the minimum tools and targets required for their function. **Zero Trust**: Even though traffic flows through SSH tunnels, we don't trust the transport alone. Every request is authenticated and authorized. --- ## Threat Model ### In Scope **Threats We Defend Against:** 1. **Compromised Remote Host** - Attacker gains root on remote machine - Attempts to hijack SSH tunnel - Tries to impersonate legitimate team 2. **Man-in-the-Middle Attacks** - Attacker intercepts traffic between remote Claude and Iris - Attempts to modify or replay requests 3. **Denial of Service** - Malicious or compromised remote team floods Iris with requests - Resource exhaustion attacks 4. **Privilege Escalation** - Remote team attempts to access tools/teams outside their authorization - Lateral movement between teams 5. **Data Exfiltration** - Remote team attempts to read sensitive session data - Cache snooping from other teams ### Out of Scope **Assumptions:** 1. **Local Machine Security**: We assume the local machine running Iris is trusted and secure 2. **SSH Key Security**: We assume SSH private keys are protected (encrypted, not shared) 3. **Physical Security**: Physical access to either machine is assumed secure 4. **Side Channel Attacks**: Timing attacks, speculative execution vulnerabilities are out of scope for Phase 1 --- ## Security Architecture Layers ### Layer 1: Transport Security (HTTPS over SSH) #### 1.1 HTTPS Requirement **Requirement**: Iris MUST run HTTPS server, not HTTP, even though traffic is inside SSH tunnel. **Rationale**: Defense in depth. If SSH tunnel is misconfigured or compromised, HTTPS provides backup encryption. **Implementation**: ```typescript // src/mcp_server.ts const httpsServer = https.createServer({ key: fs.readFileSync('~/.iris/certs/iris-key.pem'), cert: fs.readFileSync('~/.iris/certs/iris-cert.pem'), }, app); ``` **Config**: ```json { "settings": { "defaultTransport": "https", // Changed from "http" "httpsPort": 1615, "tlsCertPath": "~/.iris/certs/iris-cert.pem", "tlsKeyPath": "~/.iris/certs/iris-key.pem" } } ``` #### 1.2 Certificate Management **Self-Signed with Fingerprint Pinning** Generate certificate on first run: ```bash # Automatically generated by Iris on first start openssl req -x509 -newkey rsa:4096 -nodes \ -keyout ~/.iris/certs/iris-key.pem \ -out ~/.iris/certs/iris-cert.pem \ -days 365 -subj "/CN=localhost" # Calculate fingerprint openssl x509 -in ~/.iris/certs/iris-cert.pem -noout -fingerprint -sha256 ``` **Store fingerprint in team config**: ```json { "team-inanna": { "remote": "ssh inanna", "enableReverseMcp": true, "reverseMcpCertFingerprint": "sha256:A1:B2:C3:D4:E5:F6:..." } } ``` **Remote Claude verifies fingerprint**: ```bash # In --mcp-config { "iris": { "url": "https://localhost:1615/mcp", "tlsCertFingerprint": "sha256:A1:B2:C3:D4:E5:F6:..." } } ``` **Certificate rotation**: Auto-rotate every 365 days, update fingerprints in config. #### 1.3 SSH Tunnel Hardening **Enforce localhost-only binding**: ```bash ssh -R 1615:127.0.0.1:1615 \ # Explicit 127.0.0.1, not localhost -o GatewayPorts=no \ # Force no gateway ports -o ServerAliveInterval=30 \ # Keepalive every 30s -o ServerAliveCountMax=3 \ # 3 failed = disconnect -o StrictHostKeyChecking=yes \ # Require known host user@remote ``` **Verify binding after connection**: ```typescript // After spawning SSH process, verify tunnel binding async function verifyTunnelBinding(teamName: string) { // Execute remote command to check netstat const result = await execRemoteCommand( teamName, 'netstat -an | grep :1615' ); // Verify binding is 127.0.0.1:1615, not 0.0.0.0:1615 if (result.includes('0.0.0.0:1615')) { throw new SecurityError( `Tunnel for ${teamName} is exposed! Expected 127.0.0.1, got 0.0.0.0` ); } } ``` --- ### Layer 2: Authentication #### 2.1 API Key Authentication **Generation**: ```typescript import crypto from 'crypto'; import keytar from 'keytar'; function generateTeamApiKey(teamName: string): string { // 256-bit random key const apiKey = crypto.randomBytes(32).toString('hex'); // Store in OS keychain (NOT in config file) keytar.setPassword('iris-mcp', `team-${teamName}`, apiKey); logger.info({ teamName }, 'Generated API key for team'); return apiKey; } ``` **Storage**: - **Local**: OS Keychain (macOS), Credential Manager (Windows), Secret Service (Linux) - **Remote**: Environment variable passed to Claude process - **NOT in config files** (ever!) **Transmission**: ```bash # Remote Claude MCP config includes API key { "iris": { "url": "https://localhost:1615/mcp", "headers": { "X-Iris-Api-Key": "${IRIS_TEAM_INANNA_API_KEY}" } } } ``` **Validation**: ```typescript function authenticateReverseMcpRequest(req: McpRequest): boolean { const providedKey = req.headers['x-iris-api-key']; const expectedKey = keytar.getPassword('iris-mcp', `team-${req.sourceTeam}`); if (!providedKey || !expectedKey) { throw new AuthenticationError('Missing API key'); } // Constant-time comparison to prevent timing attacks const providedBuffer = Buffer.from(providedKey); const expectedBuffer = Buffer.from(expectedKey); if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) { // Log failed attempt auditLog.warn({ event: 'auth_failure', team: req.sourceTeam, reason: 'invalid_api_key' }); throw new AuthenticationError('Invalid API key'); } return true; } ``` #### 2.2 SSH Session Binding **Prevent session hijacking**: ```typescript // When spawning remote team, generate unique session ID const sshSessionId = crypto.randomUUID(); sessionBindings.set(teamName, sshSessionId); // Pass to remote Claude via env var process.env.IRIS_SSH_SESSION_ID = sshSessionId; // In reverse MCP handler function validateSshSession(req: McpRequest): boolean { const providedSessionId = req.headers['x-iris-session-id']; const expectedSessionId = sessionBindings.get(req.sourceTeam); if (providedSessionId !== expectedSessionId) { auditLog.error({ event: 'session_hijack_attempt', team: req.sourceTeam, provided: providedSessionId, expected: expectedSessionId }); throw new SecurityError('Invalid SSH session'); } return true; } ``` --- ### Layer 3: Authorization #### 3.1 Tool-Level Permissions **Default Deny**: Remote teams have NO access unless explicitly granted. **Config Schema**: ```json { "team-inanna": { "remote": "ssh inanna", "enableReverseMcp": true, "reverseMcpPermissions": { "allowedTools": [ "team_wake", "team_status", "send_message", "session_fork" ], "deniedTools": [ "session_delete", // Too dangerous "session_reboot" // Could lose data ], "allowedTargets": [ "team-alpha", "team-beta" ], "deniedTargets": [ "team-production" // Never allow production access ] } } } ``` **Authorization Check**: ```typescript function authorizeToolAccess( sourceTeam: string, tool: string, targetTeam?: string ): boolean { const config = configManager.getIrisConfig(sourceTeam); const perms = config.reverseMcpPermissions; if (!perms) { throw new AuthorizationError( `Team ${sourceTeam} has no reverse MCP permissions` ); } // Check tool allow list if (perms.allowedTools && !perms.allowedTools.includes(tool)) { auditLog.warn({ event: 'authorization_failure', team: sourceTeam, tool, reason: 'tool_not_allowed' }); throw new AuthorizationError(`Tool ${tool} not allowed for ${sourceTeam}`); } // Check tool deny list (explicit deny overrides allow) if (perms.deniedTools && perms.deniedTools.includes(tool)) { auditLog.warn({ event: 'authorization_failure', team: sourceTeam, tool, reason: 'tool_explicitly_denied' }); throw new AuthorizationError(`Tool ${tool} explicitly denied for ${sourceTeam}`); } // Check target team permissions if (targetTeam) { if (perms.allowedTargets && !perms.allowedTargets.includes(targetTeam)) { auditLog.warn({ event: 'authorization_failure', team: sourceTeam, tool, target: targetTeam, reason: 'target_not_allowed' }); throw new AuthorizationError( `Team ${sourceTeam} cannot access target ${targetTeam}` ); } if (perms.deniedTargets && perms.deniedTargets.includes(targetTeam)) { auditLog.warn({ event: 'authorization_failure', team: sourceTeam, tool, target: targetTeam, reason: 'target_explicitly_denied' }); throw new AuthorizationError( `Team ${sourceTeam} explicitly denied access to ${targetTeam}` ); } } return true; } ``` #### 3.2 Wildcard Patterns **Support glob patterns** for flexibility: ```json { "reverseMcpPermissions": { "allowedTools": ["team_*"], // All team_ tools "allowedTargets": ["team-dev-*"] // All dev teams } } ``` --- ### Layer 4: Rate Limiting #### 4.1 Token Bucket Algorithm **Per-Team Limits**: ```typescript import { RateLimiterMemory } from 'rate-limiter-flexible'; class ReverseMcpRateLimiter { private limiters = new Map<string, RateLimiterMemory>(); constructor( private defaultLimit = { points: 20, // 20 requests duration: 60, // Per 60 seconds blockDuration: 300 // Block for 5 minutes if exceeded } ) {} async checkLimit(teamName: string): Promise<void> { const limiter = this.getOrCreateLimiter(teamName); try { await limiter.consume(teamName, 1); } catch (error) { // Rate limit exceeded auditLog.warn({ event: 'rate_limit_exceeded', team: teamName, limit: this.defaultLimit }); throw new RateLimitError( `Rate limit exceeded for ${teamName}. ` + `Max ${this.defaultLimit.points} requests per ${this.defaultLimit.duration}s. ` + `Blocked for ${this.defaultLimit.blockDuration}s.` ); } } private getOrCreateLimiter(teamName: string): RateLimiterMemory { if (!this.limiters.has(teamName)) { const config = configManager.getIrisConfig(teamName); const limit = config.reverseMcpRateLimit || this.defaultLimit; this.limiters.set( teamName, new RateLimiterMemory(limit) ); } return this.limiters.get(teamName)!; } } ``` **Per-Team Custom Limits**: ```json { "team-inanna": { "reverseMcpRateLimit": { "points": 50, // Higher limit for trusted team "duration": 60, "blockDuration": 60 // Shorter block time } } } ``` #### 4.2 Circuit Breaker Pattern **Auto-disable after repeated violations**: ```typescript class CircuitBreaker { private failures = new Map<string, number>(); private openCircuits = new Set<string>(); recordFailure(teamName: string): void { const count = (this.failures.get(teamName) || 0) + 1; this.failures.set(teamName, count); // Open circuit after 5 failures if (count >= 5) { this.openCircuits.add(teamName); auditLog.error({ event: 'circuit_breaker_open', team: teamName, reason: 'repeated_security_violations' }); // Auto-close after 15 minutes setTimeout(() => { this.closeCircuit(teamName); }, 15 * 60 * 1000); } } isOpen(teamName: string): boolean { return this.openCircuits.has(teamName); } closeCircuit(teamName: string): void { this.openCircuits.delete(teamName); this.failures.delete(teamName); auditLog.info({ event: 'circuit_breaker_closed', team: teamName }); } } ``` --- ### Layer 5: Audit Logging #### 5.1 Structured Audit Log Format **Every reverse MCP call is logged**: ```typescript interface AuditLogEntry { timestamp: string; // ISO 8601 event: string; // Event type sourceTeam: string; // Remote caller targetTeam?: string; // Target (if applicable) tool: string; // MCP tool invoked result: 'success' | 'failure'; duration_ms: number; authenticated: boolean; authorized: boolean; error?: string; // Error message if failed requestId: string; // Unique request ID sshSessionId: string; // SSH session binding } ``` **Example log entries**: ```json { "timestamp": "2025-10-16T15:30:45.123Z", "event": "reverse_mcp_call", "sourceTeam": "team-inanna", "targetTeam": "team-alpha", "tool": "team_wake", "result": "success", "duration_ms": 145, "authenticated": true, "authorized": true, "requestId": "req-abc123", "sshSessionId": "session-xyz789" } ``` ```json { "timestamp": "2025-10-16T15:31:20.456Z", "event": "reverse_mcp_call", "sourceTeam": "team-inanna", "targetTeam": "team-production", "tool": "session_delete", "result": "failure", "duration_ms": 2, "authenticated": true, "authorized": false, "error": "Tool session_delete not allowed for team-inanna", "requestId": "req-def456", "sshSessionId": "session-xyz789" } ``` #### 5.2 Audit Log Storage **SQLite for queryability**: ```sql CREATE TABLE reverse_mcp_audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, event TEXT NOT NULL, source_team TEXT NOT NULL, target_team TEXT, tool TEXT NOT NULL, result TEXT NOT NULL, duration_ms INTEGER, authenticated BOOLEAN, authorized BOOLEAN, error TEXT, request_id TEXT UNIQUE NOT NULL, ssh_session_id TEXT NOT NULL, INDEX idx_timestamp (timestamp), INDEX idx_source_team (source_team), INDEX idx_result (result), INDEX idx_event (event) ); ``` **Retention**: Keep 90 days, auto-purge older entries. **Export**: Support export to SIEM systems (JSON, CSV, Splunk HEC). --- ### Layer 6: Monitoring & Alerting #### 6.1 Security Metrics **Prometheus metrics**: ```typescript import { Counter, Histogram, Gauge } from 'prom-client'; // Request counters const reverseMcpRequests = new Counter({ name: 'iris_reverse_mcp_requests_total', help: 'Total reverse MCP requests', labelNames: ['team', 'tool', 'result'] }); // Authentication failures const authFailures = new Counter({ name: 'iris_reverse_mcp_auth_failures_total', help: 'Authentication failures', labelNames: ['team', 'reason'] }); // Authorization failures const authzFailures = new Counter({ name: 'iris_reverse_mcp_authz_failures_total', help: 'Authorization failures', labelNames: ['team', 'tool'] }); // Rate limit violations const rateLimitViolations = new Counter({ name: 'iris_reverse_mcp_rate_limit_violations_total', help: 'Rate limit violations', labelNames: ['team'] }); // Latency histogram const requestLatency = new Histogram({ name: 'iris_reverse_mcp_latency_seconds', help: 'Request latency', labelNames: ['team', 'tool'], buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5] }); // Active tunnels const activeTunnels = new Gauge({ name: 'iris_reverse_mcp_active_tunnels', help: 'Number of active reverse MCP tunnels', labelNames: ['team'] }); ``` #### 6.2 Alert Rules **Prometheus alert rules** (`alerts/reverse-mcp.yml`): ```yaml groups: - name: reverse_mcp_security interval: 30s rules: # High authentication failure rate - alert: ReverseMcpAuthFailureSpike expr: rate(iris_reverse_mcp_auth_failures_total[5m]) > 0.1 for: 2m labels: severity: warning annotations: summary: "High auth failure rate for {{ $labels.team }}" description: "{{ $labels.team }} has {{ $value }} auth failures/sec" # Repeated authorization failures - alert: ReverseMcpAuthzViolations expr: increase(iris_reverse_mcp_authz_failures_total[15m]) > 5 labels: severity: warning annotations: summary: "Repeated authz failures for {{ $labels.team }}" description: "{{ $labels.team }} attempted {{ $value }} unauthorized actions" # Rate limit violations - alert: ReverseMcpRateLimitAbuse expr: increase(iris_reverse_mcp_rate_limit_violations_total[10m]) > 3 labels: severity: critical annotations: summary: "Rate limit abuse by {{ $labels.team }}" description: "{{ $labels.team }} violated rate limits {{ $value }} times" # Tunnel disconnections - alert: ReverseMcpTunnelDown expr: iris_reverse_mcp_active_tunnels == 0 for: 5m labels: severity: warning annotations: summary: "Reverse MCP tunnel down for {{ $labels.team }}" description: "{{ $labels.team }} tunnel has been down for 5+ minutes" ``` #### 6.3 Alert Channels **Notification integrations**: ```json { "monitoring": { "alerts": { "channels": [ { "type": "slack", "webhook": "https://hooks.slack.com/services/...", "severity": ["critical", "warning"] }, { "type": "email", "recipients": ["security@company.com"], "severity": ["critical"] }, { "type": "pagerduty", "integrationKey": "...", "severity": ["critical"] } ] } } } ``` --- ### Layer 7: Input Validation #### 7.1 Request Size Limits ```typescript const SECURITY_LIMITS = { MAX_MESSAGE_SIZE: 100 * 1024, // 100KB MAX_NESTING_DEPTH: 10, MAX_ARRAY_LENGTH: 1000, MAX_STRING_LENGTH: 10000, MAX_REQUEST_RATE: 20, // Per minute }; function validateRequestSize(req: McpRequest): void { const size = JSON.stringify(req).length; if (size > SECURITY_LIMITS.MAX_MESSAGE_SIZE) { auditLog.warn({ event: 'request_too_large', team: req.sourceTeam, size, limit: SECURITY_LIMITS.MAX_MESSAGE_SIZE }); throw new ValidationError( `Request size ${size} exceeds limit ${SECURITY_LIMITS.MAX_MESSAGE_SIZE}` ); } } function validateNestingDepth(obj: any, maxDepth = 10, currentDepth = 0): void { if (currentDepth > maxDepth) { throw new ValidationError(`Object nesting depth exceeds ${maxDepth}`); } if (typeof obj === 'object' && obj !== null) { for (const key in obj) { validateNestingDepth(obj[key], maxDepth, currentDepth + 1); } } } ``` #### 7.2 String Sanitization ```typescript function sanitizeString(str: string): string { // Remove null bytes str = str.replace(/\0/g, ''); // Remove control characters (except newline, tab) str = str.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, ''); // Limit length if (str.length > SECURITY_LIMITS.MAX_STRING_LENGTH) { str = str.substring(0, SECURITY_LIMITS.MAX_STRING_LENGTH); } return str; } ``` --- ### Layer 8: Fail-Secure Pattern **All security checks must pass**: ```typescript async function handleReverseMcpRequest(req: McpRequest): Promise<McpResponse> { const requestId = crypto.randomUUID(); const startTime = Date.now(); try { // Security checkpoint 1: Input validation validateRequestSize(req); validateNestingDepth(req); // Security checkpoint 2: Authentication await authenticateApiKey(req); await validateSshSession(req); // Security checkpoint 3: Authorization await authorizeToolAccess(req.sourceTeam, req.tool, req.targetTeam); // Security checkpoint 4: Rate limiting await rateLimiter.checkLimit(req.sourceTeam); // Security checkpoint 5: Circuit breaker if (circuitBreaker.isOpen(req.sourceTeam)) { throw new SecurityError('Circuit breaker open for team'); } // Security checkpoint 6: Audit logging (fail if can't log) await auditLog.log({ timestamp: new Date().toISOString(), event: 'reverse_mcp_call', requestId, sourceTeam: req.sourceTeam, tool: req.tool, targetTeam: req.targetTeam }); // All checks passed - process request const response = await processToolRequest(req); // Log success await auditLog.log({ requestId, result: 'success', duration_ms: Date.now() - startTime }); // Update metrics reverseMcpRequests.inc({ team: req.sourceTeam, tool: req.tool, result: 'success' }); return response; } catch (error) { // Log failure await auditLog.log({ requestId, result: 'failure', error: error.message, duration_ms: Date.now() - startTime }); // Update metrics reverseMcpRequests.inc({ team: req.sourceTeam, tool: req.tool, result: 'failure' }); // Record failure for circuit breaker if (error instanceof SecurityError) { circuitBreaker.recordFailure(req.sourceTeam); } // Fail secure - deny request throw error; } } ``` --- ## Security Checklist ### Setup Checklist Before enabling reverse MCP for a team: - [ ] Generate TLS certificate and calculate fingerprint - [ ] Generate strong API key (256-bit) and store in keychain - [ ] Configure `reverseMcpPermissions` (allowedTools, allowedTargets) - [ ] Set appropriate rate limits - [ ] Enable audit logging - [ ] Configure monitoring alerts - [ ] Verify GatewayPorts=no on remote host - [ ] Test certificate pinning - [ ] Test authentication with invalid key (should fail) - [ ] Test authorization with denied tool (should fail) - [ ] Test rate limiting (should block after threshold) - [ ] Verify audit logs are being written - [ ] Review security documentation with team ### Operational Checklist Regular security maintenance: - [ ] Review audit logs weekly for anomalies - [ ] Rotate API keys every 90 days - [ ] Rotate TLS certificates annually - [ ] Review and update authorization rules monthly - [ ] Monitor security metrics in Grafana - [ ] Test incident response playbook quarterly - [ ] Update security documentation as needed --- ## Incident Response Playbook ### Suspected Compromise **If you suspect a team's credentials are compromised:** 1. **Immediate Actions** (within 5 minutes): ```bash # Revoke API key iris revoke-key team-inanna # Disable reverse MCP iris disable-reverse-mcp team-inanna # Kill active SSH tunnel iris kill-tunnel team-inanna ``` 2. **Investigation** (within 1 hour): ```bash # Review audit logs iris audit-query --team team-inanna --since "24 hours ago" # Check for unauthorized access attempts iris audit-query --result failure --team team-inanna # Review metrics for anomalies iris metrics --team team-inanna ``` 3. **Remediation** (within 4 hours): ```bash # Rotate all credentials iris rotate-key team-inanna --force iris regenerate-cert # Update fingerprint in config iris update-cert-fingerprint team-inanna # Re-enable with stricter permissions iris enable-reverse-mcp team-inanna --strict ``` 4. **Post-Incident Review** (within 1 week): - Document timeline of events - Identify security gaps - Update playbook with lessons learned - Brief team on incident --- ## Security Testing ### Penetration Testing Scenarios **Test cases to validate security**: 1. **Authentication Bypass Attempts** - Invalid API key โ†’ Should fail with 401 - Missing API key โ†’ Should fail with 401 - Reused API key from different team โ†’ Should fail with 403 2. **Authorization Escalation** - Call denied tool โ†’ Should fail with 403 - Access denied target team โ†’ Should fail with 403 - Wildcard bypass attempt โ†’ Should fail with 403 3. **Rate Limiting** - Send 30 requests in 60 seconds โ†’ Last 10 should be blocked with 429 - Wait for refill โ†’ Should succeed again 4. **Input Validation** - Send 200KB payload โ†’ Should fail with 413 - Send deeply nested JSON (20 levels) โ†’ Should fail with 400 - Send null bytes in string โ†’ Should be sanitized 5. **Certificate Validation** - Connect with wrong cert fingerprint โ†’ Should fail - Connect without HTTPS โ†’ Should fail - MITM attack simulation โ†’ Should detect and fail ### Automated Security Scanning ```bash # Run security test suite pnpm test:security # Run with specific scenario pnpm test:security --scenario auth-bypass # Generate security report pnpm security-report ``` --- ## Future Enhancements ### Phase 2: Advanced Security 1. **Mutual TLS (mTLS)** - Client certificates for remote teams - Stronger authentication than API keys 2. **JWT Tokens with Expiry** - Short-lived tokens (1 hour) - Auto-refresh mechanism - Revocation support 3. **IP Allowlisting** - Restrict reverse MCP to specific SSH source IPs - Geographic restrictions 4. **Behavioral Analytics** - ML-based anomaly detection - Automatic threat response 5. **Hardware Security Module (HSM)** - Store keys in HSM instead of OS keychain - FIPS 140-2 compliance --- ## References - [OWASP API Security Top 10](https://owasp.org/www-project-api-security/) - [SSH Tunnel Security Best Practices](https://www.ssh.com/academy/ssh/tunneling) - [Defense in Depth Strategy](https://www.nist.gov/cybersecurity) - [Fail-Secure Design Principles](https://en.wikipedia.org/wiki/Fail-safe) --- **Document Status**: Draft - Pending Security Review **Next Review Date**: 2025-11-16 (30 days) **Approvers**: - Security Team: _____________ - Engineering Lead: _____________ - Product Owner: _____________

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jenova-marie/iris-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server