From 8e6cd24076e3ecc4e593d75b84dd1ae6c0085c03 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 27 Aug 2026 16:19:27 +0300 Subject: [PATCH] feat: add rate-limited trend granularity and typed output schemas for all tools --- package.json | 2 +- src/core/index.ts | 1 + src/core/types.ts | 12 ++ src/mcp-server.ts | 251 +++++++++++++++++++++++++++++++--- src/resources/growth/index.ts | 149 +++++++++++++++++++- test/client.test.ts | 35 ++++- 6 files changed, 424 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 0e1a2a5..303427f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "unofficial-substack-sdk", - "version": "0.3.7", + "version": "0.3.8", "description": "Unofficial, portable TypeScript SDK for Substack's web API", "type": "module", "license": "MIT", diff --git a/src/core/index.ts b/src/core/index.ts index 99e885a..86799f4 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -16,6 +16,7 @@ export { type EmailStatsPage, type EmailStatsRow, type FetchLike, + type GrowthInterval, type GrowthMetric, type GrowthMetricTimeseriesPoint, type GrowthSourceItem, diff --git a/src/core/types.ts b/src/core/types.ts index 16447ed..9d2a7b1 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -473,6 +473,14 @@ export type UnreadActivityFeed = ActivityFeed & { unread: UnreadActivityMetadata } +export interface GrowthInterval { + startDate: string + endDate: string + totals?: TTotal[] + sourceMetrics?: TSource[] + [key: string]: unknown +} + export interface GrowthSourcesOptions { /** Start date in YYYY-MM-DD format. */ fromDate?: string @@ -490,6 +498,8 @@ export interface GrowthSourcesOptions { orderDirection?: 'asc' | 'desc' /** Sort direction alias (snake_case). */ order_direction?: 'asc' | 'desc' + /** Trend aggregation granularity: 'total' (default), 'day' (max 31 days), 'week', or 'month'. */ + granularity?: 'total' | 'day' | 'week' | 'month' } export interface GrowthMetricTimeseriesPoint { @@ -533,5 +543,7 @@ export type GrowthSourcesResponse< > = { sourceMetrics?: TSource[] totals?: TTotal[] + granularity?: 'total' | 'day' | 'week' | 'month' + intervals?: GrowthInterval[] [key: string]: unknown } diff --git a/src/mcp-server.ts b/src/mcp-server.ts index a322ef3..ee796a8 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -119,7 +119,216 @@ const readOnlyAnnotations = { openWorldHint: true } as const -const outputSchema = { data: z.unknown() } +const authenticatedProfileOutputSchema = { + data: z + .object({ + id: z.number().describe('Numeric author / user profile ID'), + handle: z.string().optional().describe('Substack username handle'), + name: z.string().optional().describe('Display name'), + photo_url: z.string().optional(), + bio: z.string().optional() + }) + .passthrough() +} + +const recentPostsOutputSchema = { + data: z + .object({ + posts: z.array(z.record(z.string(), z.unknown())).describe('Recent published posts') + }) + .passthrough() +} + +const emailStatsOutputSchema = { + data: z + .object({ + rows: z.array(z.record(z.string(), z.unknown())).describe('Email stats rows for sent posts'), + offset: z.number().optional().describe('Pagination offset'), + limit: z.number().optional().describe('Row limit') + }) + .passthrough() +} + +const publicationAnalyticsOutputSchema = { + data: z + .object({ + totals: z.record(z.string(), z.unknown()).describe('Aggregate metric totals across email history'), + summary: z.record(z.string(), z.unknown()).optional().describe('Average open, click, and engagement rates'), + breakdowns: z.record(z.string(), z.unknown()).optional().describe('Aggregates broken down by audience and post type'), + top_posts: z.array(z.record(z.string(), z.unknown())).optional().describe('Top performing posts ranked by requested metric'), + rows: z.array(z.record(z.string(), z.unknown())).optional().describe('Bounded raw email rows if include_rows was requested') + }) + .passthrough() +} + +const postEngagementOutputSchema = { + data: z + .object({ + post: z.record(z.string(), z.unknown()).optional().describe('Post content and metadata'), + comments: z.array(z.record(z.string(), z.unknown())).optional().describe('Visible reader comments'), + engagement: z.record(z.string(), z.unknown()).optional().describe('Calculated reaction, comment, and engagement totals') + }) + .passthrough() +} + +const postAnalyticsOutputSchema = { + data: z + .object({ + post: z.record(z.string(), z.unknown()).optional().describe('Post details'), + engagement: z.record(z.string(), z.unknown()).optional().describe('Calculated engagement summary'), + stats: z.record(z.string(), z.unknown()).optional().describe('Author analytics including traffic, conversions, and delivery') + }) + .passthrough() +} + +const notesOutputSchema = { + data: z + .object({ + items: z.array(z.record(z.string(), z.unknown())).describe('Notes items'), + cursor: z.string().optional().describe('Pagination cursor for next page') + }) + .passthrough() +} + +const profileNotesOutputSchema = { + data: z + .object({ + items: z.array(z.record(z.string(), z.unknown())).describe('Notes published by profile'), + cursor: z.string().optional().describe('Pagination cursor for next page') + }) + .passthrough() +} + +const noteEngagementOutputSchema = { + data: z + .object({ + item: z.record(z.string(), z.unknown()).optional().describe('Note item details'), + replies: z.array(z.record(z.string(), z.unknown())).optional().describe('Visible Note replies'), + engagement: z.record(z.string(), z.unknown()).optional().describe('Calculated reaction and restack totals') + }) + .passthrough() +} + +const subscriberSummaryOutputSchema = { + data: z + .object({ + count: z.number().optional().describe('Total active subscriber count'), + aggregates: z.record(z.string(), z.unknown()).optional().describe('Aggregated subscriber stats'), + subscribers: z.array(z.record(z.string(), z.unknown())).optional().describe('Raw subscriber records (only when requested)') + }) + .passthrough() +} + +const subscriberStatsOutputSchema = { + data: z + .object({ + total_subscribers: z.number().optional().describe('Total subscriber count'), + active_subscribers_delivered: z.number().optional().describe('Subscribers delivered on latest email'), + recent_signups: z.number().optional().describe('Recent email signups count'), + open_rate: z.number().optional().describe('Open rate percentage'), + latest_post_title: z.string().optional().describe('Title of latest delivered post'), + derived_from_delivery: z.boolean().optional().describe('Whether stats were derived from email delivery') + }) + .passthrough() +} + +const activityOutputSchema = { + data: z + .object({ + activityItems: z.array(z.record(z.string(), z.unknown())).describe('Activity notification items') + }) + .passthrough() +} + +const unreadActivityOutputSchema = { + data: z + .object({ + activityItems: z.array(z.record(z.string(), z.unknown())).describe('Unread activity notification items'), + unread: z.record(z.string(), z.unknown()).optional().describe('Unread count and metadata') + }) + .passthrough() +} + +const analyzeContentOutputSchema = { + data: z + .object({ + post_id: z.union([z.number(), z.string()]).optional().describe('Post ID'), + title: z.string().optional().describe('Post title'), + performance: z.record(z.string(), z.unknown()).optional().describe('Performance and conversion summary'), + engagement: z.record(z.string(), z.unknown()).optional().describe('Engagement summary') + }) + .passthrough() +} + +const growthMetricPointSchema = z.object({ + date: z.string().describe('Snapshot or entry date'), + value: z.number().describe('Metric value') +}) + +const growthMetricSchema = z.object({ + name: z.string().describe('Metric name (Traffic, Subscribers, Revenue)'), + total: z.number().nullable().optional().describe('Total aggregate for the period'), + timeseries: z.array(growthMetricPointSchema).optional() +}) + +const growthSourceItemSchema: z.ZodType = z.lazy(() => + z.object({ + source: z.string().optional().describe('Source identifier slug'), + sourceName: z.string().optional().describe('Display source name'), + category: z.string().optional().describe('Source category'), + logoUrl: z.string().optional(), + metrics: z.array(growthMetricSchema).optional(), + children: z.array(growthSourceItemSchema).optional() + }) +) + +const growthIntervalSchema = z.object({ + startDate: z.string().describe('Interval start date (YYYY-MM-DD)'), + endDate: z.string().describe('Interval end date (YYYY-MM-DD)'), + totals: z + .array( + z.object({ + name: z.string().optional(), + total: z.number().nullable().optional() + }) + ) + .optional(), + sourceMetrics: z.array(growthSourceItemSchema).optional() +}) + +const growthSourcesOutputSchema = { + data: z + .object({ + granularity: z + .enum(['total', 'day', 'week', 'month']) + .optional() + .describe('Aggregation granularity'), + totals: z + .array( + z.object({ + name: z.string().optional().describe('Metric name (traffic, subscribers, revenue)'), + total: z.number().nullable().optional().describe('Total value across period') + }) + ) + .optional(), + intervals: z + .array(growthIntervalSchema) + .optional() + .describe('Interval slices when granularity is day, week, or month'), + sourceMetrics: z + .array(growthSourceItemSchema) + .optional() + .describe('Acquisition sources when granularity is total') + }) + .passthrough() +} + +const growthSourcesGranularity = z + .enum(['total', 'day', 'week', 'month']) + .default('total') + .describe( + 'Aggregation interval: "total" for full period aggregate (1 fast call), "week" for weekly trend intervals (max 26 weeks), "day" for daily trend intervals (max 31 days), or "month" for monthly trend intervals (max 24 months). Requests are rate-limited to max 2 req/sec.' + ) function result(data: unknown): ToolResult { const output = { data } @@ -406,7 +615,7 @@ export function createToolHandlers(client: ReadOnlyClient) { } export function createMcpServer(client: ReadOnlyClient): McpServer { - const server = new McpServer({ name: 'substack-mcp', version: '0.3.7' }) + const server = new McpServer({ name: 'substack-mcp', version: '0.3.8' }) const tools = createToolHandlers(client) server.registerTool( @@ -414,7 +623,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { { title: 'Get authenticated Substack profile', description: 'Get the authenticated profile, including the profile ID needed by profile tools.', - outputSchema, + outputSchema: authenticatedProfileOutputSchema, annotations: readOnlyAnnotations }, () => tools.getAuthenticatedProfile() @@ -425,7 +634,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { title: 'Get recent posts', description: 'Get recent posts for a Substack profile.', inputSchema: { profile_id: id, limit }, - outputSchema, + outputSchema: recentPostsOutputSchema, annotations: readOnlyAnnotations }, ({ profile_id, limit }) => tools.getRecentPosts(profile_id, limit) @@ -442,7 +651,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { order_by: z.string().min(1).default('post_date'), order_direction: orderDirection }, - outputSchema, + outputSchema: emailStatsOutputSchema, annotations: readOnlyAnnotations }, ({ offset, limit, order_by, order_direction }) => @@ -465,7 +674,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { include_rows: z.boolean().default(false), row_limit: rawRowLimit }, - outputSchema, + outputSchema: publicationAnalyticsOutputSchema, annotations: readOnlyAnnotations }, ({ @@ -497,7 +706,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { title: 'Get post engagement', description: 'Get a post, its visible comments, and content engagement totals.', inputSchema: { post_id: id, comment_limit: limit }, - outputSchema, + outputSchema: postEngagementOutputSchema, annotations: readOnlyAnnotations }, ({ post_id, comment_limit }) => tools.getPostEngagement(post_id, comment_limit) @@ -513,7 +722,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { comment_limit: limit, include_raw: z.boolean().default(false) }, - outputSchema, + outputSchema: postAnalyticsOutputSchema, annotations: readOnlyAnnotations }, ({ post_id, comment_limit, include_raw }) => @@ -525,7 +734,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { title: 'Get publication Notes', description: 'Get a bounded page of authenticated publication Notes.', inputSchema: { cursor: z.string().optional(), limit }, - outputSchema, + outputSchema: notesOutputSchema, annotations: readOnlyAnnotations }, ({ cursor, limit }) => tools.getNotes(cursor, limit) @@ -536,7 +745,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { title: 'Get profile Notes', description: 'Get a bounded profile Notes page with raw per-Note engagement fields.', inputSchema: { profile_id: id, cursor: z.string().optional(), limit }, - outputSchema, + outputSchema: profileNotesOutputSchema, annotations: readOnlyAnnotations }, ({ profile_id, cursor, limit }) => tools.getProfileNotes(profile_id, cursor, limit) @@ -552,7 +761,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { reply_limit: limit, include_raw_pages: z.boolean().default(false) }, - outputSchema, + outputSchema: noteEngagementOutputSchema, annotations: readOnlyAnnotations }, ({ note_id, reply_limit, include_raw_pages }) => @@ -568,7 +777,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { include_records: z.boolean().default(false), record_limit: rawRowLimit }, - outputSchema, + outputSchema: subscriberSummaryOutputSchema, annotations: readOnlyAnnotations }, ({ include_records, record_limit }) => tools.getSubscriberSummary(include_records, record_limit) @@ -579,7 +788,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { title: 'Get subscriber stats', description: 'Get publication subscriber statistics or delivery-derived stats if subscriber-stats is unavailable.', - outputSchema, + outputSchema: subscriberStatsOutputSchema, annotations: readOnlyAnnotations }, () => tools.getSubscriberStats() @@ -590,7 +799,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { title: 'Get Substack activity', description: 'Get bounded authenticated activity for all events, replies and mentions, or restacks.', inputSchema: { filter: activityFilter, limit }, - outputSchema, + outputSchema: activityOutputSchema, annotations: readOnlyAnnotations }, ({ filter, limit }) => tools.getActivity(filter, limit) @@ -601,7 +810,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { title: 'Get unread Substack activity', description: 'Get bounded unread authenticated activity plus unread-count metadata.', inputSchema: { limit }, - outputSchema, + outputSchema: unreadActivityOutputSchema, annotations: readOnlyAnnotations }, ({ limit }) => tools.getUnreadActivity(limit) @@ -613,7 +822,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { description: 'Return complete author and content engagement analytics for one post without comment or raw-response payloads.', inputSchema: { post_id: id }, - outputSchema, + outputSchema: analyzeContentOutputSchema, annotations: readOnlyAnnotations }, ({ post_id }) => tools.analyzeContent(post_id) @@ -623,7 +832,7 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { { title: 'Get growth and traffic sources', description: - 'Get historical breakdown of publication traffic, subscriber acquisition, and revenue by referrer / growth channel over a date range.', + 'Get historical breakdown of publication traffic, subscriber acquisition, and revenue by referrer / growth channel over a date range. Supports granularity: "total" (default 1-shot aggregate), "week" (weekly trend lines), "day" (daily trend lines, max 31 days), or "month" (monthly trend lines).', inputSchema: { from_date: date.optional(), fromDate: date.optional(), @@ -632,9 +841,10 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { order_by: growthSourcesOrderBy.optional(), orderBy: growthSourcesOrderBy.optional(), order_direction: orderDirection.optional(), - orderDirection: orderDirection.optional() + orderDirection: orderDirection.optional(), + granularity: growthSourcesGranularity.optional() }, - outputSchema, + outputSchema: growthSourcesOutputSchema, annotations: readOnlyAnnotations }, (args: any) => @@ -642,7 +852,8 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { fromDate: args.from_date ?? args.fromDate, toDate: args.to_date ?? args.toDate, orderBy: args.order_by ?? args.orderBy ?? 'users', - orderDirection: args.order_direction ?? args.orderDirection ?? 'desc' + orderDirection: args.order_direction ?? args.orderDirection ?? 'desc', + granularity: args.granularity ?? 'total' }) ) diff --git a/src/resources/growth/index.ts b/src/resources/growth/index.ts index b419d0c..96c69be 100644 --- a/src/resources/growth/index.ts +++ b/src/resources/growth/index.ts @@ -1,5 +1,12 @@ +import { SubstackConfigurationError } from '../../core/errors.js' import type { EndpointContext } from '../../core/transport.js' -import type { GrowthSourcesOptions, GrowthSourcesResponse, GrowthSourceItem } from '../../core/types.js' +import type { + GrowthInterval, + GrowthSourcesOptions, + GrowthSourcesResponse, + GrowthSourceItem, + GrowthTotalItem +} from '../../core/types.js' function growthSourcesQuery(options: GrowthSourcesOptions): URLSearchParams { const params = new URLSearchParams() @@ -15,13 +22,147 @@ function growthSourcesQuery(options: GrowthSourcesOptions): URLSearchParams { return params } +function parseDate(d: string): Date { + const parts = d.split('-').map(Number) + return new Date(Date.UTC(parts[0], parts[1] - 1, parts[2])) +} + +function formatDate(d: Date): string { + return d.toISOString().slice(0, 10) +} + +function generateDateWindows( + fromDate: string, + toDate: string, + granularity: 'day' | 'week' | 'month' +): Array<{ fromDate: string; toDate: string }> { + const start = parseDate(fromDate) + const end = parseDate(toDate) + const windows: Array<{ fromDate: string; toDate: string }> = [] + + let current = new Date(start) + + while (current <= end) { + const windowStart = new Date(current) + let windowEnd: Date + + if (granularity === 'day') { + windowEnd = new Date(current) + current.setUTCDate(current.getUTCDate() + 1) + } else if (granularity === 'week') { + windowEnd = new Date(current) + windowEnd.setUTCDate(windowEnd.getUTCDate() + 6) + if (windowEnd > end) windowEnd = new Date(end) + current.setUTCDate(current.getUTCDate() + 7) + } else { + // month + windowEnd = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() + 1, 0)) + if (windowEnd > end) windowEnd = new Date(end) + current = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() + 1, 1)) + } + + windows.push({ + fromDate: formatDate(windowStart), + toDate: formatDate(windowEnd) + }) + } + + return windows +} + /** * Returns historical breakdown of traffic, subscriber growth, and revenue by acquisition source. + * When `granularity` ('day' | 'week' | 'month') is specified with a date range, interval slices + * are fetched with strict rate-limiting (max 2 requests/second). */ -export function getGrowthSources( +export async function getGrowthSources( context: EndpointContext, options: GrowthSourcesOptions = {} ): Promise> { - const query = growthSourcesQuery(options).toString() - return context.publication(`/publication/stats/growth/sources${query ? `?${query}` : ''}`) + const granularity = options.granularity ?? 'total' + const fromDate = options.fromDate ?? options.from_date + const toDate = options.toDate ?? options.to_date + + if (fromDate && toDate && fromDate > toDate) { + throw new SubstackConfigurationError('Growth sources fromDate cannot be after toDate.') + } + + if (granularity === 'total' || !fromDate || !toDate) { + const query = growthSourcesQuery(options).toString() + const res = await context.publication>( + `/publication/stats/growth/sources${query ? `?${query}` : ''}` + ) + return { + ...res, + granularity: 'total' + } + } + + const windows = generateDateWindows(fromDate, toDate, granularity) + + if (granularity === 'day' && windows.length > 31) { + throw new SubstackConfigurationError( + 'Daily granularity is limited to a maximum range of 31 days. Use weekly or monthly granularity for larger date ranges.' + ) + } + if (granularity === 'week' && windows.length > 26) { + throw new SubstackConfigurationError( + 'Weekly granularity is limited to a maximum range of 26 weeks. Use monthly granularity for larger date ranges.' + ) + } + if (granularity === 'month' && windows.length > 24) { + throw new SubstackConfigurationError( + 'Monthly granularity is limited to a maximum range of 24 months.' + ) + } + + const intervals: GrowthInterval[] = [] + let totalTraffic = 0 + let totalSubscribers = 0 + let totalRevenue = 0 + let lastCallTime = 0 + + for (const w of windows) { + const now = Date.now() + const elapsed = now - lastCallTime + if (lastCallTime > 0 && elapsed < 500) { + await new Promise((resolve) => setTimeout(resolve, 500 - elapsed)) + } + lastCallTime = Date.now() + + const windowOptions: GrowthSourcesOptions = { + ...options, + fromDate: w.fromDate, + toDate: w.toDate + } + const query = growthSourcesQuery(windowOptions).toString() + const page = await context.publication>( + `/publication/stats/growth/sources${query ? `?${query}` : ''}` + ) + + const windowTraffic = page.totals?.find((t) => t.name === 'traffic')?.total ?? 0 + const windowSubs = page.totals?.find((t) => t.name === 'subscribers')?.total ?? 0 + const windowRev = page.totals?.find((t) => t.name === 'revenue')?.total ?? 0 + + totalTraffic += windowTraffic ?? 0 + totalSubscribers += windowSubs ?? 0 + totalRevenue += windowRev ?? 0 + + intervals.push({ + startDate: w.fromDate, + endDate: w.toDate, + totals: page.totals, + sourceMetrics: page.sourceMetrics + }) + } + + return { + granularity, + totals: [ + { name: 'traffic', total: totalTraffic }, + { name: 'subscribers', total: totalSubscribers }, + { name: 'revenue', total: totalRevenue } + ], + intervals + } } diff --git a/test/client.test.ts b/test/client.test.ts index 1e5f03d..f2e69d8 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -682,7 +682,7 @@ describe('SubstackClient', () => { orderDirection: 'desc' }) - expect(result).toEqual(fakeResponse) + expect(result).toEqual({ ...fakeResponse, granularity: 'total' }) expect(requests[0].url).toBe( 'https://allagentsconsidered.substack.com/api/v1/publication/stats/growth/sources?order_by=users&order_direction=desc&from_date=2026-07-29&to_date=2026-08-27' ) @@ -697,6 +697,39 @@ describe('SubstackClient', () => { expect(requests[1].url).toBe( 'https://allagentsconsidered.substack.com/api/v1/publication/stats/growth/sources?order_by=subscriptions&order_direction=asc&from_date=2026-03-01&to_date=2026-03-31' ) + + const weeklyResult = await client.getGrowthSources({ + fromDate: '2026-08-01', + toDate: '2026-08-15', + granularity: 'week' + }) + + expect(weeklyResult.granularity).toBe('week') + expect(weeklyResult.intervals?.length).toBe(3) + expect(weeklyResult.intervals?.[0].startDate).toBe('2026-08-01') + expect(weeklyResult.intervals?.[0].endDate).toBe('2026-08-07') + }) + + test('enforces granularity limits and rejects inverted ranges', async () => { + const client = new SubstackClient({ + sessionToken: 'session-value', + publicationUrl: 'https://allagentsconsidered.substack.com' + }) + + await expect( + client.getGrowthSources({ + fromDate: '2026-01-01', + toDate: '2026-03-01', + granularity: 'day' + }) + ).rejects.toThrow('Daily granularity is limited to a maximum range of 31 days') + + await expect( + client.getGrowthSources({ + fromDate: '2026-08-27', + toDate: '2026-08-01' + }) + ).rejects.toThrow('Growth sources fromDate cannot be after toDate') }) test('requires a publication URL for growth sources', () => {