Skip to content
70 changes: 70 additions & 0 deletions src/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const servers: Array<ReturnType<typeof createPirschServer>> = [];
const clients: Client[] = [];

afterEach(async () => {
vi.useRealTimers();
await Promise.all(clients.splice(0).map((client) => client.close()));
await Promise.all(servers.splice(0).map((server) => server.close()));
});
Expand Down Expand Up @@ -82,6 +83,75 @@ describe('Pirsch MCP tool contracts', () => {
expect(get).toHaveBeenCalledWith('/options/event', 'default-domain', {});
});

it('resolves named comparison periods in the requested timezone', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-01T00:30:00.000Z'));
const get = vi.fn().mockResolvedValue({ visitors: 1 });
const client = await connect(() => ({ listDomains: vi.fn(), get }));

const result = await client.callTool({
name: 'pirsch_compare_periods',
arguments: { period: 'today', timezone: 'America/Los_Angeles' },
});

expect(result.isError).toBeUndefined();
expect(get.mock.calls[0]).toEqual(['/statistics/total', 'default-domain', expect.objectContaining({
from: '2026-07-31',
to: '2026-07-31',
timezone: 'America/Los_Angeles',
})]);
});

it('uses the timezone-local weekday for named weekly periods', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-03T00:30:00.000Z'));
const get = vi.fn().mockResolvedValue({ visitors: 1 });
const client = await connect(() => ({ listDomains: vi.fn(), get }));

const result = await client.callTool({
name: 'pirsch_compare_periods',
arguments: { period: 'week', timezone: 'America/Los_Angeles' },
});

expect(result.isError).toBeUndefined();
expect(get.mock.calls[0]).toEqual(['/statistics/total', 'default-domain', expect.objectContaining({
from: '2026-07-27',
to: '2026-08-02',
})]);
});

it('resolves named comparison periods in the configured timezone', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-01T00:30:00.000Z'));
const get = vi.fn().mockResolvedValue({ visitors: 1 });
const client = await connectOptions({
clientFactory: () => ({ listDomains: vi.fn(), get }),
clientOptions: { timezone: 'America/Los_Angeles' },
defaultDomainId: 'default-domain',
});

const result = await client.callTool({ name: 'pirsch_compare_periods', arguments: { period: 'today' } });

expect(result.isError).toBeUndefined();
expect(get.mock.calls[0]).toEqual(['/statistics/total', 'default-domain', expect.objectContaining({
from: '2026-07-31',
to: '2026-07-31',
})]);
});

it('rejects reverse clocks after a named same-day period is resolved', async () => {
const get = vi.fn();
const client = await connect(() => ({ listDomains: vi.fn(), get }));

const result = await client.callTool({
name: 'pirsch_compare_periods',
arguments: { period: 'today', fromTime: '18:00', toTime: '09:00' },
});

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';
Expand Down
35 changes: 23 additions & 12 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
type StatisticsQuery,
} from './schemas.js';
import type { PirschCredentials, PirschFilter, SafeDomain, StatisticsTotals, VisitorsPoint } from './types.js';
import { getDateRange, isoDate, pctChange } from './utils.js';
import { getDateRange, isoDate, pctChange, type PirschPeriod } from './utils.js';

export interface PirschReader {
listDomains(): Promise<SafeDomain[]>;
Expand Down Expand Up @@ -83,20 +83,30 @@ function previousRange(from: string, to: string): { from: string; to: string } {
};
}

function resolveComparisonRanges(input: ComparisonQuery) {
if (input.from && input.to && input.compareFrom && input.compareTo) {
return { current: { from: input.from, to: input.to }, previous: { from: input.compareFrom, to: input.compareTo } };
function validateResolvedClockRange(input: ComparisonQuery, range: { from: string; to: string }): void {
if (range.from === range.to && input.fromTime && input.toTime && input.fromTime > input.toTime) {
throw new Error('toTime must be on or after fromTime for a same-day range.');
}
if (input.period) {
const range = getDateRange(input.period);
}

function resolveComparisonRanges(input: ComparisonQuery, timezone?: string) {
let ranges: { current: { from: string; to: string }; previous: { from: string; to: string } };
if (input.from && input.to && input.compareFrom && input.compareTo) {
ranges = { current: { from: input.from, to: input.to }, previous: { from: input.compareFrom, to: input.compareTo } };
} else if (input.period) {
const range = getDateRange(input.period as PirschPeriod, timezone);
const current = { from: isoDate(range.start), to: isoDate(range.end) };
return { current, previous: previousRange(current.from, current.to) };
ranges = { current, previous: previousRange(current.from, current.to) };
} else {
throw new Error('Provide period or from/to plus compareFrom/compareTo.');
}
throw new Error('Provide period or from/to plus compareFrom/compareTo.');
validateResolvedClockRange(input, ranges.current);
validateResolvedClockRange(input, ranges.previous);
return ranges;
}

async function comparePeriods(reader: PirschReader, domainId: string, input: ComparisonQuery) {
const { current, previous } = resolveComparisonRanges(input);
async function comparePeriods(reader: PirschReader, domainId: string, input: ComparisonQuery, configuredTimezone?: string) {
const { current, previous } = resolveComparisonRanges(input, input.timezone ?? configuredTimezone);
const { domainId: _domainId, period: _period, compareFrom: _compareFrom, compareTo: _compareTo, ...filters } = input;
const currentFilter = { ...filters, ...current };
const previousFilter = { ...filters, ...previous };
Expand All @@ -120,11 +130,12 @@ async function comparePeriods(reader: PirschReader, domainId: string, input: Com

export function createPirschServer(options: PirschServerOptions = {}): McpServer {
const defaultDomainId = options.defaultDomainId ?? process.env.PIRSCH_DEFAULT_DOMAIN_ID;
const configuredTimezone = options.clientOptions?.timezone ?? process.env.PIRSCH_TIMEZONE;
let reader: PirschReader | undefined;
const getReader = () => {
reader ??= options.clientFactory?.() ?? new PirschClient(
options.credentials ?? { clientId: process.env.PIRSCH_CLIENT_ID, clientSecret: process.env.PIRSCH_CLIENT_SECRET },
{ ...options.clientOptions, timezone: options.clientOptions?.timezone ?? process.env.PIRSCH_TIMEZONE }
{ ...options.clientOptions, timezone: configuredTimezone }
);
return reader;
};
Expand Down Expand Up @@ -202,7 +213,7 @@ export function createPirschServer(options: PirschServerOptions = {}): McpServer
async (input) => {
try {
const domainId = resolveDomain(input.domainId, defaultDomainId);
return jsonResult(await comparePeriods(getReader(), domainId, input));
return jsonResult(await comparePeriods(getReader(), domainId, input, configuredTimezone));
} catch (error) {
return errorResult(error);
}
Expand Down
22 changes: 17 additions & 5 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import type { VisitorsPoint } from './types.js';

export function getDateRange(period: 'today' | 'yesterday' | 'week' | 'lastWeek' | 'month' | 'lastMonth') {
const now = new Date();
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
export type PirschPeriod = 'today' | 'yesterday' | 'week' | 'lastWeek' | 'month' | 'lastMonth';

function dateInTimezone(now: Date, timezone: string): Date {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now);
const value = Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]));
return new Date(Date.UTC(Number(value.year), Number(value.month) - 1, Number(value.day)));
}

export function getDateRange(period: PirschPeriod, timezone = 'UTC', now = new Date()) {
const start = dateInTimezone(now, timezone);
const end = new Date(start);

switch (period) {
Expand All @@ -15,15 +27,15 @@ export function getDateRange(period: 'today' | 'yesterday' | 'week' | 'lastWeek'
end.setUTCHours(23, 59, 59, 999);
return { start, end };
case 'week': {
const day = now.getUTCDay();
const day = start.getUTCDay();
start.setUTCDate(start.getUTCDate() - ((day + 6) % 7));
end.setTime(start.getTime());
end.setUTCDate(start.getUTCDate() + 6);
end.setUTCHours(23, 59, 59, 999);
return { start, end };
}
case 'lastWeek': {
const day = now.getUTCDay();
const day = start.getUTCDay();
start.setUTCDate(start.getUTCDate() - ((day + 6) % 7) - 7);
end.setTime(start.getTime());
end.setUTCDate(start.getUTCDate() + 6);
Expand Down
Loading