Skip to main content
Glama

MCP Social Network

by GrahamMcBain
server.ts29 kB
#!/usr/bin/env node import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import bcrypt from 'bcrypt'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; import { Database } from './database.js'; // Environment variables const SUPABASE_URL = process.env.SUPABASE_URL; const SUPABASE_KEY = process.env.SUPABASE_KEY; const PORT = process.env.PORT || 3000; if (!SUPABASE_URL || !SUPABASE_KEY) { console.error('Error: SUPABASE_URL and SUPABASE_KEY environment variables are required'); process.exit(1); } // Initialize database const db = new Database(SUPABASE_URL, SUPABASE_KEY); // Create Express app const app = express(); app.use(cors()); app.use(express.json()); // Serve static files from public directory app.use('/setup', express.static('public')); app.use(express.static('public')); // Also serve at root for assets // Health check endpoint app.get('/', (req, res) => { res.sendFile('index.html', { root: '.' }); }); // Authentication endpoints app.post('/auth/signup', async (req, res) => { try { const { username, password, bio } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } if (username.length < 3 || username.length > 20) { return res.status(400).json({ error: 'Username must be between 3 and 20 characters' }); } if (password.length < 6) { return res.status(400).json({ error: 'Password must be at least 6 characters' }); } // Hash password const passwordHash = await bcrypt.hash(password, 10); // Create user const user = await db.createUser(username, bio, passwordHash); res.status(201).json({ message: 'Account created successfully', username: user.username }); } catch (error) { res.status(400).json({ error: error instanceof Error ? error.message : 'Failed to create account' }); } }); app.post('/auth/login', async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } // Get user credentials const user = await db.getUserByCredentials(username); if (!user || !user.password_hash) { return res.status(401).json({ error: 'Invalid username or password' }); } // Verify password const isValid = await bcrypt.compare(password, user.password_hash); if (!isValid) { return res.status(401).json({ error: 'Invalid username or password' }); } res.json({ message: 'Login successful', username: user.username }); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : 'Login failed' }); } }); // Helper function to format posts for display function formatPost(post: any): string { const timeAgo = getTimeAgo(new Date(post.created_at)); let formatted = `@${post.username} (${timeAgo}) [ID: ${post.id.slice(0, 8)}]\n"${post.content}"`; if (post.code) { formatted += `\n\`\`\`${post.language || ''}\n${post.code}\n\`\`\``; } if (post.tags && post.tags.length > 0) { formatted += `\nTags: ${post.tags.map((tag: string) => `#${tag}`).join(' ')}`; } formatted += `\n❤️ ${post.like_count} likes | 💬 ${post.reply_count} replies`; return formatted; } // Helper function to get time ago function getTimeAgo(date: Date): string { const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays > 0) { return `${diffDays}d ago`; } else if (diffHours > 0) { return `${diffHours}h ago`; } else { const diffMins = Math.floor(diffMs / (1000 * 60)); return `${Math.max(1, diffMins)}m ago`; } } // Helper function to parse basic auth function parseBasicAuth(authHeader: string): { username: string; password: string } | null { if (!authHeader || !authHeader.startsWith('Basic ')) { return null; } try { const base64Credentials = authHeader.slice(6); const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); const [username, password] = credentials.split(':'); if (!username || !password) { return null; } return { username, password }; } catch { return null; } } // Helper function to authenticate user async function authenticateUser(authHeader: string): Promise<string> { const credentials = parseBasicAuth(authHeader); if (!credentials) { throw new Error('Missing or invalid Authorization header. Use Basic authentication with username:password. Create an account first with create_account tool.'); } const { username, password } = credentials; // Get user credentials const user = await db.getUserByCredentials(username); if (!user || !user.password_hash) { throw new Error('Invalid username or password'); } // Verify password const isValid = await bcrypt.compare(password, user.password_hash); if (!isValid) { throw new Error('Invalid username or password'); } return username; } // Define tools const tools: Tool[] = [ { name: 'create_account', description: 'Create a new user account with username and password', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Username (3-20 characters, must be unique)', }, password: { type: 'string', description: 'Password (minimum 6 characters)', }, bio: { type: 'string', description: 'Optional bio (max 500 characters)', }, }, required: ['username', 'password'], }, }, { name: 'login', description: 'Login to your existing account', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Your username', }, password: { type: 'string', description: 'Your password', }, }, required: ['username', 'password'], }, }, { name: 'get_profile', description: 'Get a user profile by username', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Username to look up', }, }, required: ['username'], }, }, { name: 'update_profile', description: 'Update your profile bio (requires Basic authentication)', inputSchema: { type: 'object', properties: { bio: { type: 'string', description: 'New bio (max 500 characters)', }, }, required: ['bio'], }, }, { name: 'search_users', description: 'Search for users by username', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query', }, limit: { type: 'number', description: 'Maximum number of results (default: 10)', default: 10, }, }, required: ['query'], }, }, { name: 'post_update', description: 'Post a text update (requires Basic authentication)', inputSchema: { type: 'object', properties: { content: { type: 'string', description: 'Post content (max 280 characters)', }, tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags (without # symbol)', }, }, required: ['content'], }, }, { name: 'post_code', description: 'Post a code snippet with description (requires Basic authentication)', inputSchema: { type: 'object', properties: { code: { type: 'string', description: 'Code snippet', }, language: { type: 'string', description: 'Programming language', }, description: { type: 'string', description: 'Description of the code (max 280 characters)', }, tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags (without # symbol)', }, }, required: ['code', 'language', 'description'], }, }, { name: 'get_feed', description: 'Get your personalized feed (posts from users you follow, requires Basic authentication)', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Maximum number of posts (default: 20)', default: 20, }, }, }, }, { name: 'get_global_feed', description: 'Get the global feed (all public posts)', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Maximum number of posts (default: 20)', default: 20, }, }, }, }, { name: 'get_user_posts', description: 'Get posts from a specific user', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Username to get posts from', }, limit: { type: 'number', description: 'Maximum number of posts (default: 20)', default: 20, }, }, required: ['username'], }, }, { name: 'follow_user', description: 'Follow a user (requires Basic authentication)', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Username to follow', }, }, required: ['username'], }, }, { name: 'unfollow_user', description: 'Unfollow a user (requires Basic authentication)', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Username to unfollow', }, }, required: ['username'], }, }, { name: 'get_following', description: 'Get list of users you are following (requires Basic authentication)', inputSchema: { type: 'object', properties: {}, }, }, { name: 'get_followers', description: 'Get list of your followers (requires Basic authentication)', inputSchema: { type: 'object', properties: {}, }, }, { name: 'like_post', description: 'Like a post by post ID (requires Basic authentication)', inputSchema: { type: 'object', properties: { post_id: { type: 'string', description: 'ID of the post to like', }, }, required: ['post_id'], }, }, { name: 'unlike_post', description: 'Unlike a post by post ID (requires Basic authentication)', inputSchema: { type: 'object', properties: { post_id: { type: 'string', description: 'ID of the post to unlike', }, }, required: ['post_id'], }, }, ]; // Create tool handler function async function handleToolCall(name: string, args: any, authHeader: string) { try { switch (name) { case 'create_account': { const { username, password, bio } = args as { username: string; password: string; bio?: string }; if (username.length < 3 || username.length > 20) { throw new Error('Username must be between 3 and 20 characters'); } if (password.length < 6) { throw new Error('Password must be at least 6 characters'); } // Hash password const passwordHash = await bcrypt.hash(password, 10); // Create user const user = await db.createUser(username, bio, passwordHash); return { content: [ { type: 'text', text: `✅ Account created successfully!\n\nUsername: @${user.username}\nBio: ${user.bio || 'No bio yet'}\nJoined: ${new Date(user.created_at).toLocaleDateString()}\n\n🔐 Your account is now secured with a password!\n\nYou can now start posting and following other users!`, }, ], }; } case 'login': { const { username, password } = args as { username: string; password: string }; // Get user credentials const user = await db.getUserByCredentials(username); if (!user || !user.password_hash) { throw new Error('Invalid username or password'); } // Verify password const isValid = await bcrypt.compare(password, user.password_hash); if (!isValid) { throw new Error('Invalid username or password'); } return { content: [ { type: 'text', text: `✅ Login successful!\n\nWelcome back, @${user.username}!\n\nYou can now access all your social features!`, }, ], }; } case 'get_profile': { const { username } = args as { username: string }; const user = await db.getUser(username); if (!user) { throw new Error(`User @${username} not found`); } return { content: [ { type: 'text', text: `👤 Profile: @${user.username}\n\nBio: ${user.bio || 'No bio'}\nPosts: ${user.post_count}\nFollowers: ${user.follower_count}\nFollowing: ${user.following_count}\nJoined: ${new Date(user.created_at).toLocaleDateString()}`, }, ], }; } case 'update_profile': { const { bio } = args as { bio: string }; const username = await authenticateUser(authHeader); if (bio.length > 500) { throw new Error('Bio must be 500 characters or less'); } const user = await db.updateUser(username, bio); return { content: [ { type: 'text', text: `✅ Profile updated!\n\nNew bio: ${user.bio}`, }, ], }; } case 'search_users': { const { query, limit = 10 } = args as { query: string; limit?: number }; const users = await db.searchUsers(query, limit); if (users.length === 0) { return { content: [ { type: 'text', text: `No users found matching "${query}"`, }, ], }; } const userList = users .map(user => `@${user.username} - ${user.bio || 'No bio'} (${user.follower_count} followers)`) .join('\n'); return { content: [ { type: 'text', text: `🔍 Found ${users.length} user(s) matching "${query}":\n\n${userList}`, }, ], }; } case 'post_update': { const { content, tags } = args as { content: string; tags?: string[] }; const username = await authenticateUser(authHeader); if (content.length > 280) { throw new Error('Post content must be 280 characters or less'); } const userId = await db.getUserId(username); if (!userId) throw new Error('User not found'); const post = await db.createPost(userId, content, undefined, undefined, tags); return { content: [ { type: 'text', text: `✅ Posted successfully!\n\n${formatPost(post)}`, }, ], }; } case 'post_code': { const { code, language, description, tags } = args as { code: string; language: string; description: string; tags?: string[] }; const username = await authenticateUser(authHeader); if (description.length > 280) { throw new Error('Description must be 280 characters or less'); } const userId = await db.getUserId(username); if (!userId) throw new Error('User not found'); const post = await db.createPost(userId, description, code, language, tags); return { content: [ { type: 'text', text: `✅ Code snippet posted successfully!\n\n${formatPost(post)}`, }, ], }; } case 'get_feed': { const { limit = 20 } = args as { limit?: number }; const username = await authenticateUser(authHeader); const userId = await db.getUserId(username); if (!userId) throw new Error('User not found'); const posts = await db.getUserFeed(userId, limit); if (posts.length === 0) { return { content: [ { type: 'text', text: `📱 Your feed is empty!\n\nTry following some users to see their posts here. Use search_users() to find people to follow.`, }, ], }; } const feedText = posts.map(formatPost).join('\n\n' + '─'.repeat(50) + '\n\n'); return { content: [ { type: 'text', text: `📱 Your Feed (${posts.length} posts)\n\n${feedText}`, }, ], }; } case 'get_global_feed': { const { limit = 20 } = args as { limit?: number }; const posts = await db.getGlobalFeed(limit); if (posts.length === 0) { return { content: [ { type: 'text', text: `🌍 Global feed is empty!\n\nBe the first to post something!`, }, ], }; } const feedText = posts.map(formatPost).join('\n\n' + '─'.repeat(50) + '\n\n'); return { content: [ { type: 'text', text: `🌍 Global Feed (${posts.length} posts)\n\n${feedText}`, }, ], }; } case 'get_user_posts': { const { username, limit = 20 } = args as { username: string; limit?: number }; const userId = await db.getUserId(username); if (!userId) { throw new Error(`User @${username} not found`); } const posts = await db.getUserPosts(userId, limit); if (posts.length === 0) { return { content: [ { type: 'text', text: `@${username} hasn't posted anything yet.`, }, ], }; } const feedText = posts.map(formatPost).join('\n\n' + '─'.repeat(50) + '\n\n'); return { content: [ { type: 'text', text: `📝 Posts by @${username} (${posts.length} posts)\n\n${feedText}`, }, ], }; } case 'follow_user': { const { username } = args as { username: string }; const currentUsername = await authenticateUser(authHeader); const userId = await db.getUserId(username); const currentUserId = await db.getUserId(currentUsername); if (!userId) { throw new Error(`User @${username} not found`); } if (!currentUserId) { throw new Error('Current user not found'); } await db.followUser(currentUserId, userId); return { content: [ { type: 'text', text: `✅ You are now following @${username}!`, }, ], }; } case 'unfollow_user': { const { username } = args as { username: string }; const currentUsername = await authenticateUser(authHeader); const userId = await db.getUserId(username); const currentUserId = await db.getUserId(currentUsername); if (!userId) { throw new Error(`User @${username} not found`); } if (!currentUserId) { throw new Error('Current user not found'); } await db.unfollowUser(currentUserId, userId); return { content: [ { type: 'text', text: `✅ You have unfollowed @${username}`, }, ], }; } case 'get_following': { const username = await authenticateUser(authHeader); const userId = await db.getUserId(username); if (!userId) throw new Error('User not found'); const following = await db.getFollowing(userId); if (following.length === 0) { return { content: [ { type: 'text', text: `You're not following anyone yet. Use search_users() to find people to follow!`, }, ], }; } const followingList = following .map(user => `@${user.username} - ${user.bio || 'No bio'} (${user.follower_count} followers)`) .join('\n'); return { content: [ { type: 'text', text: `👥 You are following ${following.length} user(s):\n\n${followingList}`, }, ], }; } case 'get_followers': { const username = await authenticateUser(authHeader); const userId = await db.getUserId(username); if (!userId) throw new Error('User not found'); const followers = await db.getFollowers(userId); if (followers.length === 0) { return { content: [ { type: 'text', text: `You don't have any followers yet. Keep posting great content!`, }, ], }; } const followersList = followers .map(user => `@${user.username} - ${user.bio || 'No bio'} (${user.post_count} posts)`) .join('\n'); return { content: [ { type: 'text', text: `👥 You have ${followers.length} follower(s):\n\n${followersList}`, }, ], }; } case 'like_post': { const { post_id } = args as { post_id: string }; const username = await authenticateUser(authHeader); const userId = await db.getUserId(username); if (!userId) throw new Error('User not found'); const post = await db.getPost(post_id); if (!post) { throw new Error('Post not found'); } await db.likePost(userId, post_id); return { content: [ { type: 'text', text: `❤️ You liked @${post.username}'s post!`, }, ], }; } case 'unlike_post': { const { post_id } = args as { post_id: string }; const username = await authenticateUser(authHeader); const userId = await db.getUserId(username); if (!userId) throw new Error('User not found'); const post = await db.getPost(post_id); if (!post) { throw new Error('Post not found'); } await db.unlikePost(userId, post_id); return { content: [ { type: 'text', text: `💔 You unliked @${post.username}'s post`, }, ], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [ { type: 'text', text: `❌ Error: ${error instanceof Error ? error.message : 'Unknown error occurred'}`, }, ], isError: true, }; } } // MCP Server-Sent Events endpoint for Amp import crypto from 'crypto'; interface Session { id: string; stream: express.Response | null; } const sessions = new Map<string, Session>(); // POST /mcp - JSON-RPC requests (always returns JSON) app.post('/mcp', async (req, res) => { const msg = req.body; const sessionId = req.headers['mcp-session-id'] as string; try { // Handle initialize request - always return JSON if (msg.method === 'initialize') { const newSessionId = crypto.randomUUID(); // Store session for later SSE connection sessions.set(newSessionId, { id: newSessionId, stream: null }); return res .set('Mcp-Session-Id', newSessionId) .json({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: '1.0.0', capabilities: { tools: {} }, serverInfo: { name: 'mcp-social-network', version: '1.0.0' } } }); } // Handle other requests const incoming = Array.isArray(msg) ? msg : [msg]; const outgoing: any[] = []; for (const rpc of incoming) { if (!('method' in rpc)) continue; try { switch (rpc.method) { case 'tools/list': { outgoing.push({ jsonrpc: '2.0', id: rpc.id, result: { tools } }); break; } case 'tools/call': { const { name, arguments: args } = rpc.params; const result = await handleToolCall(name, args, req.headers.authorization ?? ''); outgoing.push({ jsonrpc: '2.0', id: rpc.id, result }); break; } case 'ping': { outgoing.push({ jsonrpc: '2.0', id: rpc.id, result: 'pong' }); break; } default: outgoing.push({ jsonrpc: '2.0', id: rpc.id, error: { code: -32601, message: `Unknown method ${rpc.method}` } }); } } catch (e: any) { outgoing.push({ jsonrpc: '2.0', id: rpc.id, error: { code: -32000, message: e.message || 'Internal error' } }); } } if (outgoing.length === 0) return res.status(204).end(); res.json(outgoing.length === 1 ? outgoing[0] : outgoing); } catch (error) { res.status(400).json({ error: 'Invalid request' }); } }); // GET /mcp - SSE stream app.get('/mcp', (req, res) => { try { console.log('GET /mcp - Starting SSE connection'); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' }); // Send initialize message immediately (matching original working flow) const initMessage = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '1.0.0', capabilities: { tools: {} }, serverInfo: { name: 'mcp-social-network', version: '1.0.0' } } }; res.write(`data: ${JSON.stringify(initMessage)}\n\n`); console.log('Sent initialize message'); req.on('close', () => { console.log('SSE connection closed'); }); req.on('error', (err) => { console.error('SSE connection error:', err); }); } catch (error) { console.error('Error in GET /mcp:', error); res.status(500).json({ error: 'Server error' }); } }); // MCP-compatible HTTP endpoints app.get('/tools', (req, res) => { res.json({ tools }); }); app.post('/tools/:toolName', async (req, res) => { try { const { toolName } = req.params; const { arguments: args } = req.body; const authHeader = req.headers.authorization || ''; const result = await handleToolCall(toolName, args, authHeader); res.json(result); } catch (error) { const statusCode = error instanceof Error && error.message.includes('authentication') ? 401 : 500; res.status(statusCode).json({ content: [ { type: 'text', text: `❌ Error: ${error instanceof Error ? error.message : 'Unknown error occurred'}`, }, ], isError: true, }); } }); // Start server app.listen(PORT, () => { console.log(`🚀 MCP Social Network server running on port ${PORT}`); console.log(`📡 Tools endpoint: http://localhost:${PORT}/tools`); console.log(`🔧 Tool execution: POST http://localhost:${PORT}/tools/{toolName}`); console.log(`🌍 Health check: http://localhost:${PORT}/`); });

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/GrahamMcBain/MCP-Social'

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