Skip to main content
Glama
index.ts17 kB
#!/usr/bin/env node /** * Relentless MCP Server v2.0 * * Enables AI assistants (like Claude) to interact with Notion databases * through the Relentless CMS API. * * Tools provided: * - relentless_insert: Create new entries in Notion databases (with validation) * - relentless_read: Read specific entries by slug * - relentless_list: List all entries from a database * - relentless_index: Get index of all entries (slugs & titles only) */ import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js' import { DatabaseValidator, type DatabaseSchema } from './database-validator.js' // Configuration from environment variables const RELENTLESS_API_BASE = 'https://api.relentless.so' const RELENTLESS_API_KEY = process.env.RELENTLESS_API_KEY if (!RELENTLESS_API_KEY) { console.error('❌ Error: Missing required environment variable:') console.error(' - RELENTLESS_API_KEY') console.error('') console.error('Please set this in your MCP configuration.') process.exit(1) } // Create validator (no Notion client needed!) const validator = new DatabaseValidator() // Create MCP server const server = new Server( { name: 'relentless-mcp', version: '2.0.0', }, { capabilities: { tools: {}, }, } ) /** * Sleep utility for retry backoff */ function sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)) } /** * Helper function to make API requests to Relentless with retry logic */ async function relentlessRequest( endpoint: string, options?: RequestInit, maxRetries = 3 ): Promise<any> { const url = endpoint.includes('?') ? `${endpoint}&api_key=${RELENTLESS_API_KEY}` : `${endpoint}?api_key=${RELENTLESS_API_KEY}` let lastError: Error | null = null for (let attempt = 0; attempt < maxRetries; attempt++) { try { // Add timeout using AbortController const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 30000) // 30s timeout const response = await fetch(url, { ...options, signal: controller.signal, headers: { 'Content-Type': 'application/json', ...options?.headers, }, }) clearTimeout(timeoutId) // Handle rate limiting (429) if (response.status === 429) { const retryAfter = response.headers.get('Retry-After') const delay = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, attempt) * 1000 console.error(`⏳ Rate limited. Retrying in ${delay / 1000}s...`) await sleep(delay) continue } // Handle success if (response.ok) { return response.json() } // Handle errors with detailed messages const errorBody = await response.json().catch(() => ({ message: 'Unknown error' })) let errorMessage = errorBody.message || errorBody.error || 'Unknown error' // Provide helpful error messages based on status code switch (response.status) { case 401: errorMessage = 'Invalid API key. Check RELENTLESS_API_KEY environment variable.' break case 403: errorMessage = 'Access forbidden. Verify you own this API and have proper permissions.' break case 404: errorMessage = `API path not found. Check that the API exists in your Relentless dashboard.` break case 422: errorMessage = `Validation error: ${errorMessage}. Check that property names match your Notion database exactly (case-sensitive).` break case 500: errorMessage = `Relentless API error: ${errorMessage}` break } // Don't retry client errors (4xx) except 429 if (response.status >= 400 && response.status < 500) { throw new McpError( ErrorCode.InvalidRequest, `Relentless API error (${response.status}): ${errorMessage}` ) } // Server errors (5xx) - will retry lastError = new Error(`Server error (${response.status}): ${errorMessage}`) console.error(`❌ Attempt ${attempt + 1}/${maxRetries} failed: ${errorMessage}`) } catch (error: any) { lastError = error // Handle timeout if (error.name === 'AbortError') { throw new McpError(ErrorCode.InternalError, 'Request timeout after 30 seconds') } // Handle MCP errors (don't retry) if (error instanceof McpError) { throw error } // Network errors - will retry console.error(`❌ Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`) } // Exponential backoff before retry (except on last attempt) if (attempt < maxRetries - 1) { const backoffDelay = Math.min(Math.pow(2, attempt) * 1000, 10000) // Max 10s console.error(`⏳ Retrying in ${backoffDelay / 1000}s...`) await sleep(backoffDelay) } } // All retries exhausted throw new McpError( ErrorCode.InternalError, `Failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}` ) } /** * Get database schema from Relentless API */ async function getDatabaseSchema(dbName: string): Promise<DatabaseSchema | null> { try { const endpoint = `${RELENTLESS_API_BASE}/api/v1/public/db/${dbName}/schema` const schema = await relentlessRequest(endpoint, undefined, 1) // No retry for schema return schema as DatabaseSchema } catch (error) { console.error(`⚠️ Could not fetch schema for ${dbName}: ${error}`) return null } } /** * Get list of databases from Relentless API */ async function listDatabases(): Promise<any> { try { const endpoint = `${RELENTLESS_API_BASE}/api/v1/public/databases` return await relentlessRequest(endpoint, undefined, 1) } catch (error) { console.error(`⚠️ Could not fetch databases: ${error}`) throw error } } /** * List available tools */ server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'relentless_list_databases', description: 'List all Notion databases connected to your Relentless account. Use this first to discover available databases, then use other tools to read/write data. Returns database names that can be used with other tools.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'relentless_insert', description: 'Insert a new entry into a Notion database via Relentless API. Data is automatically validated before insertion to catch errors early. Use this to create new documentation, blog posts, leads, or any structured data. The data will be immediately visible in Notion and accessible via the Relentless API.', inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'The database name (e.g., "blog", "docs", "leads"). Use relentless_list_databases to see available databases.', }, data: { type: 'object', description: 'The data to insert. Keys should match your Notion database property names exactly (e.g., {"Title": "My Post", "Content": "...", "Published": true}). Property names are case-sensitive. Will be validated before insertion.', additionalProperties: true, }, skipValidation: { type: 'boolean', description: 'Skip pre-insert validation. Use only if you know the data is correct and want to speed up insertion.', default: false, }, }, required: ['database', 'data'], }, }, { name: 'relentless_read', description: 'Read a specific entry from a Notion database by its slug. Returns the full entry with all properties and content.', inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'The database name (e.g., "blog", "docs", "leads")', }, slug: { type: 'string', description: 'The slug of the entry to read (e.g., "getting-started", "hello-world")', }, }, required: ['database', 'slug'], }, }, { name: 'relentless_list', description: 'List all entries from a Notion database. Returns full content for all entries. Use this to see what content exists or to search through entries.', inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'The database name (e.g., "blog", "docs", "leads")', }, }, required: ['database'], }, }, { name: 'relentless_index', description: 'Get an index of all entries (slugs and titles only). This is faster than relentless_list when you only need to see what entries exist without their full content. Useful for navigation or sitemap generation.', inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'The database name (e.g., "blog", "docs", "leads")', }, format: { type: 'string', enum: ['array', 'object'], description: 'Return format: "array" returns [{slug, title, url}], "object" returns {slug: {title, url}}', default: 'array', }, }, required: ['database'], }, }, ], } }) /** * Handle tool calls */ server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params try { switch (name) { case 'relentless_list_databases': { console.error(`[${new Date().toISOString()}] Listing databases`) const databases = await listDatabases() return { content: [ { type: 'text', text: `Found ${databases.count} database(s):\n\n${JSON.stringify( databases.databases, null, 2 )}`, }, ], } } case 'relentless_insert': { const { database, data, skipValidation = false } = args as { database: string data: Record<string, any> skipValidation?: boolean } if (!database || !data) { throw new McpError( ErrorCode.InvalidParams, 'Missing required parameters: database and data' ) } console.error(`[${new Date().toISOString()}] Starting insert to ${database}`) // Basic validation if (!data || typeof data !== 'object' || Array.isArray(data)) { throw new McpError(ErrorCode.InvalidParams, 'data must be a non-null object') } const dataSize = JSON.stringify(data).length if (dataSize > 1_000_000) { throw new McpError( ErrorCode.InvalidParams, `Data too large: ${(dataSize / 1_000_000).toFixed(2)}MB (max 1MB)` ) } // Pre-insertion validation (fetch schema from Relentless API) if (!skipValidation) { console.error('🔍 Validating data before insertion...') const schema = await getDatabaseSchema(database) if (schema) { try { const validation = validator.validateWithSchema(schema, data) if (!validation.valid) { const errorMessage = validation.errors .map( (err: any) => ` • ${err.field}: ${err.error}${ err.expected ? ` (expected: ${err.expected})` : '' }` ) .join('\n') throw new McpError( ErrorCode.InvalidParams, `❌ Validation failed:\n${errorMessage}\n\nFix these errors and try again, or use skipValidation: true to bypass.` ) } // Show warnings if any if (validation.warnings.length > 0) { console.error('⚠️ Warnings:') validation.warnings.forEach((warning: string) => console.error(` • ${warning}`)) } console.error('✅ Validation passed') } catch (error) { if (error instanceof McpError) { throw error } console.error(`⚠️ Validation error (will proceed anyway): ${error}`) } } else { console.error('⚠️ Could not validate: schema not available') } } // Perform insert with retry console.error('📤 Inserting data...') const endpoint = `${RELENTLESS_API_BASE}/api/v1/public/db/${database}/insert` const result = await relentlessRequest(endpoint, { method: 'POST', body: JSON.stringify(data), }) console.error(`[${new Date().toISOString()}] ✅ Insert successful`) return { content: [ { type: 'text', text: `✅ Successfully inserted into ${database}!\n\nResponse:\n${JSON.stringify( result, null, 2 )}`, }, ], } } case 'relentless_read': { const { database, slug } = args as { database: string; slug: string } if (!database || !slug) { throw new McpError( ErrorCode.InvalidParams, 'Missing required parameters: database and slug' ) } console.error(`[${new Date().toISOString()}] Reading ${database}/${slug}`) const endpoint = `${RELENTLESS_API_BASE}/api/v1/public/db/${database}/read/${slug}` const result = await relentlessRequest(endpoint) return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], } } case 'relentless_list': { const { database } = args as { database: string } if (!database) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: database') } console.error(`[${new Date().toISOString()}] Listing all from ${database}`) const endpoint = `${RELENTLESS_API_BASE}/api/v1/public/db/${database}/list` const result = await relentlessRequest(endpoint) return { content: [ { type: 'text', text: `Found ${Array.isArray(result) ? result.length : 0} entries:\n\n${JSON.stringify( result, null, 2 )}`, }, ], } } case 'relentless_index': { const { database, format = 'array' } = args as { database: string; format?: string } if (!database) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: database') } console.error(`[${new Date().toISOString()}] Getting index for ${database}`) const endpoint = `${RELENTLESS_API_BASE}/api/v1/public/db/${database}/index${ format !== 'array' ? `?format=${format}` : '' }` const result = await relentlessRequest(endpoint) return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], } } default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`) } } catch (error) { if (error instanceof McpError) { throw error } const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' console.error(`[${new Date().toISOString()}] ❌ Error: ${errorMessage}`) throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${errorMessage}`) } }) /** * Start the server */ async function main() { const transport = new StdioServerTransport() await server.connect(transport) console.error('🚀 Relentless MCP server running (v2.0.0)') console.error(` API: ${RELENTLESS_API_BASE}`) console.error(` Validation: enabled (via Relentless API schema endpoint)`) } main().catch((error) => { console.error('Fatal error:', error) process.exit(1) })

Implementation Reference

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/PranaytheSingh/relentless-mcp'

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