#!/usr/bin/env node
/**
* Claude Viewer - MCP Server
* Exposes Claude Code conversation data via Model Context Protocol
*
* Tools:
* - get_claude_users: List users with Claude history
* - get_conversations: Get conversations with filters
* - get_stats: Get aggregated statistics
* - get_conversation_details: Get full transcript of a session
* - open_dashboard: Start web server and open browser to dashboard
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { spawn, exec } from 'child_process';
import { platform } from 'os';
import { fileURLToPath } from 'url';
import path from 'path';
// ES module __dirname equivalent
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Default port (configurable via CLAUDE_VIEWER_PORT env)
const DEFAULT_PORT = 2204;
// Import data access functions from shared library
import {
getClaudeUsers,
getAllConversations,
calculateStats,
getConversationDetails
} from './lib/data-access.js';
// Create MCP server instance
const server = new McpServer({
name: "claude-viewer",
version: "0.1.0"
});
/**
* Tool 1: get_claude_users
* Returns list of users with Claude Code history files
*/
server.tool(
"get_claude_users",
"Get list of users with Claude Code conversation history on this machine",
{},
async () => {
try {
const users = getClaudeUsers();
const result = {
success: true,
count: users.length,
users: users.map(u => ({
username: u.username,
fileSize: u.fileSize,
lastModified: u.lastModified
}))
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (err) {
return {
content: [{
type: "text",
text: JSON.stringify({ success: false, error: err.message })
}],
isError: true
};
}
}
);
/**
* Tool 2: get_conversations
* Returns conversations with optional filters
*/
server.tool(
"get_conversations",
"Get Claude Code conversations with optional filters (username, search text, project, date range)",
{
username: z.string().optional().describe("Filter by username"),
search: z.string().optional().describe("Search in conversation text (full-text search)"),
project: z.string().optional().describe("Filter by project path (partial match)"),
dateFrom: z.string().optional().describe("Filter from date (ISO format: YYYY-MM-DD)"),
dateTo: z.string().optional().describe("Filter to date (ISO format: YYYY-MM-DD)"),
limit: z.number().optional().default(50).describe("Maximum conversations to return (default 50)"),
offset: z.number().optional().default(0).describe("Pagination offset (default 0)")
},
async ({ username, search, project, dateFrom, dateTo, limit = 50, offset = 0 }) => {
try {
let conversations = getAllConversations();
// Apply filters
if (username) {
conversations = conversations.filter(c => c.username === username);
}
if (project) {
const projectLower = project.toLowerCase();
conversations = conversations.filter(c =>
c.project && c.project.toLowerCase().includes(projectLower)
);
}
if (search) {
const searchLower = search.toLowerCase();
conversations = conversations.filter(c => {
// Search in display text
if (c.display && c.display.toLowerCase().includes(searchLower)) {
return true;
}
// Search in full-text index
if (c.searchableText && c.searchableText.includes(searchLower)) {
return true;
}
return false;
});
}
if (dateFrom) {
const fromTimestamp = new Date(dateFrom).getTime();
conversations = conversations.filter(c => c.timestamp >= fromTimestamp);
}
if (dateTo) {
// Add one day to include the end date
const toTimestamp = new Date(dateTo).getTime() + 86400000;
conversations = conversations.filter(c => c.timestamp < toTimestamp);
}
// Pagination
const total = conversations.length;
const paginated = conversations.slice(offset, offset + limit);
const result = {
success: true,
total: total,
returned: paginated.length,
offset: offset,
limit: limit,
conversations: paginated.map(c => ({
username: c.username,
display: c.display ? c.display.substring(0, 200) : '',
timestamp: c.timestamp,
date: new Date(c.timestamp).toISOString(),
project: c.project,
sessionId: c.sessionId,
messageCount: c.messageCount || 0,
totalTokens: c.totalTokens || 0,
model: c.model || '',
toolsUsed: c.toolsUsed || [],
hasDetails: c.hasDetails || false
}))
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (err) {
return {
content: [{
type: "text",
text: JSON.stringify({ success: false, error: err.message })
}],
isError: true
};
}
}
);
/**
* Tool 3: get_stats
* Returns aggregated statistics across all conversations
*/
server.tool(
"get_stats",
"Get aggregated statistics for Claude Code usage (tokens, models, tools, daily stats)",
{},
async () => {
try {
const stats = calculateStats();
const result = {
success: true,
stats: {
totalConversations: stats.totalConversations,
totalUsers: stats.totalUsers,
totalTokens: stats.totalTokens,
inputTokens: stats.inputTokens,
outputTokens: stats.outputTokens,
cacheTokens: stats.cacheTokens,
totalMessages: stats.totalMessages,
userStats: stats.userStats,
projectStats: stats.projectStats,
tokensByUser: stats.tokensByUser,
dailyStats: stats.dailyStats,
dailyTokenStats: stats.dailyTokenStats,
modelStats: stats.modelStats,
toolStats: stats.toolStats
}
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (err) {
return {
content: [{
type: "text",
text: JSON.stringify({ success: false, error: err.message })
}],
isError: true
};
}
}
);
/**
* Tool 4: get_conversation_details
* Returns full transcript of a specific conversation
*/
server.tool(
"get_conversation_details",
"Get full transcript and details of a specific conversation session",
{
sessionId: z.string().describe("Session UUID (from get_conversations)"),
username: z.string().describe("Username who owns the session")
},
async ({ sessionId, username }) => {
try {
const details = getConversationDetails(sessionId, username);
if (!details) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: "Session not found"
})
}],
isError: true
};
}
const result = {
success: true,
sessionId: details.sessionId,
username: details.username,
projectDir: details.projectDir,
messageCount: details.messageCount,
totalTokens: details.totalTokens,
inputTokens: details.inputTokens,
outputTokens: details.outputTokens,
cacheTokens: details.cacheTokens,
threadLength: details.thread.length,
// Summarize thread instead of full content to avoid token limits
thread: details.thread.map(item => {
if (item.type === 'user') {
let content = item.content;
if (typeof content === 'string') {
content = content.substring(0, 500);
} else if (Array.isArray(content)) {
const textBlock = content.find(c => c.type === 'text');
content = textBlock ? textBlock.text.substring(0, 500) : '[multimodal content]';
}
return {
type: 'user',
timestamp: item.timestamp,
content: content
};
} else if (item.type === 'assistant') {
let text = '';
if (Array.isArray(item.content)) {
const textBlock = item.content.find(c => c.type === 'text');
text = textBlock ? textBlock.text.substring(0, 500) : '';
}
return {
type: 'assistant',
timestamp: item.timestamp,
model: item.model,
content: text
};
} else if (item.type === 'tool_use') {
return {
type: 'tool_use',
toolName: item.toolName,
timestamp: item.timestamp
};
} else if (item.type === 'tool_result') {
return {
type: 'tool_result',
toolName: item.toolName,
isError: item.isError,
timestamp: item.timestamp
};
}
return item;
}),
subagentsCount: details.subagents.length,
subagents: details.subagents.map(a => ({
agentId: a.agentId,
sessionId: a.sessionId,
messageCount: a.messageCount,
totalTokens: a.totalTokens,
model: a.model,
firstPrompt: a.firstPrompt ? a.firstPrompt.substring(0, 200) : ''
}))
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (err) {
return {
content: [{
type: "text",
text: JSON.stringify({ success: false, error: err.message })
}],
isError: true
};
}
}
);
/**
* Tool 5: open_dashboard
* Starts the Express server and opens browser to the dashboard
*/
server.tool(
"open_dashboard",
"Start the Claude Viewer web server and open the dashboard in your default browser",
{
port: z.number().optional().describe(`Port to run server on (default: ${DEFAULT_PORT})`)
},
async ({ port }) => {
const serverPort = port || process.env.CLAUDE_VIEWER_PORT || DEFAULT_PORT;
const url = `http://localhost:${serverPort}`;
try {
// Check if server is already running
const isRunning = await new Promise((resolve) => {
const checkReq = exec(`curl -s -o /dev/null -w "%{http_code}" ${url}/api/users`, (error, stdout) => {
resolve(stdout === '200');
});
setTimeout(() => {
checkReq.kill();
resolve(false);
}, 2000);
});
if (!isRunning) {
// Start the server in background
const serverPath = path.join(__dirname, 'server.js');
const serverProcess = spawn('node', [serverPath], {
detached: true,
stdio: 'ignore',
env: { ...process.env, CLAUDE_VIEWER_PORT: String(serverPort) }
});
serverProcess.unref();
// Wait for server to start
await new Promise(resolve => setTimeout(resolve, 2000));
}
// Open browser (cross-platform)
const openCommand = platform() === 'darwin' ? 'open' :
platform() === 'win32' ? 'start' : 'xdg-open';
exec(`${openCommand} ${url}`);
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: isRunning ?
`Dashboard already running. Opened browser to ${url}` :
`Started server and opened browser to ${url}`,
url: url,
port: serverPort
}, null, 2)
}]
};
} catch (err) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: err.message,
hint: `You can manually start with: npm start (in claude-viewer directory)`
})
}],
isError: true
};
}
}
);
// Connect to stdio transport and start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Claude Viewer MCP server running on stdio");
}
main().catch(err => {
console.error("Fatal error:", err);
process.exit(1);
});