planpost-mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +177 -10
  2. package/dist/index.js +69 -14
  3. package/package.json +28 -7
package/README.md CHANGED
@@ -12,13 +12,36 @@ In the PlanPost dashboard go to **Settings → API Keys → Create Key** and sel
12
12
 
13
13
  ### 2. Add the server to your agent
14
14
 
15
- **Claude Code:**
15
+ Two ways to connect. **Hosted** is the default: point your client at
16
+ `https://mcp.planpo.st/mcp`, nothing to install, your key travels as a header.
17
+ **Local** runs the same server on your machine with `npx`, so the key never
18
+ leaves it. Both expose the same 47 tools.
19
+
20
+ Replace `pk_live_xxx` with your key throughout.
21
+
22
+ **Claude Code**
23
+
24
+ ```bash
25
+ claude mcp add --transport http planpost https://mcp.planpo.st/mcp \
26
+ --header "Authorization: Bearer pk_live_xxx"
27
+ ```
28
+
29
+ Local fallback:
16
30
 
17
31
  ```bash
18
32
  claude mcp add planpost -e PLANPOST_API_KEY=pk_live_xxx -- npx -y planpost-mcp
19
33
  ```
20
34
 
21
- **Claude Desktop** (`claude_desktop_config.json`):
35
+ Run `claude mcp list` afterwards to confirm it is connected.
36
+
37
+ **Claude Desktop**
38
+
39
+ Hosted: Settings → Connectors → Add custom connector. Name `PlanPost`, URL
40
+ `https://mcp.planpo.st/mcp`, then add the header `Authorization: Bearer pk_live_xxx`.
41
+
42
+ Local fallback, in `claude_desktop_config.json` (macOS:
43
+ `~/Library/Application Support/Claude/`, Windows: `%APPDATA%\Claude\`), then
44
+ restart the app:
22
45
 
23
46
  ```json
24
47
  {
@@ -32,24 +55,87 @@ claude mcp add planpost -e PLANPOST_API_KEY=pk_live_xxx -- npx -y planpost-mcp
32
55
  }
33
56
  ```
34
57
 
35
- **Any other MCP client:** run `npx -y planpost-mcp` over stdio with the `PLANPOST_API_KEY` environment variable set.
58
+ **Cursor** (`~/.cursor/mcp.json`, or `.cursor/mcp.json` per project)
36
59
 
37
- ### Remote (HTTP) mode
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "planpost": {
64
+ "url": "https://mcp.planpo.st/mcp",
65
+ "headers": { "Authorization": "Bearer pk_live_xxx" }
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ Local fallback: replace the entry with
72
+ `{ "command": "npx", "args": ["-y", "planpost-mcp"], "env": { "PLANPOST_API_KEY": "pk_live_xxx" } }`.
73
+ A project-level `.cursor/mcp.json` is committed with your repo, so prefer the
74
+ hosted form and keep the key out of the file.
75
+
76
+ **OpenAI Codex CLI** (`~/.codex/config.toml`)
77
+
78
+ ```toml
79
+ [mcp_servers.planpost]
80
+ url = "https://mcp.planpo.st/mcp"
81
+ bearer_token_env_var = "PLANPOST_API_KEY"
82
+ ```
83
+
84
+ Export `PLANPOST_API_KEY` in your shell; Codex reads the token from the
85
+ environment, not the file. `codex mcp add` does the same thing interactively.
86
+
87
+ Local fallback:
88
+
89
+ ```toml
90
+ [mcp_servers.planpost]
91
+ command = "npx"
92
+ args = ["-y", "planpost-mcp"]
38
93
 
39
- The server can also run as a shared, multi-tenant HTTP endpoint — no local install for users, each request authenticated by its own key:
94
+ [mcp_servers.planpost.env]
95
+ PLANPOST_API_KEY = "pk_live_xxx"
96
+ ```
97
+
98
+ **Gemini CLI** (`~/.gemini/settings.json`, or `.gemini/settings.json` per project)
99
+
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "planpost": {
104
+ "httpUrl": "https://mcp.planpo.st/mcp",
105
+ "headers": { "Authorization": "Bearer pk_live_xxx" },
106
+ "timeout": 10000
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ Gemini CLI uses `httpUrl` for streamable HTTP, not `url`; the wrong key is a
113
+ silent no-op. Local fallback: the same `command`/`args`/`env` entry as Cursor.
114
+
115
+ **Any other MCP client:** connect to `https://mcp.planpo.st/mcp` (streamable
116
+ HTTP, `Authorization: Bearer pk_live_xxx` on every request), or run
117
+ `npx -y planpost-mcp` over stdio with `PLANPOST_API_KEY` set. Per-client
118
+ walkthroughs with screenshots: [planpo.st/docs/agents](https://planpo.st/docs/agents).
119
+
120
+ ### Self-hosting the HTTP endpoint
121
+
122
+ `mcp.planpo.st` is this same package running with `--http`. To run your own:
40
123
 
41
124
  ```bash
42
125
  npx -y planpost-mcp --http --port 3020 # exposes POST /mcp (+ /health)
43
126
  ```
44
127
 
45
- Connect from any HTTP-capable MCP client, e.g.:
128
+ Behind a reverse proxy, bind to loopback so the port isn't reachable directly:
46
129
 
47
130
  ```bash
48
- claude mcp add --transport http planpost https://mcp.yourhost.com/mcp \
49
- --header "Authorization: Bearer pk_live_xxx"
131
+ HOST=127.0.0.1 npx -y planpost-mcp --http --port 3020
50
132
  ```
51
133
 
52
- The transport is stateless (streamable HTTP); the `Authorization: Bearer pk_...` header is required on every request and scoped per request, so one hosted instance serves many users safely. Put it behind HTTPS (reverse proxy) in production.
134
+ The transport is stateless (streamable HTTP); the `Authorization: Bearer pk_...`
135
+ header is required on every request and scoped per request, so one hosted
136
+ instance serves many users safely. Put it behind HTTPS in production.
137
+ `--host` / `HOST` default to `0.0.0.0`, so containers and LAN use work without
138
+ extra flags.
53
139
 
54
140
  ### 3. Talk to it
55
141
 
@@ -70,6 +156,8 @@ The transport is stateless (streamable HTTP); the `Authorization: Bearer pk_...`
70
156
 
71
157
  ## Tools
72
158
 
159
+ 47 tools, grouped below. The permission column is the API-key scope a tool needs; a key without the scope gets a clear error rather than a silent failure.
160
+
73
161
  ### Posts & publishing
74
162
 
75
163
  | Tool | Required permission | Description |
@@ -80,6 +168,11 @@ The transport is stateless (streamable HTTP); the `Authorization: Bearer pk_...`
80
168
  | `update_post` | `posts:write` | Update a draft/scheduled post |
81
169
  | `delete_post` | `posts:write` | Delete a post |
82
170
  | `publish_post` | `posts:write` | Publish immediately |
171
+ | `preview_post` | `posts:read` | Show exactly what each platform will receive (text after overrides, attachments, warnings) |
172
+ | `bulk_create_posts` | `posts:write` | Create a whole campaign of posts from one manifest |
173
+ | `list_campaigns` | `posts:read` | Campaign tags in use, with post counts by status and platform |
174
+ | `delete_posts` | `posts:write` | Bulk-delete posts matching a filter (a filter is required) |
175
+ | `list_media` | `media:read` | Media already uploaded, so an asset can be reused instead of re-uploaded |
83
176
  | `upload_media` | `media:write` | Get a presigned URL to upload an image/video |
84
177
  | `repurpose_content` | `repurpose:write` | Save repurposed content as draft posts |
85
178
 
@@ -90,8 +183,31 @@ The transport is stateless (streamable HTTP); the `Authorization: Bearer pk_...`
90
183
  | `list_connections` | `connections:read` | Connected social accounts |
91
184
  | `list_account_groups` | `groups:read` | Named account groups (e.g. "Polish Pages") |
92
185
  | `get_account_group` | `groups:read` | Resolve a group by id/name/default → connection IDs |
186
+ | `create_account_group` | `groups:write` | Create a named group bundling connections |
187
+ | `update_account_group` | `groups:write` | Rename, recolour, set default, or replace a group's connections |
188
+ | `delete_account_group` | `groups:write` | Delete a group (the accounts stay connected) |
189
+ | `add_connections_to_group` | `groups:write` | Add connections to a group |
190
+ | `remove_connections_from_group` | `groups:write` | Remove connections from a group |
191
+ | `get_connection_link` | `connections:read` | Link the user opens in a browser to connect a new social account (OAuth needs a human) |
192
+
193
+ Connecting a new social account requires OAuth in a browser: `get_connection_link` hands the user the link, and the agent can target the account once it is connected.
194
+
195
+ ### Approvals
196
+
197
+ | Tool | Required permission | Description |
198
+ |---|---|---|
199
+ | `submit_for_approval` | `approvals:write` | Send a post to a human reviewer (required when the API key is approval-gated) |
200
+ | `list_pending_approvals` | `approvals:read` | Posts waiting for review, with outcomes |
201
+ | `get_approval_status` | `approvals:read` | Pending / approved / rejected / changes requested for one submission |
93
202
 
94
- Connecting a new social account requires OAuth in the dashboard — agents can target any account that's already connected.
203
+ ### Scheduling
204
+
205
+ | Tool | Required permission | Description |
206
+ |---|---|---|
207
+ | `get_best_times` | `schedule:read` | Best posting times per platform from the account's engagement history |
208
+ | `suggest_schedule` | `schedule:read` | Spread N posts across a date range at optimal times; returns concrete timestamps |
209
+ | `get_posting_schedule` | `schedule:read` | Weekly queue slots, global plus per-group overrides |
210
+ | `set_posting_schedule` | `schedule:write` | Replace the weekly queue slots for the global schedule or one group (Pro) |
95
211
 
96
212
  ### Video & carousels
97
213
 
@@ -105,6 +221,7 @@ Connecting a new social account requires OAuth in the dashboard — agents can t
105
221
  | `render_carousel` | `carousels:write` | Render slides into branded PNGs in the media library |
106
222
  | `get_carousel_render` | `carousels:read` | Poll a render; completed renders return post-ready media assets |
107
223
  | `list_brand_kits` | `brand-kits:read` | Saved brand kits (palette, fonts, logo, handle) for `render_carousel` |
224
+ | `generate_image` | `images:write` | Generate an image with AI for a post (1 AI credit); pass the URL to `create_post` |
108
225
 
109
226
  ### Content sources
110
227
 
@@ -125,6 +242,13 @@ Connecting a new social account requires OAuth in the dashboard — agents can t
125
242
 
126
243
  Revenue data is read-only over the API; connecting Stripe/RevenueCat/Shopify happens in the dashboard (Settings → Revenue).
127
244
 
245
+ ### Brand voice (Pro)
246
+
247
+ | Tool | Required permission | Description |
248
+ |---|---|---|
249
+ | `get_brand_voices` | `brand-voice:read` | Tone-of-voice profiles analysed from the user's content |
250
+ | `get_brand_voice_prompt` | `brand-voice:read` | Ready-to-use system prompt for a voice; apply it when drafting posts |
251
+
128
252
  ## A typical agent workflow
129
253
 
130
254
  1. `get_account_group` (`default: true`) → connection IDs
@@ -144,11 +268,54 @@ Or, for a branded carousel post end-to-end:
144
268
  ```bash
145
269
  npm install
146
270
  npm run build # tsc → dist/
271
+ npm test # validates server.json against the registry schema, boots the stdio server
147
272
  PLANPOST_API_KEY=pk_test_xxx node dist/index.js
148
273
  ```
149
274
 
150
275
  The server speaks MCP over stdio. It calls the PlanPost public API (`/api/public/v1`) with your key; nothing else is stored or sent anywhere.
151
276
 
277
+ ## Maintainers
278
+
279
+ Releasing a version means publishing twice: the code to npm, then the metadata
280
+ to the [official MCP Registry](https://registry.modelcontextprotocol.io), which
281
+ only stores `server.json` and checks that the npm package it points at exists
282
+ and carries the matching `mcpName`. Glama and other directories import from the
283
+ registry, so the second step is what makes a release discoverable.
284
+
285
+ 1. Bump `version` in `package.json` **and** in `server.json` (top level and
286
+ `packages[0].version`). `npm test` fails if they disagree.
287
+ 2. Publish to npm first; the registry rejects a version npm does not have yet:
288
+
289
+ ```bash
290
+ npm publish --access public
291
+ ```
292
+
293
+ 3. Install the publisher CLI once (`brew install mcp-publisher`, or the tarball
294
+ from the registry's GitHub releases), then log in. The `io.github.lukBB/*`
295
+ namespace is proven by GitHub device-flow login as that account:
296
+
297
+ ```bash
298
+ mcp-publisher login github
299
+ ```
300
+
301
+ 4. Validate and publish from this directory:
302
+
303
+ ```bash
304
+ mcp-publisher validate
305
+ mcp-publisher publish
306
+ ```
307
+
308
+ 5. Confirm the listing:
309
+
310
+ ```bash
311
+ curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.lukBB/planpost"
312
+ ```
313
+
314
+ An expired token shows as "Invalid or expired Registry JWT token": run the login
315
+ again. `mcp-publisher status` deprecates or deletes a published version.
316
+ Directory listings beyond the registry (mcp.so, Cursor Directory, outreach) are
317
+ tracked in [DISTRIBUTION.md](./DISTRIBUTION.md).
318
+
152
319
  ## License
153
320
 
154
321
  MIT
package/dist/index.js CHANGED
@@ -74,7 +74,10 @@ function buildServer() {
74
74
  // ─── Tools ───
75
75
  function registerTools(server) {
76
76
  server.tool("list_posts", "List your scheduled and published social media posts. Filter by status, platform, campaign, label, post type, or free text.", {
77
- status: z.enum(["draft", "scheduled", "processing", "published", "failed", "cancelled"]).optional().describe("Filter by post status"),
77
+ // `needs_approval` is a first-class status, not an internal one: a post
78
+ // parked for human sign-off is exactly what an agent needs to be able to
79
+ // find again after submitting it.
80
+ status: z.enum(["draft", "needs_approval", "scheduled", "processing", "published", "failed", "cancelled"]).optional().describe("Filter by post status ('needs_approval' = waiting on human sign-off)"),
78
81
  platform: z.string().optional().describe("Filter by platform (instagram, twitter, linkedin, etc.)"),
79
82
  campaign: z.string().optional().describe("Filter by campaign tag, e.g. 'garmin-cirqa-2026' (matches utm.campaign; slugified automatically)"),
80
83
  label: z.string().optional().describe("Filter by internal label (case-insensitive substring), e.g. 'Garmin CIRQA'"),
@@ -112,7 +115,7 @@ function registerTools(server) {
112
115
  const data = await api(`/posts/${id}`);
113
116
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
114
117
  });
115
- server.tool("create_post", "Create a new social media post. Saves as draft or schedules it for a specific time.", {
118
+ server.tool("create_post", "Create a new social media post. Saves as draft or schedules it for a specific time. X (Twitter): nothing sent to X may contain a link (URL, bare domain or t.co) — not the post, any thread segment, or a first comment (posted to X as a reply). The X API bills each link post at $0.20 vs $0.015, so the API rejects them; put a link-free version in platformContent.twitter.", {
116
119
  content: z.string().describe("Post text content"),
117
120
  connectionIds: z.array(z.string()).describe("Array of social connection IDs to post to. Use list_connections or list_account_groups to find IDs."),
118
121
  platformContent: z.record(z.string(), z.string()).optional().describe("Per-platform content overrides, e.g. { twitter: 'shorter version', linkedin: 'longer version' }"),
@@ -193,7 +196,9 @@ function registerTools(server) {
193
196
  const media = pl.mediaCount === 0
194
197
  ? `no media${pl.mediaSource === "override" ? " (text-only override)" : ""}`
195
198
  : `${pl.mediaCount} ${pl.mediaCount === 1 ? "item" : "items"} (${pl.media.map((m) => m.filename ?? m.type).join(", ")})`;
196
- const warn = pl.warnings?.length ? `\n warnings: ${pl.warnings.map((w) => w.code).join(", ")}` : "";
199
+ const warn = pl.warnings?.length
200
+ ? `\n warnings:\n${pl.warnings.map((w) => ` - [${w.code}] ${w.message}`).join("\n")}`
201
+ : "";
197
202
  return [
198
203
  `── ${pl.platform.toUpperCase()} ─────────────`,
199
204
  ` text (${pl.contentSource}): ${limit}${pl.linksClickable ? "" : " [links NOT clickable]"}`,
@@ -212,7 +217,7 @@ function registerTools(server) {
212
217
  }],
213
218
  };
214
219
  });
215
- server.tool("bulk_create_posts", "Create a whole campaign of posts from one manifest. Each entry accepts the same fields as create_post; `defaults` supplies values shared by every entry (campaign, connectionIds, timezone, ...) so you don't repeat them. Rows are independent — one bad row fails alone and the rest still get created. Max 50 per call. Returns a per-row result with ids, warnings and edit links.", {
220
+ server.tool("bulk_create_posts", "Create a whole campaign of posts from one manifest. Each entry accepts the same fields as create_post; `defaults` supplies values shared by every entry (campaign, connectionIds, timezone, ...) so you don't repeat them. Rows are independent — one bad row fails alone and the rest still get created. Max 50 per call. Returns a per-row result with ids, warnings and edit links. X (Twitter): nothing sent to X may contain a link (URL, bare domain or t.co) — not the post, any thread segment, or a first comment (posted to X as a reply). The X API bills each link post at $0.20 vs $0.015, so the API rejects them; put a link-free version in platformContent.twitter.", {
216
221
  posts: z.array(z.record(z.string(), z.any())).describe("Array of post payloads. Each accepts every create_post field: content, connectionIds, platformContent, platformSettings, postType, threadSegments, label, campaign, schedule/scheduledFor, media, platformMedia, utm."),
217
222
  defaults: z.record(z.string(), z.any()).optional().describe("Fields merged into every entry before its own values (which always win). Ideal for campaign, connectionIds and timezone."),
218
223
  }, async ({ posts, defaults }) => {
@@ -280,7 +285,7 @@ function registerTools(server) {
280
285
  const data = await api(`/media?${params}`);
281
286
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
282
287
  });
283
- server.tool("update_post", "Update a draft or scheduled post.", {
288
+ server.tool("update_post", "Update a draft or scheduled post. X (Twitter): nothing sent to X may contain a link (URL, bare domain or t.co) — not the post, any thread segment, or a first comment (posted to X as a reply). The X API bills each link post at $0.20 vs $0.015, so the API rejects them; put a link-free version in platformContent.twitter.", {
284
289
  id: z.string().describe("Post ID to update"),
285
290
  content: z.string().optional().describe("New post text content"),
286
291
  connectionIds: z.array(z.string()).optional().describe("New connection IDs"),
@@ -356,7 +361,7 @@ function registerTools(server) {
356
361
  await api(`/posts/${id}`, { method: "DELETE" });
357
362
  return { content: [{ type: "text", text: `Post ${id} deleted.` }] };
358
363
  });
359
- server.tool("publish_post", "Publish a draft or scheduled post immediately to all connected platforms.", {
364
+ server.tool("publish_post", "Publish a draft or scheduled post immediately to all connected platforms. X (Twitter): nothing sent to X may contain a link (URL, bare domain or t.co) — not the post, any thread segment, or a first comment (posted to X as a reply). The X API bills each link post at $0.20 vs $0.015, so the API rejects them; put a link-free version in platformContent.twitter.", {
360
365
  id: z.string().describe("Post ID to publish now"),
361
366
  }, async ({ id }) => {
362
367
  const data = await api(`/posts/${id}/publish`, { method: "POST" });
@@ -774,6 +779,31 @@ function registerTools(server) {
774
779
  });
775
780
  return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
776
781
  });
782
+ server.tool("get_posting_schedule", "Read the weekly posting queue schedule: the global schedule plus any per-group overrides. Slots are {day: 1-7 (Mon=1..Sun=7), time: 'HH:MM' 24h} in the user's timezone. Groups without an override inherit the global schedule. Needs the schedule:read permission.", {}, async () => {
783
+ const data = await api("/posting-queue/queues");
784
+ return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
785
+ });
786
+ server.tool("set_posting_schedule", "Replace the weekly posting queue slots for the global schedule or one account group (Pro feature; needs the schedule:write permission). Pass the FULL desired slot list — it replaces the existing one. An empty array on a group removes its override so it inherits the global schedule again.", {
787
+ groupId: z
788
+ .string()
789
+ .optional()
790
+ .describe("Account group id (from list_account_groups); omit for the global schedule"),
791
+ slots: z
792
+ .array(z.object({
793
+ day: z.number().int().min(1).max(7).describe("ISO weekday: 1=Monday .. 7=Sunday"),
794
+ time: z
795
+ .string()
796
+ .regex(/^([01]\d|2[0-3]):[0-5]\d$/)
797
+ .describe("24h time 'HH:MM' in the user's timezone, e.g. '09:30'"),
798
+ }))
799
+ .describe("The complete weekly slot list to store"),
800
+ }, async ({ groupId, slots }) => {
801
+ const data = await api(`/posting-queue/queues/${encodeURIComponent(groupId ?? "global")}`, {
802
+ method: "PUT",
803
+ body: JSON.stringify({ slots }),
804
+ });
805
+ return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
806
+ });
777
807
  }
778
808
  // ─── Start ───
779
809
  async function startStdio() {
@@ -813,24 +843,44 @@ function readBody(req) {
813
843
  * (e.g. `claude mcp add --transport http planpost https://host/mcp --header
814
844
  * "Authorization: Bearer pk_..."`) at it without local installs.
815
845
  */
816
- async function startHttp(port) {
846
+ async function startHttp(port, host) {
817
847
  const httpServer = createHttpServer(async (req, res) => {
848
+ // An API host must never rank: Google indexed the bare /mcp 405 JSON as a
849
+ // page under the brand. The header, not robots.txt, is the fix on purpose:
850
+ // a crawler has to be able to fetch the URL to see the noindex, so
851
+ // robots.txt below allows crawling and this header does the de-indexing.
852
+ res.setHeader("X-Robots-Tag", "noindex, nofollow");
818
853
  const fail = (status, message) => {
819
- res.writeHead(status, { "Content-Type": "application/json" });
854
+ // charset is load-bearing: without it, any non-ASCII byte in a message
855
+ // renders as mojibake in browsers that assume latin-1 for JSON.
856
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
820
857
  res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }));
821
858
  };
822
859
  try {
823
860
  const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
824
861
  if (url.pathname === "/health") {
825
- res.writeHead(200, { "Content-Type": "application/json" });
862
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
826
863
  res.end(JSON.stringify({ status: "ok" }));
827
864
  return;
828
865
  }
866
+ if (url.pathname === "/robots.txt") {
867
+ // Allow crawling so the X-Robots-Tag noindex is actually seen.
868
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
869
+ res.end("User-agent: *\nDisallow:\n");
870
+ return;
871
+ }
872
+ if (url.pathname === "/" && (req.method === "GET" || req.method === "HEAD")) {
873
+ // A human (or crawler) landing on the bare host gets the docs page
874
+ // instead of a JSON error.
875
+ res.writeHead(302, { Location: "https://planpo.st/mcp" });
876
+ res.end();
877
+ return;
878
+ }
829
879
  if (url.pathname !== "/mcp")
830
- return fail(404, "Not found — MCP endpoint is /mcp");
880
+ return fail(404, "Not found. The MCP endpoint is /mcp");
831
881
  if (req.method !== "POST") {
832
882
  // Stateless mode: no SSE resumption stream, no sessions to delete.
833
- return fail(405, "Method not allowed — stateless transport accepts POST only");
883
+ return fail(405, "Method not allowed. This stateless transport accepts POST only; setup guide at https://planpo.st/mcp");
834
884
  }
835
885
  const auth = req.headers.authorization || "";
836
886
  const key = auth.startsWith("Bearer ") ? auth.slice(7) : "";
@@ -855,8 +905,8 @@ async function startHttp(port) {
855
905
  fail(500, "Internal error");
856
906
  }
857
907
  });
858
- httpServer.listen(port, () => {
859
- console.log(`planpost-mcp listening on http://0.0.0.0:${port}/mcp (streamable HTTP, stateless)`);
908
+ httpServer.listen(port, host, () => {
909
+ console.log(`planpost-mcp listening on http://${host}:${port}/mcp (streamable HTTP, stateless)`);
860
910
  });
861
911
  }
862
912
  async function main() {
@@ -864,7 +914,12 @@ async function main() {
864
914
  if (args.includes("--http")) {
865
915
  const portArg = args[args.indexOf("--port") + 1];
866
916
  const port = Number(portArg) || Number(process.env.PORT) || 3020;
867
- await startHttp(port);
917
+ // Defaults to all interfaces so `--http` works in a container or on a LAN
918
+ // without extra flags. Behind a reverse proxy set HOST=127.0.0.1 so the
919
+ // port isn't reachable except through the proxy.
920
+ const hostArg = args.includes("--host") ? args[args.indexOf("--host") + 1] : undefined;
921
+ const host = hostArg || process.env.HOST || "0.0.0.0";
922
+ await startHttp(port, host);
868
923
  return;
869
924
  }
870
925
  await startStdio();
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "planpost-mcp",
3
- "version": "0.2.0",
4
- "description": "PlanPost MCP server — let Claude and other AI agents schedule posts, manage social media, create videos and carousels, and read analytics via the PlanPost public API",
3
+ "version": "0.3.0",
4
+ "//mcpName": "Ownership marker for the official MCP Registry: must equal `name` in server.json, or `mcp-publisher publish` fails validation. Namespace is the GitHub account that owns this repo, verified with `mcp-publisher login github`.",
5
+ "mcpName": "io.github.lukBB/planpost",
6
+ "description": "PlanPost MCP server: schedule and publish social posts to Instagram, TikTok, LinkedIn, X, Facebook, Threads and YouTube from Claude, Cursor, Codex or any MCP client; render video and carousels, read analytics and revenue.",
5
7
  "license": "MIT",
6
8
  "type": "module",
7
9
  "bin": {
@@ -17,26 +19,43 @@
17
19
  "engines": {
18
20
  "node": ">=18"
19
21
  },
20
- "homepage": "https://planpo.st",
22
+ "homepage": "https://planpo.st/mcp",
21
23
  "repository": {
22
24
  "type": "git",
23
25
  "url": "git+https://github.com/lukBB/social-poster-v2.git",
24
26
  "directory": "packages/mcp-server"
25
27
  },
28
+ "bugs": {
29
+ "url": "https://planpo.st/support"
30
+ },
26
31
  "keywords": [
27
32
  "mcp",
28
33
  "model-context-protocol",
29
- "claude",
30
- "ai-agent",
34
+ "mcp-server",
31
35
  "social-media",
36
+ "social-media-scheduling",
32
37
  "scheduling",
38
+ "instagram",
39
+ "tiktok",
40
+ "linkedin",
41
+ "twitter",
42
+ "facebook",
43
+ "threads",
44
+ "youtube",
45
+ "claude",
46
+ "claude-code",
47
+ "cursor",
48
+ "codex",
49
+ "gemini-cli",
50
+ "ai-agent",
33
51
  "planpost"
34
52
  ],
35
53
  "scripts": {
36
54
  "build": "tsc",
37
55
  "dev": "tsc --watch",
38
56
  "start": "node dist/index.js",
39
- "prepublishOnly": "npm run build"
57
+ "prepublishOnly": "npm run build",
58
+ "test": "node --test test/*.test.mjs"
40
59
  },
41
60
  "dependencies": {
42
61
  "@modelcontextprotocol/sdk": "^1.12.1",
@@ -44,6 +63,8 @@
44
63
  },
45
64
  "devDependencies": {
46
65
  "typescript": "^5.7.0",
47
- "@types/node": "^22.0.0"
66
+ "@types/node": "^22.0.0",
67
+ "ajv": "^8.17.1",
68
+ "ajv-formats": "^3.0.1"
48
69
  }
49
70
  }