Skip to main content
Glama
clientMetadata.js5.93 kB
// src/server/auth/clientMetadata.js // MCP 2025-11-25 Client ID Metadata Documents (SEP-991) // URL-based client registration replacing Dynamic Client Registration const fetch = require('node-fetch'); const NodeCache = require('node-cache'); const config = require('../../../config'); // Cache client metadata with configurable TTL (max 24 hours per spec) const metadataCache = new NodeCache({ stdTTL: 86400, // 24 hours max checkperiod: 3600, // Check every hour maxKeys: 1000 }); class ClientMetadataValidator { constructor() { this.enabled = config.mcp?.auth?.clientMetadataEnabled || false; this.allowHttp = process.env.MCP_CIMD_ALLOW_HTTP === 'true'; // For testing only } /** * Check if a client ID is a URL-formatted CIMD identifier * @param {string} clientId - Client identifier * @returns {boolean} True if URL-formatted */ isUrlClientId(clientId) { if (!clientId || typeof clientId !== 'string') return false; return clientId.startsWith('https://') || (this.allowHttp && clientId.startsWith('http://')); } /** * Fetch and validate client metadata document * @param {string} clientId - Client ID URL (must be HTTPS) * @returns {Object} Validated client metadata * @throws {Error} If validation fails */ async fetchClientMetadata(clientId) { // Validate URL format if (!this.isUrlClientId(clientId)) { throw new Error('Client ID must be an HTTPS URL for CIMD'); } // Enforce HTTPS in production if (!this.allowHttp && !clientId.startsWith('https://')) { throw new Error('Client ID must use HTTPS'); } // Check cache first const cached = metadataCache.get(clientId); if (cached) { process.stderr.write(`[${new Date().toISOString()}] CIMD: Cache hit for ${clientId}\n`); return cached; } process.stderr.write(`[${new Date().toISOString()}] CIMD: Fetching metadata from ${clientId}\n`); // Fetch metadata document let response; try { response = await fetch(clientId, { headers: { 'Accept': 'application/json', 'User-Agent': `openrouter-agents-mcp/${config.server.version}` }, timeout: 10000 // 10 second timeout }); } catch (fetchError) { throw new Error(`Failed to fetch client metadata: ${fetchError.message}`); } if (!response.ok) { throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`); } let metadata; try { metadata = await response.json(); } catch (parseError) { throw new Error('Client metadata is not valid JSON'); } // Validate required fields per OAuth 2.0 Dynamic Client Registration spec this.validateMetadata(clientId, metadata); // Determine cache TTL from HTTP headers (max 24 hours) let ttl = 86400; // Default 24 hours const cacheControl = response.headers.get('cache-control'); if (cacheControl) { const maxAgeMatch = cacheControl.match(/max-age=(\d+)/); if (maxAgeMatch) { ttl = Math.min(parseInt(maxAgeMatch[1], 10), 86400); } } // Cache the validated metadata metadataCache.set(clientId, metadata, ttl); process.stderr.write(`[${new Date().toISOString()}] CIMD: Cached metadata for ${clientId} (TTL: ${ttl}s)\n`); return metadata; } /** * Validate client metadata document * @param {string} clientId - Expected client ID * @param {Object} metadata - Metadata document * @throws {Error} If validation fails */ validateMetadata(clientId, metadata) { // client_id MUST match the URL exactly if (metadata.client_id !== clientId) { throw new Error(`Client ID mismatch: document says "${metadata.client_id}", expected "${clientId}"`); } // Required fields const required = ['client_id', 'client_name']; for (const field of required) { if (!metadata[field]) { throw new Error(`Missing required field in client metadata: ${field}`); } } // redirect_uris required for authorization code flow if (metadata.grant_types?.includes('authorization_code')) { if (!metadata.redirect_uris || !Array.isArray(metadata.redirect_uris) || metadata.redirect_uris.length === 0) { throw new Error('redirect_uris required for authorization_code grant type'); } } // Validate redirect URIs format if present if (metadata.redirect_uris) { for (const uri of metadata.redirect_uris) { try { new URL(uri); } catch (_) { throw new Error(`Invalid redirect_uri: ${uri}`); } } } } /** * Validate that a redirect URI is registered for the client * @param {Object} metadata - Client metadata * @param {string} redirectUri - Redirect URI to validate * @returns {boolean} True if valid */ validateRedirectUri(metadata, redirectUri) { if (!metadata.redirect_uris || !Array.isArray(metadata.redirect_uris)) { return false; } return metadata.redirect_uris.includes(redirectUri); } /** * Get client display information for consent screens * @param {Object} metadata - Client metadata * @returns {Object} Display info */ getClientDisplayInfo(metadata) { return { name: metadata.client_name, uri: metadata.client_uri || null, logoUri: metadata.logo_uri || null, tosUri: metadata.tos_uri || null, policyUri: metadata.policy_uri || null, contacts: metadata.contacts || [] }; } /** * Clear cached metadata for a client * @param {string} clientId - Client ID to clear */ clearCache(clientId) { if (clientId) { metadataCache.del(clientId); } else { metadataCache.flushAll(); } } /** * Get cache statistics * @returns {Object} Cache stats */ getCacheStats() { return metadataCache.getStats(); } } module.exports = new ClientMetadataValidator();

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/wheattoast11/openrouter-deep-research-mcp'

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