Skip to content

Commit 6ad9f15

Browse files
Add MCP tool annotations; make the safety banner honest about Notes (#6)
Mirrors the gws-mcp-server v0.2.0 annotations pattern: - New src/annotations.ts: exhaustive tool-name -> side-effect-class registry (read / additive-write / publish) and buildAnnotations(). Reads get readOnlyHint:true; draft/upload writes readOnlyHint:false; create_note and create_note_with_link additionally carry openWorldHint:true because they publish public content immediately. No destructive tools exist (no deletes) -- verified, documented. - server.ts: migrate to registerTool with annotations attached; Note tool descriptions now say PUBLISHES IMMEDIATELY loudly (no draft state, no undo from this server). - New annotations.test.ts: mapping unit tests + completeness test (registered tool set must equal the classification registry, so new tools cannot ship unclassified) + description guard for the Note tools. 90 tests green. - README: scope the 'Safe by design' banner to long-form posts and call out the Notes exception explicitly; split the Write table into additive writes vs immediate publishes; annotations one-liner. package.json/server.json descriptions no longer claim a blanket 'cannot publish'. - Version 0.2.2 -> 0.3.0 (minor: new annotation surface), synced in package.json, server.json (both fields), McpServer version string, and the client User-Agent. Closes #4 Closes #3 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a2947ab commit 6ad9f15

8 files changed

Lines changed: 320 additions & 71 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ MCP server for Substack — read posts, manage drafts, create notes. Cannot publ
55
## Architecture
66
- `src/index.ts` — MCP server bootstrap and entry point
77
- `src/server.ts` — Tool registration, request handlers, Zod schema generation
8+
- `src/annotations.ts` — Tool side-effect classification (read / additive-write / publish) → MCP annotations
89
- `src/api/client.ts` — HTTP client for Substack API (session cookie auth)
910
- `src/api/types.ts` — TypeScript interfaces for API responses
1011
- `src/utils/errors.ts` — Error handling utilities
@@ -26,10 +27,11 @@ npm test # vitest run
2627
```
2728

2829
## Testing
29-
3 test suites:
30+
4 test suites:
3031
- `client.test.ts` — API client auth validation
3132
- `errors.test.ts` — Error handling and wrapping
3233
- `markdown-to-prosemirror.test.ts` — Markdown to ProseMirror AST conversion
34+
- `annotations.test.ts` — Annotation mapping + completeness (every registered tool classified)
3335

3436
## Agent workflow
3537
- Always work on a branch. Never push directly to main.

README.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# substack-mcp
44

5-
An MCP server for Substack. Read your publication data and manage drafts from your AI agent. No publish or delete by design.
5+
An MCP server for Substack. Read your publication data and manage drafts from your AI agent. Long-form posts are draft-only by design — no publish, no delete. Short-form Notes publish immediately.
66

77
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](LICENSE)
88
[![Language: TypeScript](https://img.shields.io/badge/TypeScript-3178c6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
@@ -19,7 +19,7 @@ An MCP server for Substack. Read your publication data and manage drafts from yo
1919

2020
An MCP server for Substack that lets AI assistants read your publication data and manage drafts. The draft list shown in the demo above is sample data, not real account values.
2121

22-
**Safe by design:** This server can create and edit drafts but cannot publish or delete posts. You always review and publish manually through Substack's editor.
22+
**Safe by design — with one loud exception:** This server cannot publish or delete long-form posts. Post tools create and edit drafts only; you review and publish manually through Substack's editor. The exception is Substack **Notes**: `create_note` and `create_note_with_link` publish short-form Notes immediately, because Notes have no draft state on Substack. Treat the Note tools as public-publish actions — there is no preview step and no undo from this server.
2323

2424
<a href="https://glama.ai/mcp/servers/conorbronsdon/substack-mcp">
2525
<img width="380" height="200" src="https://glama.ai/mcp/servers/conorbronsdon/substack-mcp/badge" alt="substack-mcp MCP server" />
@@ -35,6 +35,8 @@ Built and maintained by [Conor Bronsdon](https://github.com/conorbronsdon) for t
3535

3636
## Tools
3737

38+
Every tool declares MCP [tool annotations](https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations): reads carry `readOnlyHint: true`, draft/upload writes carry `readOnlyHint: false`, and the Note tools additionally carry `openWorldHint: true` so clients don't treat an immediate public publish as a low-stakes write.
39+
3840
### Read
3941

4042
| Tool | Description |
@@ -46,19 +48,26 @@ Built and maintained by [Conor Bronsdon](https://github.com/conorbronsdon) for t
4648
| `get_draft` | Get full content of a draft by ID |
4749
| `get_post_comments` | Get comments on a published post |
4850

49-
### Write
51+
### Write (drafts and uploads — nothing goes public)
5052

5153
| Tool | Description |
5254
|------|-------------|
5355
| `create_draft` | Create a new draft from markdown |
5456
| `update_draft` | Update an existing draft (unpublished only) |
5557
| `upload_image` | Upload an image to Substack's CDN |
56-
| `create_note` | Publish a Substack Note (short-form, publishes immediately) |
57-
| `create_note_with_link` | Publish a Note with a link card attachment |
58+
59+
### Publish (Notes — public immediately)
60+
61+
| Tool | Description |
62+
|------|-------------|
63+
| `create_note` | Publish a Substack Note (short-form, **publishes immediately**) |
64+
| `create_note_with_link` | Publish a Note with a link card attachment (**publishes immediately**) |
65+
66+
Notes have no draft state on Substack, so there is no draft-first option for these two tools.
5867

5968
### Intentionally excluded
6069

61-
- **Publish posts** — Publishing long-form posts should be a deliberate human action
70+
- **Publish posts** — Publishing long-form posts should be a deliberate human action (Notes are the documented exception above)
6271
- **Delete** — Too destructive for an AI tool
6372
- **Schedule** — Use Substack's editor for scheduling
6473

@@ -160,7 +169,7 @@ npm start
160169

161170
## Contributing
162171

163-
Issues and pull requests are welcome. Because this server uses Substack's unofficial API, the most useful contributions are fixes when an endpoint changes. If a tool stops working, open an issue with the tool name and the error. The safe-by-design boundary stays: no publish, no delete, no schedule.
172+
Issues and pull requests are welcome. Because this server uses Substack's unofficial API, the most useful contributions are fixes when an endpoint changes. If a tool stops working, open an issue with the tool name and the error. The safe-by-design boundary stays: no publish, no delete, no schedule for long-form posts. Notes publish immediately by design and must keep saying so loudly in their descriptions.
164173

165174
---
166175

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@conorbronsdon/substack-mcp",
3-
"version": "0.2.2",
4-
"description": "MCP server for Substack — read posts, manage drafts. No publish or delete by design.",
3+
"version": "0.3.0",
4+
"description": "MCP server for Substack — read posts, manage drafts, publish Notes. Long-form posts are draft-only by design (no publish, no delete); Notes publish immediately.",
55
"type": "module",
66
"bin": {
77
"substack-mcp": "./dist/index.js"

server.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@
22
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
33
"name": "io.github.conorbronsdon/substack-mcp",
44
"title": "Substack MCP Server",
5-
"description": "MCP server for Substack — read posts, manage drafts, upload images. Safe by design: cannot publish or delete. Uses unofficial Substack API.",
5+
"description": "MCP server for Substack — read posts, manage drafts, upload images, publish Notes. Long-form posts are draft-only (no publish or delete); Notes publish immediately. Uses unofficial Substack API.",
66
"repository": {
77
"url": "https://github.com/conorbronsdon/substack-mcp",
88
"source": "github"
99
},
10-
"version": "0.2.2",
10+
"version": "0.3.0",
1111
"packages": [
1212
{
1313
"registryType": "npm",
1414
"identifier": "@conorbronsdon/substack-mcp",
15-
"version": "0.2.2",
15+
"version": "0.3.0",
1616
"runtimeHint": "npx",
1717
"transport": {
1818
"type": "stdio"

src/__tests__/annotations.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, it, expect } from "vitest";
2+
import { buildAnnotations, TOOL_KINDS, type ToolName } from "../annotations.js";
3+
import { createServer } from "../server.js";
4+
import type { SubstackClient } from "../api/client.js";
5+
6+
// The server only touches the client inside tool handlers, which these tests
7+
// never invoke — a bare object is enough to register everything.
8+
const server = createServer({} as SubstackClient);
9+
10+
interface RegisteredToolShape {
11+
description?: string;
12+
annotations?: Record<string, unknown>;
13+
}
14+
15+
// SDK-private registry; the McpServer API has no public tool introspection.
16+
const registered = (
17+
server as unknown as { _registeredTools: Record<string, RegisteredToolShape> }
18+
)._registeredTools;
19+
20+
const READ_TOOLS: ToolName[] = [
21+
"get_subscriber_count",
22+
"list_published_posts",
23+
"list_drafts",
24+
"get_post",
25+
"get_draft",
26+
"get_post_comments",
27+
];
28+
29+
const ADDITIVE_WRITE_TOOLS: ToolName[] = [
30+
"create_draft",
31+
"update_draft",
32+
"upload_image",
33+
];
34+
35+
const PUBLISH_TOOLS: ToolName[] = ["create_note", "create_note_with_link"];
36+
37+
describe("buildAnnotations mapping", () => {
38+
it("read -> { readOnlyHint: true } and nothing else", () => {
39+
const a = buildAnnotations("get_post");
40+
expect(a).toEqual({ readOnlyHint: true });
41+
});
42+
43+
it("additive write -> { readOnlyHint: false } with no destructive or open-world hint", () => {
44+
const a = buildAnnotations("create_draft");
45+
expect(a).toEqual({ readOnlyHint: false });
46+
expect(a.destructiveHint).toBeUndefined();
47+
expect(a.openWorldHint).toBeUndefined();
48+
});
49+
50+
it("publish (Notes) -> { readOnlyHint: false, openWorldHint: true }", () => {
51+
const a = buildAnnotations("create_note");
52+
expect(a).toEqual({ readOnlyHint: false, openWorldHint: true });
53+
});
54+
});
55+
56+
describe("tool annotation classifications", () => {
57+
it("completeness: registered tools and the classification registry match exactly", () => {
58+
// Every registered tool must be classified, and every classified tool
59+
// must exist — new tools cannot slip through unannotated, and stale
60+
// registry entries cannot linger.
61+
expect(Object.keys(registered).sort()).toEqual(
62+
Object.keys(TOOL_KINDS).sort(),
63+
);
64+
});
65+
66+
it("every registered tool carries the annotations its classification dictates", () => {
67+
for (const name of Object.keys(TOOL_KINDS) as ToolName[]) {
68+
expect(
69+
registered[name].annotations,
70+
`${name} annotations should match buildAnnotations`,
71+
).toEqual(buildAnnotations(name));
72+
}
73+
});
74+
75+
it("read tools are readOnlyHint:true", () => {
76+
for (const name of READ_TOOLS) {
77+
expect(buildAnnotations(name).readOnlyHint, name).toBe(true);
78+
}
79+
});
80+
81+
it("additive writes are readOnlyHint:false with no destructive or open-world hint", () => {
82+
for (const name of ADDITIVE_WRITE_TOOLS) {
83+
const a = buildAnnotations(name);
84+
expect(a.readOnlyHint, name).toBe(false);
85+
expect(a.destructiveHint, name).toBeUndefined();
86+
expect(a.openWorldHint, name).toBeUndefined();
87+
}
88+
});
89+
90+
it("Note tools (immediate public publish) carry openWorldHint:true", () => {
91+
for (const name of PUBLISH_TOOLS) {
92+
const a = buildAnnotations(name);
93+
expect(a.readOnlyHint, name).toBe(false);
94+
expect(a.openWorldHint, name).toBe(true);
95+
}
96+
});
97+
98+
it("no tool is destructive (this server has no deletes by design)", () => {
99+
for (const name of Object.keys(TOOL_KINDS) as ToolName[]) {
100+
expect(buildAnnotations(name).destructiveHint, name).toBeUndefined();
101+
}
102+
});
103+
104+
it("Note tool descriptions say loudly that they publish immediately", () => {
105+
for (const name of PUBLISH_TOOLS) {
106+
expect(registered[name].description, name).toMatch(
107+
/publishes immediately/i,
108+
);
109+
}
110+
});
111+
112+
it("the classification groups above cover the whole registry", () => {
113+
const grouped = [...READ_TOOLS, ...ADDITIVE_WRITE_TOOLS, ...PUBLISH_TOOLS];
114+
expect(grouped.sort()).toEqual(Object.keys(TOOL_KINDS).sort());
115+
});
116+
});

src/annotations.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Tool side-effect classification → MCP tool annotations.
3+
*
4+
* Mirrors the gws-mcp-server pattern: every tool declares exactly one
5+
* side-effect class in an exhaustive registry, and `buildAnnotations` maps
6+
* that class to MCP annotation hints so clients can reason about side
7+
* effects and render accurate consent UI. A completeness test asserts the
8+
* registry matches the set of tools actually registered on the server, so
9+
* new tools cannot ship unclassified.
10+
*/
11+
12+
/** Side-effect classes for substack-mcp tools. */
13+
export type ToolKind =
14+
/** Pure read: no side effects on the user's Substack data. */
15+
| "read"
16+
/**
17+
* Additive write to PRIVATE state (drafts, CDN image uploads). Reversible
18+
* in Substack's editor; nothing becomes public from these tools.
19+
*/
20+
| "additive-write"
21+
/**
22+
* Write with IMMEDIATE PUBLIC effect: Substack Notes publish the moment
23+
* the tool runs. Notes have no draft state on Substack, and this server
24+
* has no delete tools, so there is no undo from here.
25+
*/
26+
| "publish";
27+
28+
/**
29+
* Exhaustive tool-name → kind registry.
30+
*
31+
* This server has NO destructive tools (no deletes) by design. If one is
32+
* ever added, introduce a "destructive" kind mapping to
33+
* `{ readOnlyHint: false, destructiveHint: true }` per the MCP spec.
34+
*/
35+
export const TOOL_KINDS = {
36+
// Reads
37+
get_subscriber_count: "read",
38+
list_published_posts: "read",
39+
list_drafts: "read",
40+
get_post: "read",
41+
get_draft: "read",
42+
get_post_comments: "read",
43+
// Additive writes (private: drafts and uploads — nothing goes public)
44+
create_draft: "additive-write",
45+
update_draft: "additive-write",
46+
upload_image: "additive-write",
47+
// Immediate public publishes (Substack Notes)
48+
create_note: "publish",
49+
create_note_with_link: "publish",
50+
} as const satisfies Record<string, ToolKind>;
51+
52+
export type ToolName = keyof typeof TOOL_KINDS;
53+
54+
/** MCP tool annotations derived from a tool's side-effect class. */
55+
export interface ToolAnnotationHints {
56+
readOnlyHint: boolean;
57+
destructiveHint?: boolean;
58+
openWorldHint?: boolean;
59+
}
60+
61+
/**
62+
* Map a tool's declared kind into MCP `annotations`.
63+
*
64+
* Every tool gets an explicit `readOnlyHint` (true for reads, false for any
65+
* write) so clients always know the side-effect class. Additive writes carry
66+
* `readOnlyHint: false` and no destructive hint (matching gws-mcp-server:
67+
* they create or update reversible private state, never remove data). The
68+
* Note tools additionally carry `openWorldHint: true` because they publish
69+
* public content immediately — clients should not treat them as low-stakes
70+
* writes.
71+
*/
72+
export function buildAnnotations(name: ToolName): ToolAnnotationHints {
73+
switch (TOOL_KINDS[name]) {
74+
case "read":
75+
return { readOnlyHint: true };
76+
case "additive-write":
77+
return { readOnlyHint: false };
78+
case "publish":
79+
return { readOnlyHint: false, openWorldHint: true };
80+
}
81+
}

src/api/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export class SubstackClient {
3535
const headers: Record<string, string> = {
3636
Cookie: this.cookie,
3737
"Content-Type": "application/json",
38-
"User-Agent": "substack-mcp/0.2.2",
38+
"User-Agent": "substack-mcp/0.3.0",
3939
...(options.headers as Record<string, string> || {}),
4040
};
4141

0 commit comments

Comments
 (0)