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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ Keep the session token local and out of source control. All MCP tools are read-o
| `get_paid_subscribers` | Structured breakdown of paid vs free subscribers, subscription tiers (comp, gift, trial, founding), and pledges. |
| `get_activity` | Bounded activity filtered by all events, replies and mentions, or restacks. |
| `get_unread_activity` | Bounded unread activity plus unread metadata. |
| `get_growth_sources` | Historical publication traffic, subscriber acquisition, and revenue by referrer channel. |
| `get_following` | Accounts followed by the authenticated account or a specified profile ID. |
| `get_subscriptions` | Publication subscriptions for the authenticated account or public subscriptions for a handle/profile ID. |
| `analyze_content` | Compact complete analytics for one post without comment or raw-response payloads. |

`get_publication_analytics` follows every email-stat page before calculating its summary, so it can make several authenticated requests for a large archive. Raw rows are excluded by default and capped when requested. `get_notes` and `get_profile_notes` default to 10 complete Note bodies; set `fetch_all: true` to follow every cursor up to `max_items` (default 500, maximum 5,000). `get_subscriber_summary` excludes subscriber records by default because they can contain email addresses and other personal data. See [MCP analytics](docs/mcp-analytics.md) for output semantics and usage examples.
Expand Down Expand Up @@ -112,8 +115,9 @@ Keep the session token local and out of source control. All MCP tools are read-o
| `getActivity(filter)` | Activity feed. Filters: `all`, `replies-and-mentions`, `restacks`. |
| `getActivityPage({ filter, after })` | One complete historical activity page with validated `more` and `nextAfter`. |
| `getUnreadActivity()` | Activity feed annotated using Substack's unread count. |
| `getFollowing()` | Accounts followed by the authenticated account. |
| `testConnectivity()` | Whether the session can perform a lightweight API request. |
| `getFollowing({ profileId })` | Accounts followed by the authenticated account (or explicit profile ID). Resolves profile ID automatically if omitted. |
| `getSubscriptions({ handle, profileId })` | Publication subscriptions for the authenticated account (or public subscriptions for a handle or profile ID). |
| `testConnectivity()` | Whether the session can perform a lightweight authenticated API request. |
| `uploadImage(dataUrl)` | Uploads a base64 data-URL image and returns Substack media metadata. |
| `createImageAttachment(uploadedImage)` | Creates a Note image attachment from an uploaded image. |
| `createAttachment(request)` | Creates a link or image attachment for a Note. |
Expand Down
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.15",
"version": "0.3.16",
"description": "Unofficial, portable TypeScript SDK for Substack's web API",
"type": "module",
"license": "MIT",
Expand Down
15 changes: 11 additions & 4 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
EmailStatsPage,
EmailStatsRow,
FetchLike,
FollowingOptions,
GrowthSourceItem,
GrowthSourcesOptions,
GrowthSourcesResponse,
Expand Down Expand Up @@ -40,6 +41,7 @@ import type {
ProfilePostsOptions,
ScheduleNoteRequest,
SubstackClientOptions,
SubscriptionsOptions,
SubscriberStatsResponse,
UnreadActivityFeed,
UploadedImage,
Expand Down Expand Up @@ -77,7 +79,8 @@ import {
getProfileFeed,
getProfilePosts,
getProfileReplies,
getPublicProfile
getPublicProfile,
getSubscriptions
} from '../resources/profiles/index.js'
import { getPaidSubscribers, getSubscriberStats } from '../resources/subscriber-stats/index.js'

Expand Down Expand Up @@ -399,13 +402,17 @@ export class SubstackClient {
return getActivityPage(this.endpoints, options)
}

getFollowing(): Promise<unknown> {
return getFollowing(this.endpoints)
getFollowing(options: FollowingOptions = {}): Promise<unknown> {
return getFollowing(this.endpoints, options)
}

getSubscriptions(options: SubscriptionsOptions = {}): Promise<unknown> {
return getSubscriptions(this.endpoints, options)
}

async testConnectivity(): Promise<boolean> {
try {
await this.endpoints.put('/user-setting', { type: 'last_home_tab', value_text: 'inbox' })
await this.endpoints.global('/handle/options')
return true
} catch {
return false
Expand Down
2 changes: 2 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {
type EmailStatsPage,
type EmailStatsRow,
type FetchLike,
type FollowingOptions,
type GrowthInterval,
type GrowthMetric,
type GrowthMetricTimeseriesPoint,
Expand Down Expand Up @@ -74,6 +75,7 @@ export {
type ScheduleNoteRequest,
type SubstackPostComment,
type SubstackClientOptions,
type SubscriptionsOptions,
type SubscriberStatsResponse,
type UnreadActivityFeed,
type UploadedImage,
Expand Down
13 changes: 13 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ export interface NotesOptions extends ProfileNotesOptions {
profileId?: number | string
}

export interface FollowingOptions {
/** Explicit Substack profile/user ID. If omitted, the authenticated account's ID is discovered automatically. */
profileId?: number | string
}

export interface SubscriptionsOptions {
/** Optional profile handle to retrieve public subscriptions for another account. If omitted, returns the authenticated user's subscriptions. */
handle?: string
/** Optional profile ID to retrieve public subscriptions for another account. */
profileId?: number | string
}


export interface NoteActionOptions {
/** Feed tab context sent to Substack. Defaults to `for-you`. */
tabId?: string
Expand Down
56 changes: 53 additions & 3 deletions src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
SubstackConfigurationError,
type EmailStatsOptions,
type EmailStatsRow,
type GrowthSourcesOptions
type FollowingOptions,
type GrowthSourcesOptions,
type SubscriptionsOptions
} from './core/index.js'

type ReadOnlyClient = Pick<
Expand All @@ -25,6 +27,8 @@ type ReadOnlyClient = Pick<
| 'getActivity'
| 'getUnreadActivity'
| 'getGrowthSources'
| 'getFollowing'
| 'getSubscriptions'
>

type ToolResult = {
Expand Down Expand Up @@ -135,6 +139,14 @@ const authenticatedProfileOutputSchema = {
.passthrough()
}

const followingOutputSchema = {
data: z.record(z.string(), z.unknown())
}

const subscriptionsOutputSchema = {
data: z.union([z.array(z.unknown()), z.record(z.string(), z.unknown())])
}

const recentPostsOutputSchema = {
data: z
.object({
Expand Down Expand Up @@ -1056,12 +1068,16 @@ export function createToolHandlers(client: ReadOnlyClient) {
throw new SubstackConfigurationError('Growth sources fromDate cannot be after toDate.')
}
return client.getGrowthSources(options)
})
}),
getFollowing: (profileId?: string | number) =>
run(async () => client.getFollowing(profileId ? { profileId } : {})),
getSubscriptions: (options: SubscriptionsOptions = {}) =>
run(async () => client.getSubscriptions(options))
}
}

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

const register = (
Expand All @@ -1088,6 +1104,40 @@ export function createMcpServer(client: ReadOnlyClient): McpServer {
},
() => tools.getAuthenticatedProfile()
)
register(
['get_following', 'getFollowing'],
{
title: 'Get followed accounts',
description: 'Get accounts followed by the authenticated user or a specified profile ID.',
inputSchema: {
profile_id: id.optional(),
profileId: id.optional()
},
outputSchema: followingOutputSchema,
annotations: readOnlyAnnotations
},
(args: any) => tools.getFollowing(args.profile_id ?? args.profileId)
)
register(
['get_subscriptions', 'getSubscriptions'],
{
title: 'Get publication subscriptions',
description:
'Get publication subscriptions for the authenticated user, or public subscriptions for a specified handle or profile ID.',
inputSchema: {
handle: z.string().optional(),
profile_id: id.optional(),
profileId: id.optional()
},
outputSchema: subscriptionsOutputSchema,
annotations: readOnlyAnnotations
},
(args: any) =>
tools.getSubscriptions({
handle: args.handle,
profileId: args.profile_id ?? args.profileId
})
)
register(
['get_recent_posts', 'getRecentPosts'],
{
Expand Down
38 changes: 31 additions & 7 deletions src/resources/profiles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { SubstackApiError, SubstackConfigurationError } from '../../core/errors.
import type { EndpointContext } from '../../core/transport.js'
import { positiveInteger } from '../../core/validation.js'
import type {
FollowingOptions,
ProfileFeedFilter,
ProfileFeedOptions,
ProfileFeedPage,
ProfilePostsOptions,
ProfileRepliesOptions
ProfileRepliesOptions,
SubscriptionsOptions
} from '../../core/types.js'

type ProfileFeedItem = {
Expand Down Expand Up @@ -98,11 +100,33 @@ export function getProfilePosts(
return context.global(`/profile/posts?profile_user_id=${profileId}`)
}

export async function getFollowing(context: EndpointContext): Promise<unknown> {
const settings = await context.put<{ user_id?: unknown }>('/user-setting', {
type: 'last_home_tab',
value_text: 'inbox'
})
const userId = positiveInteger(Number(settings.user_id), 'Authenticated user ID')
export async function getFollowing(
context: EndpointContext,
options: FollowingOptions = {}
): Promise<unknown> {
let profileId = options.profileId
if (!profileId) {
const profile = (await getAuthenticatedProfile(context)) as { id?: number | string }
if (!profile?.id) {
throw new SubstackApiError('Authenticated Substack profile ID was not found.', 502, '/handle/options')
}
profileId = profile.id
}
const userId = positiveInteger(profileId, 'Profile ID')
return context.publication(`/user/${userId}/subscriber-lists?lists=following`)
}

export async function getSubscriptions(
context: EndpointContext,
options: SubscriptionsOptions = {}
): Promise<unknown> {
if (options.handle) {
const profile = (await getPublicProfile(context, options.handle)) as Record<string, unknown>
return profile?.subscriptions ?? []
}
if (options.profileId) {
const profile = (await getProfileById(context, options.profileId)) as Record<string, unknown>
return profile?.subscriptions ?? []
}
return context.global('/subscriptions')
}
124 changes: 124 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,130 @@ describe('SubstackClient', () => {
})
})

test('getFollowing resolves authenticated user ID via /handle/options when profileId is omitted', async () => {
const urls: string[] = []
const client = new SubstackClient({
sessionToken: 'session-value',
publicationUrl: 'https://newsletter.example.com',
fetch: async (input) => {
const url = new Request(input).url
urls.push(url)
if (url.endsWith('/handle/options')) {
return Response.json({
potentialHandles: [{ handle: 'testauthor', type: 'existing' }]
})
}
if (url.includes('/user/testauthor/public_profile')) {
return Response.json({ id: 44242110, handle: 'testauthor', name: 'Test Author' })
}
if (url.includes('/subscriber-lists?lists=following')) {
return Response.json({
subscriberLists: [{ id: 'following', name: 'Following', groups: [{ users: [{ id: 101 }] }] }]
})
}
return Response.json({})
}
})

const result = (await client.getFollowing()) as { subscriberLists: unknown[] }
expect(urls).toEqual([
'https://substack.com/api/v1/handle/options',
'https://substack.com/api/v1/user/testauthor/public_profile',
'https://newsletter.example.com/api/v1/user/44242110/subscriber-lists?lists=following'
])
expect(result.subscriberLists).toHaveLength(1)
})

test('getFollowing uses explicit profileId and skips discovery calls', async () => {
const urls: string[] = []
const client = new SubstackClient({
sessionToken: 'session-value',
publicationUrl: 'https://newsletter.example.com',
fetch: async (input) => {
const url = new Request(input).url
urls.push(url)
return Response.json({
subscriberLists: [{ id: 'following', name: 'Following', groups: [] }]
})
}
})

await client.getFollowing({ profileId: 44242110 })
expect(urls).toEqual([
'https://newsletter.example.com/api/v1/user/44242110/subscriber-lists?lists=following'
])
})

test('testConnectivity verifies authenticated session via GET /handle/options', async () => {
const requests: Request[] = []
const client = new SubstackClient({
sessionToken: 'session-value',
fetch: async (input, init) => {
const req = new Request(input, init)
requests.push(req)
return Response.json({
potentialHandles: [{ handle: 'testauthor', type: 'existing' }]
})
}
})

const isConnected = await client.testConnectivity()
expect(isConnected).toBe(true)
expect(requests).toHaveLength(1)
expect(requests[0].method).toBe('GET')
expect(requests[0].url).toBe('https://substack.com/api/v1/handle/options')
})

test('testConnectivity returns false on authentication or network failure', async () => {
const failingClient = new SubstackClient({
sessionToken: 'bad-session',
fetch: async () => Response.json({ error: 'unauthorized' }, { status: 401 })
})

const isConnected = await failingClient.testConnectivity()
expect(isConnected).toBe(false)
})

test('getSubscriptions fetches authenticated user subscriptions when options are omitted', async () => {
const urls: string[] = []
const client = new SubstackClient({
sessionToken: 'session-value',
fetch: async (input) => {
const url = new Request(input).url
urls.push(url)
return Response.json([{ id: 1, publication: { name: 'Tech Newsletter' } }])
}
})

const subscriptions = (await client.getSubscriptions()) as Array<{ id: number }>
expect(urls).toEqual(['https://substack.com/api/v1/subscriptions'])
expect(subscriptions).toHaveLength(1)
expect(subscriptions[0].id).toBe(1)
})

test('getSubscriptions extracts subscriptions for explicit handle or profileId', async () => {
const urls: string[] = []
const client = new SubstackClient({
sessionToken: 'session-value',
fetch: async (input) => {
const url = new Request(input).url
urls.push(url)
if (url.includes('/public_profile')) {
return Response.json({
id: 10,
subscriptions: [{ publication_id: 101, membership_state: 'subscribed' }]
})
}
return Response.json({})
}
})

const byHandle = (await client.getSubscriptions({ handle: 'otheruser' })) as Array<{ publication_id: number }>
expect(urls).toEqual(['https://substack.com/api/v1/user/otheruser/public_profile'])
expect(byHandle).toHaveLength(1)
expect(byHandle[0].publication_id).toBe(101)
})

test('surfaces configuration and upstream API errors predictably', async () => {
const noPublication = new SubstackClient({ sessionToken: 'session-value' })
expect(() => noPublication.getProfileNotes(123)).toThrow(SubstackConfigurationError)
Expand Down
Loading
Loading