From bc496ea20c75a0e4b16529d26594e0e0b2e4c932 Mon Sep 17 00:00:00 2001 From: Dan Date: Tue, 22 Sep 2026 12:47:20 +0300 Subject: [PATCH] fix: resolve following profile id without user-setting and fix testConnectivity (v0.3.16) --- README.md | 8 ++- package.json | 2 +- src/core/client.ts | 15 ++-- src/core/index.ts | 2 + src/core/types.ts | 13 ++++ src/mcp-server.ts | 56 ++++++++++++++- src/resources/profiles/index.ts | 38 ++++++++-- test/client.test.ts | 124 ++++++++++++++++++++++++++++++++ test/mcp.test.ts | 14 ++++ 9 files changed, 255 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 463bc7b..23fe845 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. | diff --git a/package.json b/package.json index a2dd9d7..9ea4d07 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/client.ts b/src/core/client.ts index b691e30..bcffac4 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -13,6 +13,7 @@ import type { EmailStatsPage, EmailStatsRow, FetchLike, + FollowingOptions, GrowthSourceItem, GrowthSourcesOptions, GrowthSourcesResponse, @@ -40,6 +41,7 @@ import type { ProfilePostsOptions, ScheduleNoteRequest, SubstackClientOptions, + SubscriptionsOptions, SubscriberStatsResponse, UnreadActivityFeed, UploadedImage, @@ -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' @@ -399,13 +402,17 @@ export class SubstackClient { return getActivityPage(this.endpoints, options) } - getFollowing(): Promise { - return getFollowing(this.endpoints) + getFollowing(options: FollowingOptions = {}): Promise { + return getFollowing(this.endpoints, options) + } + + getSubscriptions(options: SubscriptionsOptions = {}): Promise { + return getSubscriptions(this.endpoints, options) } async testConnectivity(): Promise { 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 diff --git a/src/core/index.ts b/src/core/index.ts index 8df3882..adf8def 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -18,6 +18,7 @@ export { type EmailStatsPage, type EmailStatsRow, type FetchLike, + type FollowingOptions, type GrowthInterval, type GrowthMetric, type GrowthMetricTimeseriesPoint, @@ -74,6 +75,7 @@ export { type ScheduleNoteRequest, type SubstackPostComment, type SubstackClientOptions, + type SubscriptionsOptions, type SubscriberStatsResponse, type UnreadActivityFeed, type UploadedImage, diff --git a/src/core/types.ts b/src/core/types.ts index 99cd42e..81da086 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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 diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 82bce17..ea43551 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -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< @@ -25,6 +27,8 @@ type ReadOnlyClient = Pick< | 'getActivity' | 'getUnreadActivity' | 'getGrowthSources' + | 'getFollowing' + | 'getSubscriptions' > type ToolResult = { @@ -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({ @@ -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 = ( @@ -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'], { diff --git a/src/resources/profiles/index.ts b/src/resources/profiles/index.ts index 7e0618f..c2440ec 100644 --- a/src/resources/profiles/index.ts +++ b/src/resources/profiles/index.ts @@ -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 = { @@ -98,11 +100,33 @@ export function getProfilePosts( return context.global(`/profile/posts?profile_user_id=${profileId}`) } -export async function getFollowing(context: EndpointContext): Promise { - 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 { + 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 { + if (options.handle) { + const profile = (await getPublicProfile(context, options.handle)) as Record + return profile?.subscriptions ?? [] + } + if (options.profileId) { + const profile = (await getProfileById(context, options.profileId)) as Record + return profile?.subscriptions ?? [] + } + return context.global('/subscriptions') +} diff --git a/test/client.test.ts b/test/client.test.ts index 788eb1d..aea94f2 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -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) diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 0267c21..157c857 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -108,6 +108,12 @@ const mockClient = (overrides: Record = {}) => ({ sourceMetrics: [{ source: 'substack', sourceName: 'Substack' }], totals: [{ name: 'traffic', total: 149 }] }), + getFollowing: async () => ({ + subscriberLists: [{ id: 'following', name: 'Following', groups: [] }] + }), + getSubscriptions: async () => [ + { publication_id: 101, publication: { name: 'Tech Insights' } } + ], ...overrides }) @@ -145,6 +151,14 @@ describe('MCP tools', () => { cursor: null } }) + expect((await tools.getFollowing()).structuredContent).toEqual({ + data: { + subscriberLists: [{ id: 'following', name: 'Following', groups: [] }] + } + }) + expect((await tools.getSubscriptions()).structuredContent).toEqual({ + data: [{ publication_id: 101, publication: { name: 'Tech Insights' } }] + }) }) test('fetches every profile Note page, normalizes bodies, and deduplicates IDs', async () => {