← back to Linkedin Agent

src/mcp/server.ts

133 lines

/**
 * MCP server (stdio) — exposes LinkedIn Agent tools to Claude Code.
 *
 * Tools:
 *   linkedin_post            — create a text post on Steve's personal feed
 *   linkedin_post_page       — create a text post on a Company Page
 *   linkedin_comment         — comment on a given post URN
 *   linkedin_react           — react to a post URN (LIKE/PRAISE/etc.)
 *   linkedin_compose_preview — preview composed post (with bio tagline)
 *
 * Wire into Claude Code via:
 *   ~/.claude.json → mcpServers → linkedin-agent → command:
 *     "node --import tsx /Users/macstudio3/Projects/linkedin-agent/src/mcp/server.ts"
 *
 * 2026-05-06 (tick 53): scaffold landed.
 */

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 { postText, postOnPage, commentOnPost, react } from '../lib/linkedin.js';
import { composePost } from '../lib/compose.js';

const server = new Server(
  { name: 'linkedin-agent', version: '0.1.0' },
  { capabilities: { tools: {} } },
);

const TOOLS = [
  {
    name: 'linkedin_post',
    description: 'Create a text post on Steve\'s personal LinkedIn feed. Composes with STEVE_BIO tagline by default.',
    inputSchema: {
      type: 'object',
      properties: {
        text: { type: 'string', description: 'Post body (max ~3000 chars). Plain text + LinkedIn-supported markdown.' },
        includeBio: { type: 'boolean', description: 'Append the rotating "Building lately:" tagline. Default true.' },
      },
      required: ['text'],
    },
  },
  {
    name: 'linkedin_post_page',
    description: 'Create a text post on Steve\'s Company Page (LINKEDIN_ORG_URN env). NO tagline — page posts are first-person from the brand.',
    inputSchema: {
      type: 'object',
      properties: { text: { type: 'string' } },
      required: ['text'],
    },
  },
  {
    name: 'linkedin_comment',
    description: 'Comment on a given LinkedIn post URN.',
    inputSchema: {
      type: 'object',
      properties: {
        postUrn: { type: 'string', description: 'Full URN like urn:li:share:1234567890' },
        text: { type: 'string' },
      },
      required: ['postUrn', 'text'],
    },
  },
  {
    name: 'linkedin_react',
    description: 'React to a post URN. Default reaction is LIKE.',
    inputSchema: {
      type: 'object',
      properties: {
        postUrn: { type: 'string' },
        reaction: { type: 'string', enum: ['LIKE', 'PRAISE', 'EMPATHY', 'INTEREST', 'APPRECIATION', 'MAYBE'] },
      },
      required: ['postUrn'],
    },
  },
  {
    name: 'linkedin_compose_preview',
    description: 'Preview the final composed post (draft + tagline) without sending.',
    inputSchema: {
      type: 'object',
      properties: {
        text: { type: 'string' },
        includeBio: { type: 'boolean' },
      },
      required: ['text'],
    },
  },
];

server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  const a = (args || {}) as Record<string, unknown>;

  try {
    if (name === 'linkedin_post') {
      const final = composePost(String(a.text), { tagline: a.includeBio !== false });
      const result = await postText(final);
      return { content: [{ type: 'text', text: `Posted. URN: ${result.id}\n\n---\n${final}` }] };
    }
    if (name === 'linkedin_post_page') {
      const final = composePost(String(a.text), { tagline: false });
      const result = await postOnPage(final);
      return { content: [{ type: 'text', text: `Posted on Company Page. URN: ${result.id}` }] };
    }
    if (name === 'linkedin_comment') {
      const result = await commentOnPost(String(a.postUrn), String(a.text));
      return { content: [{ type: 'text', text: `Comment posted. URN: ${result.id}` }] };
    }
    if (name === 'linkedin_react') {
      await react(String(a.postUrn), (a.reaction as 'LIKE') || 'LIKE');
      return { content: [{ type: 'text', text: `Reacted ${a.reaction || 'LIKE'} on ${a.postUrn}.` }] };
    }
    if (name === 'linkedin_compose_preview') {
      const final = composePost(String(a.text), { tagline: a.includeBio !== false });
      return { content: [{ type: 'text', text: final }] };
    }
    return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
  } catch (err) {
    return {
      content: [{ type: 'text', text: `Error: ${(err as Error).message}` }],
      isError: true,
    };
  }
});

const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[linkedin-agent MCP] ready (stdio)');