diff --git a/README.md b/README.md index ac5ec7a..356fd26 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Keep the session token local and out of source control. All MCP tools are read-o | `get_publication_analytics` | Full-history totals, average upstream rates, audience/section/type breakdowns, top posts, and optional raw rows. | | `get_post_engagement` | Post content engagement and a bounded visible-comment sample. | | `get_post_analytics` | Combined author analytics, delivery, conversion, media, links, referrers, comparison data, and visible engagement. | -| `get_notes` | Bounded authenticated-publication Notes page. | +| `get_notes` | Bounded Notes page from authenticated profile or optional `profile_id`. | | `get_profile_notes` | Bounded profile Notes page with raw per-Note metrics. | | `get_note_engagement` | Reactions, restacks, viewer state, and fully paginated direct/nested reply totals. | | `get_subscriber_summary` | Privacy-safe subscriber totals. Raw records require explicit `include_records: true`. | @@ -93,7 +93,7 @@ Keep the session token local and out of source control. All MCP tools are read-o | `getEmailStats({ offset, orderBy, orderDirection })` | Publication email delivery and engagement stats. Uses Substack's required fixed page size of 20. Requires a publication administrator session. | | `getAllEmailStats({ offset, orderBy, orderDirection })` | Fetches every 20-row email-stat page and returns one flat array of rows. | | `getSubscriberStats()` | Publication subscriber records and aggregate count. The response may contain subscriber personal data. | -| `getNotes({ cursor })` | Authenticated publication Notes feed. | +| `getNotes({ cursor, profileId })` | Authenticated profile Notes feed (resolves profile ID automatically if omitted). | | `getDraftNotes({ limit })` | Scheduled Note drafts for the authenticated account. Defaults to 20. | | `getNote(id)` | Raw, typed Note by ID. | | `getNoteWithEngagement(id)` | Raw Note and reply pages plus normalized, fully paginated visible reply totals. | diff --git a/package.json b/package.json index 303427f..d49d70d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "unofficial-substack-sdk", - "version": "0.3.8", + "version": "0.3.9", "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 f3fbcf6..ab31255 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -22,6 +22,7 @@ import type { NoteRepliesResponse, NoteResponse, NoteRestackOptions, + NotesOptions, NoteWithEngagement, PostManagementDetail, PostManagementPost, @@ -193,8 +194,10 @@ export class SubstackClient { return getProfilePosts(this.endpoints, id, options) } - getNotes(options: CursorOptions = {}): Promise { - return getNotes(this.endpoints, options) + getNotes = NoteFeedItem>( + options: NotesOptions = {} + ): Promise> { + return getNotes(this.endpoints, options) } /** Returns scheduled Note drafts for the authenticated account. */ diff --git a/src/core/index.ts b/src/core/index.ts index 86799f4..76a17c9 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -40,6 +40,7 @@ export { type NoteRepliesResponse, type NoteResponse, type NoteRestackOptions, + type NotesOptions, type NoteTrackingParameters, type NoteWithEngagement, type PostEngagement, diff --git a/src/core/types.ts b/src/core/types.ts index 9d2a7b1..60845db 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -36,6 +36,10 @@ export interface CursorOptions { cursor?: string } +export interface NotesOptions extends CursorOptions { + 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 ee796a8..2bd1647 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -545,8 +545,8 @@ export function createToolHandlers(client: ReadOnlyClient) { return { post, engagement, comments: commentItems.slice(0, maximum) } }), getPostAnalytics, - getNotes: (cursor: string | undefined, maximum = 20) => - run(async () => capped(await client.getNotes({ cursor }), maximum)), + getNotes: (cursor: string | undefined, maximum = 20, profileId?: string | number) => + run(async () => capped(await client.getNotes({ cursor, profileId }), maximum)), getProfileNotes: (profileId: string | number, cursor: string | undefined, maximum = 20) => run(async () => capped(await client.getProfileNotes(profileId, { cursor }), maximum)), getNoteEngagement: ( @@ -615,7 +615,7 @@ export function createToolHandlers(client: ReadOnlyClient) { } export function createMcpServer(client: ReadOnlyClient): McpServer { - const server = new McpServer({ name: 'substack-mcp', version: '0.3.8' }) + const server = new McpServer({ name: 'substack-mcp', version: '0.3.9' }) const tools = createToolHandlers(client) server.registerTool( @@ -732,12 +732,13 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { 'get_notes', { title: 'Get publication Notes', - description: 'Get a bounded page of authenticated publication Notes.', - inputSchema: { cursor: z.string().optional(), limit }, + description: + 'Get a bounded page of Notes from the authenticated publication/profile feed or a specified profile ID.', + inputSchema: { profile_id: id.optional(), cursor: z.string().optional(), limit }, outputSchema: notesOutputSchema, annotations: readOnlyAnnotations }, - ({ cursor, limit }) => tools.getNotes(cursor, limit) + ({ profile_id, cursor, limit }) => tools.getNotes(cursor, limit, profile_id) ) server.registerTool( 'get_profile_notes', diff --git a/src/resources/notes/index.ts b/src/resources/notes/index.ts index 61be287..a2490a8 100644 --- a/src/resources/notes/index.ts +++ b/src/resources/notes/index.ts @@ -1,3 +1,4 @@ +import { SubstackApiError } from '../../core/errors.js' import type { EndpointContext } from '../../core/transport.js' import { boundedString, positiveInteger } from '../../core/validation.js' import type { @@ -14,6 +15,7 @@ import type { NoteRepliesResponse, NoteResponse, NoteRestackOptions, + NotesOptions, NoteWithEngagement, ProfileNotesPage, PublishNoteRequest, @@ -21,6 +23,7 @@ import type { UploadedImage, UpdateScheduledNoteRequest } from '../../core/types.js' +import { getAuthenticatedProfile } from '../profiles/index.js' const DEFAULT_TAB_ID = 'for-you' @@ -45,8 +48,21 @@ function noteBodyJson(body: string): Record { } } -export function getNotes(context: EndpointContext, options: CursorOptions = {}): Promise { - return context.publication(`/notes${cursorQuery(options)}`) +export async function getNotes< + T extends Record = NoteFeedItem +>( + context: EndpointContext, + options: NotesOptions = {} +): 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 + } + return getProfileNotes(context, profileId, options) } /** Returns scheduled Note drafts for the authenticated account. */ diff --git a/test/client.test.ts b/test/client.test.ts index f2e69d8..a17b175 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -246,12 +246,42 @@ describe('SubstackClient', () => { } }) - await client.getNotes() + await client.getProfileNotes(123) - expect(request?.url).toBe('https://newsletter.example.com/api/v1/notes') + expect(request?.url).toBe('https://newsletter.example.com/api/v1/reader/feed/profile/123?types=note') expect(request?.headers.get('cookie')).toBe('substack.sid=session-value') }) + test('resolves authenticated profile and fetches profile Notes feed', async () => { + const urls: string[] = [] + const client = new SubstackClient({ + sessionToken: 'session-value', + publicationUrl: 'https://newsletter.example.com', + fetch: async (input, init) => { + const req = new Request(input, init) + urls.push(req.url) + if (req.url.endsWith('/handle/options')) { + return Response.json({ + potentialHandles: [{ handle: 'authorhandle', type: 'existing' }] + }) + } + if (req.url.includes('/user/authorhandle/public_profile')) { + return Response.json({ id: 12345, handle: 'authorhandle', name: 'Author Name' }) + } + return Response.json({ items: [{ comment: { id: 1 } }] }) + } + }) + + const result = await client.getNotes() + + expect(urls).toEqual([ + 'https://substack.com/api/v1/handle/options', + 'https://substack.com/api/v1/user/authorhandle/public_profile', + 'https://newsletter.example.com/api/v1/reader/feed/profile/12345?types=note' + ]) + expect(result.items).toHaveLength(1) + }) + test('keeps the global receiver when it uses native fetch', async () => { const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch') let receiver: unknown @@ -275,7 +305,7 @@ describe('SubstackClient', () => { } }) - test('uses the publication origin for Notes and encodes cursor values', async () => { + test('fetches Notes with explicit profileId and encodes cursor values', async () => { let request: Request | undefined const client = new SubstackClient({ sessionToken: 'session-value', @@ -286,9 +316,11 @@ describe('SubstackClient', () => { } }) - await client.getNotes({ cursor: 'next page' }) + await client.getNotes({ profileId: 42, cursor: 'next page' }) - expect(request?.url).toBe('https://allagentsconsidered.substack.com/api/v1/notes?cursor=next%20page') + expect(request?.url).toBe( + 'https://allagentsconsidered.substack.com/api/v1/reader/feed/profile/42?types=note&cursor=next+page' + ) }) test('returns typed profile Notes without changing the upstream response', async () => { @@ -1232,7 +1264,7 @@ describe('SubstackClient', () => { test('surfaces configuration and upstream API errors predictably', async () => { const noPublication = new SubstackClient({ sessionToken: 'session-value' }) - expect(() => noPublication.getNotes()).toThrow(SubstackConfigurationError) + expect(() => noPublication.getProfileNotes(123)).toThrow(SubstackConfigurationError) const rejected = new SubstackClient({ sessionToken: 'session-value',