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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand All @@ -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. |
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.8",
"version": "0.3.9",
"description": "Unofficial, portable TypeScript SDK for Substack's web API",
"type": "module",
"license": "MIT",
Expand Down
7 changes: 5 additions & 2 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
NoteRepliesResponse,
NoteResponse,
NoteRestackOptions,
NotesOptions,
NoteWithEngagement,
PostManagementDetail,
PostManagementPost,
Expand Down Expand Up @@ -193,8 +194,10 @@ export class SubstackClient {
return getProfilePosts(this.endpoints, id, options)
}

getNotes(options: CursorOptions = {}): Promise<unknown> {
return getNotes(this.endpoints, options)
getNotes<T extends Record<string, unknown> = NoteFeedItem>(
options: NotesOptions = {}
): Promise<ProfileNotesPage<T>> {
return getNotes<T>(this.endpoints, options)
}

/** Returns scheduled Note drafts for the authenticated account. */
Expand Down
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export {
type NoteRepliesResponse,
type NoteResponse,
type NoteRestackOptions,
type NotesOptions,
type NoteTrackingParameters,
type NoteWithEngagement,
type PostEngagement,
Expand Down
4 changes: 4 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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',
Expand Down
20 changes: 18 additions & 2 deletions src/resources/notes/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -14,13 +15,15 @@ import type {
NoteRepliesResponse,
NoteResponse,
NoteRestackOptions,
NotesOptions,
NoteWithEngagement,
ProfileNotesPage,
PublishNoteRequest,
ScheduleNoteRequest,
UploadedImage,
UpdateScheduledNoteRequest
} from '../../core/types.js'
import { getAuthenticatedProfile } from '../profiles/index.js'

const DEFAULT_TAB_ID = 'for-you'

Expand All @@ -45,8 +48,21 @@ function noteBodyJson(body: string): Record<string, unknown> {
}
}

export function getNotes(context: EndpointContext, options: CursorOptions = {}): Promise<unknown> {
return context.publication(`/notes${cursorQuery(options)}`)
export async function getNotes<
T extends Record<string, unknown> = NoteFeedItem
>(
context: EndpointContext,
options: NotesOptions = {}
): Promise<ProfileNotesPage<T>> {
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<T>(context, profileId, options)
}

/** Returns scheduled Note drafts for the authenticated account. */
Expand Down
44 changes: 38 additions & 6 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand Down