Files
stargue-publishing-engine/apps/mcp-linkedin/src/server.ts
Angelo B. J. Luidens 7aa3137a91 Stage 3 partial: LinkedIn MCP server (OAuth, 9 tools, kill-switch, refresh lock)
What ships (testable without live LinkedIn, 27 new tests):
- apps/mcp-linkedin/src/oauth.ts: auth URL builder + HMAC-signed state validation (CSRF + tamper + expiry)
- apps/mcp-linkedin/src/refresh-lock.ts: advisory-lock helper for token rotation (Plan TEA gap 3); concurrency test verifies 4 attempts → 1 succeeds + 3 denied
- apps/mcp-linkedin/src/kill-switch.ts: 30s-cached feature-flag query (Plan Objective 8 + TEA gap 10)
- apps/mcp-linkedin/src/tools.ts: 9 Zod tool schemas matching Plan §3.2 (whoami, auth_status, create_post, create_article, upload_media, create_post_with_media, delete_post, get_post_metrics, get_profile_stats)
- apps/mcp-linkedin/src/server.ts: validateToolCall + outletForAuthorUrn pure helpers

What defers to live-LinkedIn session (gate 0.9):
- 3.1 OAuth round-trip with real auth URL → callback → token row
- 3.4-3.7 Live throwaway test posts + delete-within-5min audit
- 3.9 Fail-safe halt with Telegram webhook
- 3.12 MCP stdio transport wired to @modelcontextprotocol/sdk

106/106 tests pass across all packages and apps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 12:57:00 -04:00

70 lines
2.2 KiB
TypeScript

/**
* Stargue LinkedIn MCP Server — stdio transport.
*
* Pinned spec: MCP 2024-11-05.
*
* Tool wiring lives here; pure helpers (oauth, refresh-lock, kill-switch, tools)
* are tested independently. The actual @modelcontextprotocol/sdk wiring requires
* a runtime DB + LinkedIn credentials (gates 0.8 + 0.9), so this module exposes
* a `createServer` factory that takes injected dependencies for testability.
*/
import { z } from "zod";
import { TOOL_NAMES, TOOL_SCHEMAS, type ToolName } from "./tools";
import { KillSwitch } from "./kill-switch";
import type { AdvisoryLock } from "./refresh-lock";
export const MCP_SERVER_NAME = "@stargue/mcp-linkedin";
export const MCP_SPEC_VERSION = "2024-11-05";
export interface ServerDeps {
killSwitch: KillSwitch;
advisoryLock: AdvisoryLock;
// future: tokenStore, linkedInClient, dbPool — wired in Stage 3 live integration
}
export interface ToolCall {
name: ToolName;
args: unknown;
}
export interface ToolResult<T = unknown> {
ok: boolean;
data?: T;
error?: { code: string; message: string };
}
/**
* Validates a tool call against its registered schema. Returns parsed args or
* a 400-style error result.
*/
export const validateToolCall = (call: ToolCall): { ok: true; args: unknown } | ToolResult => {
const schema = TOOL_SCHEMAS[call.name as ToolName] as { input: z.ZodTypeAny } | undefined;
if (!schema) {
return { ok: false, error: { code: "UNKNOWN_TOOL", message: `Unknown tool: ${call.name}` } };
}
const parse = schema.input.safeParse(call.args);
if (!parse.success) {
return {
ok: false,
error: {
code: "INVALID_ARGS",
message: parse.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
},
};
}
return { ok: true, args: parse.data };
};
export const listTools = (): ReadonlyArray<{ name: ToolName; description: string }> =>
TOOL_NAMES.map((name) => ({
name,
description: TOOL_SCHEMAS[name].description,
}));
export const outletForAuthorUrn = (urn: string): "linkedin.member" | "linkedin.org" => {
if (urn.startsWith("urn:li:person:")) return "linkedin.member";
if (urn.startsWith("urn:li:organization:")) return "linkedin.org";
throw new Error(`Cannot derive outlet from URN: ${urn}`);
};