diff --git a/.env.example b/.env.example
index a94ee34..5dc873c 100644
--- a/.env.example
+++ b/.env.example
@@ -4,10 +4,7 @@
PIRSCH_CLIENT_ID=
PIRSCH_CLIENT_SECRET=
-# Optional defaults
+# Optional defaults. Without PIRSCH_DEFAULT_DOMAIN_ID, every query requires domainId.
# PIRSCH_DEFAULT_DOMAIN_ID=
# PIRSCH_TIMEZONE=Europe/Berlin
-# Optional cache TTLs in ms
-# PIRSCH_TOKEN_SKEW_MS=60000 # Refresh if expiring in < 60s
-
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..7c2d3a5
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,17 @@
+# MCP Pirsch contributor guide
+
+## Commands
+
+Run `npm run typecheck`, `npm run lint`, `npm test`, and `npm run build` before handing off changes. Use `npx -y @modelcontextprotocol/inspector@latest --cli node dist/index.js --method tools/list --format json` for an MCP surface check.
+
+## Design constraints
+
+- Keep the server stdio-only and read-only.
+- Keep exactly four public tools unless the maintainers approve an API expansion.
+- Tool handlers must declare Zod input/output schemas, read-only annotations, structured content, matching JSON text, and `isError: true` for expected failures.
+- Do not validate credentials at process start, select a domain automatically, return raw domain/account metadata, log secrets, or include raw upstream error bodies.
+- Use `PIRSCH_DEFAULT_DOMAIN_ID` only as an explicit configured default; otherwise require `domainId`.
+
+## Delivery
+
+Do not publish packages or registry entries from local work. The release workflow publishes a release tag to npm first, then the MCP Registry through GitHub OIDC.
diff --git a/CLAUDE.md b/CLAUDE.md
index 76d9571..257f35c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,102 +1,3 @@
-# CLAUDE.md
+# Compatibility note
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Project Overview
-
-MCP Pirsch Server - A Model Context Protocol server that provides analytics tools for Pirsch Analytics. It enables natural language queries, comparisons, and trend analysis of website traffic through an MCP interface.
-
-## Development Commands
-
-```bash
-# Install dependencies
-npm install
-
-# Build TypeScript to JavaScript (dist/)
-npm run build
-
-# Development mode with auto-reload
-npm run dev
-
-# Start production server
-npm start
-
-# Quick test (runs help command)
-npm test
-```
-
-## Architecture
-
-### Core Components
-
-- **src/index.ts**: MCP server implementation that registers tools and handles requests
-- **src/pirsch-api.ts**: Pirsch API client with token caching and auto-refresh
-- **src/filters.ts**: Builds URL parameters from filter objects for API queries
-- **src/types.ts**: TypeScript interfaces for Pirsch data structures
-- **src/utils.ts**: Date range helpers and data aggregation utilities
-
-### Token Management
-
-The PirschAPI class implements intelligent token caching:
-- Tokens are cached with expiration tracking
-- Auto-refreshes 60 seconds before expiry (configurable via PIRSCH_TOKEN_SKEW_MS)
-- Handles 401 errors with automatic retry after refresh
-- Rate limiting with exponential backoff for 429 responses
-
-### MCP Tools Pattern
-
-Each tool follows this structure:
-1. Resolve domain ID (from args, env, or auto-detect)
-2. Build filter parameters using buildFilterParams()
-3. Call appropriate PirschAPI method
-4. Return formatted response
-
-## Environment Configuration
-
-Required environment variables:
-- `PIRSCH_CLIENT_ID`: OAuth client ID from Pirsch
-- `PIRSCH_CLIENT_SECRET`: OAuth client secret from Pirsch
-
-Optional:
-- `PIRSCH_DEFAULT_DOMAIN_ID`: Default domain to query (auto-detects if not set)
-- `PIRSCH_TIMEZONE`: Default timezone for queries (e.g., 'Europe/Berlin')
-- `PIRSCH_TOKEN_SKEW_MS`: Token refresh buffer in ms (default: 60000)
-
-## Testing the MCP Server
-
-### Local Testing
-```bash
-# Test with environment variables
-PIRSCH_CLIENT_ID=xxx PIRSCH_CLIENT_SECRET=yyy npm run dev
-
-# The server expects stdio transport, so testing requires an MCP client
-```
-
-### Integration Testing
-1. Build the project: `npm run build`
-2. Configure in `.mcp.json` or Claude Desktop config
-3. Restart the MCP client to load the server
-4. Test tools like `pirsch_list_domains` to verify connection
-
-## Key Implementation Details
-
-### Filter System
-All statistics endpoints accept a FilterInput object that maps directly to Pirsch API query parameters. The buildFilterParams() function handles:
-- Date/time ranges with timezone support
-- Dimensions (path, referrer, browser, OS, etc.)
-- UTM parameters
-- Pagination and sorting
-- Custom metrics and tags
-
-### Comparison Logic
-The `pirsch_compare` tool implements period comparison by:
-1. Fetching two visitor series (current and comparison period)
-2. Computing totals using sumSeries()
-3. Calculating percentage changes with pctChange()
-4. Returning both series and delta metrics
-
-### Error Handling
-- Network errors trigger retries with backoff
-- 401 errors trigger token refresh
-- 429 rate limits respect Retry-After headers
-- Domain resolution fails gracefully with helpful messages
\ No newline at end of file
+Repository instructions are maintained in [AGENTS.md](AGENTS.md). Keep this file as a pointer for Claude Code users.
diff --git a/README.md b/README.md
index 12dee48..8177dfa 100644
--- a/README.md
+++ b/README.md
@@ -1,514 +1,112 @@
-# MCP Pirsch Server
+# Pirsch MCP Server
-[](https://www.npmjs.com/package/@verygoodplugins/mcp-pirsch)
-[](LICENSE)
+[](https://www.npmjs.com/package/@verygoodplugins/mcp-pirsch)
-A Model Context Protocol (MCP) server for Pirsch Analytics, enabling natural language analytics queries, period comparisons, and trend analysis for your website traffic.
+A focused, read-only [Model Context Protocol](https://modelcontextprotocol.io) server for [Pirsch Analytics API v1](https://docs.pirsch.io/api-sdks/api-v1). It uses MCP SDK v2 and returns both structured results and JSON text for every successful tool call.
-## Features
+## Requirements
-- ๐ **Smart Authentication** - OAuth client credentials with automatic token caching and refresh
-- ๐ **Core Analytics** - Comprehensive stats including visitors, page views, bounce rates, and conversion rates
-- ๐ **Time Series Data** - Flexible visitor trends with day/week/month/year granularity
-- ๐ **Period Comparisons** - Compare metrics across different time periods with calculated deltas
-- ๐ฏ **Goals & Events** - Read conversion goals, event activity, and event-specific page performance
-- ๐งญ **Session Drilldown** - Inspect entry pages, exit pages, session lists, and per-session timelines
-- โก **Real-time Insights** - Active visitor tracking with configurable time windows
-- ๐ฏ **Advanced Filtering** - Full support for Pirsch query parameters including UTM, referrers, and dimensions
-- ๐ **Multi-domain Support** - Manage analytics across multiple websites from one interface
+- Node.js **22.19.0 or later**
+- A Pirsch OAuth API client with read access. Do not use a write-only access key.
-## Quick Start
-
-### Installation Methods
-
-#### Option 1: Using NPX (No Installation Required)
-
-The simplest way - no need to install anything globally:
-
-```bash
-# For Claude Desktop
-npx @verygoodplugins/mcp-pirsch
-
-# For Claude Code
-claude mcp add pirsch "npx @verygoodplugins/mcp-pirsch"
-```
-
-#### Option 2: Global Installation
-
-Install once, use anywhere:
-
-```bash
-# Install globally
-npm install -g @verygoodplugins/mcp-pirsch
-
-# For Claude Code
-claude mcp add pirsch "mcp-pirsch"
-```
-
-#### Option 3: Local Development
-
-For contributing or customization:
-
-```bash
-# Clone and install
-git clone https://github.com/verygoodplugins/mcp-pirsch.git
-cd mcp-pirsch
-npm install
-npm run build
-```
-
-## Configuration
-
-### 1. Get Pirsch API Credentials
-
-1. Log into your [Pirsch Analytics Dashboard](https://pirsch.io)
-2. Navigate to Settings โ API Clients
-3. Create a new client with appropriate permissions
-4. Copy your Client ID and Client Secret
-
-### 2. Configure Your Client
-
-
-Claude Desktop Configuration
-
-**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
-**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
+## Configure
```json
{
"mcpServers": {
"pirsch": {
"command": "npx",
- "args": ["@verygoodplugins/mcp-pirsch"],
+ "args": ["-y", "@verygoodplugins/mcp-pirsch@latest"],
"env": {
- "PIRSCH_CLIENT_ID": "your_client_id",
- "PIRSCH_CLIENT_SECRET": "your_client_secret",
- "PIRSCH_DEFAULT_DOMAIN_ID": "your_domain_id",
- "PIRSCH_TIMEZONE": "America/New_York"
+ "PIRSCH_CLIENT_ID": "your-read-only-oauth-client-id",
+ "PIRSCH_CLIENT_SECRET": "your-oauth-client-secret",
+ "PIRSCH_DEFAULT_DOMAIN_ID": "optional-default-domain-id"
}
}
}
}
```
-**Or** if installed globally:
-```json
-{
- "mcpServers": {
- "pirsch": {
- "command": "mcp-pirsch",
- "env": {
- "PIRSCH_CLIENT_ID": "your_client_id",
- "PIRSCH_CLIENT_SECRET": "your_client_secret"
- }
- }
- }
-}
-```
+`PIRSCH_DEFAULT_DOMAIN_ID` is optional. When it is unset, every query tool requires `domainId`; the server never picks the first accessible domain. Use `pirsch_list_domains` to discover IDs safely.
-
+Optional `PIRSCH_TIMEZONE` supplies the default timezone for requests that do not explicitly include `timezone`.
-
-Claude Code Configuration
+## Tools
-```bash
-claude mcp add pirsch "npx @verygoodplugins/mcp-pirsch" \
- --env PIRSCH_CLIENT_ID=your_client_id \
- --env PIRSCH_CLIENT_SECRET=your_client_secret \
- --env PIRSCH_DEFAULT_DOMAIN_ID=your_domain_id
-```
+| Tool | Purpose |
+| --- | --- |
+| `pirsch_list_domains` | Lists only `id`, hostname, display name, and timezone. |
+| `pirsch_query_statistics` | Reads one documented v1 metric, with dates and filters. |
+| `pirsch_list_filter_options` | Lists allowed values for a documented filter dimension. |
+| `pirsch_compare_periods` | Compares actual totals and visitor series for two periods. |
-
+All tools are read-only. They return `structuredContent` matching their output schema as well as an equivalent JSON text block. Input or API failures use MCP `isError: true` and do not expose credentials or raw upstream bodies.
-
-Cursor IDE Configuration
+### Querying statistics
-Add to `.mcp.json` in your project:
+`pirsch_query_statistics` accepts a `metric`, optional `domainId`, and flat camel-case filters. Most metrics require ISO dates:
```json
{
- "mcpServers": {
- "pirsch": {
- "command": "node",
- "args": ["./node_modules/@verygoodplugins/mcp-pirsch/dist/index.js"],
- "env": {
- "PIRSCH_CLIENT_ID": "your_client_id",
- "PIRSCH_CLIENT_SECRET": "your_client_secret"
- }
- }
- }
+ "metric": "pages",
+ "domainId": "your-domain-id",
+ "from": "2026-08-01",
+ "to": "2026-08-23",
+ "limit": 20,
+ "sort": "visitors",
+ "direction": "desc"
}
```
-
-
-### 3. Environment Variables
-
-Create a `.env` file for local development:
-
-```env
-# Required
-PIRSCH_CLIENT_ID=your_client_id
-PIRSCH_CLIENT_SECRET=your_client_secret
-
-# Optional
-PIRSCH_DEFAULT_DOMAIN_ID=your_domain_id # Auto-detected if not set
-PIRSCH_TIMEZONE=America/New_York # Default: UTC
-PIRSCH_TOKEN_SKEW_MS=60000 # Token refresh buffer (default: 60 seconds)
-```
-
-## Available Tools
-
-### Discovery & Setup
-
-#### `pirsch_list_domains`
-List all accessible domains to discover domain IDs.
-
-**Parameters:**
-- `search` (optional): Filter domains by name
-
-**Example:**
-```
-List all my Pirsch domains
-```
-
-### Core Statistics
-
-#### `pirsch_overview`
-Get cached overview statistics for a domain.
-
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-
-**Returns:** Visitors, page views, and member counts
-
-**Note:** This is the Pirsch cached overview endpoint. Filters do not apply, and it should not be used as a substitute for `pirsch_total` over a custom date range.
-
-#### `pirsch_total`
-Get total metrics for a specific period.
-
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-- `filter` (optional): Filter object with date range, dimensions, etc.
-
-Most analytics tools accept filter fields either inside `filter` or as top-level arguments. Both forms are supported for MCP client compatibility.
-
-**Returns:** Total visitors, views, sessions, bounces, bounce rate, conversion rate, and custom metric aggregates
-
-#### `pirsch_visitors`
-Get visitor time series data.
-
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-- `filter` (optional): Including `scale` (day/week/month/year)
-
-**Example:**
-```
-Show me daily visitor trends for the last month
-```
-
-#### `pirsch_pages`
-Get top pages with performance metrics.
-
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-- `filter` (optional): Including:
- - `sort`: Sort field
- - `direction`: asc/desc
- - `search`: Search in page paths
- - `include_avg_time_on_page`: Include time metrics
- - `include_title`: Include page titles
-
-**Tip:** Exact `path: "/news/"` still matches only that URL. For section queries on page-style tools, path-shaped values such as `search: "/news/"`, `path: "~/news/"`, or `pattern: "/news/*"` are narrowed again inside the MCP so `/documentation/news/...` does not leak into `/news/...` results. `path_prefix` is also available when you want an explicit root-prefix filter.
-Top-level `search`, `path`, and `path_prefix` arguments are also accepted.
-
-#### `pirsch_entry_pages`
-Get entry page analytics.
-
-#### `pirsch_exit_pages`
-Get exit page analytics.
-
-#### `pirsch_referrers`
-Analyze traffic sources and referrers.
-
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-- `filter` (optional): Standard filter parameters
-
-#### `pirsch_goals`
-Get conversion goals together with their observed stats.
-
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-- `filter` (optional): Standard filter parameters
-
-#### `pirsch_events`
-Get aggregated event statistics including counts, visitors, conversion rate, and metadata keys.
-
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-- `filter` (optional): Standard filter parameters
-
-#### `pirsch_event_pages`
-Get pages on which a specific event fired.
-
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-- `filter` (required): Standard filter parameters, including `event`
-
-The event can also be passed as a top-level `event` argument. If your client uses goal payload field names, `event_name` is accepted as an alias and normalized to `event`. The same path-prefix narrowing described for `pirsch_pages` also applies here.
-
-#### `pirsch_utm`
-Analyze UTM campaign parameters.
-
-**Parameters:**
-- `type` (required): source | medium | campaign | content | term
-- `domain_id` (optional): Target domain ID
-- `filter` (optional): Standard filter parameters
-
-**Example:**
-```
-Show me UTM source breakdown for this week
-```
-
-#### `pirsch_growth`
-Calculate growth rates across metrics.
-
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-- `filter` (optional): Date range for growth calculation
-
-### Real-time Analytics
-
-#### `pirsch_active`
-Get currently active visitors and pages.
-
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-- `start` (optional): Seconds to look back (default: 600)
-
-**Example:**
-```
-Show me active visitors in the last 5 minutes
-```
+The server maps public camel-case fields such as `eventMetaKey`, `entryPath`, `operatingSystem`, and `utmCampaign` to Pirsch's documented API-v1 parameter names. `limit` is constrained to 1โ100 and active visitor `start` to 0โ3600 seconds.
-### Session Analytics
+Metrics include totals, visitors, pages and entry/exit pages, session and page duration, goals, events and event metadata, growth, active visitors, time breakdowns, acquisition, browser/device, geographic, UTM, tags, keywords, funnels, sessions, and session details. `session_details` requires both `visitorId` and `sessionId`; event-specific metrics require `event`.
-#### `pirsch_sessions`
-Get session list data including entry/exit pages, duration, geography, device, and traffic source context.
+### Comparing periods
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-- `filter` (optional): Standard filter parameters
+Provide a named `period` (`today`, `yesterday`, `week`, `lastWeek`, `month`, or `lastMonth`) or both explicit date pairs:
-#### `pirsch_session_details`
-Get the chronological page-view and event timeline for a single session.
-
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-- `filter` (required): Must include both `visitor_id` and `session_id`
-
-### Comparative Analytics
-
-#### `pirsch_compare`
-Compare metrics between two time periods using true period totals from Pirsch totals, plus the matching visitor series for charts.
-
-**Parameters:**
-- `domain_id` (optional): Target domain ID
-- `period` (optional): today | yesterday | week | lastWeek | month | lastMonth
-- `compare` (optional): previous | year | custom
-- `from`, `to` (optional): Custom date range (YYYY-MM-DD)
-- `compare_from`, `compare_to` (optional): Custom comparison range
-- `scale` (optional): day | week | month | year
-
-**Example:**
-```
-Compare this week's traffic to last week
-```
-
-## Filter Parameters
-
-Most tools accept a `filter` object that maps to Pirsch query parameters:
-
-```javascript
+```json
{
- // Date/Time
- "from": "2024-01-01", // Start date (YYYY-MM-DD)
- "to": "2024-01-31", // End date (YYYY-MM-DD)
- "from_time": "09:00", // Start time (HH:MM)
- "to_time": "17:00", // End time (HH:MM)
- "tz": "America/New_York", // Timezone
-
- // Dimensions
- "path": "~/news/", // Exact or operator-based path filter (~ contains, ! not, ^ does-not-contain)
- "entry_path": "/landing", // Entry page
- "exit_path": "/checkout", // Exit page
- "pattern": "*.pdf", // URL pattern
-
- // Traffic Sources
- "referrer": "google.com", // Referrer domain
- "referrer_name": "Google", // Referrer name
- "channel": "organic", // Traffic channel
-
- // UTM Parameters
- "utm_source": "newsletter",
- "utm_medium": "email",
- "utm_campaign": "summer-sale",
- "utm_content": "header-cta",
- "utm_term": "analytics",
-
- // Device/Browser
- "os": "Windows",
- "browser": "Chrome",
- "platform": "desktop", // desktop | mobile | unknown
- "screen_class": "xxl",
-
- // Location
- "country": "US",
- "city": "New York",
- "language": "en",
-
- // Pagination/Sorting
- "offset": 0,
- "limit": 100,
- "sort": "visitors",
- "direction": "desc", // asc | desc
- "search": "/news/", // Path-shaped searches are narrowed to root-prefix matches on page-style tools
- "path_prefix": "/news/", // Optional explicit MCP-local prefix matcher for page-style tools
- "keyword": "wordpress crm", // Google Search Console keyword filter
-
- // Advanced
- "event": "signup",
- "event_meta_key": "plan",
- "tag": "premium",
- "visitor_id": "12345...", // Required together with session_id for pirsch_session_details
- "session_id": "67890",
- "custom_metric_key": "revenue",
- "custom_metric_type": "float"
+ "domainId": "your-domain-id",
+ "from": "2026-08-01",
+ "to": "2026-08-07",
+ "compareFrom": "2026-07-25",
+ "compareTo": "2026-07-31",
+ "scale": "day"
}
```
-## Usage Examples
-
-### Basic Analytics Query
-```
-Show me the visitor statistics for last week
-```
-
-### Page Performance Analysis
-```
-What are my top 10 /news/ posts by traffic this month?
-```
-
-### Campaign Tracking
-```
-Analyze UTM campaign performance for the summer sale
-```
-
-### Traffic Sources
-```
-Show me referrer breakdown excluding direct traffic
-```
+The response compares `/statistics/total` and retains the two `/statistics/visitor` series; it does not estimate totals by summing charts.
-### Period Comparison
-```
-Compare this month's metrics to the same period last year
-```
+## 1.0 migration
-### Real-time Monitoring
-```
-How many people are on my site right now?
-```
+Version 1.0 intentionally replaces the former 17-tool interface. There are no default aliases because aliases would keep unsafe domain-selection and ambiguous input behavior alive.
-### Goals and Events
-```
-Show me conversion goals and top event-driven pages for the last 90 days
-```
+| Previous tools | Replacement |
+| --- | --- |
+| `pirsch_overview`, `pirsch_total`, `pirsch_pages`, `pirsch_events`, and other statistic tools | `pirsch_query_statistics` with `metric` |
+| `pirsch_utm` | `pirsch_query_statistics` with one of the `utm_*` metrics |
+| `pirsch_compare` | `pirsch_compare_periods` |
+| Domain discovery | `pirsch_list_domains` |
-### Session Investigation
-```
-List recent sessions that entered on /news/ and inspect one session in detail
-```
+Input names are now camel-case and flat (`domainId`, `compareFrom`, `eventMetaKey`), not `domain_id`, nested `filter`, or compatibility aliases.
## Development
-### Building from Source
-
```bash
npm install
-npm run build
-```
-
-### Development Mode
-
-```bash
-npm run dev # Watch mode with auto-reload
-```
-
-### Testing
-
-```bash
+npm run typecheck
+npm run lint
npm test
+npm run build
+npx -y @modelcontextprotocol/inspector@latest --cli node dist/index.js --method tools/list --format json
```
-## Troubleshooting
-
-### Authentication Issues
-
-#### Invalid credentials error
-- Verify your Client ID and Secret are correct
-- Check that your API client has appropriate permissions in Pirsch
-- Ensure credentials are properly set in environment variables
-
-#### Token refresh failures
-- The server automatically refreshes tokens 60 seconds before expiry
-- Check network connectivity to Pirsch API
-- Verify `PIRSCH_TOKEN_SKEW_MS` is not set too low
-
-### Domain Issues
-
-#### Domain not found
-- Run `pirsch_list_domains` to see available domains
-- Verify `PIRSCH_DEFAULT_DOMAIN_ID` is correct
-- Check API client has access to the domain
-
-#### No data returned
-- Verify the date range contains data
-- Check timezone settings match your Pirsch configuration
-- Ensure proper filtering parameters
-- Use `pirsch_total` for custom date range totals; `pirsch_overview` is cached and not filterable
-- For page-style tools, path-shaped `search`, `~/path/`, and `/path/*` filters are narrowed to root-prefix matches inside the MCP
-- Use `path_prefix` when you want explicit prefix behavior without relying on Pirsch operators
-
-### Performance
-
-#### Slow responses
-- Token caching reduces authentication overhead
-- Consider adjusting `PIRSCH_TOKEN_SKEW_MS` for your use case
-- Check network latency to Pirsch API endpoints
-
-## Contributing
-
-Contributions are welcome! Please:
-
-1. Fork the repository
-2. Create a feature branch
-3. Make your changes with tests
-4. Submit a pull request
-
-## License
-
-MIT - See [LICENSE](LICENSE) file for details.
+The release workflow publishes to npm with trusted publishing and then publishes the same tagged manifest to the MCP Registry through GitHub OIDC. Local development and CI never publish anything.
## Support
-- **Issues**: [GitHub Issues](https://github.com/verygoodplugins/mcp-pirsch/issues)
-- **Documentation**: [Pirsch API Docs](https://docs.pirsch.io/api-sdks/api)
-
-## Credits
-
-Built by [Jack Arturo](https://x.com/verygoodplugins) ๐งก
+For bugs and feature requests, open an issue in this repository. Pirsch questions are best answered through the [Pirsch documentation](https://docs.pirsch.io/api-sdks/api-v1); package support is maintained by [Very Good Plugins](https://verygoodplugins.com/?utm_source=github).
-- Powered by [Pirsch Analytics](https://pirsch.io)
-- Built with [Model Context Protocol SDK](https://github.com/anthropics/model-context-protocol)
-- Part of the [Very Good Plugins](https://verygoodplugins.com?utm_source=github) MCP ecosystem
+Built with ๐งก by Very Good Plugins.
diff --git a/package-lock.json b/package-lock.json
index 1f1b318..b68f46f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,6 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/server": "^2.0.0",
"dotenv": "^17.4.2",
- "node-fetch": "^3.3.2",
"zod": "^4.4.3"
},
"bin": {
@@ -2079,13 +2078,6 @@
"node": ">= 8"
}
},
- "node_modules/data-uri-to-buffer": {
- "version": "4.0.1",
- "license": "MIT",
- "engines": {
- "node": ">= 12"
- }
- },
"node_modules/debug": {
"version": "4.4.3",
"license": "MIT",
@@ -2585,27 +2577,6 @@
}
}
},
- "node_modules/fetch-blob": {
- "version": "3.2.0",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/jimmywarting"
- },
- {
- "type": "paypal",
- "url": "https://paypal.me/jimmywarting"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "node-domexception": "^1.0.0",
- "web-streams-polyfill": "^3.0.3"
- },
- "engines": {
- "node": "^12.20 || >= 14.13"
- }
- },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"dev": true,
@@ -2683,16 +2654,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/formdata-polyfill": {
- "version": "4.0.10",
- "license": "MIT",
- "dependencies": {
- "fetch-blob": "^3.1.2"
- },
- "engines": {
- "node": ">=12.20.0"
- }
- },
"node_modules/forwarded": {
"version": "0.2.0",
"license": "MIT",
@@ -3321,39 +3282,6 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/node-domexception": {
- "version": "1.0.0",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/jimmywarting"
- },
- {
- "type": "github",
- "url": "https://paypal.me/jimmywarting"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=10.5.0"
- }
- },
- "node_modules/node-fetch": {
- "version": "3.3.2",
- "license": "MIT",
- "dependencies": {
- "data-uri-to-buffer": "^4.0.0",
- "fetch-blob": "^3.1.4",
- "formdata-polyfill": "^4.0.10"
- },
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/node-fetch"
- }
- },
"node_modules/object-assign": {
"version": "4.1.1",
"license": "MIT",
@@ -4909,13 +4837,6 @@
}
}
},
- "node_modules/web-streams-polyfill": {
- "version": "3.3.3",
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/which": {
"version": "2.0.2",
"license": "ISC",
diff --git a/package.json b/package.json
index b5cc8a7..888ba5b 100644
--- a/package.json
+++ b/package.json
@@ -59,7 +59,6 @@
"@modelcontextprotocol/server": "^2.0.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"dotenv": "^17.4.2",
- "node-fetch": "^3.3.2",
"zod": "^4.4.3"
},
"devDependencies": {
diff --git a/server.json b/server.json
index 9a2261c..8747ced 100644
--- a/server.json
+++ b/server.json
@@ -22,71 +22,19 @@
"tools": [
{
"name": "pirsch_list_domains",
- "description": "List all domains in your Pirsch account"
+ "description": "List safe summaries of Pirsch domains available to the configured read-only client"
},
{
- "name": "pirsch_overview",
- "description": "Get the cached overview snapshot for a domain; filters do not apply"
+ "name": "pirsch_query_statistics",
+ "description": "Read one documented Pirsch Analytics API v1 metric for an explicitly selected domain and filter"
},
{
- "name": "pirsch_total",
- "description": "Get total visitors, views, sessions, bounce rate, conversion rate, and custom metrics"
+ "name": "pirsch_list_filter_options",
+ "description": "List supported values for one documented Pirsch Analytics API v1 filter dimension"
},
{
- "name": "pirsch_visitors",
- "description": "Get visitor time-series data"
- },
- {
- "name": "pirsch_pages",
- "description": "Get page-level analytics"
- },
- {
- "name": "pirsch_entry_pages",
- "description": "Get entry page analytics"
- },
- {
- "name": "pirsch_exit_pages",
- "description": "Get exit page analytics"
- },
- {
- "name": "pirsch_referrers",
- "description": "Get referrer statistics"
- },
- {
- "name": "pirsch_goals",
- "description": "Get conversion goals and their performance"
- },
- {
- "name": "pirsch_events",
- "description": "Get event statistics"
- },
- {
- "name": "pirsch_event_pages",
- "description": "Get pages on which a specific event fired"
- },
- {
- "name": "pirsch_utm",
- "description": "Get UTM campaign statistics"
- },
- {
- "name": "pirsch_growth",
- "description": "Get growth rates for key metrics"
- },
- {
- "name": "pirsch_active",
- "description": "Get currently active visitors"
- },
- {
- "name": "pirsch_sessions",
- "description": "Get session list with entry, exit, and device/source details"
- },
- {
- "name": "pirsch_session_details",
- "description": "Get the full page-view and event timeline for a single session"
- },
- {
- "name": "pirsch_compare",
- "description": "Compare true period totals and visitor series between two time periods"
+ "name": "pirsch_compare_periods",
+ "description": "Compare Pirsch totals and visitor series for a named or explicitly supplied pair of periods"
}
]
}
diff --git a/src/index.spawn.test.ts b/src/index.spawn.test.ts
index 3c52e78..f0a8714 100644
--- a/src/index.spawn.test.ts
+++ b/src/index.spawn.test.ts
@@ -55,6 +55,67 @@ function runViaPath(entryPath: string, flags: string[] = []): Promise {
});
}
+function discoverModernProtocol(entryPath: string): Promise<{ supportedVersions: string[] }> {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [entryPath], {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ env: {
+ ...process.env,
+ PIRSCH_CLIENT_ID: 'test-client-id',
+ PIRSCH_CLIENT_SECRET: 'test-client-secret',
+ },
+ });
+
+ let stdout = '';
+ let stderr = '';
+ let settled = false;
+ const finish = (callback: () => void) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ child.kill();
+ callback();
+ };
+ const timer = setTimeout(() => {
+ finish(() => reject(new Error(`Timed out waiting for modern discovery. stderr so far: ${stderr}`)));
+ }, 8_000);
+
+ child.stdout.on('data', (chunk: Buffer) => {
+ stdout += chunk.toString();
+ const newline = stdout.indexOf('\n');
+ if (newline === -1) return;
+ try {
+ const response = JSON.parse(stdout.slice(0, newline)) as { result?: { supportedVersions?: string[] } };
+ const supportedVersions = response.result?.supportedVersions;
+ if (!supportedVersions) throw new Error(`Unexpected modern discovery response: ${stdout}`);
+ finish(() => resolve({ supportedVersions }));
+ } catch (error) {
+ finish(() => reject(error));
+ }
+ });
+ child.stderr.on('data', (chunk: Buffer) => {
+ stderr += chunk.toString();
+ });
+ child.on('error', (error) => {
+ finish(() => reject(error));
+ });
+ child.on('exit', (code) => {
+ finish(() => reject(new Error(`Server process exited early with code ${code}. stderr: ${stderr}`)));
+ });
+ child.stdin.write(`${JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'server/discover',
+ params: {
+ _meta: {
+ 'io.modelcontextprotocol/protocolVersion': '2026-07-28',
+ 'io.modelcontextprotocol/clientCapabilities': {},
+ },
+ },
+ })}\n`);
+ });
+}
+
describe('CLI entry-point detection', () => {
const tempDirs: string[] = [];
@@ -98,4 +159,8 @@ describe('CLI entry-point detection', () => {
'Pirsch MCP server running'
);
});
+
+ it('serves modern protocol discovery over stdio', async () => {
+ await expect(discoverModernProtocol(distEntry)).resolves.toEqual({ supportedVersions: ['2026-07-28'] });
+ });
});
diff --git a/src/index.test.ts b/src/index.test.ts
deleted file mode 100644
index 8d5c509..0000000
--- a/src/index.test.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import type { StatisticsTotals, VisitorsPoint } from './types.js';
-
-process.env.PIRSCH_CLIENT_ID = process.env.PIRSCH_CLIENT_ID || 'test-client-id';
-process.env.PIRSCH_CLIENT_SECRET = process.env.PIRSCH_CLIENT_SECRET || 'test-client-secret';
-
-const { getComparisonResponse, getLocalPathPrefix, getPathFilteredStatistics, normalizeFilterArgs } = await import('./index.js');
-
-describe('getComparisonResponse', () => {
- it('uses statistics/total for totals and statistics/visitor for chart series', async () => {
- const currentTotals: StatisticsTotals = {
- visitors: 100,
- views: 250,
- sessions: 120,
- bounces: 45,
- bounce_rate: 0.375,
- cr: 0.12,
- custom_metric_avg: 14.5,
- custom_metric_total: 1450,
- };
- const previousTotals: StatisticsTotals = {
- visitors: 80,
- views: 200,
- sessions: 110,
- bounces: 50,
- bounce_rate: 0.4545,
- cr: 0.08,
- custom_metric_avg: 10,
- custom_metric_total: 800,
- };
- const currentSeries: VisitorsPoint[] = [
- { day: '2024-01-01T00:00:00Z', visitors: 40, views: 100, sessions: 50, bounces: 20, bounce_rate: 0.4, cr: 0.1 },
- ];
- const previousSeries: VisitorsPoint[] = [
- { day: '2023-12-25T00:00:00Z', visitors: 30, views: 90, sessions: 45, bounces: 18, bounce_rate: 0.4, cr: 0.08 },
- ];
-
- const getStatistics = vi
- .fn()
- .mockResolvedValueOnce(currentTotals)
- .mockResolvedValueOnce(previousTotals)
- .mockResolvedValueOnce(currentSeries)
- .mockResolvedValueOnce(previousSeries);
-
- const result = await getComparisonResponse(
- { getStatistics },
- 'domain-1',
- {
- compare: 'custom',
- from: '2024-01-01',
- to: '2024-01-07',
- compare_from: '2023-12-25',
- compare_to: '2023-12-31',
- scale: 'week',
- }
- );
-
- expect(getStatistics).toHaveBeenNthCalledWith(1, '/statistics/total', 'domain-1', {
- from: '2024-01-01',
- to: '2024-01-07',
- });
- expect(getStatistics).toHaveBeenNthCalledWith(2, '/statistics/total', 'domain-1', {
- from: '2023-12-25',
- to: '2023-12-31',
- });
- expect(getStatistics).toHaveBeenNthCalledWith(3, '/statistics/visitor', 'domain-1', {
- from: '2024-01-01',
- to: '2024-01-07',
- scale: 'week',
- });
- expect(getStatistics).toHaveBeenNthCalledWith(4, '/statistics/visitor', 'domain-1', {
- from: '2023-12-25',
- to: '2023-12-31',
- scale: 'week',
- });
-
- expect(result.totals.visitors).toEqual({ current: 100, previous: 80, change: 0.25 });
- expect(result.totals.bounce_rate.current).toBe(0.375);
- expect(result.totals.cr.current).toBe(0.12);
- expect(result.totals.custom_metric_total.current).toBe(1450);
- expect(result.series.current).toEqual(currentSeries);
- expect(result.series.previous).toEqual(previousSeries);
- });
-
- it('rejects invalid compare input', async () => {
- const getStatistics = vi.fn();
-
- await expect(getComparisonResponse({ getStatistics }, 'domain-1', { compare: 'custom' })).rejects.toThrow(
- 'Provide either period or custom from/to + compare_from/compare_to'
- );
- expect(getStatistics).not.toHaveBeenCalled();
- });
-});
-
-describe('normalizeFilterArgs', () => {
- it('merges top-level filter args for callers that do not nest filter', () => {
- expect(
- normalizeFilterArgs({
- from: '2024-03-25',
- to: '2026-03-25',
- search: '/news/',
- limit: 5,
- sort: 'visitors',
- direction: 'desc',
- })
- ).toEqual({
- from: '2024-03-25',
- to: '2026-03-25',
- search: '/news/',
- limit: 5,
- sort: 'visitors',
- direction: 'desc',
- });
- });
-
- it('prefers explicit nested filter values and supports event_name alias', () => {
- expect(
- normalizeFilterArgs({
- event: 'Top Level Event',
- event_name: 'Order',
- filter: {
- search: '/tutorials/',
- event_name: 'Live Demo Signup',
- limit: 10,
- },
- })
- ).toEqual({
- search: '/tutorials/',
- event: 'Top Level Event',
- limit: 10,
- });
-
- expect(
- normalizeFilterArgs({
- event_name: 'Order',
- filter: {
- from: '2024-03-25',
- to: '2026-03-25',
- },
- })
- ).toEqual({
- from: '2024-03-25',
- to: '2026-03-25',
- event: 'Order',
- });
- });
-});
-
-describe('getLocalPathPrefix', () => {
- it('derives a root-prefix matcher from path-shaped search and operator filters', () => {
- expect(getLocalPathPrefix({ search: '/tutorials/' })).toBe('/tutorials/');
- expect(getLocalPathPrefix({ path: '~/tutorials/' })).toBe('/tutorials/');
- expect(getLocalPathPrefix({ pattern: '/tutorials/*' })).toBe('/tutorials/');
- expect(getLocalPathPrefix({ path_prefix: '/news/' })).toBe('/news/');
- });
-
- it('leaves exact path filters alone', () => {
- expect(getLocalPathPrefix({ path: '/tutorials/' })).toBeUndefined();
- expect(getLocalPathPrefix({ search: 'tutorials' })).toBeUndefined();
- });
-});
-
-describe('getPathFilteredStatistics', () => {
- it('fetches additional batches until it has enough prefix matches', async () => {
- const firstBatch = Array.from({ length: 99 }, (_, index) => ({
- path: `/documentation/tutorials/article-${index}/`,
- }));
- firstBatch.push({ path: '/tutorials/root-one/' });
-
- const secondBatch = [
- { path: '/tutorials/root-two/' },
- { path: '/tutorials/root-three/' },
- ];
-
- const getStatistics = vi
- .fn()
- .mockResolvedValueOnce(firstBatch)
- .mockResolvedValueOnce(secondBatch);
-
- const result = await getPathFilteredStatistics(
- { getStatistics },
- '/statistics/page',
- 'domain-1',
- { search: '/tutorials/', limit: 2 },
- '/tutorials/'
- );
-
- expect(getStatistics).toHaveBeenNthCalledWith(1, '/statistics/page', 'domain-1', {
- search: '/tutorials/',
- limit: 100,
- offset: 0,
- });
- expect(getStatistics).toHaveBeenNthCalledWith(2, '/statistics/page', 'domain-1', {
- search: '/tutorials/',
- limit: 100,
- offset: 100,
- });
- expect(result).toEqual([
- { path: '/tutorials/root-one/' },
- { path: '/tutorials/root-two/' },
- ]);
- });
-});
diff --git a/src/index.ts b/src/index.ts
index d2d12ff..b196082 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,668 +1,24 @@
#!/usr/bin/env node
-import { realpathSync } from 'fs';
-import { resolve } from 'path';
-import { pathToFileURL } from 'url';
-import { Server } from '@modelcontextprotocol/sdk/server/index.js';
-import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
-import { installStdioLifecycle } from './lifecycle.js';
-import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js';
import { config } from 'dotenv';
-import { PirschAPI } from './pirsch-api.js';
-import type { Domain, FilterInput, StatisticsTotals, VisitorsPoint } from './types.js';
-import { getDateRange, isoDate, pctChange } from './utils.js';
-
-config({ quiet: true });
-
-const CLIENT_ID = process.env.PIRSCH_CLIENT_ID;
-const CLIENT_SECRET = process.env.PIRSCH_CLIENT_SECRET;
-const DEFAULT_DOMAIN_ID = process.env.PIRSCH_DEFAULT_DOMAIN_ID;
-
-if (!CLIENT_ID || !CLIENT_SECRET) {
- console.error('Missing required env: PIRSCH_CLIENT_ID or PIRSCH_CLIENT_SECRET');
- process.exit(1);
-}
-
-const api = new PirschAPI(CLIENT_ID, CLIENT_SECRET);
-
-type ToolArguments = Record;
-type PeriodName = 'today' | 'yesterday' | 'week' | 'lastWeek' | 'month' | 'lastMonth';
-type ScaleName = 'day' | 'week' | 'month' | 'year';
-type CompareMode = 'previous' | 'year' | 'custom';
-type SchemaProperty = { [key: string]: unknown };
-type ToolInputSchema = {
- type: 'object';
- properties: Record;
- required?: string[];
-};
-
-interface StatisticsToolConfig {
- name: string;
- description: string;
- endpoint: string;
- resultKey: string;
- supportsLocalPathPrefix?: boolean;
- validateFilter?: (filter: FilterInput) => void;
-}
-
-interface StatisticsReader {
- getStatistics(endpoint: string, domainId: string, filter?: FilterInput): Promise;
-}
-
-interface PathRow {
- path?: string | null;
-}
-
-const DEFAULT_LOCAL_FILTER_BATCH_SIZE = 100;
-const MAX_LOCAL_FILTER_BATCHES = 20;
-
-function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null;
-}
-
-function isDomain(value: unknown): value is Domain {
- return isRecord(value) && typeof value.id === 'string';
-}
-
-function formatResponse(payload: unknown) {
- return {
- content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
- };
-}
-
-function getErrorMessage(error: unknown): string {
- return error instanceof Error ? error.message : 'An error occurred';
-}
-
-function readArgs(value: unknown): ToolArguments | undefined {
- return isRecord(value) ? value : undefined;
-}
-
-function readOptionalString(args: ToolArguments | undefined, key: string): string | undefined {
- const value = args?.[key];
- return typeof value === 'string' && value.trim() !== '' ? value : undefined;
-}
-
-function readOptionalNumber(args: ToolArguments | undefined, key: string): number | undefined {
- const value = args?.[key];
- return typeof value === 'number' ? value : undefined;
-}
-
-function readFilter(args: ToolArguments | undefined): FilterInput {
- const filter = args?.filter;
- return isRecord(filter) ? (filter as FilterInput) : {};
-}
-
-function readFilterAlias(
- args: ToolArguments | undefined,
- nestedFilter: FilterInput,
- key: 'event_name'
-): string | undefined {
- const nestedValue = nestedFilter[key];
- if (typeof nestedValue === 'string' && nestedValue.trim() !== '') {
- return nestedValue;
- }
-
- const topLevelValue = args?.[key];
- return typeof topLevelValue === 'string' && topLevelValue.trim() !== '' ? topLevelValue : undefined;
-}
-
-export function normalizeFilterArgs(args: ToolArguments | undefined): FilterInput {
- const nestedFilter = readFilter(args);
- const mergedFilter: FilterInput = { ...nestedFilter };
- const mergedFilterRecord = mergedFilter as Record;
-
- for (const key of Object.keys(filterSchemaProperties) as Array) {
- const value = args?.[key];
- if (value !== undefined && mergedFilter[key] === undefined) {
- mergedFilterRecord[key] = value;
- }
- }
-
- if (!mergedFilter.event) {
- const eventAlias = readFilterAlias(args, nestedFilter, 'event_name');
- if (eventAlias) {
- mergedFilter.event = eventAlias;
- }
- }
-
- delete mergedFilter.event_name;
-
- return mergedFilter;
-}
-
-function readTrimmedString(value: unknown): string | undefined {
- return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
-}
-
-function normalizePathPrefix(value: string | undefined): string | undefined {
- if (!value || !value.startsWith('/')) {
- return undefined;
- }
-
- return value;
-}
-
-function extractPatternPrefix(pattern: string | undefined): string | undefined {
- if (!pattern || !pattern.startsWith('/') || !pattern.endsWith('*')) {
- return undefined;
- }
-
- const prefix = pattern.slice(0, -1);
- return prefix.endsWith('/') ? prefix : undefined;
-}
-
-export function getLocalPathPrefix(filter: FilterInput): string | undefined {
- const explicitPrefix = normalizePathPrefix(readTrimmedString(filter.path_prefix));
- if (explicitPrefix) {
- return explicitPrefix;
- }
-
- const searchPrefix = normalizePathPrefix(readTrimmedString(filter.search));
- if (searchPrefix) {
- return searchPrefix;
- }
-
- const path = readTrimmedString(filter.path);
- if (path?.startsWith('~/')) {
- return normalizePathPrefix(path.slice(1));
- }
-
- return extractPatternPrefix(readTrimmedString(filter.pattern));
-}
-
-function filterRowsByPathPrefix(rows: T[], prefix: string): T[] {
- return rows.filter((row) => isRecord(row) && typeof row.path === 'string' && row.path.startsWith(prefix));
-}
-
-function buildApiFilterForLocalPathPrefix(
- filter: FilterInput,
- prefix: string,
- offset: number,
- limit: number
-): FilterInput {
- const apiFilter: FilterInput = {
- ...filter,
- offset,
- limit,
- };
-
- delete apiFilter.path_prefix;
-
- if (!apiFilter.search && !apiFilter.path && !apiFilter.pattern) {
- apiFilter.search = prefix;
- }
-
- return apiFilter;
-}
-
-export async function getPathFilteredStatistics(
- client: StatisticsReader,
- endpoint: string,
- domainId: string,
- filter: FilterInput,
- prefix: string
-): Promise {
- const requestedOffset = filter.offset ?? 0;
- const requestedLimit = filter.limit ?? DEFAULT_LOCAL_FILTER_BATCH_SIZE;
- const targetCount = requestedOffset + requestedLimit;
- const batchSize = Math.max(requestedLimit, DEFAULT_LOCAL_FILTER_BATCH_SIZE);
- const matches: PathRow[] = [];
-
- for (let batchIndex = 0; batchIndex < MAX_LOCAL_FILTER_BATCHES && matches.length < targetCount; batchIndex += 1) {
- const data = await client.getStatistics(
- endpoint,
- domainId,
- buildApiFilterForLocalPathPrefix(filter, prefix, batchIndex * batchSize, batchSize)
- );
-
- if (!Array.isArray(data)) {
- return data;
- }
-
- matches.push(...filterRowsByPathPrefix(data, prefix));
-
- if (data.length < batchSize) {
- break;
- }
- }
-
- return matches.slice(requestedOffset, targetCount);
-}
-
-function requireFilterString(filter: FilterInput, key: 'event' | 'visitor_id' | 'session_id', toolName: string): string {
- const value = filter[key];
- if (typeof value !== 'string' || value.trim() === '') {
- throw new Error(`filter.${key} is required for ${toolName}`);
- }
- return value;
-}
-
-function buildFilterCapableInputSchema(
- extraProperties: Record = {},
- required?: string[]
-): ToolInputSchema {
- return {
- type: 'object',
- properties: {
- domain_id: domainIdSchema,
- filter: filterSchema,
- ...filterSchemaProperties,
- ...extraProperties,
- },
- ...(required ? { required } : {}),
- };
-}
-
-function compareMetric(current: number, previous: number) {
- return { current, previous, change: pctChange(current, previous) };
-}
-
-export function buildComparisonTotals(current: StatisticsTotals, previous: StatisticsTotals) {
- return {
- visitors: compareMetric(current.visitors, previous.visitors),
- views: compareMetric(current.views, previous.views),
- sessions: compareMetric(current.sessions, previous.sessions),
- bounces: compareMetric(current.bounces, previous.bounces),
- bounce_rate: compareMetric(current.bounce_rate, previous.bounce_rate),
- cr: compareMetric(current.cr, previous.cr),
- custom_metric_avg: compareMetric(current.custom_metric_avg, previous.custom_metric_avg),
- custom_metric_total: compareMetric(current.custom_metric_total, previous.custom_metric_total),
- };
-}
-
-export async function getComparisonResponse(
- client: StatisticsReader,
- domainId: string,
- args: ToolArguments | undefined
-) {
- const scale = (readOptionalString(args, 'scale') as ScaleName | undefined) || 'day';
- const compareMode = (readOptionalString(args, 'compare') as CompareMode | undefined) || 'previous';
- const period = readOptionalString(args, 'period') as PeriodName | undefined;
-
- let currentFrom: string;
- let currentTo: string;
- let previousFrom: string;
- let previousTo: string;
-
- if (period) {
- const range = getDateRange(period);
- currentFrom = isoDate(range.start);
- currentTo = isoDate(range.end);
-
- if (compareMode === 'year') {
- const previousStart = new Date(range.start);
- const previousEnd = new Date(range.end);
- previousStart.setFullYear(previousStart.getFullYear() - 1);
- previousEnd.setFullYear(previousEnd.getFullYear() - 1);
- previousFrom = isoDate(previousStart);
- previousTo = isoDate(previousEnd);
- } else {
- const lengthInDays =
- Math.ceil((range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
- const previousEnd = new Date(range.start);
- previousEnd.setDate(previousEnd.getDate() - 1);
- const previousStart = new Date(previousEnd);
- previousStart.setDate(previousEnd.getDate() - (lengthInDays - 1));
- previousFrom = isoDate(previousStart);
- previousTo = isoDate(previousEnd);
- }
- } else if (
- compareMode === 'custom' &&
- readOptionalString(args, 'from') &&
- readOptionalString(args, 'to') &&
- readOptionalString(args, 'compare_from') &&
- readOptionalString(args, 'compare_to')
- ) {
- currentFrom = readOptionalString(args, 'from')!;
- currentTo = readOptionalString(args, 'to')!;
- previousFrom = readOptionalString(args, 'compare_from')!;
- previousTo = readOptionalString(args, 'compare_to')!;
- } else {
- throw new Error('Provide either period or custom from/to + compare_from/compare_to');
- }
-
- const [currentTotals, previousTotals, currentSeries, previousSeries] = await Promise.all([
- client.getStatistics('/statistics/total', domainId, {
- from: currentFrom,
- to: currentTo,
- }),
- client.getStatistics('/statistics/total', domainId, {
- from: previousFrom,
- to: previousTo,
- }),
- client.getStatistics('/statistics/visitor', domainId, {
- from: currentFrom,
- to: currentTo,
- scale,
- }),
- client.getStatistics('/statistics/visitor', domainId, {
- from: previousFrom,
- to: previousTo,
- scale,
- }),
- ]);
-
- return {
- period: { from: currentFrom, to: currentTo },
- compare_to: { from: previousFrom, to: previousTo },
- totals: buildComparisonTotals(currentTotals, previousTotals),
- series: { current: currentSeries, previous: previousSeries },
- };
-}
-
-async function resolveDomainId(argId?: string): Promise {
- if (argId) return argId;
- if (DEFAULT_DOMAIN_ID) return DEFAULT_DOMAIN_ID;
- const res = await api.listDomains();
- if (Array.isArray(res) && res.length > 0) return res[0].id;
- if (isDomain(res)) return res.id;
- throw new Error('No domain found. Set PIRSCH_DEFAULT_DOMAIN_ID or provide domain_id');
-}
-
-const server = new Server(
- { name: 'mcp-pirsch', version: '0.1.0' },
- { capabilities: { tools: {} } }
-);
-
-const filterSchemaProperties = {
- from: { type: 'string', description: 'YYYY-MM-DD' },
- to: { type: 'string', description: 'YYYY-MM-DD' },
- from_time: { type: 'string', description: 'HH:MM (same-day only)' },
- to_time: { type: 'string', description: 'HH:MM (same-day only)' },
- tz: { type: 'string' },
- start: { type: 'number', description: 'Past seconds for active view' },
- scale: { type: 'string', enum: ['day', 'week', 'month', 'year'] },
- hostname: { type: 'string' },
- path: { type: 'string', description: 'Supports Pirsch operators like ~contains, !not, and ^does-not-contain' },
- path_prefix: { type: 'string', description: 'MCP-local path prefix filter for page-style tools, e.g. /tutorials/' },
- entry_path: { type: 'string' },
- exit_path: { type: 'string' },
- pattern: { type: 'string' },
- event: { type: 'string' },
- event_name: { type: 'string', description: 'Alias for event when callers use event_name instead of event' },
- event_meta_key: { type: 'string' },
- language: { type: 'string' },
- country: { type: 'string' },
- city: { type: 'string' },
- referrer: { type: 'string' },
- referrer_name: { type: 'string' },
- channel: { type: 'string' },
- os: { type: 'string' },
- browser: { type: 'string' },
- platform: { type: 'string', enum: ['desktop', 'mobile', 'unknown'] },
- screen_class: { type: 'string' },
- utm_source: { type: 'string' },
- utm_medium: { type: 'string' },
- utm_campaign: { type: 'string' },
- utm_content: { type: 'string' },
- utm_term: { type: 'string' },
- custom_metric_type: { type: 'string', enum: ['integer', 'float'] },
- custom_metric_key: { type: 'string' },
- tag: { type: 'string' },
- offset: { type: 'number' },
- limit: { type: 'number' },
- include_avg_time_on_page: { type: 'boolean' },
- include_title: { type: 'boolean' },
- sort: { type: 'string' },
- direction: { type: 'string', enum: ['asc', 'desc'] },
- search: { type: 'string', description: 'Contains search on the primary field, e.g. page path for page endpoints' },
- keyword: { type: 'string', description: 'Google Search Console keyword filter for keyword page lookups' },
- visitor_id: { type: 'string' },
- session_id: { type: 'string' },
-} as const;
-
-const filterSchema: ToolInputSchema = {
- type: 'object',
- properties: filterSchemaProperties,
-};
-
-const domainIdSchema = { type: 'string' } as const;
-
-const filterToolInputSchema = buildFilterCapableInputSchema();
-
-const statisticsToolConfigs: StatisticsToolConfig[] = [
- {
- name: 'pirsch_total',
- description: 'Get totals for visitors, views, sessions, bounces, bounce_rate, cr, and custom metrics with filters',
- endpoint: '/statistics/total',
- resultKey: 'total',
- },
- {
- name: 'pirsch_visitors',
- description: 'Get visitors time series with optional scale and filters',
- endpoint: '/statistics/visitor',
- resultKey: 'series',
- },
- {
- name: 'pirsch_pages',
- description: 'Get page stats with sorting, search, and optional average time on page',
- endpoint: '/statistics/page',
- resultKey: 'pages',
- supportsLocalPathPrefix: true,
- },
- {
- name: 'pirsch_entry_pages',
- description: 'Get entry page stats with sorting, search, and optional average time on page',
- endpoint: '/statistics/page/entry',
- resultKey: 'entry_pages',
- supportsLocalPathPrefix: true,
- },
- {
- name: 'pirsch_exit_pages',
- description: 'Get exit page stats with sorting and search',
- endpoint: '/statistics/page/exit',
- resultKey: 'exit_pages',
- supportsLocalPathPrefix: true,
- },
- {
- name: 'pirsch_referrers',
- description: 'Get referrer statistics with filters and sorting',
- endpoint: '/statistics/referrer',
- resultKey: 'referrers',
- },
- {
- name: 'pirsch_goals',
- description: 'Get conversion goals and their performance stats',
- endpoint: '/statistics/goals',
- resultKey: 'goals',
- },
- {
- name: 'pirsch_events',
- description: 'Get event statistics with counts, visitors, conversion rate, and metadata keys',
- endpoint: '/statistics/events',
- resultKey: 'events',
- },
- {
- name: 'pirsch_event_pages',
- description: 'Get pages on which a specific event fired. Requires filter.event',
- endpoint: '/statistics/event/page',
- resultKey: 'event_pages',
- supportsLocalPathPrefix: true,
- validateFilter: (filter) => {
- requireFilterString(filter, 'event', 'pirsch_event_pages');
- },
- },
- {
- name: 'pirsch_growth',
- description: 'Get growth rates across core metrics for the selected period',
- endpoint: '/statistics/growth',
- resultKey: 'growth',
- },
- {
- name: 'pirsch_sessions',
- description: 'Get session list with entry/exit pages, duration, device, and traffic source details',
- endpoint: '/statistics/session/list',
- resultKey: 'sessions',
- },
- {
- name: 'pirsch_session_details',
- description: 'Get chronological page views and events for a single session. Requires filter.visitor_id and filter.session_id',
- endpoint: '/statistics/session/details',
- resultKey: 'session_details',
- validateFilter: (filter) => {
- requireFilterString(filter, 'visitor_id', 'pirsch_session_details');
- requireFilterString(filter, 'session_id', 'pirsch_session_details');
- },
- },
-];
-
-const statisticsToolMap = new Map(statisticsToolConfigs.map((config) => [config.name, config]));
-
-const tools: Tool[] = [
- {
- name: 'pirsch_list_domains',
- description: 'List accessible Pirsch domains to discover domain IDs',
- inputSchema: { type: 'object', properties: { search: { type: 'string' } } },
- },
- {
- name: 'pirsch_overview',
- description: 'Get cached overview statistics for a domain. Filters do not apply to this endpoint',
- inputSchema: { type: 'object', properties: { domain_id: domainIdSchema } },
- },
- ...statisticsToolConfigs.map((config) => ({
- name: config.name,
- description: config.description,
- inputSchema: filterToolInputSchema,
- })),
- {
- name: 'pirsch_utm',
- description: 'Get UTM stats by dimension (source, medium, campaign, content, term)',
- inputSchema: buildFilterCapableInputSchema(
- { type: { type: 'string', enum: ['source', 'medium', 'campaign', 'content', 'term'] } },
- ['type']
- ),
- },
- {
- name: 'pirsch_active',
- description: 'Get active visitors and pages for the past N seconds (default 600)',
- inputSchema: { type: 'object', properties: { domain_id: domainIdSchema, start: { type: 'number' } } },
- },
- {
- name: 'pirsch_compare',
- description: 'Compare totals and visitor series between two periods using true period totals',
- inputSchema: {
- type: 'object',
- properties: {
- domain_id: domainIdSchema,
- period: { type: 'string', enum: ['today', 'yesterday', 'week', 'lastWeek', 'month', 'lastMonth'] },
- compare: {
- type: 'string',
- enum: ['previous', 'year', 'custom'],
- description: 'Compare to the previous period, same period last year, or a custom range',
- },
- from: { type: 'string' },
- to: { type: 'string' },
- compare_from: { type: 'string' },
- compare_to: { type: 'string' },
- scale: { type: 'string', enum: ['day', 'week', 'month', 'year'] },
- },
- },
- },
-];
-
-server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
-
-server.setRequestHandler(CallToolRequestSchema, async (req) => {
- const { name, arguments: rawArgs } = req.params;
- const args = readArgs(rawArgs);
-
- try {
- if (name === 'pirsch_list_domains') {
- const search = readOptionalString(args, 'search');
- const res = await api.listDomains(search ? { search } : undefined);
- const arr = Array.isArray(res) ? res : [res];
- return formatResponse({ count: arr.length, domains: arr });
- }
-
- if (name === 'pirsch_overview') {
- const domainId = await resolveDomainId(readOptionalString(args, 'domain_id'));
- const data = await api.getOverview(domainId);
- return formatResponse({ domain_id: domainId, overview: data });
- }
-
- const statisticsTool = statisticsToolMap.get(name);
- if (statisticsTool) {
- const domainId = await resolveDomainId(readOptionalString(args, 'domain_id'));
- const filter = normalizeFilterArgs(args);
- statisticsTool.validateFilter?.(filter);
- const localPathPrefix = statisticsTool.supportsLocalPathPrefix ? getLocalPathPrefix(filter) : undefined;
- const data = localPathPrefix
- ? await getPathFilteredStatistics(api, statisticsTool.endpoint, domainId, filter, localPathPrefix)
- : await api.getStatistics(statisticsTool.endpoint, domainId, filter);
- return formatResponse({ domain_id: domainId, [statisticsTool.resultKey]: data });
- }
-
- if (name === 'pirsch_utm') {
- const domainId = await resolveDomainId(readOptionalString(args, 'domain_id'));
- const filter = normalizeFilterArgs(args);
- const type = readOptionalString(args, 'type');
- if (!type) {
- throw new Error('type is required for pirsch_utm');
- }
- const endpoint = `/statistics/utm/${type}`;
- const data = await api.getStatistics(endpoint, domainId, filter);
- return formatResponse({ domain_id: domainId, type, utm: data });
- }
-
- if (name === 'pirsch_active') {
- const domainId = await resolveDomainId(readOptionalString(args, 'domain_id'));
- const start = readOptionalNumber(args, 'start') ?? 600;
- const data = await api.getActive(domainId, start);
- return formatResponse({ domain_id: domainId, start, active: data });
- }
-
- if (name === 'pirsch_compare') {
- const domainId = await resolveDomainId(readOptionalString(args, 'domain_id'));
- const result = await getComparisonResponse(api, domainId, args);
- return formatResponse(result);
- }
-
- throw new Error(`Unknown tool: ${name}`);
- } catch (error) {
- return formatResponse({ error: true, message: getErrorMessage(error) });
- }
-});
+import { StdioServerTransport, serveStdio } from '@modelcontextprotocol/server/stdio';
+import { installStdioLifecycle } from './lifecycle.js';
+import { createPirschServer } from './server.js';
-async function main() {
- // Capture before any await โ process.ppid is dynamic.
+function main(): void {
const parentPid = process.ppid;
+ config({ quiet: true });
const transport = new StdioServerTransport();
- installStdioLifecycle({
- transport,
- onCloseAssignable: server,
- envName: 'PIRSCH_PARENT_WATCHDOG_MS',
- parentPid,
- });
- await server.connect(transport);
- console.error('Pirsch MCP server running');
-}
-
-function isDirectExecution(): boolean {
- if (typeof process.argv[1] !== 'string') {
- return false;
- }
-
- try {
- // npm/npx invoke this file through a symlinked bin (e.g. node_modules/.bin/mcp-pirsch),
- // so process.argv[1] is the symlink path while import.meta.url is already resolved to
- // the real target. Resolve the symlink before comparing or this always evaluates false
- // under npx, main() never runs, and the process exits cleanly with no output.
- const entrypointPath = resolve(process.argv[1]);
- const entrypointUrls = [entrypointPath, realpathSync(entrypointPath)].map((path) =>
- pathToFileURL(path).href
- );
-
- // `--preserve-symlinks-main` keeps the symlink in import.meta.url, while
- // the default Node behavior resolves it. Accept both representations.
- return entrypointUrls.includes(import.meta.url);
- } catch {
- return false;
- }
-}
-
-if (isDirectExecution()) {
- main().catch((error: unknown) => {
- console.error('Server error:', error);
- process.exit(1);
- });
+ installStdioLifecycle({ transport, parentPid });
+ serveStdio(
+ () => createPirschServer({ defaultDomainId: process.env.PIRSCH_DEFAULT_DOMAIN_ID }),
+ { transport, onerror: (error) => console.error('Server error:', error) }
+ );
+ console.error('Pirsch MCP server running on stdio');
+}
+
+try {
+ main();
+} catch (error) {
+ console.error('Server error:', error);
+ process.exit(1);
}
diff --git a/src/mcp.test.ts b/src/mcp.test.ts
index f01a8c2..fc6a1d9 100644
--- a/src/mcp.test.ts
+++ b/src/mcp.test.ts
@@ -1,4 +1,7 @@
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import packageJson from '../package.json' with { type: 'json' };
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createPirschServer, type PirschReader } from './server.js';
import { comparisonInputSchema, filterOptionsInputSchema, statisticsQuerySchema } from './schemas.js';
@@ -28,6 +31,20 @@ async function connect(clientFactory: () => PirschReader) {
}
describe('Pirsch MCP tool contracts', () => {
+ it('keeps the published manifest aligned with the runtime tool catalog', async () => {
+ const client = await connect(() => ({ listDomains: vi.fn(), get: vi.fn() }));
+ const manifest = JSON.parse(readFileSync(fileURLToPath(new URL('../server.json', import.meta.url)), 'utf8')) as {
+ version: string;
+ packages: Array<{ version: string }>;
+ tools: Array<{ name: string }>;
+ };
+
+ expect(manifest.tools.map((tool) => tool.name)).toEqual((await client.listTools()).tools.map((tool) => tool.name));
+ expect(manifest.version).toBe(packageJson.version);
+ expect(manifest.packages[0].version).toBe(packageJson.version);
+ expect(client.getServerVersion()).toMatchObject({ name: 'mcp-pirsch', version: packageJson.version });
+ });
+
it('returns only safe domains as structured content with a JSON text fallback', async () => {
const listDomains = vi.fn().mockResolvedValue([{ id: 'domain-1', hostname: 'example.com', timezone: 'UTC' }]);
const client = await connect(() => ({ listDomains, get: vi.fn() }));
@@ -46,6 +63,11 @@ describe('Pirsch MCP tool contracts', () => {
const result = await client.callTool({ name: 'pirsch_query_statistics', arguments: { metric: 'pages' } });
expect(result.isError).toBe(true);
+ expect(result.structuredContent).toEqual({
+ error: true,
+ message: "metric 'pages' requires both from and to dates.",
+ });
+ expect(JSON.parse((result.content as Array<{ text: string }>)[0].text)).toEqual(result.structuredContent);
expect(get).not.toHaveBeenCalled();
});
@@ -151,7 +173,6 @@ describe('Pirsch MCP tool contracts', () => {
expect(result.isError).toBe(true);
expect(get).not.toHaveBeenCalled();
});
-
it('keeps the environment timezone when custom client options are supplied', async () => {
const originalTimezone = process.env.PIRSCH_TIMEZONE;
process.env.PIRSCH_TIMEZONE = 'Europe/Berlin';
diff --git a/src/pirsch-api.test.ts b/src/pirsch-api.test.ts
deleted file mode 100644
index fa33a5f..0000000
--- a/src/pirsch-api.test.ts
+++ /dev/null
@@ -1,384 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { PirschAPI } from './pirsch-api.js';
-import type { Response, Headers } from 'node-fetch';
-
-// Mock node-fetch
-vi.mock('node-fetch', () => ({
- default: vi.fn(),
-}));
-
-import fetch from 'node-fetch';
-const mockFetch = vi.mocked(fetch);
-
-// Helper to create mock response objects
-const mockResponse = (data: Partial): Response => data as Response;
-
-describe('PirschAPI', () => {
- const clientId = 'test-client-id';
- const clientSecret = 'test-client-secret';
- let api: PirschAPI;
-
- const mockTokenResponse = {
- access_token: 'test-token-123',
- expires_at: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now
- };
-
- beforeEach(() => {
- vi.clearAllMocks();
- api = new PirschAPI(clientId, clientSecret);
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- describe('authentication', () => {
- it('should fetch a new token when none exists', async () => {
- mockFetch
- .mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => mockTokenResponse,
- })
- )
- .mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => [{ id: 'domain-1', hostname: 'example.com' }],
- })
- );
-
- await api.listDomains();
-
- expect(mockFetch).toHaveBeenCalledTimes(2);
- expect(mockFetch).toHaveBeenNthCalledWith(
- 1,
- 'https://api.pirsch.io/api/v1/token',
- expect.objectContaining({
- method: 'POST',
- body: JSON.stringify({
- client_id: clientId,
- client_secret: clientSecret,
- }),
- })
- );
- });
-
- it('should reuse cached token for subsequent requests', async () => {
- mockFetch
- .mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => mockTokenResponse,
- })
- )
- .mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => [{ id: 'domain-1' }],
- })
- )
- .mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => [{ id: 'domain-1' }],
- })
- );
-
- await api.listDomains();
- await api.listDomains();
-
- expect(mockFetch).toHaveBeenCalledTimes(3);
- });
-
- it('should throw error on auth failure', async () => {
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: false,
- status: 401,
- text: async () => 'Invalid credentials',
- })
- );
-
- await expect(api.listDomains()).rejects.toThrow('Pirsch auth failed (401)');
- });
- });
-
- describe('listDomains', () => {
- beforeEach(() => {
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => mockTokenResponse,
- })
- );
- });
-
- it('should list domains without filters', async () => {
- const domains = [
- { id: 'domain-1', hostname: 'example.com' },
- { id: 'domain-2', hostname: 'test.com' },
- ];
-
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => domains,
- })
- );
-
- const result = await api.listDomains();
-
- expect(result).toEqual(domains);
- expect(mockFetch).toHaveBeenLastCalledWith(
- 'https://api.pirsch.io/api/v1/domain?',
- expect.objectContaining({
- method: 'GET',
- headers: expect.objectContaining({
- Authorization: `Bearer ${mockTokenResponse.access_token}`,
- }),
- })
- );
- });
-
- it('should pass search parameter', async () => {
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => [{ id: 'domain-1' }],
- })
- );
-
- await api.listDomains({ search: 'example' });
-
- expect(mockFetch).toHaveBeenLastCalledWith(
- expect.stringContaining('search=example'),
- expect.anything()
- );
- });
- });
-
- describe('getOverview', () => {
- beforeEach(() => {
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => mockTokenResponse,
- })
- );
- });
-
- it('should fetch overview for a domain', async () => {
- const overview = { visitors: 1000, views: 5000 };
-
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => overview,
- })
- );
-
- const result = await api.getOverview('domain-1');
-
- expect(result).toEqual(overview);
- expect(mockFetch).toHaveBeenLastCalledWith(
- 'https://api.pirsch.io/api/v1/statistics/overview?id=domain-1',
- expect.anything()
- );
- });
- });
-
- describe('getStatistics', () => {
- beforeEach(() => {
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => mockTokenResponse,
- })
- );
- });
-
- it('should fetch statistics with filters', async () => {
- const stats = { visitors: 500, views: 1500 };
-
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => stats,
- })
- );
-
- const result = await api.getStatistics('/statistics/total', 'domain-1', {
- from: '2024-01-01',
- to: '2024-01-31',
- });
-
- expect(result).toEqual(stats);
- expect(mockFetch).toHaveBeenLastCalledWith(
- expect.stringContaining('from=2024-01-01'),
- expect.anything()
- );
- });
- });
-
- describe('getActive', () => {
- beforeEach(() => {
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => mockTokenResponse,
- })
- );
- });
-
- it('should fetch active visitors with default time window', async () => {
- const active = { visitors: 10, pages: [] };
-
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => active,
- })
- );
-
- const result = await api.getActive('domain-1');
-
- expect(result).toEqual(active);
- });
-
- it('should fetch active visitors with custom time window', async () => {
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => ({ visitors: 5 }),
- })
- );
-
- await api.getActive('domain-1', 300);
-
- expect(mockFetch).toHaveBeenLastCalledWith(
- expect.stringContaining('start=300'),
- expect.anything()
- );
- });
- });
-
- describe('retry logic', () => {
- it('should retry on 401 and refresh token', async () => {
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => mockTokenResponse,
- })
- );
-
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: false,
- status: 401,
- text: async () => 'Token expired',
- })
- );
-
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => ({
- ...mockTokenResponse,
- access_token: 'new-token-456',
- }),
- })
- );
-
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => [{ id: 'domain-1' }],
- })
- );
-
- const result = await api.listDomains();
-
- expect(result).toEqual([{ id: 'domain-1' }]);
- expect(mockFetch).toHaveBeenCalledTimes(4);
- });
-
- it('should retry on 429 with backoff', async () => {
- vi.useFakeTimers();
-
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => mockTokenResponse,
- })
- );
-
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: false,
- status: 429,
- headers: { get: () => '2', raw: () => ({}) } as unknown as Headers,
- text: async () => 'Rate limited',
- })
- );
-
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => [{ id: 'domain-1' }],
- })
- );
-
- const resultPromise = api.listDomains();
-
- await vi.advanceTimersByTimeAsync(2500);
-
- const result = await resultPromise;
-
- expect(result).toEqual([{ id: 'domain-1' }]);
- });
-
- it('should handle 204 No Content response', async () => {
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => mockTokenResponse,
- })
- );
-
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- status: 204,
- })
- );
-
- const result = await api.getStatistics('/statistics/total', 'domain-1', {});
-
- expect(result).toEqual({});
- });
- });
-
- describe('error handling', () => {
- beforeEach(() => {
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: true,
- json: async () => mockTokenResponse,
- })
- );
- });
-
- it('should throw on API error', async () => {
- mockFetch.mockResolvedValueOnce(
- mockResponse({
- ok: false,
- status: 400,
- text: async () => 'Bad request',
- })
- );
-
- await expect(api.listDomains()).rejects.toThrow('Pirsch API error (400)');
- });
- });
-});
diff --git a/src/pirsch-api.ts b/src/pirsch-api.ts
deleted file mode 100644
index f59a7ca..0000000
--- a/src/pirsch-api.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import fetch from 'node-fetch';
-import type { PirschTokenResponse, Domain, FilterInput } from './types.js';
-import { buildFilterParams } from './filters.js';
-
-const BASE_URL = 'https://api.pirsch.io/api/v1';
-
-class AuthError extends Error {
- constructor(message: string) {
- super(message);
- this.name = 'AuthError';
- }
-}
-
-interface TokenCache {
- token: string | null;
- expiresAt: number; // epoch ms
-}
-
-export class PirschAPI {
- private clientId: string;
- private clientSecret: string;
- private token: TokenCache = { token: null, expiresAt: 0 };
- private tokenSkewMs: number;
-
- constructor(clientId: string, clientSecret: string) {
- this.clientId = clientId;
- this.clientSecret = clientSecret;
- this.tokenSkewMs = parseInt(process.env.PIRSCH_TOKEN_SKEW_MS || '60000', 10);
- }
-
- private isTokenValid(): boolean {
- if (!this.token.token) return false;
- const now = Date.now();
- return now + this.tokenSkewMs < this.token.expiresAt;
- }
-
- private async refreshToken(): Promise {
- if (!this.clientId || !this.clientSecret) {
- throw new AuthError(
- 'Pirsch credentials missing. PIRSCH_CLIENT_ID and PIRSCH_CLIENT_SECRET must be set.'
- );
- }
- const url = `${BASE_URL}/token`;
- const res = await fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- client_id: this.clientId,
- client_secret: this.clientSecret,
- }),
- });
- if (!res.ok) {
- const body = await res.text();
- const masked = this.clientId.slice(0, 6) + '***';
- throw new AuthError(
- `Pirsch auth failed (${res.status}) for client_id=${masked}. ` +
- `Verify PIRSCH_CLIENT_ID and PIRSCH_CLIENT_SECRET are valid in the Pirsch dashboard. ` +
- `Response: ${body}`
- );
- }
- const data = (await res.json()) as PirschTokenResponse;
- this.token.token = data.access_token;
- this.token.expiresAt = Date.parse(data.expires_at);
- }
-
- private async ensureToken(): Promise {
- if (!this.isTokenValid()) {
- await this.refreshToken();
- }
- }
-
- private async request(
- method: string,
- endpoint: string,
- options?: { params?: URLSearchParams; body?: unknown },
- retries = 2
- ): Promise {
- await this.ensureToken();
-
- const url = `${BASE_URL}${endpoint}${options?.params ? `?${options.params.toString()}` : ''}`;
- const headers: Record = {
- 'Authorization': `Bearer ${this.token.token}`,
- 'Content-Type': 'application/json'
- };
-
- for (let i = 0; i <= retries; i++) {
- const res = await fetch(url, {
- method,
- headers,
- body: options?.body ? JSON.stringify(options.body) : undefined,
- });
-
- if (res.status === 401 && i < retries) {
- // Refresh token and retry
- await this.refreshToken();
- headers['Authorization'] = `Bearer ${this.token.token}`;
- continue;
- }
- if (res.status === 429 && i < retries) {
- const retryAfter = res.headers.get('Retry-After');
- const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : (i + 1) * 1500;
- await new Promise(r => setTimeout(r, delay));
- continue;
- }
- if (!res.ok) {
- const text = await res.text();
- throw new Error(`Pirsch API error (${res.status}): ${text}`);
- }
- if (res.status === 204) return {} as T;
- return (await res.json()) as T;
- }
- throw new Error('Max retries exceeded');
- }
-
- // Domains
- async listDomains(query?: { search?: string; id?: string; subdomain?: string; domain?: string; access?: string; }): Promise {
- const params = new URLSearchParams();
- if (query?.search) params.set('search', query.search);
- if (query?.id) params.set('id', query.id);
- if (query?.subdomain) params.set('subdomain', query.subdomain);
- if (query?.domain) params.set('domain', query.domain);
- if (query?.access) params.set('access', query.access);
-
- const result = await this.request('GET', '/domain', { params });
- return result;
- }
-
- // Overview (cached totals and members)
- async getOverview(domainId: string): Promise {
- const params = new URLSearchParams({ id: domainId });
- return this.request('GET', '/statistics/overview', { params });
- }
-
- // Generic statistics endpoint helper using filters
- async getStatistics(
- endpoint: string,
- domainId: string,
- filter: FilterInput = {}
- ): Promise {
- const params = buildFilterParams(filter, domainId, { tz: process.env.PIRSCH_TIMEZONE });
- return this.request('GET', endpoint, { params });
- }
-
- // Active visitors
- async getActive(domainId: string, startSeconds?: number): Promise {
- const params = buildFilterParams({ start: startSeconds }, domainId, { tz: process.env.PIRSCH_TIMEZONE });
- return this.request('GET', '/statistics/active', { params });
- }
-}
diff --git a/src/server.ts b/src/server.ts
index 14d451d..65366cc 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,4 +1,5 @@
import { McpServer } from '@modelcontextprotocol/server';
+import packageJson from '../package.json' with { type: 'json' };
import { filterOptionMetrics, statisticsMetrics } from './metrics.js';
import { PirschClient, type PirschClientOptions } from './pirsch-client.js';
import {
@@ -40,7 +41,7 @@ function jsonResult(output: T) {
function errorResult(error: unknown) {
const message = error instanceof Error ? error.message : 'Pirsch request failed.';
- return { content: [{ type: 'text' as const, text: message }], isError: true };
+ return { ...jsonResult({ error: true, message }), isError: true };
}
function resolveDomain(domainId: string | undefined, defaultDomainId: string | undefined): string {
@@ -140,7 +141,7 @@ export function createPirschServer(options: PirschServerOptions = {}): McpServer
return reader;
};
- const server = new McpServer({ name: 'mcp-pirsch', version: '1.0.0' });
+ const server = new McpServer({ name: 'mcp-pirsch', version: packageJson.version });
server.registerTool(
'pirsch_list_domains',