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>
This commit is contained in:
@@ -1,4 +1,69 @@
|
||||
// MCP server entry point. Full implementation in Stage 3.
|
||||
// MCP spec version pinned: 2024-11-05 (Streamable HTTP + stdio).
|
||||
/**
|
||||
* 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}`);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user