ihateposting-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.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # ihateposting-mcp
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/ihateposting-mcp)](https://www.npmjs.com/package/ihateposting-mcp)
4
+
3
5
  MCP server for [iHatePosting](https://ihateposting.com) — draft, schedule, and inspect
4
6
  social posts from Claude, Cursor, ChatGPT, or any MCP client.
5
7
 
@@ -16,10 +18,17 @@ Treat it like a password: it can post on your behalf.
16
18
 
17
19
  | Tool | What it does |
18
20
  |---|---|
21
+ | `whoami` | Which iHatePosting account this key belongs to — the login, not a social handle |
22
+ | `get_platform_rules` | Each network's real limits: characters, media, aspect ratios, video specs |
23
+ | `validate_post` | Check a draft against those rules before spending a post |
19
24
  | `create_post` | Create a post — publish now, schedule, or save as a draft |
20
25
  | `list_posts` | The 50 most recent posts with per-platform status + live URLs |
21
26
  | `list_accounts` | Your connected social accounts and their health |
22
27
 
28
+ They are declared in that order because it is the order a model reads them,
29
+ and it is the working sequence: know whose account this is, see what the
30
+ networks allow, check the draft, then spend the post.
31
+
23
32
  ## Use it
24
33
 
25
34
  ### Claude Desktop / Claude Code / Cursor (local, stdio)
@@ -36,15 +45,27 @@ Treat it like a password: it can post on your behalf.
36
45
  }
37
46
  ```
38
47
 
39
- ### ChatGPT and other remote clients (hosted, HTTP)
48
+ ### Claude connectors, ChatGPT, and other remote clients (hosted, HTTP)
40
49
 
41
- ChatGPT can't run a local process, so point it at the hosted endpoint instead:
50
+ These can't run a local process, so point them at the hosted endpoint:
42
51
 
43
52
  ```
44
53
  https://ihateposting.com/api/mcp
45
54
  ```
46
55
 
47
- Authenticate with a header: `Authorization: Bearer pk_live_…`
56
+ **Auth pick whichever your client supports:**
57
+
58
+ | Client can send | Use |
59
+ |---|---|
60
+ | A custom header | `Authorization: Bearer pk_live_…` *(preferred)* |
61
+ | Only a URL | `https://ihateposting.com/api/mcp?key=pk_live_…` |
62
+
63
+ Claude's and ChatGPT's "add custom connector" dialogs currently offer only
64
+ OAuth or no-auth — no field for an API key — so use the `?key=` form there.
65
+
66
+ ⚠️ A key in a URL can end up in browser history, proxy logs and Referer
67
+ headers. Prefer the header when your client allows it, and rotate the key
68
+ (Settings → API & MCP) if a URL containing it gets shared.
48
69
 
49
70
  ## Environment
50
71
 
package/dist/tools.d.ts CHANGED
@@ -10,9 +10,25 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  * the tools stay identical and there is exactly one place to add a new one.
11
11
  */
12
12
  export declare const MCP_NAME = "ihateposting";
13
- export declare const MCP_VERSION = "0.2.0";
14
- /** Platforms the text-only create_post tool can target. */
15
- export declare const MCP_PLATFORMS: readonly ["bluesky", "x", "linkedin", "facebook", "threads", "mastodon", "telegram", "discord", "tumblr"];
13
+ export declare const MCP_VERSION = "0.3.0";
14
+ /**
15
+ * Every platform a client may target.
16
+ *
17
+ * This list used to stop at the ten text-first networks, because create_post
18
+ * could send text and nothing else — so Instagram, Pinterest, TikTok and
19
+ * YouTube were simply unreachable through MCP. Not refused with a reason:
20
+ * absent from the enum, which reads to a model as "this product does not
21
+ * support Pinterest".
22
+ *
23
+ * They are here now that create_post carries `options` and `mediaIds`. What
24
+ * each one REQUIRES — a board, a title, media at all — comes from
25
+ * get_platform_rules, and validate_post says whether a given post clears it.
26
+ *
27
+ * Written out rather than imported from the app's shared package on purpose:
28
+ * this file ships to npm as a standalone server and must not drag the whole
29
+ * monorepo behind it. Google Business is absent because it is not live yet.
30
+ */
31
+ export declare const MCP_PLATFORMS: readonly ["bluesky", "x", "linkedin", "facebook", "threads", "mastodon", "telegram", "discord", "tumblr", "slack", "instagram", "pinterest", "tiktok", "youtube"];
16
32
  /** Calls the iHatePosting REST API with the caller's key. Never throws — every
17
33
  * failure is returned as JSON text so the model can explain it to the user. */
18
34
  export type ApiCaller = (path: string, init?: RequestInit) => Promise<string>;
package/dist/tools.js CHANGED
@@ -10,8 +10,24 @@ import { z } from "zod";
10
10
  * the tools stay identical and there is exactly one place to add a new one.
11
11
  */
12
12
  export const MCP_NAME = "ihateposting";
13
- export const MCP_VERSION = "0.2.0";
14
- /** Platforms the text-only create_post tool can target. */
13
+ export const MCP_VERSION = "0.3.0";
14
+ /**
15
+ * Every platform a client may target.
16
+ *
17
+ * This list used to stop at the ten text-first networks, because create_post
18
+ * could send text and nothing else — so Instagram, Pinterest, TikTok and
19
+ * YouTube were simply unreachable through MCP. Not refused with a reason:
20
+ * absent from the enum, which reads to a model as "this product does not
21
+ * support Pinterest".
22
+ *
23
+ * They are here now that create_post carries `options` and `mediaIds`. What
24
+ * each one REQUIRES — a board, a title, media at all — comes from
25
+ * get_platform_rules, and validate_post says whether a given post clears it.
26
+ *
27
+ * Written out rather than imported from the app's shared package on purpose:
28
+ * this file ships to npm as a standalone server and must not drag the whole
29
+ * monorepo behind it. Google Business is absent because it is not live yet.
30
+ */
15
31
  export const MCP_PLATFORMS = [
16
32
  "bluesky",
17
33
  "x",
@@ -22,6 +38,11 @@ export const MCP_PLATFORMS = [
22
38
  "telegram",
23
39
  "discord",
24
40
  "tumblr",
41
+ "slack",
42
+ "instagram",
43
+ "pinterest",
44
+ "tiktok",
45
+ "youtube",
25
46
  ];
26
47
  export function makeApiCaller(apiUrl, apiKey) {
27
48
  const base = apiUrl.replace(/\/$/, "");
@@ -34,7 +55,18 @@ export function makeApiCaller(apiUrl, apiKey) {
34
55
  try {
35
56
  const res = await fetch(`${base}${path}`, {
36
57
  ...init,
37
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...init?.headers },
58
+ headers: {
59
+ Authorization: `Bearer ${apiKey}`,
60
+ "Content-Type": "application/json",
61
+ /* Tells the API this call came from the MCP server rather than a
62
+ raw script, so usage can be reported per client. Set HERE, in the
63
+ shared caller, deliberately: it is the one code path both the
64
+ hosted /api/mcp endpoint and the stdio npm build run through, so
65
+ an MCP call is identifiable even when it originates on a user's
66
+ own machine and never touches our MCP route. */
67
+ "X-IHP-Client": `mcp/${MCP_VERSION}`,
68
+ ...init?.headers,
69
+ },
38
70
  });
39
71
  const text = await res.text();
40
72
  if (!res.ok)
@@ -48,15 +80,124 @@ export function makeApiCaller(apiUrl, apiKey) {
48
80
  }
49
81
  /** Registers every iHatePosting tool on a server instance. */
50
82
  export function registerTools(server, api) {
51
- server.tool("create_post", "Create a social post on iHatePosting. action 'now' publishes immediately to every connected account of the named platforms; 'schedule' needs scheduledDate (YYYY-MM-DD) + scheduledTime (e.g. '9:00 AM', in the account owner's timezone); 'draft' just saves it. Note: Instagram/Pinterest/YouTube need media, which this tool can't attach — save a draft for those and finish in the composer.", {
83
+ /* The order these are declared in is the order a model reads them, and the
84
+ working sequence is: see the accounts, learn the platform's rules, check
85
+ the draft against them, then create it. Tools that answer a question come
86
+ before the one that spends a post. */
87
+ /* WHO THE KEY BELONGS TO — first, because it is the cheapest question and
88
+ the one that goes wrong most expensively.
89
+
90
+ Reported 2026-09-07: "when asked username it told hiren devani but the
91
+ user is dhruv". Nothing was mis-scoped — the key was right, the account
92
+ was right, and list_accounts named it correctly in `account`. But its
93
+ payload also carries the CONNECTED social accounts, and the first of them
94
+ was an X profile whose display name is another person's. Asked "what is
95
+ my username", a model reaches for the first thing shaped like one.
96
+
97
+ So identity gets its own tool, with an answer that cannot be mistaken for
98
+ a handle: the login email, the account name, and a sentence saying that
99
+ the social handles are a different thing. */
100
+ server.tool("whoami", "Who the API key belongs to: the iHatePosting login (email and account name). Use this for any question about who the user is or which account is connected. This is NOT a social media handle — the handles from list_accounts belong to the connected profiles, which may carry other people's names.", {}, async () => {
101
+ const raw = await api("/api/v1/accounts");
102
+ try {
103
+ const body = JSON.parse(raw);
104
+ if (!body.account)
105
+ return { content: [{ type: "text", text: raw }] };
106
+ return {
107
+ content: [
108
+ {
109
+ type: "text",
110
+ text: JSON.stringify({
111
+ iHatePostingAccount: body.account,
112
+ connectedAccounts: body.accounts?.length ?? 0,
113
+ note: "This is the iHatePosting login the API key belongs to. Connected social handles are listed by list_accounts and are not this person's username.",
114
+ }),
115
+ },
116
+ ],
117
+ };
118
+ }
119
+ catch {
120
+ // An error envelope from the caller (401, unreachable) — pass it on
121
+ // rather than dressing it up as an identity.
122
+ return { content: [{ type: "text", text: raw }] };
123
+ }
124
+ });
125
+ server.tool("get_platform_rules", "What each platform will and will not accept: character limit, whether media is required, how many images, video formats and length, and which per-platform options are MANDATORY (e.g. Pinterest needs a board, YouTube needs a title). Call this BEFORE writing a post — it is the difference between composing something that publishes and something the platform rejects.", {}, async () => ({ content: [{ type: "text", text: await api("/api/v1/platforms") }] }));
126
+ server.tool("validate_post", "Check a post against every platform you plan to send it to, WITHOUT creating anything. Runs the same checks the publisher runs seconds before it posts, so the answer is what will really happen — 'X counts this as 1200/280 characters', 'YouTube needs a title', 'TikTok slideshows take JPEG or WebP photos'. Free to call as often as you like; use it before create_post rather than discovering a problem after the post is spent.", {
127
+ text: z.string().describe("The post text"),
128
+ /* `targets` is the full shape; `platforms` is the SAME shorthand
129
+ create_post takes. Both are accepted because these two tools are
130
+ documented as a pair — "use it before create_post" — and until
131
+ 2026-08-25 you could not hand them the same arguments: create_post
132
+ takes platforms[], validate_post took targets[] only. An agent that
133
+ followed the advice got "Invalid input: expected array, received
134
+ undefined at targets", which does not hint at the transform it needs.
135
+
136
+ Additive on purpose. Changing `targets` to `platforms` would be a
137
+ breaking change for anything already calling this tool. */
138
+ targets: z
139
+ .array(z.object({
140
+ platform: z.enum(MCP_PLATFORMS),
141
+ contentOverride: z.string().optional().describe("Different words for this platform"),
142
+ options: z.record(z.string(), z.unknown()).optional().describe("Per-platform options — see get_platform_rules"),
143
+ }))
144
+ .min(1)
145
+ .optional()
146
+ .describe("Platforms with their per-platform options. Use this OR `platforms`."),
147
+ platforms: z
148
+ .array(z.enum(MCP_PLATFORMS))
149
+ .min(1)
150
+ .optional()
151
+ .describe("Shorthand for targets, the same argument create_post takes. Use this OR `targets`."),
152
+ mediaIds: z.array(z.string()).optional().describe("Media library ids, in attach order"),
153
+ }, async (input) => {
154
+ const targets = input.targets ?? (input.platforms ?? []).map((platform) => ({ platform }));
155
+ if (targets.length === 0) {
156
+ return {
157
+ content: [
158
+ {
159
+ type: "text",
160
+ // Names both spellings, because the whole point of this branch is
161
+ // an agent that guessed the wrong one.
162
+ text: JSON.stringify({
163
+ error: "Give either `targets` (with per-platform options) or `platforms` (just the names, as create_post takes).",
164
+ }),
165
+ },
166
+ ],
167
+ };
168
+ }
169
+ const { platforms: _shorthand, ...rest } = input;
170
+ return {
171
+ content: [
172
+ {
173
+ type: "text",
174
+ text: await api("/api/v1/posts/validate", { method: "POST", body: JSON.stringify({ ...rest, targets }) }),
175
+ },
176
+ ],
177
+ };
178
+ });
179
+ server.tool("create_post", "Create a social post on iHatePosting. action 'draft' saves it (the default, and the safe choice); 'schedule' needs scheduledDate (YYYY-MM-DD) + scheduledTime (e.g. '9:00 AM', in the account owner's timezone); 'now' publishes immediately to a real audience. Platforms with requirements — Pinterest a board, YouTube a title, TikTok and Instagram media — are set through `options` and `mediaIds`; call get_platform_rules for what each one needs, and validate_post to check before you commit.", {
52
180
  text: z.string().min(1).describe("The post text"),
53
- platforms: z.array(z.enum(MCP_PLATFORMS)).min(1).describe("Platforms to post to"),
181
+ platforms: z.array(z.enum(MCP_PLATFORMS)).min(1).describe("Platform names, or account ids from list_accounts to target ONE account"),
54
182
  action: z.enum(["now", "schedule", "draft"]).default("draft"),
55
183
  scheduledDate: z.string().optional().describe("YYYY-MM-DD (schedule only)"),
56
184
  scheduledTime: z.string().optional().describe("e.g. '9:00 AM' (schedule only)"),
185
+ /* A plain string key, not z.enum: Zod v4 makes a record over an enum
186
+ EXHAUSTIVE, so the enum form demanded an entry for all fourteen
187
+ platforms and refused `{ pinterest: {…} }` — the exact call this tool
188
+ exists to allow. The API validates the names on arrival. */
189
+ options: z
190
+ .record(z.string(), z.record(z.string(), z.unknown()))
191
+ .optional()
192
+ .describe("Per-platform options, e.g. { pinterest: { boardId: '...' }, youtube: { ytTitle: '...' } }"),
193
+ overrides: z
194
+ .record(z.string(), z.string())
195
+ .optional()
196
+ .describe("Different text for one platform, e.g. { x: 'a shorter version' }"),
197
+ mediaIds: z.array(z.string()).optional().describe("Media library ids, in attach order"),
57
198
  }, async (input) => ({
58
199
  content: [{ type: "text", text: await api("/api/v1/posts", { method: "POST", body: JSON.stringify(input) }) }],
59
200
  }));
60
201
  server.tool("list_posts", "List the 50 most recent iHatePosting posts with per-platform status (draft/scheduled/published/failed) and live URLs.", {}, async () => ({ content: [{ type: "text", text: await api("/api/v1/posts") }] }));
61
- server.tool("list_accounts", "List the connected social accounts (platform, handle, health) available for posting.", {}, async () => ({ content: [{ type: "text", text: await api("/api/v1/accounts") }] }));
202
+ server.tool("list_accounts", "List the connected social accounts (platform, handle, health) available for posting. The response also names the iHatePosting account the API key belongs to — if the accounts listed are not the ones you expect, check that `account.email` is the right person before assuming anything is missing.", {}, async () => ({ content: [{ type: "text", text: await api("/api/v1/accounts") }] }));
62
203
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ihateposting-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for iHatePosting — draft, schedule, and inspect social posts from Claude, Cursor, or any MCP client.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,7 +13,7 @@
13
13
  ".": "./src/index.ts",
14
14
  "./tools": "./src/tools.ts"
15
15
  },
16
- "files": ["dist", "src", "README.md"],
16
+ "files": ["dist", "src", "!src/**/*.test.ts", "README.md"],
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },
package/src/tools.ts CHANGED
@@ -13,9 +13,25 @@ import { z } from "zod";
13
13
  */
14
14
 
15
15
  export const MCP_NAME = "ihateposting";
16
- export const MCP_VERSION = "0.2.0";
16
+ export const MCP_VERSION = "0.3.0";
17
17
 
18
- /** Platforms the text-only create_post tool can target. */
18
+ /**
19
+ * Every platform a client may target.
20
+ *
21
+ * This list used to stop at the ten text-first networks, because create_post
22
+ * could send text and nothing else — so Instagram, Pinterest, TikTok and
23
+ * YouTube were simply unreachable through MCP. Not refused with a reason:
24
+ * absent from the enum, which reads to a model as "this product does not
25
+ * support Pinterest".
26
+ *
27
+ * They are here now that create_post carries `options` and `mediaIds`. What
28
+ * each one REQUIRES — a board, a title, media at all — comes from
29
+ * get_platform_rules, and validate_post says whether a given post clears it.
30
+ *
31
+ * Written out rather than imported from the app's shared package on purpose:
32
+ * this file ships to npm as a standalone server and must not drag the whole
33
+ * monorepo behind it. Google Business is absent because it is not live yet.
34
+ */
19
35
  export const MCP_PLATFORMS = [
20
36
  "bluesky",
21
37
  "x",
@@ -26,6 +42,11 @@ export const MCP_PLATFORMS = [
26
42
  "telegram",
27
43
  "discord",
28
44
  "tumblr",
45
+ "slack",
46
+ "instagram",
47
+ "pinterest",
48
+ "tiktok",
49
+ "youtube",
29
50
  ] as const;
30
51
 
31
52
  /** Calls the iHatePosting REST API with the caller's key. Never throws — every
@@ -43,7 +64,18 @@ export function makeApiCaller(apiUrl: string, apiKey: string | undefined): ApiCa
43
64
  try {
44
65
  const res = await fetch(`${base}${path}`, {
45
66
  ...init,
46
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...init?.headers },
67
+ headers: {
68
+ Authorization: `Bearer ${apiKey}`,
69
+ "Content-Type": "application/json",
70
+ /* Tells the API this call came from the MCP server rather than a
71
+ raw script, so usage can be reported per client. Set HERE, in the
72
+ shared caller, deliberately: it is the one code path both the
73
+ hosted /api/mcp endpoint and the stdio npm build run through, so
74
+ an MCP call is identifiable even when it originates on a user's
75
+ own machine and never touches our MCP route. */
76
+ "X-IHP-Client": `mcp/${MCP_VERSION}`,
77
+ ...init?.headers,
78
+ },
47
79
  });
48
80
  const text = await res.text();
49
81
  if (!res.ok) return JSON.stringify({ error: `iHatePosting API ${res.status}`, detail: text.slice(0, 500) });
@@ -56,15 +88,143 @@ export function makeApiCaller(apiUrl: string, apiKey: string | undefined): ApiCa
56
88
 
57
89
  /** Registers every iHatePosting tool on a server instance. */
58
90
  export function registerTools(server: McpServer, api: ApiCaller): void {
91
+ /* The order these are declared in is the order a model reads them, and the
92
+ working sequence is: see the accounts, learn the platform's rules, check
93
+ the draft against them, then create it. Tools that answer a question come
94
+ before the one that spends a post. */
95
+
96
+ /* WHO THE KEY BELONGS TO — first, because it is the cheapest question and
97
+ the one that goes wrong most expensively.
98
+
99
+ Reported 2026-09-07: "when asked username it told hiren devani but the
100
+ user is dhruv". Nothing was mis-scoped — the key was right, the account
101
+ was right, and list_accounts named it correctly in `account`. But its
102
+ payload also carries the CONNECTED social accounts, and the first of them
103
+ was an X profile whose display name is another person's. Asked "what is
104
+ my username", a model reaches for the first thing shaped like one.
105
+
106
+ So identity gets its own tool, with an answer that cannot be mistaken for
107
+ a handle: the login email, the account name, and a sentence saying that
108
+ the social handles are a different thing. */
109
+ server.tool(
110
+ "whoami",
111
+ "Who the API key belongs to: the iHatePosting login (email and account name). Use this for any question about who the user is or which account is connected. This is NOT a social media handle — the handles from list_accounts belong to the connected profiles, which may carry other people's names.",
112
+ {},
113
+ async () => {
114
+ const raw = await api("/api/v1/accounts");
115
+ try {
116
+ const body = JSON.parse(raw) as { account?: { id: number; email: string | null; name: string | null }; accounts?: unknown[] };
117
+ if (!body.account) return { content: [{ type: "text" as const, text: raw }] };
118
+ return {
119
+ content: [
120
+ {
121
+ type: "text" as const,
122
+ text: JSON.stringify({
123
+ iHatePostingAccount: body.account,
124
+ connectedAccounts: body.accounts?.length ?? 0,
125
+ note: "This is the iHatePosting login the API key belongs to. Connected social handles are listed by list_accounts and are not this person's username.",
126
+ }),
127
+ },
128
+ ],
129
+ };
130
+ } catch {
131
+ // An error envelope from the caller (401, unreachable) — pass it on
132
+ // rather than dressing it up as an identity.
133
+ return { content: [{ type: "text" as const, text: raw }] };
134
+ }
135
+ },
136
+ );
137
+
138
+ server.tool(
139
+ "get_platform_rules",
140
+ "What each platform will and will not accept: character limit, whether media is required, how many images, video formats and length, and which per-platform options are MANDATORY (e.g. Pinterest needs a board, YouTube needs a title). Call this BEFORE writing a post — it is the difference between composing something that publishes and something the platform rejects.",
141
+ {},
142
+ async () => ({ content: [{ type: "text" as const, text: await api("/api/v1/platforms") }] }),
143
+ );
144
+
145
+ server.tool(
146
+ "validate_post",
147
+ "Check a post against every platform you plan to send it to, WITHOUT creating anything. Runs the same checks the publisher runs seconds before it posts, so the answer is what will really happen — 'X counts this as 1200/280 characters', 'YouTube needs a title', 'TikTok slideshows take JPEG or WebP photos'. Free to call as often as you like; use it before create_post rather than discovering a problem after the post is spent.",
148
+ {
149
+ text: z.string().describe("The post text"),
150
+ /* `targets` is the full shape; `platforms` is the SAME shorthand
151
+ create_post takes. Both are accepted because these two tools are
152
+ documented as a pair — "use it before create_post" — and until
153
+ 2026-08-25 you could not hand them the same arguments: create_post
154
+ takes platforms[], validate_post took targets[] only. An agent that
155
+ followed the advice got "Invalid input: expected array, received
156
+ undefined at targets", which does not hint at the transform it needs.
157
+
158
+ Additive on purpose. Changing `targets` to `platforms` would be a
159
+ breaking change for anything already calling this tool. */
160
+ targets: z
161
+ .array(
162
+ z.object({
163
+ platform: z.enum(MCP_PLATFORMS),
164
+ contentOverride: z.string().optional().describe("Different words for this platform"),
165
+ options: z.record(z.string(), z.unknown()).optional().describe("Per-platform options — see get_platform_rules"),
166
+ }),
167
+ )
168
+ .min(1)
169
+ .optional()
170
+ .describe("Platforms with their per-platform options. Use this OR `platforms`."),
171
+ platforms: z
172
+ .array(z.enum(MCP_PLATFORMS))
173
+ .min(1)
174
+ .optional()
175
+ .describe("Shorthand for targets, the same argument create_post takes. Use this OR `targets`."),
176
+ mediaIds: z.array(z.string()).optional().describe("Media library ids, in attach order"),
177
+ },
178
+ async (input) => {
179
+ const targets = input.targets ?? (input.platforms ?? []).map((platform) => ({ platform }));
180
+ if (targets.length === 0) {
181
+ return {
182
+ content: [
183
+ {
184
+ type: "text" as const,
185
+ // Names both spellings, because the whole point of this branch is
186
+ // an agent that guessed the wrong one.
187
+ text: JSON.stringify({
188
+ error: "Give either `targets` (with per-platform options) or `platforms` (just the names, as create_post takes).",
189
+ }),
190
+ },
191
+ ],
192
+ };
193
+ }
194
+ const { platforms: _shorthand, ...rest } = input;
195
+ return {
196
+ content: [
197
+ {
198
+ type: "text" as const,
199
+ text: await api("/api/v1/posts/validate", { method: "POST", body: JSON.stringify({ ...rest, targets }) }),
200
+ },
201
+ ],
202
+ };
203
+ },
204
+ );
205
+
59
206
  server.tool(
60
207
  "create_post",
61
- "Create a social post on iHatePosting. action 'now' publishes immediately to every connected account of the named platforms; 'schedule' needs scheduledDate (YYYY-MM-DD) + scheduledTime (e.g. '9:00 AM', in the account owner's timezone); 'draft' just saves it. Note: Instagram/Pinterest/YouTube need media, which this tool can't attach save a draft for those and finish in the composer.",
208
+ "Create a social post on iHatePosting. action 'draft' saves it (the default, and the safe choice); 'schedule' needs scheduledDate (YYYY-MM-DD) + scheduledTime (e.g. '9:00 AM', in the account owner's timezone); 'now' publishes immediately to a real audience. Platforms with requirements — Pinterest a board, YouTube a title, TikTok and Instagram mediaare set through `options` and `mediaIds`; call get_platform_rules for what each one needs, and validate_post to check before you commit.",
62
209
  {
63
210
  text: z.string().min(1).describe("The post text"),
64
- platforms: z.array(z.enum(MCP_PLATFORMS)).min(1).describe("Platforms to post to"),
211
+ platforms: z.array(z.enum(MCP_PLATFORMS)).min(1).describe("Platform names, or account ids from list_accounts to target ONE account"),
65
212
  action: z.enum(["now", "schedule", "draft"]).default("draft"),
66
213
  scheduledDate: z.string().optional().describe("YYYY-MM-DD (schedule only)"),
67
214
  scheduledTime: z.string().optional().describe("e.g. '9:00 AM' (schedule only)"),
215
+ /* A plain string key, not z.enum: Zod v4 makes a record over an enum
216
+ EXHAUSTIVE, so the enum form demanded an entry for all fourteen
217
+ platforms and refused `{ pinterest: {…} }` — the exact call this tool
218
+ exists to allow. The API validates the names on arrival. */
219
+ options: z
220
+ .record(z.string(), z.record(z.string(), z.unknown()))
221
+ .optional()
222
+ .describe("Per-platform options, e.g. { pinterest: { boardId: '...' }, youtube: { ytTitle: '...' } }"),
223
+ overrides: z
224
+ .record(z.string(), z.string())
225
+ .optional()
226
+ .describe("Different text for one platform, e.g. { x: 'a shorter version' }"),
227
+ mediaIds: z.array(z.string()).optional().describe("Media library ids, in attach order"),
68
228
  },
69
229
  async (input) => ({
70
230
  content: [{ type: "text" as const, text: await api("/api/v1/posts", { method: "POST", body: JSON.stringify(input) }) }],
@@ -80,7 +240,7 @@ export function registerTools(server: McpServer, api: ApiCaller): void {
80
240
 
81
241
  server.tool(
82
242
  "list_accounts",
83
- "List the connected social accounts (platform, handle, health) available for posting.",
243
+ "List the connected social accounts (platform, handle, health) available for posting. The response also names the iHatePosting account the API key belongs to — if the accounts listed are not the ones you expect, check that `account.email` is the right person before assuming anything is missing.",
84
244
  {},
85
245
  async () => ({ content: [{ type: "text" as const, text: await api("/api/v1/accounts") }] }),
86
246
  );