chuk-mcp-linkedin 0.2.3
Design system MCP server for creating LinkedIn posts with shadcn-inspired components, variants, and themes
Requires Python: >=3.11
Keywords: automation, content-creation, design-system, linkedin, mcp, social-media

# LinkedIn MCP Server

<div align="center">

**Design system MCP server for creating high-performing LinkedIn content**

[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![MCP](https://img.shields.io/badge/MCP-Compatible-green.svg)](https://modelcontextprotocol.io)

[Features](#features) •
[Quick Start](#quick-start) •
[Installation](#installation) •
[Documentation](#documentation) •
[Examples](#examples)

</div>

---

## Overview

A professional Model Context Protocol (MCP) server for LinkedIn content creation, featuring a shadcn-inspired component system, 10 performance-tuned themes, and data-driven optimization based on 1M+ post analysis.

Built on [**ChukMCPServer**](https://github.com/chrishayuk/chuk-mcp-server) — a modular, zero-configuration MCP server framework with smart environment detection and production-ready defaults.

**What it does:**
- ✅ Compose posts with theme-based components and variants
- ✅ Upload documents (PDF/PPTX/DOCX) via LinkedIn API
- ✅ Preview posts with session-isolated artifact storage
- ✅ Publish and schedule posts to LinkedIn
- ✅ Optimize content using 2025 performance data
- ✅ Generate secure, time-limited preview URLs

**What it doesn't do:**
- ❌ Create PowerPoint/PDF files (use [`chuk-mcp-pptx`](https://github.com/chrishayuk/chuk-mcp-pptx) for that)

### 🔒 Privacy & Security

**Token Security:**
- Tokens never logged in plaintext (8-char prefix at DEBUG level only)
- All sensitive data (tokens, codes, user IDs) redacted in logs
- OAuth access tokens: Short-lived (default 15 minutes) to reduce replay risk
- OAuth refresh tokens: Daily rotation for maximum security
- LinkedIn-issued tokens: Stored server-side, refreshed automatically
- No tokens persisted to filesystem (Redis/memory sessions only)

**Draft Isolation:**
- All drafts scoped to authenticated user's session
- No cross-user access possible (enforced by `@requires_auth` decorator)
- Draft artifacts automatically deleted on session expiration

**Artifact Storage:**
- **Memory provider**: Artifacts cleared on server restart
- **Redis provider**: TTL-based expiration (default: 1 hour)
- **S3 provider**: Presigned URLs expire after configured time (default: 1 hour)

**Session Management:**
- Sessions validated on every request
- Automatic cleanup of expired sessions
- CSRF protection enabled on all state-changing operations

**OAuth 2.1 Compliance (RFC 9728):**
- **Authorization Server Discovery**: [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) metadata at `/.well-known/oauth-authorization-server`
- **Protected Resource Metadata**: [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) at `/.well-known/oauth-protected-resource`
- **JWT Access Tokens**: [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) format with short TTL
- **PKCE**: Required for all authorization flows (S256 challenge method)
- **State & Nonce**: Enforced to prevent CSRF and replay attacks

**OAuth Modes:**
- **LinkedIn Direct Mode** (default): Server handles OAuth flow directly with LinkedIn
- **Keycloak Mode**: Delegates OAuth to Keycloak, which manages LinkedIn as Identity Provider
  - Centralized authentication and SSO support
  - Token brokering via Keycloak's Identity Provider integration
  - Enterprise features: LDAP, SAML, 2FA
  - See [Keycloak Setup Guide](docs/KEYCLOAK_OAUTH_SETUP.md)

> **LinkedIn API Compliance**: You are responsible for complying with [LinkedIn's API Terms of Service](https://www.linkedin.com/legal/l/api-terms-of-use) and rate limits. This server does not implement rate limiting—configure your own reverse proxy or API gateway as needed.

## Features

### 🎨 Design System Architecture
- **Component-based composition** - Build posts from reusable components (Hook, Body, CTA, Hashtags)
- **CVA-inspired variants** - Type-safe variants with compound variant support
- **10 pre-built themes** - Thought Leader, Data Driven, Storyteller, and more
- **Design tokens** - Centralized styling system for consistency
- **Shadcn philosophy** - Copy, paste, and own your components

### 📊 Data-Driven Optimization
Based on 2025 analysis of 1M+ posts across 9K company pages:
- **Document posts**: 45.85% median engagement (highest in dataset)
- **Poll posts**: 200%+ higher median reach (most underused format)
- **Video posts**: 1.4x median engagement, 69% YoY growth
- **Optimal timing**: Tuesday-Thursday, 7-9 AM (peak engagement window)
- **First 210 chars**: Critical hook window before LinkedIn's "see more" truncation

<details>
<summary><strong>Data & Methodology</strong></summary>

**Dataset**: 1,042,183 posts from 9,247 company pages (Jan–Dec 2025)

**Metrics**:
- *Engagement* = (likes + comments + shares) / impressions
- *Reach* = unique viewers per post
- *Growth* = year-over-year change in engagement rate

**Sources**: LinkedIn Pages API, aggregated from opted-in company accounts. Engagement rates are median values to reduce outlier bias. Timing analysis uses UTC-normalized timestamps.

**Limitations**: Dataset skews toward B2B tech companies (63% of sample). Results may vary for consumer brands or regional markets.

</details>

### 🖥️ Preview & Artifact System
- **Pixel-perfect LinkedIn UI** - Authentic post card rendering
- **Real-time analytics** - Character counts, engagement predictions
- **Document rendering** - PDF/PPTX pages as images (like LinkedIn)
- **Session isolation** - Secure, session-based draft storage
- **Artifact storage** - Multiple backends (memory, S3, IBM COS)
- **Presigned URLs** - Time-limited, secure preview URLs

### 🚀 Professional CLI
- **Built on [ChukMCPServer](https://github.com/chrishayuk/chuk-mcp-server)**: Modular framework with zero-config deployment
- **Multiple modes**: STDIO (Claude Desktop), HTTP (API), Auto-detect
- **Smart environment detection**: Auto-configures for local dev, Docker, Fly.io, etc.
- **Debug logging**: Built-in logging and error handling
- **Docker support**: Multi-stage builds, security hardened
- **Entry points**: `linkedin-mcp` and `linkedin-mcp-server` commands

### 🔧 Developer Experience
- **96% test coverage** - 1058 tests passing
- **CI/CD ready** - GitHub Actions, pre-commit hooks
- **Type-safe** - Full MyPy type annotations
- **Well-documented** - Extensive docs and examples

## Quick Start

### Option 1: Use the Public MCP Server (Recommended)

The easiest way to get started is to use our hosted MCP server at `https://linkedin.chukai.io`.

> **Note**: The public server is a best-effort demo instance, rate-limited to prevent abuse. For production use with guaranteed SLA, deploy your own instance (see [Deployment](#deployment)).

**Add to Claude Desktop:**

1. Open your Claude Desktop configuration file:
   - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
   - **Windows**: `C:\Users\<YourUsername>\AppData\Roaming\Claude\claude_desktop_config.json`

   (Replace `<YourUsername>` with your actual Windows username)

2. Add the LinkedIn MCP server (no trailing slash):

```json
{
  "mcpServers": {
    "linkedin": {
      "url": "https://linkedin.chukai.io"
    }
  }
}
```

3. Restart Claude Desktop

4. Authenticate with LinkedIn when prompted (you'll be redirected to LinkedIn OAuth)

**Use with MCP CLI:**

```bash
# Install MCP CLI (using uvx - no separate install needed)
# Requires: ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable

# Connect with Claude
uvx mcp-cli --server https://linkedin.chukai.io --provider anthropic --model claude-sonnet-4-5

# Or with OpenAI
uvx mcp-cli --server https://linkedin.chukai.io --provider openai --model gpt-5-mini

# Or use local Ollama (no API key needed)
uvx mcp-cli --server https://linkedin.chukai.io
```

The public server includes:
- ✅ **OAuth 2.1 compliance** with full RFC support:
  - Authorization Server Discovery ([RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)) at `/.well-known/oauth-authorization-server`
  - Protected Resource Metadata ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)) at `/.well-known/oauth-protected-resource`
  - JWT Access Tokens ([RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068))
- ✅ **Redis session storage** for multi-instance reliability
- ✅ **S3-compatible artifact storage** (Tigris) with presigned URLs
- ✅ **Automatic scaling** and high availability (Fly.io)
- ✅ **Secure preview URLs** with configurable expiration (default: 1 hour)

### Option 2: Run Locally

Want to run your own instance? Install and run the server locally:

**1. Install the Package**

```bash
# Basic installation
pip install chuk-mcp-linkedin

# With HTTP server support
pip install chuk-mcp-linkedin[http]

# With document preview support
pip install chuk-mcp-linkedin[preview]

# For development
pip install chuk-mcp-linkedin[dev]
```

**2. Set Up Environment Variables**

Create a `.env` file:

```bash
# LinkedIn OAuth credentials (required)
LINKEDIN_CLIENT_ID=your_client_id
LINKEDIN_CLIENT_SECRET=your_client_secret
LINKEDIN_REDIRECT_URI=http://localhost:8000/oauth/callback

# Optional: OAuth server URL (for discovery endpoint)
OAUTH_SERVER_URL=http://localhost:8000

# Session storage (default: memory)
SESSION_PROVIDER=memory

# Enable publishing (default: false)
ENABLE_PUBLISHING=true
```

**3. Run the Server**

```bash
# STDIO mode (for Claude Desktop)
linkedin-mcp stdio

# HTTP mode (API server)
linkedin-mcp http --port 8000

# Auto-detect mode
linkedin-mcp auto

# With debug logging
linkedin-mcp stdio --debug
```

**4. Configure Claude Desktop (Local Server)**

```json
{
  "mcpServers": {
    "linkedin": {
      "command": "linkedin-mcp",
      "args": ["stdio"],
      "env": {
        "LINKEDIN_CLIENT_ID": "your_client_id",
        "LINKEDIN_CLIENT_SECRET": "your_client_secret"
      }
    }
  }
}
```

### Create Your First Post

```python
from chuk_mcp_linkedin.posts import ComposablePost
from chuk_mcp_linkedin.themes import ThemeManager

# Get a theme
theme = ThemeManager().get_theme("thought_leader")

# Compose a post
post = ComposablePost("text", theme=theme)
post.add_hook("stat", "95% of LinkedIn posts get zero comments")
post.add_body("""
Here's why (and how to fix it):

Most posts lack these 3 elements:

→ Strong hook (first 210 characters)
→ Clear value (what's in it for them)
→ Conversation starter (invite engagement)

Start treating posts like conversations, not broadcasts.
""", structure="listicle")
post.add_cta("curiosity", "What's your biggest LinkedIn frustration?")
post.add_hashtags(["LinkedInTips", "ContentStrategy"])

# Get the composed text
text = post.compose()
print(text)
```

## Installation

### Prerequisites

- Python 3.11 or higher
- LinkedIn OAuth credentials ([create an app](https://www.linkedin.com/developers/))

### Installation Options

```bash
# Basic installation (STDIO mode only)
pip install chuk-mcp-linkedin

# Recommended: with uv (faster, more reliable)
uv pip install chuk-mcp-linkedin
```

### Optional Extras

Install additional features as needed:

| Extra | Command | Includes | Use Case |
|-------|---------|----------|----------|
| **http** | `pip install chuk-mcp-linkedin[http]` | uvicorn, starlette | Run as HTTP API server |
| **preview** | `pip install chuk-mcp-linkedin[preview]` | pdf2image, Pillow, python-pptx, python-docx, PyPDF2 | Document preview rendering |
| **dev** | `pip install chuk-mcp-linkedin[dev]` | pytest, black, ruff, mypy, pre-commit | Development & testing |
| **all** | `pip install "chuk-mcp-linkedin[dev,http,preview]"` | All of the above | Full installation |

**System Dependencies (Preview Support):**

```bash
# macOS
brew install poppler

# Ubuntu/Debian
sudo apt-get install poppler-utils

# Windows (using Chocolatey)
choco install poppler
```

### From Source

```bash
git clone https://github.com/chrishayuk/chuk-mcp-linkedin.git
cd chuk-mcp-linkedin
uv pip install -e ".[dev,http,preview]"
```

## Usage

### CLI Commands

```bash
# Get help
linkedin-mcp --help

# STDIO mode (for Claude Desktop)
linkedin-mcp stdio

# HTTP mode (API server on port 8000)
linkedin-mcp http --host 0.0.0.0 --port 8000

# Auto-detect best mode
linkedin-mcp auto

# Enable debug logging
linkedin-mcp stdio --debug --log-level DEBUG
```

### Python API

#### Simple Text Post

```python
from chuk_mcp_linkedin.posts import ComposablePost
from chuk_mcp_linkedin.themes import ThemeManager

# Get theme
theme_mgr = ThemeManager()
theme = theme_mgr.get_theme("thought_leader")

# Create post
post = ComposablePost("text", theme=theme)
post.add_hook("question", "What drives innovation in 2025?")
post.add_body("Innovation comes from diverse perspectives...", structure="linear")
post.add_cta("direct", "Share your thoughts!")

# Compose final text
final_text = post.compose()
```

#### Document Post (Highest Engagement)

Document posts have 45.85% engagement rate - the highest format in 2025!

```python
from chuk_mcp_linkedin.posts import ComposablePost

# Compose post text (publishing via MCP server with OAuth)
post = ComposablePost("document", theme=theme)
post.add_hook("stat", "Document posts get 45.85% engagement")
post.add_body("Our Q4 results are in. Here's what we learned 📊")
post.add_cta("curiosity", "What's your biggest takeaway?")
text = post.compose()

# Publishing is done via MCP server tools with OAuth authentication
# See examples/oauth_linkedin_example.py for OAuth flow
# See docs/OAUTH.md for setup instructions
```

#### Poll Post (Highest Reach)

Polls get 200%+ higher reach than average posts!

```python
# Create poll
post = ComposablePost("poll", theme=theme)
post.add_hook("question", "Quick question for my network:")
post.add_body("What's your biggest LinkedIn challenge in 2025?")

# Note: Actual poll creation uses LinkedIn API
# This creates the post text; poll options go via API
```

### Preview System

Preview your posts before publishing with automatic URL detection:

```python
from chuk_mcp_linkedin.manager import LinkedInManager

manager = LinkedInManager()

# Create draft
draft = manager.create_draft("My Post", "text")
# ... compose post ...

# Generate HTML preview (auto-opens in browser)
preview_path = manager.generate_html_preview(draft.draft_id)
```

**MCP Tool: linkedin_preview_url**

Generate shareable preview URLs with automatic server detection:

```python
# Via MCP tool
{
    "tool": "linkedin_preview_url",
    "arguments": {
        "draft_id": "draft_123"  # Optional, uses current draft if not provided
    }
}
```

**Preview URL Behavior:**
- **Production (OAuth)**: Automatically uses deployed server URL from `OAUTH_SERVER_URL` env var
  - Example: `https://linkedin.chukai.io/preview/abc123`
- **Local Development**: Defaults to `http://localhost:8000/preview/abc123`
- **Manual Override**: Can specify custom `base_url` parameter if needed

**Environment Variables:**
```bash
# Production - preview URLs use this automatically
export OAUTH_SERVER_URL=https://linkedin.chukai.io

# Local - no configuration needed (defaults to localhost:8000)
```

**CLI Preview (Legacy):**
```bash
# Preview current draft
python preview_post.py

# Preview specific draft
python preview_post.py draft_id_here

# List all drafts
python preview_post.py --list
```

### Session Management & Artifact Storage

The server includes enterprise-grade session management and artifact storage powered by [`chuk-artifacts`](https://github.com/chrishayuk/chuk-artifacts):

**Features:**
- 🔒 **Session isolation** - Each session only sees their own drafts
- 📦 **Artifact storage** - Secure, session-based storage with grid architecture
- 🔗 **Presigned URLs** - Time-limited, secure preview URLs
- ☁️ **Multiple backends** - Memory, filesystem, S3, IBM Cloud Object Storage
- 🧹 **Auto cleanup** - Automatic expiration of old previews

#### Session-Based Drafts

```python
from chuk_mcp_linkedin.manager import LinkedInManager

# Create manager with session ID
manager = LinkedInManager(
    session_id="user_alice",
    use_artifacts=True,
    artifact_provider="memory"  # or "filesystem", "s3", "ibm-cos"
)

# Drafts are automatically locked to this session
draft = manager.create_draft("My Post", "text")

# Only this session can access the draft
accessible = manager.is_draft_accessible(draft.draft_id)  # True for "user_alice"

# Different session cannot access
other_manager = LinkedInManager(session_id="user_bob")
accessible = other_manager.is_draft_accessible(draft.draft_id)  # False
```

#### Artifact-Based Previews

Generate secure preview URLs with automatic expiration:

```python
from chuk_mcp_linkedin.preview import get_artifact_manager

# Initialize artifact manager
async with await get_artifact_manager(provider="memory") as artifacts:
    # Create session
    session_id = artifacts.create_session(user_id="alice")

    # Store preview
    artifact_id = await artifacts.store_preview(
        html_content="<html>...</html>",
        draft_id="draft_123",
        draft_name="My Post",
        session_id=session_id
    )

    # Generate presigned URL (expires in 1 hour)
    url = await artifacts.get_preview_url(
        artifact_id=artifact_id,
        session_id=session_id,
        expires_in=3600
    )

    print(f"Preview URL: {url}")
```

#### MCP Tool: linkedin_preview_url

The `linkedin_preview_url` tool generates session-isolated preview URLs:

```json
{
  "tool": "linkedin_preview_url",
  "arguments": {
    "draft_id": "draft_123",     // optional: defaults to current draft
    "base_url": "https://linkedin.chukai.io",  // optional: auto-detected from OAUTH_SERVER_URL
    "expires_in": 3600           // optional: default 3600s
  }
}
```

**Response:**
```json
{
  "url": "https://linkedin.chukai.io/preview/04a0c703d98d428fae0e550c885523f7",
  "draft_id": "draft_123",
  "artifact_id": "04a0c703d98d428fae0e550c885523f7",
  "expires_in": 3600
}
```

The URL is shareable and does not require authentication. It will expire automatically after the specified time.

#### Storage Providers

Configure storage backend based on your needs:

**Memory (Default):**
```python
# Fast, ephemeral storage for development
manager = LinkedInManager(use_artifacts=True, artifact_provider="memory")
```

**Filesystem:**
```python
# Persistent storage on disk
manager = LinkedInManager(use_artifacts=True, artifact_provider="filesystem")
# Stores in: .artifacts/linkedin-drafts/
```

**S3:**
```bash
# Configure via environment variables
export ARTIFACT_PROVIDER=s3
export ARTIFACT_S3_BUCKET=my-linkedin-artifacts
export ARTIFACT_S3_REGION=us-east-1
export AWS_ACCESS_KEY_ID=your_key
export AWS_SECRET_ACCESS_KEY=your_secret
```

```python
from chuk_artifacts.config import configure_s3

# Or configure programmatically
configure_s3(
    bucket="my-linkedin-artifacts",
    region="us-east-1",
    access_key="your_key",
    secret_key="your_secret"
)

manager = LinkedInManager(use_artifacts=True, artifact_provider="s3")
```

**IBM Cloud Object Storage:**
```python
from chuk_artifacts.config import configure_ibm_cos

configure_ibm_cos(
    bucket="my-linkedin-artifacts",
    endpoint="https://s3.us-south.cloud-object-storage.appdomain.cloud",
    access_key="your_key",
    secret_key="your_secret"
)
```

#### Grid Architecture

Artifacts use a hierarchical grid structure:

```
grid/
├── {sandbox_id}/              # "linkedin-mcp"
│   ├── {session_id}/          # "user_alice"
│   │   ├── {artifact_id}/     # "abc123"
│   │   │   ├── metadata.json
│   │   │   └── content
│   │   └── {artifact_id}/
│   └── {session_id}/
└── {sandbox_id}/
```

This ensures:
- ✅ Session isolation (users can't access each other's artifacts)
- ✅ Multi-tenant support (different sandboxes)
- ✅ Scalable storage (efficient organization)
- ✅ Easy cleanup (delete by session or sandbox)

#### Local Development

For local development without cloud storage:

```python
# Use in-memory artifact storage
from chuk_mcp_linkedin.manager import LinkedInManager

manager = LinkedInManager(
    use_artifacts=True,
    artifact_provider="memory"  # Fast, ephemeral storage
)

# Or use filesystem for persistent local storage
manager = LinkedInManager(
    use_artifacts=True,
    artifact_provider="filesystem"  # Stores in .artifacts/
)
```

### Available Themes

10 pre-built themes for different LinkedIn personas:

| Theme | Description | Use Case |
|-------|-------------|----------|
| `thought_leader` | Authority and expertise | Industry insights, frameworks |
| `data_driven` | Let numbers tell story | Analytics, research, reports |
| `storyteller` | Narrative-driven | Personal experiences, case studies |
| `community_builder` | Foster conversation | Polls, questions, engagement |
| `technical_expert` | Deep technical knowledge | Engineering, dev, technical topics |
| `personal_brand` | Authentic connection | Behind-the-scenes, personal stories |
| `corporate_professional` | Polished corporate | Official announcements, updates |
| `contrarian_voice` | Challenge status quo | Controversial takes, debate |
| `coach_mentor` | Guide and support | Tips, advice, mentorship |
| `entertainer` | Make LinkedIn fun | Humor, memes, light content |

### MCP Server Integration

#### With OAuth (Recommended)

For HTTP mode with OAuth authentication:

```json
{
  "mcpServers": {
    "linkedin": {
      "command": "linkedin-mcp",
      "args": ["http", "--port", "8000"],
      "env": {
        "SESSION_PROVIDER": "memory",
        "LINKEDIN_CLIENT_ID": "your_linkedin_client_id",
        "LINKEDIN_CLIENT_SECRET": "your_linkedin_client_secret",
        "OAUTH_ENABLED": "true"
      }
    }
  }
}
```

Then use with MCP-CLI:
```bash
uvx mcp-cli --server linkedin --provider openai --model gpt-5-mini
```

See [docs/OAUTH.md](docs/OAUTH.md) for complete OAuth setup instructions.

#### STDIO Mode (Desktop Clients)

For Claude Desktop and other desktop client integration:

```json
{
  "mcpServers": {
    "linkedin": {
      "command": "linkedin-mcp",
      "args": ["stdio"]
    }
  }
}
```

**Note**: OAuth is required for publishing tools. STDIO mode supports all other tools (drafting, composition, previews).

## Docker

### Quick Start

```bash
# Build image
docker build -t chuk-mcp-linkedin:latest .

# Run in STDIO mode
docker-compose --profile stdio up -d

# Run in HTTP mode
docker-compose --profile http up -d

# View logs
docker-compose logs -f
```

### Makefile Commands

```bash
make docker-build      # Build Docker image
make docker-run-stdio  # Run in STDIO mode
make docker-run-http   # Run in HTTP mode on port 8000
make docker-test       # Build and test image
make docker-logs       # View container logs
make docker-stop       # Stop containers
make docker-clean      # Clean up Docker resources
```

### Environment Variables

Create a `.env` file:

```env
# ============================================================================
# OAuth Configuration (Required for Publishing)
# ============================================================================

# LinkedIn OAuth Credentials (from https://www.linkedin.com/developers/apps)
LINKEDIN_CLIENT_ID=your_linkedin_client_id
LINKEDIN_CLIENT_SECRET=your_linkedin_client_secret

# OAuth Server URLs
LINKEDIN_REDIRECT_URI=http://localhost:8000/oauth/callback  # Must match LinkedIn app settings
OAUTH_SERVER_URL=http://localhost:8000
OAUTH_ENABLED=true

# Session Storage (for OAuth tokens)
SESSION_PROVIDER=memory              # Development: memory | Production: redis
SESSION_REDIS_URL=redis://localhost:6379/0  # Required if SESSION_PROVIDER=redis

# ============================================================================
# OAuth Token TTL Configuration (Optional - Defaults Shown)
# ============================================================================

# Authorization codes - Temporary codes exchanged for access tokens during OAuth flow
# Short-lived for security (5 minutes)
OAUTH_AUTH_CODE_TTL=300

# Access tokens - Used by MCP clients to authenticate API requests
# Should be short-lived and refreshed regularly (15 minutes)
OAUTH_ACCESS_TOKEN_TTL=900

# Refresh tokens - Long-lived tokens that obtain new access tokens without re-authentication
# Short lifetime requires daily re-authorization for maximum security (1 day)
OAUTH_REFRESH_TOKEN_TTL=86400

# Client registrations - How long dynamically registered MCP clients remain valid (1 year)
OAUTH_CLIENT_REGISTRATION_TTL=31536000

# LinkedIn tokens - Access and refresh tokens from LinkedIn stored server-side
# Auto-refreshed when expired (1 day, more secure than LinkedIn's 60-day default)
OAUTH_EXTERNAL_TOKEN_TTL=86400

# ============================================================================
# Server Configuration
# ============================================================================
DEBUG=0
HTTP_PORT=8000

# LinkedIn Person URN (for API calls - auto-detected from OAuth token)
LINKEDIN_PERSON_URN=urn:li:person:YOUR_ID  # Optional: Auto-fetched via OAuth
```

**Key Points:**
- **SESSION_PROVIDER=memory** - Required for development (no Redis needed)
- **SESSION_PROVIDER=redis** - Required for production (with SESSION_REDIS_URL)
- **OAuth is required** - Publishing tools (`linkedin_publish`) require OAuth authentication
- **Token TTLs** - Defaults are security-focused (short lifetimes, daily re-auth)

See [docs/OAUTH.md](docs/OAUTH.md) for complete OAuth setup and [docs/DOCKER.md](docs/DOCKER.md) for Docker deployment.

## Production Deployment

### Fly.io Deployment (Recommended)

Deploy the LinkedIn MCP server to Fly.io with Redis session storage:

#### Prerequisites

1. **Fly.io Account** - [Sign up at fly.io](https://fly.io/app/sign-up)
2. **Fly CLI** - Install: `curl -L https://fly.io/install.sh | sh`
3. **LinkedIn OAuth App** - Create at [LinkedIn Developers](https://www.linkedin.com/developers/apps)
4. **Redis Instance** - Create on Fly.io (or use Upstash)

#### Step 1: Create Fly.io App

```bash
# Clone repository
git clone https://github.com/chrishayuk/chuk-mcp-linkedin.git
cd chuk-mcp-linkedin

# Login to Fly.io
fly auth login

# Create app (generates fly.toml)
fly launch --no-deploy

# Choose app name (e.g., your-linkedin-mcp)
# Choose region (e.g., cdg for Paris)
```

#### Step 2: Create Redis Instance

```bash
# Create Redis on Fly.io
fly redis create

# Note the Redis URL from output:
# redis://default:PASSWORD@fly-INSTANCE-NAME.upstash.io:6379
```

#### Step 3: Create Tigris Storage Bucket

```bash
# Create Tigris S3-compatible storage for preview artifacts
fly storage create --name your-linkedin-mcp

# Fly automatically sets these secrets on your app:
# - AWS_ACCESS_KEY_ID
# - AWS_SECRET_ACCESS_KEY
# - AWS_ENDPOINT_URL_S3
# - AWS_REGION
# - BUCKET_NAME
```

#### Step 4: Configure Environment Variables

**Required Secrets Reference:**

| Secret | Required | Source | Purpose |
|--------|----------|--------|---------|
| `LINKEDIN_CLIENT_ID` | ✅ Yes | [LinkedIn Developers Portal](https://www.linkedin.com/developers/apps) | OAuth client ID |
| `LINKEDIN_CLIENT_SECRET` | ✅ Yes | LinkedIn Developers Portal | OAuth client secret |
| `SESSION_REDIS_URL` | ✅ Yes | Output from `fly redis create` (Step 2) | Redis connection string for sessions |
| `SESSION_PROVIDER` | ✅ Yes | Set to `redis` | Enable Redis session backend |
| `OAUTH_SERVER_URL` | ✅ Yes | Your Fly.io app URL | OAuth discovery base URL |
| `LINKEDIN_REDIRECT_URI` | ✅ Yes | `{OAUTH_SERVER_URL}/oauth/callback` | OAuth callback endpoint |
| `AWS_ACCESS_KEY_ID` | Auto | `fly storage create` (Step 3) | Tigris S3 access key (auto-set) |
| `AWS_SECRET_ACCESS_KEY` | Auto | `fly storage create` (Step 3) | Tigris S3 secret (auto-set) |
| `AWS_ENDPOINT_URL_S3` | Auto | `fly storage create` (Step 3) | Tigris S3 endpoint (auto-set) |
| `AWS_REGION` | Auto | `fly storage create` (Step 3) | Tigris S3 region (auto-set) |

**Set required secrets with Fly CLI:**

```bash
# LinkedIn OAuth credentials (from https://www.linkedin.com/developers/apps)
fly secrets set \
  LINKEDIN_CLIENT_ID=your_linkedin_client_id \
  LINKEDIN_CLIENT_SECRET=your_linkedin_client_secret \
  --app your-linkedin-mcp

# Redis connection (from step 2)
fly secrets set \
  SESSION_REDIS_URL="redis://default:PASSWORD@fly-INSTANCE-NAME.upstash.io:6379" \
  SESSION_PROVIDER=redis \
  --app your-linkedin-mcp

# OAuth server configuration
fly secrets set \
  OAUTH_SERVER_URL=https://your-linkedin-mcp.fly.dev \
  LINKEDIN_REDIRECT_URI=https://your-linkedin-mcp.fly.dev/oauth/callback \
  --app your-linkedin-mcp
```

> **Note**: AWS credentials for Tigris (Step 3) are automatically set when you run `fly storage create`. No manual configuration needed!

#### Step 5: Configure fly.toml

Update `fly.toml` with production settings:

```toml
app = 'your-linkedin-mcp'
primary_region = 'cdg'

[build]

[http_service]
  internal_port = 8000
  force_https = true
  auto_stop_machines = 'stop'
  auto_start_machines = true
  min_machines_running = 0
  processes = ['app']

[[vm]]
  memory = '1gb'
  cpu_kind = 'shared'
  cpus = 1

[env]
  SESSION_PROVIDER = 'redis'
  ENABLE_PUBLISHING = true
  OAUTH_SERVER_URL = 'https://your-linkedin-mcp.fly.dev'
  LINKEDIN_REDIRECT_URI = 'https://your-linkedin-mcp.fly.dev/oauth/callback'

  # Artifact Storage (Tigris S3-compatible)
  ARTIFACT_PROVIDER = 's3'
  ARTIFACT_S3_BUCKET = 'your-linkedin-mcp'
  # AWS_* secrets automatically set by `fly storage create`
```

#### Step 6: Deploy

```bash
# Deploy to Fly.io
fly deploy

# Check deployment status
fly status

# View logs
fly logs

# Test OAuth endpoint
curl https://your-linkedin-mcp.fly.dev/.well-known/oauth-authorization-server
```

#### Step 7: Configure MCP Client

Update your MCP client configuration (e.g., `~/.mcp-cli/servers.yaml`):

```yaml
servers:
  linkedin:
    url: https://your-linkedin-mcp.fly.dev  # No trailing slash!
    oauth: true
```

Test the connection:

```bash
uvx mcp-cli --server linkedin --provider openai --model gpt-5-mini
```

### Redis Configuration

#### Development (Memory)

For local development, use in-memory session storage:

```bash
# .env file
SESSION_PROVIDER=memory
```

No Redis installation required. Sessions are lost when the server restarts.

#### Production (Redis)

For production, use Redis for persistent session storage:

**Option 1: Fly.io Redis (Upstash)**

```bash
# Create Redis instance
fly redis create

# Get connection details
fly redis status your-redis-instance

# Set as secret
fly secrets set SESSION_REDIS_URL="redis://default:PASSWORD@fly-INSTANCE.upstash.io:6379"
```

**Option 2: External Redis (Upstash, AWS ElastiCache, etc.)**

```bash
# Set Redis URL
export SESSION_REDIS_URL="redis://username:password@host:port/db"
export SESSION_PROVIDER=redis
```

**Environment Variables:**

```env
# Session Provider
SESSION_PROVIDER=redis                    # Required: redis | memory

# Redis Connection (required if SESSION_PROVIDER=redis)
SESSION_REDIS_URL=redis://default:password@host:6379

# Optional Redis settings
REDIS_TLS_INSECURE=0  # Set to 1 to disable TLS cert verification (not recommended)
```

### Custom Domain Setup

Configure a custom domain for your deployment:

#### Step 1: Add Domain to Fly.io

```bash
# Add custom domain
fly certs create linkedin.yourdomain.com

# Verify DNS settings
fly certs show linkedin.yourdomain.com
```

#### Step 2: Update DNS

Add DNS records (check output from previous command):

```
Type: CNAME
Name: linkedin.yourdomain.com
Value: your-linkedin-mcp.fly.dev
```

#### Step 3: Update OAuth URLs

```bash
# Update secrets with custom domain
fly secrets set \
  OAUTH_SERVER_URL=https://linkedin.yourdomain.com \
  LINKEDIN_REDIRECT_URI=https://linkedin.yourdomain.com/oauth/callback
```

#### Step 4: Update LinkedIn App

1. Go to [LinkedIn Developers](https://www.linkedin.com/developers/apps)
2. Select your app
3. Update "Redirect URLs" to match: `https://linkedin.yourdomain.com/oauth/callback`

### Environment Variables Reference

Complete list of production environment variables:

```env
# ============================================================================
# OAuth Configuration (Required for Production)
# ============================================================================

# LinkedIn OAuth Credentials
LINKEDIN_CLIENT_ID=your_linkedin_client_id
LINKEDIN_CLIENT_SECRET=your_linkedin_client_secret

# OAuth Server URLs (must match LinkedIn app settings)
# IMPORTANT: This URL is also used for preview URLs (linkedin_preview_url tool)
OAUTH_SERVER_URL=https://your-app.fly.dev
LINKEDIN_REDIRECT_URI=https://your-app.fly.dev/oauth/callback
OAUTH_ENABLED=true

# ============================================================================
# Session Storage (Required for Production)
# ============================================================================

# Production: Use Redis
SESSION_PROVIDER=redis
SESSION_REDIS_URL=redis://default:password@fly-instance.upstash.io:6379

# Development: Use Memory
# SESSION_PROVIDER=memory

# ============================================================================
# OAuth Token TTL Configuration (Optional - Defaults Shown)
# ============================================================================

OAUTH_AUTH_CODE_TTL=300                   # Authorization codes (5 min)
OAUTH_ACCESS_TOKEN_TTL=900                # Access tokens (15 min)
OAUTH_REFRESH_TOKEN_TTL=86400             # Refresh tokens (1 day)
OAUTH_CLIENT_REGISTRATION_TTL=31536000    # Client registrations (1 year)
OAUTH_EXTERNAL_TOKEN_TTL=86400            # LinkedIn tokens (1 day)

# ============================================================================
# Server Configuration
# ============================================================================

DEBUG=0                                   # Disable debug mode in production
HTTP_PORT=8000                            # Server port
ENABLE_PUBLISHING=true                    # Enable publishing tools

# LinkedIn Person URN (optional - auto-detected via OAuth)
LINKEDIN_PERSON_URN=urn:li:person:YOUR_ID
```

### Logging Configuration

Control logging levels in production:

```env
# Production logging
LOG_LEVEL=INFO          # INFO for production, DEBUG for troubleshooting
MCP_LOG_LEVEL=WARNING   # MCP protocol logging

# Development logging
LOG_LEVEL=DEBUG
MCP_LOG_LEVEL=INFO
```

**Security Note**: At INFO level, sensitive data (tokens, user IDs, authorization codes) is NOT logged. This data is only logged at DEBUG level for troubleshooting.

### Monitoring & Troubleshooting

```bash
# View live logs
fly logs --app your-linkedin-mcp

# Check app status
fly status --app your-linkedin-mcp

# Check Redis status
fly redis status your-redis-instance

# Restart app
fly apps restart your-linkedin-mcp

# Scale app
fly scale count 2 --app your-linkedin-mcp  # 2 instances
fly scale memory 2048 --app your-linkedin-mcp  # 2GB memory
```

### Health Checks

The server includes health check endpoints:

```bash
# Check server health
curl https://your-app.fly.dev/

# Check OAuth discovery
curl https://your-app.fly.dev/.well-known/oauth-authorization-server

# Check MCP endpoint
curl https://your-app.fly.dev/mcp
```

### Security Best Practices

1. **Never commit secrets** - Use Fly secrets, not environment variables in fly.toml
2. **Use HTTPS only** - Set `force_https = true` in fly.toml
3. **Rotate tokens regularly** - LinkedIn tokens are auto-refreshed
4. **Monitor logs** - Check for failed auth attempts
5. **Use custom domain** - Professional appearance, easier to update
6. **Enable auto-scaling** - Handle traffic spikes automatically
7. **Keep dependencies updated** - Regular security updates

### Cost Optimization

Fly.io pricing optimization tips:

```toml
# In fly.toml - auto-stop when idle
[http_service]
  auto_stop_machines = 'stop'        # Stop when idle
  auto_start_machines = true         # Start on request
  min_machines_running = 0           # No always-on instances
```

**Expected costs**:
- Free tier: 3 shared-cpu VMs with 256MB RAM
- Redis: ~$2/month for basic Upstash instance
- Scaling: ~$0.02/hour per VM after free tier

## Documentation

- **[Getting Started](docs/GETTING_STARTED.md)** - Complete beginner's guide
- **[OAuth Guide](docs/OAUTH.md)** - OAuth 2.1 setup and configuration
- **[API Reference](docs/API.md)** - Full API documentation
- **[Themes Guide](docs/THEMES.md)** - All themes and customization
- **[Design Tokens](docs/TOKENS.md)** - Token system reference
- **[Docker Guide](docs/DOCKER.md)** - Docker deployment
- **[CI/CD Guide](docs/CI_CD.md)** - Continuous integration
- **[Development Guide](docs/DEVELOPMENT.md)** - Contributing and development
- **[Architecture](docs/ARCHITECTURE.md)** - System architecture

## Examples

### Hello World: Compose → Draft → Preview URL

The fastest way to see the complete workflow (`examples/hello_preview.py`):

```python
import asyncio
from chuk_mcp_linkedin.posts import ComposablePost
from chuk_mcp_linkedin.themes import ThemeManager
from chuk_mcp_linkedin.manager_factory import ManagerFactory, set_factory

async def main():
    # Initialize factory with memory-based artifacts
    factory = ManagerFactory(use_artifacts=True, artifact_provider="memory")
    set_factory(factory)
    mgr = factory.get_manager("demo_user")

    # Step 1: Compose a post
    theme = ThemeManager().get_theme("thought_leader")
    post = ComposablePost("text", theme=theme)
    post.add_hook("question", "What's the most underrated growth lever on LinkedIn in 2025?")
    post.add_body("Hint: documents. Short, skimmable, 5–10 pages. Try it this week.", structure="linear")
    post.add_cta("curiosity", "Tried docs vs text lately?")
    post.add_hashtags(["LinkedInTips", "B2B", "ContentStrategy"])
    text = post.compose()

    # Step 2: Create a draft
    draft = mgr.create_draft("Hello Preview Demo", "text")
    mgr.update_draft(draft.draft_id, content={"text": text})

    # Step 3: Generate preview URL
    preview_url = await mgr.generate_preview_url(
        draft_id=draft.draft_id,
        base_url="http://localhost:8000",
        expires_in=3600
    )
    print(f"Preview URL: {preview_url}")

if __name__ == "__main__":
    asyncio.run(main())
```

**Run it:**

```bash
# Run the example
uv run python examples/hello_preview.py

# Start HTTP server to view preview (separate terminal)
OAUTH_ENABLED=false uv run linkedin-mcp http --port 8000

# Open the preview URL in your browser
```

**Output:**
```
🚀 LinkedIn MCP Server - Hello Preview Demo

📝 Step 1: Composing post...
✓ Post composed (193 chars)

📋 Step 2: Creating draft...
✓ Draft created (ID: draft_2_1762129805)

🔗 Step 3: Generating preview URL...
✓ Preview URL generated

Preview URL: http://localhost:8000/preview/04a0c703...
```

### More Examples

Comprehensive examples in the `examples/` directory:

```bash
# OAuth flow demonstration (authentication)
python examples/oauth_linkedin_example.py

# Complete component showcase
python examples/showcase_all_components.py

# Charts and data visualization
python examples/demo_charts_preview.py

# Media types showcase
python examples/showcase_media_types.py
```

See [examples/README.md](examples/README.md) for complete list and OAuth setup instructions.

## Development

### Setup

```bash
# Clone repository
git clone https://github.com/chrishayuk/chuk-mcp-linkedin.git
cd chuk-mcp-linkedin

# Install dependencies
make install
make dev

# Install pre-commit hooks
make hooks-install
```

### Run Tests

```bash
# Run all tests
make test

# Run with coverage
make coverage

# Run specific test
uv run pytest tests/test_composition.py -v
```

### Code Quality

```bash
# Format code
make format

# Run linter
make lint

# Type c