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:
@@ -11,7 +11,6 @@
|
||||
"lint": "echo 'lint pending'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.0.3",
|
||||
"@stargue/linkedin-client": "workspace:*",
|
||||
"@stargue/schema": "workspace:*",
|
||||
"@stargue/observability": "workspace:*",
|
||||
|
||||
57
apps/mcp-linkedin/src/kill-switch.test.ts
Normal file
57
apps/mcp-linkedin/src/kill-switch.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { KillSwitch, KillSwitchError, type FeatureFlagStore } from "./kill-switch";
|
||||
|
||||
const makeStore = (state: Map<string, { enabled: boolean; reason: string | null }>): 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);
|
||||
});
|
||||
});
|
||||
51
apps/mcp-linkedin/src/kill-switch.ts
Normal file
51
apps/mcp-linkedin/src/kill-switch.ts
Normal file
@@ -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<string, { value: { enabled: boolean; reason: string | null }; expires: number }>();
|
||||
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<void> {
|
||||
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";
|
||||
}
|
||||
}
|
||||
107
apps/mcp-linkedin/src/oauth.test.ts
Normal file
107
apps/mcp-linkedin/src/oauth.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
124
apps/mcp-linkedin/src/oauth.ts
Normal file
124
apps/mcp-linkedin/src/oauth.ts
Normal file
@@ -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,
|
||||
});
|
||||
43
apps/mcp-linkedin/src/refresh-lock.test.ts
Normal file
43
apps/mcp-linkedin/src/refresh-lock.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
45
apps/mcp-linkedin/src/refresh-lock.ts
Normal file
45
apps/mcp-linkedin/src/refresh-lock.ts
Normal file
@@ -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<boolean>;
|
||||
release: (key: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const lockKeyFor = (subjectUrn: string): string => `linkedin_refresh:${subjectUrn}`;
|
||||
|
||||
export const withRefreshLock = async <T>(
|
||||
lock: AdvisoryLock,
|
||||
subjectUrn: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
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<string>();
|
||||
async acquire(key: string): Promise<boolean> {
|
||||
if (this.held.has(key)) return false;
|
||||
this.held.add(key);
|
||||
return true;
|
||||
}
|
||||
async release(key: string): Promise<void> {
|
||||
this.held.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
99
apps/mcp-linkedin/src/tools.test.ts
Normal file
99
apps/mcp-linkedin/src/tools.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
84
apps/mcp-linkedin/src/tools.ts
Normal file
84
apps/mcp-linkedin/src/tools.ts
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user