Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "unofficial-substack-sdk",
"version": "0.3.4",
"version": "0.3.5",
"description": "Unofficial, portable TypeScript SDK for Substack's web API",
"type": "module",
"license": "MIT",
Expand Down
17 changes: 16 additions & 1 deletion src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import type {
EmailStatsPage,
EmailStatsRow,
FetchLike,
GrowthSourceItem,
GrowthSourcesOptions,
GrowthSourcesResponse,
NoteComment,
NoteCommentOptions,
NoteFeedItem,
Expand All @@ -36,6 +39,7 @@ import type {
} from './types.js'
import { getActivity, getUnreadActivity } from '../resources/activity/index.js'
import { getAllEmailStats, getEmailStats } from '../resources/email-stats/index.js'
import { getGrowthSources } from '../resources/growth/index.js'
import {
commentOnNote,
createAttachment,
Expand Down Expand Up @@ -292,10 +296,21 @@ export class SubstackClient {
* Returns the publication's subscriber records and aggregate subscriber count.
* The response may include subscriber personal data, including email addresses.
*/
getSubscriberStats<T = unknown>(): Promise<SubscriberStatsResponse<T>> {
getSubscriberStats<T = unknown>(): Promise<SubscriberStatsResponse<T> | Record<string, unknown>> {
return getSubscriberStats(this.endpoints)
}

/**
* Returns historical traffic, subscriber growth, and revenue breakdown by acquisition source.
* A publication URL is required.
*/
getGrowthSources<TSource = GrowthSourceItem>(
options: GrowthSourcesOptions = {}
): Promise<GrowthSourcesResponse<TSource>> {
this.requirePublicationApiBase()
return getGrowthSources(this.endpoints, options)
}

/**
* Creates a link attachment for a Note. Pass the returned attachment ID to
* `publishNote` as an `attachmentIds` entry.
Expand Down
7 changes: 7 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ export {
type CursorOptions,
type DraftNotesOptions,
type DraftNotesPage,
type EmailStatsItem,
type EmailStatsOptions,
type EmailStatsPage,
type EmailStatsRow,
type FetchLike,
type GrowthMetric,
type GrowthMetricTimeseriesPoint,
type GrowthSourceItem,
type GrowthSourcesOptions,
type GrowthSourcesResponse,
type GrowthTotalItem,
type NoteActionOptions,
type NoteBodyInlineNode,
type NoteBodyJson,
Expand Down
58 changes: 58 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@ export interface EmailStatsOptions {
orderDirection?: 'asc' | 'desc'
}

/** One raw row from the publication email statistics endpoint. */
export type EmailStatsItem = EmailStatsRow

/** One raw row from the publication email statistics endpoint. */
export interface EmailStatsRow {
post_id?: number
Expand Down Expand Up @@ -469,3 +472,58 @@ export type UnreadActivityFeed = ActivityFeed & {
activityItems: unknown[]
unread: UnreadActivityMetadata
}

export interface GrowthSourcesOptions {
/** Start date in YYYY-MM-DD format. */
fromDate?: string
/** End date in YYYY-MM-DD format. */
toDate?: string
/** Upstream metric to order by. Defaults to `users`. */
orderBy?: 'users' | 'subscriptions' | 'annual_subscriptions' | 'revenue' | string
/** Sort direction. Defaults to `desc`. */
orderDirection?: 'asc' | 'desc'
}

export interface GrowthMetricTimeseriesPoint {
date: string
value: number
[key: string]: unknown
}

export interface GrowthMetric {
name: 'Traffic' | 'Subscribers' | 'Revenue' | string
timeseries?: GrowthMetricTimeseriesPoint[]
total?: number
[key: string]: unknown
}

export interface GrowthSourceItem {
source?: string
sourceName?: string
originalSourceName?: string
category?: string
logoUrl?: string
href?: string
pubId?: number
noteId?: string
isAggregation?: boolean
metrics?: GrowthMetric[]
children?: GrowthSourceItem[]
[key: string]: unknown
}

export interface GrowthTotalItem {
name?: 'traffic' | 'subscribers' | 'revenue' | string
total?: number
[key: string]: unknown
}

/** Response from Substack's publication growth sources endpoint. */
export type GrowthSourcesResponse<
TSource = GrowthSourceItem,
TTotal = GrowthTotalItem
> = {
sourceMetrics?: TSource[]
totals?: TTotal[]
[key: string]: unknown
}
66 changes: 63 additions & 3 deletions src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
SubstackClient,
SubstackConfigurationError,
type EmailStatsOptions,
type EmailStatsRow
type EmailStatsRow,
type GrowthSourcesOptions
} from './index.js'

type ReadOnlyClient = Pick<
Expand All @@ -22,6 +23,7 @@ type ReadOnlyClient = Pick<
| 'getSubscriberStats'
| 'getActivity'
| 'getUnreadActivity'
| 'getGrowthSources'
>

type ToolResult = {
Expand All @@ -47,6 +49,9 @@ const emailRowLimit = z.number().int().min(1).max(20).default(20)
const rawRowLimit = z.number().int().min(1).max(200).default(20)
const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD.')
const orderDirection = z.enum(['asc', 'desc']).default('desc')
const growthSourcesOrderBy = z
.enum(['users', 'subscriptions', 'annual_subscriptions', 'revenue'])
.default('users')
const activityFilter = z.enum(['all', 'replies-and-mentions', 'restacks']).default('all')
const topMetrics = [
'opens',
Expand Down Expand Up @@ -373,12 +378,33 @@ export function createToolHandlers(client: ReadOnlyClient) {
run(async () => capped(await client.getActivity(filter), maximum)),
getUnreadActivity: (maximum = 20) =>
run(async () => capped(await client.getUnreadActivity(), maximum)),
analyzeContent: (postId: string | number) => getPostAnalytics(postId, 0, false)
analyzeContent: (postId: string | number) => getPostAnalytics(postId, 0, false),
getSubscriberStats: async (): Promise<ToolResult> => {
try {
const stats = await client.getSubscriberStats()
return {
content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }],
structuredContent: stats as Record<string, unknown>
}
} catch (error: any) {
return {
isError: true,
content: [{ type: 'text', text: `Failed to fetch subscriber stats: ${error.message}` }]
}
}
},
getGrowthSources: (options: GrowthSourcesOptions = {}) =>
run(async () => {
if (options.fromDate && options.toDate && options.fromDate > options.toDate) {
throw new SubstackConfigurationError('Growth sources fromDate cannot be after toDate.')
}
return client.getGrowthSources(options)
})
}
}

export function createMcpServer(client: ReadOnlyClient): McpServer {
const server = new McpServer({ name: 'substack-mcp', version: '0.3.4' })
const server = new McpServer({ name: 'substack-mcp', version: '0.3.5' })
const tools = createToolHandlers(client)

server.registerTool(
Expand Down Expand Up @@ -545,6 +571,17 @@ export function createMcpServer(client: ReadOnlyClient): McpServer {
},
({ include_records, record_limit }) => tools.getSubscriberSummary(include_records, record_limit)
)
server.registerTool(
'get_subscriber_stats',
{
title: 'Get subscriber stats',
description:
'Get publication subscriber statistics or delivery-derived stats if subscriber-stats is unavailable.',
outputSchema,
annotations: readOnlyAnnotations
},
() => tools.getSubscriberStats()
)
server.registerTool(
'get_activity',
{
Expand Down Expand Up @@ -579,6 +616,29 @@ export function createMcpServer(client: ReadOnlyClient): McpServer {
},
({ post_id }) => tools.analyzeContent(post_id)
)
server.registerTool(
'get_growth_sources',
{
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.',
inputSchema: {
from_date: date.optional(),
to_date: date.optional(),
order_by: growthSourcesOrderBy,
order_direction: orderDirection
},
outputSchema,
annotations: readOnlyAnnotations
},
({ from_date, to_date, order_by, order_direction }) =>
tools.getGrowthSources({
fromDate: from_date,
toDate: to_date,
orderBy: order_by,
orderDirection: order_direction
})
)

return server
}
Expand Down
22 changes: 22 additions & 0 deletions src/resources/growth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { EndpointContext } from '../../core/transport.js'
import type { GrowthSourcesOptions, GrowthSourcesResponse, GrowthSourceItem } from '../../core/types.js'

function growthSourcesQuery(options: GrowthSourcesOptions): URLSearchParams {
const params = new URLSearchParams()
params.set('order_by', options.orderBy ?? 'users')
params.set('order_direction', options.orderDirection ?? 'desc')
if (options.fromDate) params.set('from_date', options.fromDate)
if (options.toDate) params.set('to_date', options.toDate)
return params
}

/**
* Returns historical breakdown of traffic, subscriber growth, and revenue by acquisition source.
*/
export function getGrowthSources<TSource = GrowthSourceItem>(
context: EndpointContext,
options: GrowthSourcesOptions = {}
): Promise<GrowthSourcesResponse<TSource>> {
const query = growthSourcesQuery(options).toString()
return context.publication(`/publication/stats/growth/sources${query ? `?${query}` : ''}`)
}
41 changes: 33 additions & 8 deletions src/resources/subscriber-stats/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,37 @@
import type { EndpointContext } from '../../core/transport.js'
import type { SubscriberStatsResponse } from '../../core/types.js'
import type { SubscriberStatsResponse, EmailStatsItem } from '../../core/types.js'

/**
* Returns the publication's subscriber records and aggregate subscriber count.
* The response can contain subscriber personal data, including email addresses.
*/
export function getSubscriberStats<T = unknown>(
export async function getSubscriberStats<T = unknown>(
context: EndpointContext
): Promise<SubscriberStatsResponse<T>> {
return context.publication('/subscriber-stats')
): Promise<SubscriberStatsResponse<T> | Record<string, unknown>> {
try {
// 1. Try legacy/native endpoint
return await context.publication<SubscriberStatsResponse<T>>('/subscriber-stats')
} catch (error: any) {
// 2. If Substack returns 404 (endpoint deprecated), fall back to email-stats
if (error?.status === 404 || error?.message?.includes('404')) {
let emailStats: any
try {
emailStats = await context.publication<EmailStatsItem[]>('/email-stats')
} catch (err: any) {
if (err?.status === 404 || err?.message?.includes('404')) {
const res = await context.publication<{ rows?: EmailStatsItem[] }>(
'/publication/stats/email_stats?offset=0&limit=20'
)
emailStats = res?.rows ?? []
} else {
throw err
}
}
const latest = Array.isArray(emailStats) && emailStats.length > 0 ? emailStats[0] : null
return {
derived_from_delivery: true,
active_subscribers_delivered: latest?.delivered ?? latest?.queued ?? 0,
recent_signups: latest?.signups ?? 0,
latest_post_title: latest?.title ?? '',
open_rate: latest?.open_rate ? `${(latest.open_rate * 100).toFixed(1)}%` : '0%'
}
}
throw error
}
}
Loading