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 -[![Version](https://img.shields.io/npm/v/@verygoodplugins/mcp-pirsch)](https://www.npmjs.com/package/@verygoodplugins/mcp-pirsch) -[![License](https://img.shields.io/npm/l/@verygoodplugins/mcp-pirsch)](LICENSE) +[![npm](https://img.shields.io/npm/v/@verygoodplugins/mcp-pirsch)](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',