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 +24 -3
- package/dist/tools.d.ts +19 -3
- package/dist/tools.js +147 -6
- package/package.json +2 -2
- package/src/tools.ts +166 -6
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# ihateposting-mcp
|
|
2
2
|
|
|
3
|
+
[](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
|
-
|
|
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
|
-
|
|
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.
|
|
14
|
-
/**
|
|
15
|
-
|
|
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.
|
|
14
|
-
/**
|
|
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: {
|
|
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
|
-
|
|
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("
|
|
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.
|
|
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.
|
|
16
|
+
export const MCP_VERSION = "0.3.0";
|
|
17
17
|
|
|
18
|
-
/**
|
|
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: {
|
|
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 '
|
|
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 media — are 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("
|
|
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
|
);
|