Skip to main content
Glama
kevin-weitgenant

LinkedIn-Posts-Hunter-MCP-Server

index.ts9.83 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { handleLinkedInAuth } from './tools/authenticate.js'; import { handleLinkedInSearchPosts } from './tools/search-posts/mcp-handler.js'; import { handleLinkedInManagePosts } from './tools/posts-manager.js'; import { handleLinkedInManageFilters } from './tools/filter-manager.js'; import { startViteViewer, stopViteViewer } from './tools/start-server.js'; import { closeDatabase } from './db/database.js'; // Initialize MCP server const server = new Server( { name: "linkedin-playwright-mcp", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "auth", description: "Manage LinkedIn authentication: authenticate, check status, or clear credentials", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["authenticate", "status", "clear"], description: "Action to perform: 'authenticate' to log in, 'status' to check credentials, 'clear' to remove credentials" }, force_reauth: { type: "boolean", description: "Force new authentication even if valid credentials exist (only used with 'authenticate' action)", default: false } }, required: ["action"] }, }, { name: "search_posts", description: "Search LinkedIn posts with keywords and optional pagination", inputSchema: { type: "object", properties: { keywords: { type: "string", description: "Search keywords or query (e.g., 'machine learning', '\"AI engineer\"')" }, pagination: { type: "number", description: "Number of scroll pages to load more results)", default: 2, minimum: 1, maximum: 10 }, headless: { type: "boolean", description: "Run browser in headless mode (default: false). Headless mode is faster and uses less resources. ", default: false } }, required: ["keywords"] }, }, { name: "manage_posts", description: "Query and manage LinkedIn posts. Focused on keywords + descriptions. Process in small batches to manage context efficiently.", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["read", "delete", "count"], description: "read: get posts with full descriptions | delete: remove posts | count: get totals with filters" }, keyword: { type: "string", description: "Filter by LinkedIn search keyword column (e.g., 'python engineer', 'backend developer')" }, contains: { type: "string", description: "Search for text within post descriptions (e.g., 'remote', '$150k', 'healthcare benefits')" }, ids: { type: "array", items: { type: "number" }, description: "Specific post IDs to read or delete" }, limit: { type: "number", description: "How many posts to return (default: 5, max: 20). Use small batches to manage context.", default: 5, minimum: 1, maximum: 20 }, offset: { type: "number", description: "Skip first N posts for pagination (default: 0). Use with limit to process posts in batches.", default: 0, minimum: 0 }, applied: { type: "boolean", description: "Filter by applied status: true for applied, false for not applied" }, saved: { type: "boolean", description: "Filter by saved status: true for saved, false for not saved" } }, required: ["action"] }, }, { name: "start_viewer", description: "Start post viewer (React dashboard with hot reload)", inputSchema: { type: "object", properties: {}, additionalProperties: false }, }, { name: "stop_viewer", description: "Stop the running viewer server", inputSchema: { type: "object", properties: {}, additionalProperties: false }, }, { name: "viewer_filters", description: "Read or update the LinkedIn post viewer filters. Filter state syncs between MCP and the React viewer UI.", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["read", "update"], description: "Action to perform: 'read' to get current filter state, 'update' to change filters" }, keyword: { type: "string", description: "Filter by specific keyword (empty string for 'All Keywords') - only for update action" }, applied_status: { type: "string", enum: ["all", "applied", "not-applied"], description: "Filter by application status: 'all' (default), 'applied', or 'not-applied' - only for update action" }, start_date: { type: "string", description: "Filter posts from this date onwards (ISO format: YYYY-MM-DD, e.g., '2024-01-15') - only for update action" }, end_date: { type: "string", description: "Filter posts up to this date (ISO format: YYYY-MM-DD, e.g., '2024-12-31') - only for update action" }, ids: { type: "string", description: "Filter by specific post IDs, comma-separated (e.g., '1,5,10') - only for update action" }, reset: { type: "boolean", description: "If true, reset all filters to default state (clears all filters) - only for update action" } }, required: ["action"] }, } ], }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: params } = request.params; try { switch (name) { case "auth": return await handleLinkedInAuth(params as any); case "search_posts": return await handleLinkedInSearchPosts(params as any); case "manage_posts": return await handleLinkedInManagePosts(params as any); case "start_viewer": const viteResult = await startViteViewer(); return { content: [{ type: "text", text: `Viewer started successfully!\n\n${viteResult.message}\n\nThe browser should have opened automatically. If not, visit: ${viteResult.url}\n\nHot reload is enabled!` }] }; case "stop_viewer": const stopResult = stopViteViewer(); return { content: [{ type: "text", text: stopResult.message }] }; case "viewer_filters": return await handleLinkedInManageFilters(params as any); default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [{ type: "text", text: `Error executing ${name}: ${error instanceof Error ? error.message : 'Unknown error'}` }] }; } }); // Start server const transport = new StdioServerTransport(); await server.connect(transport); // MCP servers communicate via JSON-RPC over stdin/stdout // Do not write anything to stdout or it will corrupt the protocol // Graceful shutdown handlers // Ensures proper cleanup of resources when the MCP server stops const cleanup = async () => { try { // Close database connection and save any pending changes closeDatabase(); // Stop any running Vite viewer servers stopViteViewer(); } catch (error) { // Silently handle cleanup errors to avoid protocol corruption } process.exit(0); }; // Handle termination signals process.on('SIGTERM', cleanup); // Docker/systemd termination process.on('SIGINT', cleanup); // Ctrl+C in terminal // Handle normal process exit process.on('exit', () => { // Final cleanup - must be synchronous try { closeDatabase(); } catch (error) { // Ignore errors during final cleanup } }); // Global error handlers to prevent crashes // These catch unhandled errors that could otherwise crash the MCP server process.on('unhandledRejection', (reason: any, promise: Promise<any>) => { // Log to stderr (not stdout, to avoid corrupting MCP protocol) // Don't crash the server - just handle the error gracefully try { closeDatabase(); // Ensure DB is saved } catch (error) { // Ignore cleanup errors } }); process.on('uncaughtException', (error: Error) => { // Critical error - attempt cleanup and exit gracefully try { closeDatabase(); stopViteViewer(); } catch (cleanupError) { // Ignore cleanup errors } // Exit with error code process.exit(1); });

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/kevin-weitgenant/LinkedIn-Posts-Hunter-MCP-Server'

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