This document provides detailed technical information about the Medium MCP Server's architecture, code quality improvements, and internal implementation details.
- Code Quality & Refactoring History
- Type Safety Improvements
- Data Flow
- Dependency Management
- Session Management Implementation
Large, monolithic functions were broken down into focused helper methods following the Single Responsibility Principle:
Extracted Methods:
checkLoginRedirect(): Fast session validation via redirect detectiondetectLoginIndicators(): DOM-based login state detection using multiple selectorswaitForUserLogin(): Manual login flow handler with timeout management
Benefits: Improved testability, easier debugging, clearer separation of concerns
Location: browser-client.ts:172-321
Extracted Methods:
parseArticleTabs(): Tab navigation parsing with article count detectionmapTabToStatus(): Maps tab names to article status enum valuesextractArticlesFromTable(): Article metadata extraction from table rows
Benefits: Reduced complexity, reusable table extraction logic, clearer main method flow
Location: browser-client.ts:372-566
Timeout Constants: All magic numbers extracted to TIMEOUTS constant (lines 91-99)
LOGIN: 300,000ms (5 minutes)PAGE_LOAD: 60,000ms (60 seconds)SHORT_WAIT: 2,000ms (2 seconds)CONTENT_WAIT: 3,000ms (3 seconds)EDITOR_LOAD: 15,000ms (15 seconds)NETWORK_IDLE: 10,000ms (10 seconds)
Viewport Constants: VIEWPORT.WIDTH (1280) and VIEWPORT.HEIGHT (720) at line 100
Benefits: Easy to customize, self-documenting, consistent across codebase
Old: catch (error: any) { ... error.message }
New: catch (error) { const message = error instanceof Error ? error.message : String(error) }
Benefits: Safer error handling, works with both Error objects and other thrown values
Locations: browser-client.ts (2), index.ts (8)
Old: const contextOptions: any = {...}
New: const contextOptions: BrowserContextOptions = {...}
Benefits: IDE autocomplete, compile-time validation of browser options
Location: browser-client.ts:96
Old: const articles: any[] = []
New: Inline type definitions matching return interfaces
Examples:
extractArticlesFromTable: Typed array with status union typesearchMediumArticles: Typed search result structureextractArticleCards: Feed article structure with feedCategorygetLists: Reading list structure
Benefits: Type safety in browser-side code, catches property mismatches
Locations: Lines 455, 893, 1271, 1452, 1581
New Interface: StorageState (lines 64-79) based on Playwright's format
Typed Methods: validateStorageState() and getEarliestCookieExpiry()
Benefits: Structured cookie/session validation, prevents invalid state objects
Locations: browser-client.ts:1128, 1168
- Before: 25
anytype usages - After: ~5
anyuses (only in legitimate cases: log functions, test helpers) - Coverage: ~80% of previous
anyuses eliminated - Impact: Stronger compile-time safety, better IDE support, easier refactoring
MCP Client (Claude Desktop)
↓ stdio transport
MediumMcpServer (index.ts)
↓ tool invocation
BrowserMediumClient (browser-client.ts)
↓ Playwright automation
Chromium Browser → Medium Website
↓ DOM manipulation
Response → JSON → MCP Client
- MCP Server initializes and registers 8 tools with Zod schemas
- Claude Desktop sends tool invocation via stdio
- Server validates parameters with Zod
- BrowserMediumClient method called
- Browser automation performs action
- Result serialized to JSON and returned
All tools follow consistent error handling:
try {
const result = await client.someMethod();
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
isError: true
};
}Current Version: @modelcontextprotocol/sdk@1.21.2 (locked with tilde ~)
Why Locked: v1.22.0+ introduces breaking TypeScript type changes that cause infinite type recursion:
error TS2589: Type instantiation is excessively deep and possibly infinite
Timeline:
- v1.21.2 (Nov 2024) - ✅ CURRENT - Last stable version
- v1.22.0 (Nov 13, 2024) - ❌ BREAKS - New
registerToolsignature - v1.23.0 (Nov 25, 2024) - ❌ Zod v4 support changes
- v1.24.0 (Dec 2, 2024) - ❌ Typed
ToolCallbackchanges - v1.25.0+ (Dec 15, 2024) - ❌ Role type moved, dependencies removed
Root Cause: New deeply nested generic types for tool registration cause TypeScript's type checker to enter infinite recursion with complex Zod schemas (especially .optional(), .default(), .describe() chaining).
Upgrade Path: To use v1.22.0+, refactor tool registration to use explicit type annotations or simpler schema definitions.
Added for bot detection bypass: playwright-extra + puppeteer-extra-plugin-stealth
Why Needed: Medium uses Cloudflare which blocks standard headless browsers.
What They Do:
- Remove
navigator.webdriverproperty - Spoof Chrome DevTools Protocol detection
- Mask WebGL/Canvas fingerprinting
- Fix timezone/locale inconsistencies
- Pass most bot detection tests
preValidateSession()
→ Check if session file exists
→ Parse JSON
→ validateStorageState()
→ Check each auth cookie (sid, uid, session)
→ Compare cookie.expires vs Date.now()
→ Return false if ANY auth cookie expired
→ Return validation result-
Initial Login (non-headless):
- Navigate to
/m/signin - Check if already logged in (redirect to homepage)
- If not logged in, wait for user to complete login (5 min timeout)
- Save cookies + localStorage to
medium-session.json
- Navigate to
-
Subsequent Operations (headless):
- Load session from file
- Validate cookies haven't expired
- Create browser context with session
- Verify session via fast redirect check
- Auto-save session after operation completes (v1.4+)
-
Session Expiry:
- Detected by
preValidateSession()before browser launch - OR detected by
validateSessionFast()after browser launch - Triggers re-authentication flow
- Detected by
Implementation: All browser operations automatically call saveSession() after successful completion.
Operations that auto-save session:
getUserArticles()- browser-client.ts:600getArticleContent()- browser-client.ts:793publishArticle()- browser-client.ts:860, 873searchMediumArticles()- browser-client.ts:1138getFeed()- browser-client.ts:1600getLists()- browser-client.ts:1754getListArticles()- browser-client.ts:1825
Why: Medium may update cookies during normal operations (CSRF tokens, session refreshes). Capturing these updates ensures:
- Sessions stay valid during long-running operations
- Test suites can run for extended periods without re-authentication
- MCP server instances maintain authentication across multiple requests
Performance: Minimal overhead (~100ms per operation for JSON write to disk)
See: docs/adr/ADR_20260101_01_session_persistence_after_operations.md for detailed rationale
Old Method (21s): Navigate to homepage, wait for DOM, check for login indicators
New Method (5s): Navigate to /m/signin, check for redirect
await page.goto('https://medium.com/m/signin');
const currentUrl = page.url();
if (!currentUrl.includes('/m/signin')) {
// Redirected away from login page = logged in
return true;
}
// Still on login page = not logged in
return false;Benefits:
- 4x faster validation
- More reliable (uses HTTP redirects vs DOM selectors)
- Works for both fresh sessions and expired re-logins
Smart Login Check: Always navigate to /m/signin first
- If already logged in → Medium auto-redirects to homepage ✅
- If not logged in → Stays on
/m/signin(ready for login) ✅
Benefits:
- No delay checking selectors on homepage (was 10-20 seconds)
- Single navigation instead of homepage → check → login page
- Uses Medium's built-in redirect behavior
The browser is NOT persistent - it opens fresh for each operation:
- Tool invoked
client.initialize()called- Browser launches (headless if valid session exists)
- Operation executes
client.close()called- Browser terminates
Why This Design:
- ✅ Saves system resources
- ✅ Clean state for each operation
- ✅ No leaked browser processes
- ❌ Adds 5-10s startup overhead per operation
const headlessMode = forceHeadless !== undefined
? forceHeadless
: this.isAuthenticatedSession;forceHeadless=true: Force headless (for testing)forceHeadless=false: Force visible (forlogin-to-mediumtool)undefined: Auto-determine (headless if valid session exists)
All selectors use arrays with multiple fallback options:
const selectors = [
'[data-testid="headerUserIcon"]', // Most stable
'[data-testid="headerWriteButton"]', // Backup
'button[aria-label*="user"]' // Generic fallback
];
for (const selector of selectors) {
const element = await page.$(selector);
if (element) return element;
}Login Indicators:
[data-testid="headerUserIcon"][data-testid="headerWriteButton"][data-testid="headerNotificationButton"]button[aria-label*="user"]
Article List:
table tbody trcontainingh2- Links:
a[href*="/p/"][href*="/edit"](drafts) ora[href*="/@"](published)
Editor:
- Title:
[data-testid="editorTitleParagraph"] - Content:
[data-testid="editorParagraphText"]
Reading Lists:
[data-testid="readingList"](primary)a[href*="/list/"](fallback)
Feed Articles:
article[data-testid="story-preview"]- Title from
h1/h2/h3 - URL from title link (not first link)
When Medium changes UI:
- Run appropriate debug script (e.g.,
debug-login.ts) - Identify new selectors (prefer
data-testid) - Add to fallback array (don't replace existing)
- Update this documentation
- Re-capture fixtures
- Run tests
See main AGENTS.md for full debugging workflow.