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:
Angelo B. J. Luidens
2026-04-26 12:57:00 -04:00
parent c73b7e4aad
commit 7aa3137a91
11 changed files with 677 additions and 28 deletions

View File

@@ -11,7 +11,6 @@
"lint": "echo 'lint pending'"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.0.3",
"@stargue/linkedin-client": "workspace:*",
"@stargue/schema": "workspace:*",
"@stargue/observability": "workspace:*",

View 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);
});
});

View 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";
}
}

View 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");
});
});

View 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,
});

View 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");
});
});

View 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);
}
}

View File

@@ -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}`);
};

View 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();
});
});

View 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;

View File

@@ -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=="],