From 7aa3137a91fe1c0b5937cb6e44a5e24919965679 Mon Sep 17 00:00:00 2001 From: "Angelo B. J. Luidens" Date: Sun, 26 Apr 2026 12:57:00 -0400 Subject: [PATCH] Stage 3 partial: LinkedIn MCP server (OAuth, 9 tools, kill-switch, refresh lock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/mcp-linkedin/package.json | 1 - apps/mcp-linkedin/src/kill-switch.test.ts | 57 ++++++++++ apps/mcp-linkedin/src/kill-switch.ts | 51 +++++++++ apps/mcp-linkedin/src/oauth.test.ts | 107 ++++++++++++++++++ apps/mcp-linkedin/src/oauth.ts | 124 +++++++++++++++++++++ apps/mcp-linkedin/src/refresh-lock.test.ts | 43 +++++++ apps/mcp-linkedin/src/refresh-lock.ts | 45 ++++++++ apps/mcp-linkedin/src/server.ts | 69 +++++++++++- apps/mcp-linkedin/src/tools.test.ts | 99 ++++++++++++++++ apps/mcp-linkedin/src/tools.ts | 84 ++++++++++++++ bun.lock | 25 ----- 11 files changed, 677 insertions(+), 28 deletions(-) create mode 100644 apps/mcp-linkedin/src/kill-switch.test.ts create mode 100644 apps/mcp-linkedin/src/kill-switch.ts create mode 100644 apps/mcp-linkedin/src/oauth.test.ts create mode 100644 apps/mcp-linkedin/src/oauth.ts create mode 100644 apps/mcp-linkedin/src/refresh-lock.test.ts create mode 100644 apps/mcp-linkedin/src/refresh-lock.ts create mode 100644 apps/mcp-linkedin/src/tools.test.ts create mode 100644 apps/mcp-linkedin/src/tools.ts diff --git a/apps/mcp-linkedin/package.json b/apps/mcp-linkedin/package.json index 8cce104..5e660e7 100644 --- a/apps/mcp-linkedin/package.json +++ b/apps/mcp-linkedin/package.json @@ -11,7 +11,6 @@ "lint": "echo 'lint pending'" }, "dependencies": { - "@modelcontextprotocol/sdk": "1.0.3", "@stargue/linkedin-client": "workspace:*", "@stargue/schema": "workspace:*", "@stargue/observability": "workspace:*", diff --git a/apps/mcp-linkedin/src/kill-switch.test.ts b/apps/mcp-linkedin/src/kill-switch.test.ts new file mode 100644 index 0000000..3c1542e --- /dev/null +++ b/apps/mcp-linkedin/src/kill-switch.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { KillSwitch, KillSwitchError, type FeatureFlagStore } from "./kill-switch"; + +const makeStore = (state: Map): FeatureFlagStore => ({ + async get(outlet) { + return state.get(outlet) ?? null; + }, +}); + +describe("KillSwitch — Plan Objective 8 + TEA gap 10", () => { + it("defaults to enabled when outlet absent from store", async () => { + const ks = new KillSwitch(makeStore(new Map())); + await expect(ks.assertEnabled("linkedin.member")).resolves.toBeUndefined(); + }); + + it("throws KillSwitchError when outlet disabled", async () => { + const state = new Map([["linkedin.member", { enabled: false, reason: "auth expired" }]]); + const ks = new KillSwitch(makeStore(state)); + let err: unknown; + try { + await ks.assertEnabled("linkedin.member"); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(KillSwitchError); + expect((err as KillSwitchError).outlet).toBe("linkedin.member"); + expect((err as KillSwitchError).reason).toBe("auth expired"); + }); + + it("caches reads for cacheMs and re-fetches after expiry", async () => { + let calls = 0; + const dynStore: FeatureFlagStore = { + async get() { + calls++; + return { enabled: true, reason: null }; + }, + }; + let now = 1_700_000_000_000; + const ks = new KillSwitch(dynStore, { cacheMs: 100, now: () => now }); + await ks.read("linkedin.member"); + await ks.read("linkedin.member"); + expect(calls).toBe(1); + now += 200; + await ks.read("linkedin.member"); + expect(calls).toBe(2); + }); + + it("reflects toggle within 30s window via invalidate()", async () => { + let state: { enabled: boolean; reason: string | null } = { enabled: true, reason: null }; + const dynStore: FeatureFlagStore = { async get() { return state; } }; + const ks = new KillSwitch(dynStore, { cacheMs: 30_000 }); + await ks.assertEnabled("linkedin.member"); + state = { enabled: false, reason: "ops toggle" }; + ks.invalidate("linkedin.member"); + await expect(ks.assertEnabled("linkedin.member")).rejects.toBeInstanceOf(KillSwitchError); + }); +}); diff --git a/apps/mcp-linkedin/src/kill-switch.ts b/apps/mcp-linkedin/src/kill-switch.ts new file mode 100644 index 0000000..2a5d55f --- /dev/null +++ b/apps/mcp-linkedin/src/kill-switch.ts @@ -0,0 +1,51 @@ +/** + * Kill-switch (Plan Objective 8): per-outlet feature flag readable at runtime + * without redeploy. Backed by `outlet_feature_flags` table; cached for 30s. + */ + +export interface FeatureFlagStore { + get(outlet: string): Promise<{ enabled: boolean; reason: string | null } | null>; +} + +export interface KillSwitchOptions { + cacheMs?: number; + now?: () => number; +} + +export class KillSwitch { + private readonly cache = new Map(); + private readonly cacheMs: number; + private readonly now: () => number; + constructor(private readonly store: FeatureFlagStore, opts: KillSwitchOptions = {}) { + this.cacheMs = opts.cacheMs ?? 30_000; + this.now = opts.now ?? Date.now; + } + + async assertEnabled(outlet: string): Promise { + const flag = await this.read(outlet); + if (!flag.enabled) { + throw new KillSwitchError(outlet, flag.reason ?? "no reason recorded"); + } + } + + async read(outlet: string): Promise<{ enabled: boolean; reason: string | null }> { + const cached = this.cache.get(outlet); + const now = this.now(); + if (cached && cached.expires > now) return cached.value; + const fetched = (await this.store.get(outlet)) ?? { enabled: true, reason: null }; + this.cache.set(outlet, { value: fetched, expires: now + this.cacheMs }); + return fetched; + } + + invalidate(outlet?: string): void { + if (outlet) this.cache.delete(outlet); + else this.cache.clear(); + } +} + +export class KillSwitchError extends Error { + constructor(public readonly outlet: string, public readonly reason: string) { + super(`Outlet ${outlet} is disabled: ${reason}`); + this.name = "KillSwitchError"; + } +} diff --git a/apps/mcp-linkedin/src/oauth.test.ts b/apps/mcp-linkedin/src/oauth.test.ts new file mode 100644 index 0000000..594642a --- /dev/null +++ b/apps/mcp-linkedin/src/oauth.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + buildAuthUrl, + buildRefreshForm, + buildTokenExchangeForm, + PERSONAL_SCOPES, + ORG_SCOPES, + validateState, +} from "./oauth"; + +const cfg = { + clientId: "test-client", + clientSecret: "test-secret", + redirectUri: "https://publishing.stargue.net/api/auth/linkedin/callback", + stateSecret: "long-state-secret-for-hmac", +}; + +describe("OAuth — buildAuthUrl", () => { + it("constructs an authorization URL with required params", () => { + const r = buildAuthUrl(cfg, { scopes: PERSONAL_SCOPES, intent: "personal" }); + const u = new URL(r.url); + expect(u.origin + u.pathname).toBe("https://www.linkedin.com/oauth/v2/authorization"); + expect(u.searchParams.get("response_type")).toBe("code"); + expect(u.searchParams.get("client_id")).toBe("test-client"); + expect(u.searchParams.get("redirect_uri")).toBe(cfg.redirectUri); + expect(u.searchParams.get("scope")).toContain("openid"); + expect(u.searchParams.get("scope")).toContain("w_member_social"); + expect(u.searchParams.get("state")).toBeTruthy(); + expect(r.stateCookieValue).toBe(u.searchParams.get("state")); + }); + + it("encodes ORG_SCOPES for organization intent", () => { + const r = buildAuthUrl(cfg, { scopes: ORG_SCOPES, intent: "organization" }); + const u = new URL(r.url); + expect(u.searchParams.get("scope")).toContain("w_organization_social"); + }); +}); + +describe("OAuth — validateState (CSRF + tamper + expiry)", () => { + it("validates a fresh state from buildAuthUrl", () => { + const r = buildAuthUrl(cfg, { scopes: PERSONAL_SCOPES, intent: "personal" }); + const v = validateState(cfg, { + callbackState: r.stateCookieValue, + cookieState: r.stateCookieValue, + }); + expect(v.ok).toBe(true); + expect(v.intent).toBe("personal"); + }); + + it("rejects mismatched callback vs cookie state", () => { + const a = buildAuthUrl(cfg, { scopes: PERSONAL_SCOPES, intent: "personal" }); + const b = buildAuthUrl(cfg, { scopes: PERSONAL_SCOPES, intent: "personal" }); + const v = validateState(cfg, { + callbackState: a.stateCookieValue, + cookieState: b.stateCookieValue, + }); + expect(v.ok).toBe(false); + expect(v.reason).toBe("state-mismatch"); + }); + + it("rejects tampered signature", () => { + const r = buildAuthUrl(cfg, { scopes: PERSONAL_SCOPES, intent: "personal" }); + const tampered = r.stateCookieValue.replace(/.$/, (c) => (c === "0" ? "1" : "0")); + const v = validateState(cfg, { callbackState: tampered, cookieState: tampered }); + expect(v.ok).toBe(false); + expect(v.reason).toBe("bad-signature"); + }); + + it("rejects state older than maxAgeMs", () => { + const r = buildAuthUrl(cfg, { scopes: PERSONAL_SCOPES, intent: "personal" }); + const v = validateState(cfg, { + callbackState: r.stateCookieValue, + cookieState: r.stateCookieValue, + maxAgeMs: 1, + now: Date.now() + 1_000_000, + }); + expect(v.ok).toBe(false); + expect(v.reason).toBe("expired"); + }); + + it("rejects malformed state (no dot)", () => { + const v = validateState(cfg, { callbackState: "no-sig-here", cookieState: "no-sig-here" }); + expect(v.ok).toBe(false); + expect(v.reason).toBe("malformed-state"); + }); + + it("rejects missing state pair", () => { + expect(validateState(cfg, { callbackState: null, cookieState: null }).reason).toBe( + "missing-state", + ); + }); +}); + +describe("OAuth — token exchange forms", () => { + it("buildTokenExchangeForm includes authorization_code grant + secret", () => { + const f = buildTokenExchangeForm(cfg, { code: "abc", redirectUri: cfg.redirectUri }); + expect(f.get("grant_type")).toBe("authorization_code"); + expect(f.get("code")).toBe("abc"); + expect(f.get("client_secret")).toBe("test-secret"); + }); + + it("buildRefreshForm includes refresh_token grant", () => { + const f = buildRefreshForm(cfg, "rt-xyz"); + expect(f.get("grant_type")).toBe("refresh_token"); + expect(f.get("refresh_token")).toBe("rt-xyz"); + }); +}); diff --git a/apps/mcp-linkedin/src/oauth.ts b/apps/mcp-linkedin/src/oauth.ts new file mode 100644 index 0000000..48ca913 --- /dev/null +++ b/apps/mcp-linkedin/src/oauth.ts @@ -0,0 +1,124 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +export const LINKEDIN_AUTH_BASE = "https://www.linkedin.com/oauth/v2"; + +export interface OAuthAppConfig { + clientId: string; + clientSecret: string; + redirectUri: string; + stateSecret: string; +} + +export type Scope = + | "openid" + | "profile" + | "email" + | "w_member_social" + | "w_organization_social" + | "r_organization_social"; + +export const PERSONAL_SCOPES: readonly Scope[] = ["openid", "profile", "email", "w_member_social"]; +export const ORG_SCOPES: readonly Scope[] = [ + "openid", + "profile", + "email", + "w_member_social", + "w_organization_social", + "r_organization_social", +]; + +export interface AuthUrlInput { + scopes: readonly Scope[]; + intent: "personal" | "organization"; +} + +export interface AuthUrlOutput { + url: string; + stateCookieValue: string; +} + +export const buildAuthUrl = (cfg: OAuthAppConfig, input: AuthUrlInput): AuthUrlOutput => { + const nonce = randomBytes(16).toString("hex"); + const issuedAt = Date.now().toString(); + const stateCore = `${input.intent}:${nonce}:${issuedAt}`; + const sig = createHmac("sha256", cfg.stateSecret).update(stateCore).digest("hex"); + const state = `${stateCore}.${sig}`; + + const params = new URLSearchParams({ + response_type: "code", + client_id: cfg.clientId, + redirect_uri: cfg.redirectUri, + scope: input.scopes.join(" "), + state, + }); + return { + url: `${LINKEDIN_AUTH_BASE}/authorization?${params.toString()}`, + stateCookieValue: state, + }; +}; + +export interface ValidateStateInput { + callbackState: string | null | undefined; + cookieState: string | null | undefined; + maxAgeMs?: number; + now?: number; +} + +export interface ValidateStateOutput { + ok: boolean; + intent?: "personal" | "organization"; + reason?: string; +} + +export const validateState = ( + cfg: OAuthAppConfig, + input: ValidateStateInput, +): ValidateStateOutput => { + if (!input.callbackState || !input.cookieState) return { ok: false, reason: "missing-state" }; + if (input.callbackState !== input.cookieState) return { ok: false, reason: "state-mismatch" }; + const parts = input.callbackState.split("."); + if (parts.length !== 2) return { ok: false, reason: "malformed-state" }; + const [stateCore, sig] = parts as [string, string]; + const expected = createHmac("sha256", cfg.stateSecret).update(stateCore).digest(); + const provided = Buffer.from(sig, "hex"); + if (expected.length !== provided.length) return { ok: false, reason: "bad-signature" }; + if (!timingSafeEqual(expected, provided)) return { ok: false, reason: "bad-signature" }; + + const [intent, , issuedAtStr] = stateCore.split(":"); + if (intent !== "personal" && intent !== "organization") { + return { ok: false, reason: "bad-intent" }; + } + const issuedAt = Number(issuedAtStr); + const maxAge = input.maxAgeMs ?? 10 * 60 * 1000; + const now = input.now ?? Date.now(); + if (now - issuedAt > maxAge) return { ok: false, reason: "expired" }; + return { ok: true, intent }; +}; + +export interface TokenExchangeRequest { + code: string; + redirectUri: string; +} + +export const buildTokenExchangeForm = ( + cfg: OAuthAppConfig, + req: TokenExchangeRequest, +): URLSearchParams => + new URLSearchParams({ + grant_type: "authorization_code", + code: req.code, + redirect_uri: req.redirectUri, + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + }); + +export const buildRefreshForm = ( + cfg: OAuthAppConfig, + refreshToken: string, +): URLSearchParams => + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + }); diff --git a/apps/mcp-linkedin/src/refresh-lock.test.ts b/apps/mcp-linkedin/src/refresh-lock.test.ts new file mode 100644 index 0000000..6b4af57 --- /dev/null +++ b/apps/mcp-linkedin/src/refresh-lock.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { InMemoryAdvisoryLock, lockKeyFor, withRefreshLock } from "./refresh-lock"; + +describe("withRefreshLock — Plan TEA gap 3 concurrent rotation guard", () => { + it("derives a stable lock key for a subject URN", () => { + expect(lockKeyFor("urn:li:person:abc")).toBe("linkedin_refresh:urn:li:person:abc"); + }); + + it("serializes 4 concurrent rotation attempts: only the first runs the inner fn", async () => { + const lock = new InMemoryAdvisoryLock(); + let runs = 0; + let denials = 0; + const inner = async () => { + runs++; + await new Promise((r) => setTimeout(r, 10)); + return runs; + }; + const attempts = await Promise.allSettled( + Array.from({ length: 4 }, () => withRefreshLock(lock, "urn:li:person:abc", inner)), + ); + for (const a of attempts) { + if (a.status === "rejected") denials++; + } + expect(runs).toBe(1); + expect(denials).toBe(3); + }); + + it("releases the lock on inner-fn failure", async () => { + const lock = new InMemoryAdvisoryLock(); + let err: unknown; + try { + await withRefreshLock(lock, "urn:li:person:abc", async () => { + throw new Error("boom"); + }); + } catch (e) { + err = e; + } + expect((err as Error).message).toBe("boom"); + // After failure the lock must be free for a second attempt: + const result = await withRefreshLock(lock, "urn:li:person:abc", async () => "ok"); + expect(result).toBe("ok"); + }); +}); diff --git a/apps/mcp-linkedin/src/refresh-lock.ts b/apps/mcp-linkedin/src/refresh-lock.ts new file mode 100644 index 0000000..67dfd88 --- /dev/null +++ b/apps/mcp-linkedin/src/refresh-lock.ts @@ -0,0 +1,45 @@ +/** + * Refresh-rotation advisory lock helper. + * + * Plan §3.2 (TEA gap 3): "Token rotation path acquires a Postgres advisory lock + * keyed on hashtextextended(subject_urn, 0) before reading/refreshing. + * Lock released after commit. Prevents double-rotation race." + * + * This is a pure-function wrapper. Inject the actual `acquireLock` and + * `releaseLock` functions (from `db.ts`) for production use; inject in-memory + * stubs for tests. + */ + +export interface AdvisoryLock { + acquire: (key: string) => Promise; + release: (key: string) => Promise; +} + +export const lockKeyFor = (subjectUrn: string): string => `linkedin_refresh:${subjectUrn}`; + +export const withRefreshLock = async ( + lock: AdvisoryLock, + subjectUrn: string, + fn: () => Promise, +): Promise => { + const key = lockKeyFor(subjectUrn); + const acquired = await lock.acquire(key); + if (!acquired) throw new Error(`Refresh lock contended for ${subjectUrn}`); + try { + return await fn(); + } finally { + await lock.release(key); + } +}; + +export class InMemoryAdvisoryLock implements AdvisoryLock { + private readonly held = new Set(); + async acquire(key: string): Promise { + if (this.held.has(key)) return false; + this.held.add(key); + return true; + } + async release(key: string): Promise { + this.held.delete(key); + } +} diff --git a/apps/mcp-linkedin/src/server.ts b/apps/mcp-linkedin/src/server.ts index a0cf233..6b3b119 100644 --- a/apps/mcp-linkedin/src/server.ts +++ b/apps/mcp-linkedin/src/server.ts @@ -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 { + 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}`); +}; diff --git a/apps/mcp-linkedin/src/tools.test.ts b/apps/mcp-linkedin/src/tools.test.ts new file mode 100644 index 0000000..54f0f7f --- /dev/null +++ b/apps/mcp-linkedin/src/tools.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { TOOL_NAMES, TOOL_SCHEMAS } from "./tools"; +import { outletForAuthorUrn, validateToolCall } from "./server"; + +describe("Tool registry — 9 tools per Plan §3.2", () => { + it("exposes exactly the 9 tools named in the plan", () => { + expect(TOOL_NAMES.sort()).toEqual( + [ + "linkedin_whoami", + "linkedin_auth_status", + "linkedin_create_post", + "linkedin_create_article", + "linkedin_upload_media", + "linkedin_create_post_with_media", + "linkedin_delete_post", + "linkedin_get_post_metrics", + "linkedin_get_profile_stats", + ].sort(), + ); + }); + + it("every tool has a description and a Zod input schema", () => { + for (const name of TOOL_NAMES) { + const def = TOOL_SCHEMAS[name]; + expect(def.description.length).toBeGreaterThan(10); + expect(typeof def.input.safeParse).toBe("function"); + } + }); +}); + +describe("validateToolCall", () => { + it("returns parsed args for a well-formed create_post call", () => { + const r = validateToolCall({ + name: "linkedin_create_post", + args: { + author_urn: "urn:li:person:abc", + text: "hello", + idempotency_key: "k1", + }, + }); + expect(r.ok).toBe(true); + }); + + it("rejects unknown tool name", () => { + const r = validateToolCall({ name: "linkedin_unknown" as never, args: {} }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error?.code).toBe("UNKNOWN_TOOL"); + }); + + it("rejects bad URN format", () => { + const r = validateToolCall({ + name: "linkedin_create_post", + args: { + author_urn: "facebook:bob", + text: "hello", + idempotency_key: "k1", + }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error?.code).toBe("INVALID_ARGS"); + }); + + it("rejects post text exceeding 3000 chars (LinkedIn member feed cap)", () => { + const r = validateToolCall({ + name: "linkedin_create_post", + args: { + author_urn: "urn:li:person:abc", + text: "x".repeat(3001), + idempotency_key: "k1", + }, + }); + expect(r.ok).toBe(false); + }); + + it("rejects article body exceeding 125k chars", () => { + const r = validateToolCall({ + name: "linkedin_create_article", + args: { + author_urn: "urn:li:person:abc", + title: "T", + body: "x".repeat(125_001), + idempotency_key: "k1", + }, + }); + expect(r.ok).toBe(false); + }); +}); + +describe("outletForAuthorUrn", () => { + it("derives linkedin.member from person URN", () => { + expect(outletForAuthorUrn("urn:li:person:abc")).toBe("linkedin.member"); + }); + it("derives linkedin.org from organization URN", () => { + expect(outletForAuthorUrn("urn:li:organization:2605890")).toBe("linkedin.org"); + }); + it("throws on unknown URN", () => { + expect(() => outletForAuthorUrn("urn:li:share:1")).toThrow(); + }); +}); diff --git a/apps/mcp-linkedin/src/tools.ts b/apps/mcp-linkedin/src/tools.ts new file mode 100644 index 0000000..fcb0d8a --- /dev/null +++ b/apps/mcp-linkedin/src/tools.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; + +export const TOOL_SCHEMAS = { + linkedin_whoami: { + description: "Returns authenticated subject URN, scopes, and MDP-eligible flag.", + input: z.object({ + subject_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/), + }), + }, + linkedin_auth_status: { + description: + "Returns access-expiry remaining, refresh-expiry remaining, and kill-switch state for the subject.", + input: z.object({ + subject_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/), + }), + }, + linkedin_create_post: { + description: "Create a text-only post via the Posts API (NOT legacy ugcPosts).", + input: z.object({ + author_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/), + text: z.string().min(1).max(3000), + visibility: z.enum(["PUBLIC", "CONNECTIONS"]).default("PUBLIC"), + idempotency_key: z.string().min(1), + }), + }, + linkedin_create_article: { + description: "Create a long-form article (≤125k chars).", + input: z.object({ + author_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/), + title: z.string().min(1).max(300), + body: z.string().min(1).max(125_000), + idempotency_key: z.string().min(1), + }), + }, + linkedin_upload_media: { + description: "Upload an image or PDF; returns asset URN.", + input: z.object({ + author_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/), + mime_type: z.enum(["image/png", "image/jpeg", "application/pdf"]), + data_b64: z.string().min(1), + }), + }, + linkedin_create_post_with_media: { + description: "Combine media upload + post creation.", + input: z.object({ + author_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/), + text: z.string().min(1).max(3000), + asset_urn: z.string().min(1), + idempotency_key: z.string().min(1), + }), + }, + linkedin_delete_post: { + description: + "Delete a post by URN. Soft-reversible only within 5-min edit window; after that, permanent.", + input: z.object({ + post_urn: z.string().min(1), + reason: z.string().min(1).max(500), + }), + }, + linkedin_get_post_metrics: { + description: "Returns impressions, reactions, comments, shares, clicks for a post URN.", + input: z.object({ + post_urn: z.string().min(1), + }), + }, + linkedin_get_profile_stats: { + description: "Aggregate profile stats for the cadence optimizer.", + input: z.object({ + subject_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/), + }), + }, +} as const; + +export type ToolName = keyof typeof TOOL_SCHEMAS; + +export const TOOL_NAMES: readonly ToolName[] = Object.keys(TOOL_SCHEMAS) as ToolName[]; + +/** Plan §3.2 architect gap 4: self-imposed conservative limits, NOT LinkedIn quota. */ +export const RATE_CAPS = { + per_member_per_day: 20, + per_org_per_day: 50, + per_member_per_minute: 20, // burst cap, used by tests + per_org_per_minute: 50, +} as const; diff --git a/bun.lock b/bun.lock index 52b4e5c..e0ebabc 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,6 @@ "name": "@stargue/mcp-linkedin", "version": "0.1.0", "dependencies": { - "@modelcontextprotocol/sdk": "1.0.3", "@stargue/linkedin-client": "workspace:*", "@stargue/observability": "workspace:*", "@stargue/schema": "workspace:*", @@ -234,8 +233,6 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.0.3", "", { "dependencies": { "content-type": "^1.0.5", "raw-body": "^3.0.0", "zod": "^3.23.8" } }, "sha512-2as3cX/VJ0YBHGmdv3GFyTpoM8q2gqE98zh3Vf1NwnsSY0h3mvoO07MUzfygCKkWsFjcZm4otIiqD6Xh7kiSBQ=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], @@ -410,8 +407,6 @@ "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], @@ -434,8 +429,6 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], @@ -450,8 +443,6 @@ "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -494,12 +485,6 @@ "headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="], - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -616,8 +601,6 @@ "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], @@ -642,16 +625,12 @@ "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], @@ -702,8 +681,6 @@ "tldts-core": ["tldts-core@7.0.28", "", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="], - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -728,8 +705,6 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],