@blogautomation/mcp-server 0.0.0-stage → 1.0.1

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,3 +1,74 @@
1
- # Temporary Holding Version
1
+ # Blog Automation MCP server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Model Context Protocol](https://modelcontextprotocol.io) server for **Blog Automation** (https://blog-automation.com): plan ideas, write
4
+ articles with AI, publish or schedule them on WordPress and check the token balance – from Claude, ChatGPT, Cursor or any MCP client.
5
+ It is a thin wrapper around the Customer API `https://app.blog-automation.com/api/v1/ext` (app repo `blogposter-app`, `server/routes/ext.js`,
6
+ `docs/API.md`). Node 22, `@modelcontextprotocol/sdk` 1.32, zod 4. Package `@blogautomation/mcp-server`, command `blog-automation-mcp`.
7
+
8
+ ## Tools
9
+
10
+ | Tool | API call | Notes |
11
+ |---|---|---|
12
+ | `list_ideas` | `GET /ideas` | status, siteId, limit, cursor – read only |
13
+ | `create_idea` | `POST /ideas` | title (+ keyword, notes, audience, priority 1–3, language, siteId); `Idempotency-Key` |
14
+ | `write_article` | `POST /articles/generate` `{ …, async: true }` → 202 `{ job }`, then `GET /jobs/{id}` every 3 s | **uses tokens**; waits ≈ 50 s, then returns `jobId` (→ `job_status`); answer without HTML; `Idempotency-Key` |
15
+ | `job_status` | `GET /jobs/{id}` | running / done (article) / failed (error) |
16
+ | `list_articles` | `GET /articles` | status list, siteId, q, limit, cursor (no HTML) |
17
+ | `get_article` | `GET /articles/{id}` | with HTML and SEO fields |
18
+ | `article_status` | `GET /articles/{id}/status` | status, schedule, WordPress link, last error, job |
19
+ | `publish_article` | `POST /articles/{id}/publish` or, with `at`, `POST /articles/{id}/schedule` | `at` = ISO time or `"next"` (next free slot = "add to calendar") |
20
+ | `token_balance` | `GET /tokens` | balance, lots, prices |
21
+ | `list_sites` | `GET /sites` | WordPress sites (ids for `siteId`) |
22
+ | `list_templates` | `GET /templates` | built-in `b:…` and own templates |
23
+
24
+ Errors come back as readable tool results with `isError: true`: 401 (key invalid/revoked), 402 (not enough tokens: needed / balance),
25
+ 403 `insufficient_scope` (which scope is missing) / `plan_required` (which plans have the API), 404, 429 (seconds from `Retry-After`).
26
+
27
+ ## API key
28
+
29
+ Blog Automation → **Entwickler / API** → "API-Key erstellen" (plans Starter and Pro). Scopes:
30
+ `articles:read`, `articles:write`, `ai:generate` (write_article), `articles:publish` (publish_article), `ideas:read`, `ideas:write`,
31
+ `templates:read`, `sites:read`, `account:read` (token_balance). Leave out what the assistant should not do – e.g. without
32
+ `articles:publish` it can write drafts but never publish.
33
+
34
+ ## Use it
35
+
36
+ **stdio** (local; the key comes from `BA_API_KEY`):
37
+
38
+ ```bash
39
+ npx -y @blogautomation/mcp-server # once published; from this repo: node mcp/src/cli.js
40
+ ```
41
+
42
+ Claude Desktop (`claude_desktop_config.json`) / Cursor (`~/.cursor/mcp.json`) – same block:
43
+
44
+ ```json
45
+ { "mcpServers": { "blog-automation": { "command": "npx", "args": ["-y", "@blogautomation/mcp-server"], "env": { "BA_API_KEY": "ba_live_…" } } } }
46
+ ```
47
+
48
+ Claude Code: `claude mcp add blog-automation --env BA_API_KEY=ba_live_… -- npx -y @blogautomation/mcp-server`
49
+
50
+ **Streamable HTTP** (remote clients – ChatGPT connectors / developer mode, Claude "custom connector", Cursor via URL):
51
+
52
+ ```bash
53
+ blog-automation-mcp --http --port 8787 --host 127.0.0.1 --origin https://chatgpt.com,https://claude.ai
54
+ ```
55
+
56
+ - Endpoint `POST /mcp` (stateless, JSON responses), health check `GET /healthz`. GET/DELETE on `/mcp` → 405 (no sessions).
57
+ - Every request must send `Authorization: Bearer ba_live_…`; the key is used only for that request's API calls – not stored, not logged.
58
+ Missing or malformed → 401 with `WWW-Authenticate: Bearer`.
59
+ - Requests with an `Origin` header are only accepted from `--origin` / `BA_MCP_ORIGINS` (DNS-rebinding protection); without the option
60
+ browser requests are refused, server-to-server calls (no Origin) work.
61
+ - Binds 127.0.0.1 by default; put it behind the reverse proxy (TLS) for public use, e.g. `https://mcp.blog-automation.com/mcp`.
62
+ - Clients that only do OAuth (no custom header) cannot use it yet – see SUBMISSION.md.
63
+
64
+ `BA_API_BASE` overrides the API (e.g. `https://pre.blog-automation.com/api/v1/ext`).
65
+
66
+ ## Develop
67
+
68
+ ```bash
69
+ cd mcp && npm install && npm test # node:test – 7 tests, fake API (no network, no key)
70
+ ```
71
+
72
+ Tests: tools/list (names, annotations, schemas), request shape of every tool, Idempotency-Key, async job polling (done / still running /
73
+ failed), publish vs. schedule, error mapping (401/402/403/404/429), HTTP transport (healthz, 401, Origin 403, 405, tools/list + call with
74
+ the request's key).
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@blogautomation/mcp-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "MCP server for Blog Automation \u2013 plan ideas, write articles with AI, publish to WordPress, Shopify and 30+ platforms and check tokens from Claude, ChatGPT, Cursor and other MCP clients.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "mcpName": "io.github.shsoftwaresolutioncom/blog-automation-mcp",
8
+ "bin": {
9
+ "blog-automation-mcp": "src/cli.js"
10
+ },
11
+ "main": "src/server.js",
12
+ "files": [
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "scripts": {
20
+ "start": "node src/cli.js",
21
+ "start:http": "node src/cli.js --http",
22
+ "test": "node --test \"test/*.test.js\""
23
+ },
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "1.32.2",
26
+ "zod": "^4.1.0"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/shsoftwaresolutioncom/blog-automation-mcp.git"
31
+ },
32
+ "homepage": "https://blog-automation.com/en/",
33
+ "keywords": [
34
+ "mcp",
35
+ "model-context-protocol",
36
+ "blog",
37
+ "ai",
38
+ "wordpress",
39
+ "seo",
40
+ "blog-automation"
41
+ ]
42
+ }
package/src/api.js ADDED
@@ -0,0 +1,48 @@
1
+ // Thin client for the Blog Automation Customer API (/api/v1/ext). The key is passed in per client – never stored or logged.
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ export const DEFAULT_BASE = 'https://app.blog-automation.com/api/v1/ext';
5
+ export const KEY_RE = /^ba_live_[A-Za-z0-9_-]{30,64}$/;
6
+ const UA = 'BlogAutomation-MCP/1.0 (+https://blog-automation.com)';
7
+
8
+ export class ApiError extends Error {
9
+ constructor(status, body, headers) {
10
+ super(body?.message || body?.error || `HTTP ${status}`);
11
+ this.status = status; this.body = body || {}; this.retryAfter = headers?.get?.('retry-after') || null;
12
+ }
13
+ }
14
+
15
+ /** Readable text for an API error (shown to the model / person as an isError tool result). */
16
+ export function explain(e) {
17
+ if (!(e instanceof ApiError)) return `Blog Automation could not be reached: ${e?.message || e}`;
18
+ const b = e.body;
19
+ if (e.status === 401) return 'The Blog Automation API key is missing, invalid, expired or revoked. Create a key in Blog Automation → Entwickler / API.';
20
+ if (e.status === 402) return `Not enough tokens for this AI action (needed ${b.needed ?? '?'}, balance ${b.balance ?? '?'}). Buy a refill or upgrade in Blog Automation → Tokens.`;
21
+ if (e.status === 403 && b.error === 'plan_required') return `The API is not included in this plan${b.plans ? ` (available in: ${b.plans.join(', ')})` : ''}.`;
22
+ if (e.status === 403 && b.error === 'insufficient_scope') return `The API key lacks the scope ${(b.missing || []).join(', ') || '(unknown)'}. Add it to the key in Blog Automation → Entwickler / API.`;
23
+ if (e.status === 404) return `Not found: ${b.message || 'the record does not exist or belongs to another account'}.`;
24
+ if (e.status === 429) return `Rate limit reached – try again in ${e.retryAfter || 60} seconds.`;
25
+ return `Blog Automation error ${e.status}${b.error ? ` (${b.error})` : ''}: ${b.message || 'unknown error'}`;
26
+ }
27
+
28
+ /** createClient({ apiKey, base?, fetch? }) → { get, post } */
29
+ export function createClient({ apiKey, base = process.env.BA_API_BASE || DEFAULT_BASE, fetch: f = globalThis.fetch } = {}) {
30
+ const root = String(base).replace(/\/+$/, '');
31
+ async function call(method, path, { query, body, idempotent = false } = {}) {
32
+ if (!apiKey) throw new ApiError(401, { error: 'unauthorized', message: 'No API key.' });
33
+ const u = new URL(root + path);
34
+ for (const [k, v] of Object.entries(query || {})) if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, String(v));
35
+ const headers = { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', 'User-Agent': UA };
36
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
37
+ if (idempotent) headers['Idempotency-Key'] = `mcp-${randomUUID()}`;
38
+ const r = await f(u, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(180000) });
39
+ const text = await r.text();
40
+ let data; try { data = text ? JSON.parse(text) : {}; } catch { data = { error: 'invalid_response', message: text.slice(0, 200) }; }
41
+ if (!r.ok) throw new ApiError(r.status, data, r.headers);
42
+ return data;
43
+ }
44
+ return {
45
+ get: (path, query) => call('GET', path, { query }),
46
+ post: (path, body, opts = {}) => call('POST', path, { body: body ?? {}, ...opts }),
47
+ };
48
+ }
package/src/cli.js ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // blog-automation-mcp → stdio, key from BA_API_KEY
3
+ // blog-automation-mcp --http [--port 8787] [--host 127.0.0.1] [--origin https://claude.ai,...] → streamable HTTP on /mcp, key per request
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { buildServer, createHttpServer, VERSION } from './server.js';
6
+ import { KEY_RE } from './api.js';
7
+
8
+ const argv = process.argv.slice(2);
9
+ const opt = (name, d) => { const i = argv.indexOf(`--${name}`); return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
10
+ const err = (m) => process.stderr.write(`${m}\n`); // stdout belongs to the MCP protocol
11
+
12
+ if (argv.includes('--version')) { process.stdout.write(`${VERSION}\n`); process.exit(0); }
13
+ if (argv.includes('--http')) {
14
+ const port = Number(opt('port', process.env.PORT || 8787)), host = opt('host', process.env.HOST || '127.0.0.1');
15
+ const origins = String(opt('origin', process.env.BA_MCP_ORIGINS || '')).split(',').map((s) => s.trim()).filter(Boolean);
16
+ createHttpServer({ origins, log: err }).listen(port, host, () => err(`Blog Automation MCP ${VERSION}: http://${host}:${port}/mcp (allowed browser origins: ${origins.join(', ') || 'none'})`));
17
+ } else {
18
+ const apiKey = String(process.env.BA_API_KEY || '').trim();
19
+ if (!KEY_RE.test(apiKey)) { err('Set BA_API_KEY to your Blog Automation API key (ba_live_…, Blog Automation → Entwickler / API).'); process.exit(1); }
20
+ await buildServer({ apiKey }).connect(new StdioServerTransport());
21
+ err(`Blog Automation MCP ${VERSION} on stdio`);
22
+ }
package/src/server.js ADDED
@@ -0,0 +1,59 @@
1
+ // Blog Automation MCP server: buildServer() for any transport, createHttpServer() = streamable HTTP (stateless, key per request).
2
+ import http from 'node:http';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import { createClient, KEY_RE } from './api.js';
6
+ import { registerTools } from './tools.js';
7
+
8
+ export const VERSION = '1.0.1';
9
+ const INSTRUCTIONS = 'Blog Automation plans, writes and publishes blog articles to WordPress. Typical flow: list_sites → list_ideas / create_idea → write_article (uses tokens – check token_balance and confirm) → article_status → publish_article (public – confirm). Article ids, idea ids and site ids are integers.';
10
+
11
+ /** one MCP server bound to one API key */
12
+ export function buildServer({ apiKey, base, fetch } = {}) {
13
+ const server = new McpServer({ name: 'blog-automation', title: 'Blog Automation', version: VERSION }, { instructions: INSTRUCTIONS });
14
+ registerTools(server, createClient({ apiKey, base, fetch }));
15
+ return server;
16
+ }
17
+
18
+ const send = (res, code, obj, headers = {}) => { res.writeHead(code, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
19
+ const rpcError = (res, code, message, headers) => send(res, code, { jsonrpc: '2.0', error: { code: -32000, message }, id: null }, headers);
20
+
21
+ /**
22
+ * Streamable HTTP on /mcp. Stateless: every POST builds a fresh server with the key from "Authorization: Bearer ba_live_…".
23
+ * The key is only used for that request's API calls – not stored, not logged. Browsers: Origin must be in `origins` (DNS rebinding).
24
+ */
25
+ export function createHttpServer({ base, fetch, origins = [], maxBody = 1024 * 1024, log = () => {} } = {}) {
26
+ const allowed = new Set(origins.map((o) => o.replace(/\/+$/, '').toLowerCase()));
27
+ return http.createServer(async (req, res) => {
28
+ const path = (req.url || '/').split('?')[0];
29
+ if (path === '/healthz') return send(res, 200, { ok: true, name: 'blog-automation-mcp', version: VERSION });
30
+ if (path !== '/mcp') return send(res, 404, { error: 'not_found' });
31
+ const origin = req.headers.origin;
32
+ if (origin && !allowed.has(String(origin).toLowerCase())) return rpcError(res, 403, 'Origin not allowed.');
33
+ const cors = origin ? { 'Access-Control-Allow-Origin': origin, Vary: 'Origin' } : {};
34
+ if (req.method === 'OPTIONS') {
35
+ res.writeHead(204, { ...cors, 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Mcp-Protocol-Version, Accept', 'Access-Control-Max-Age': '600' });
36
+ return res.end();
37
+ }
38
+ if (req.method !== 'POST') return rpcError(res, 405, 'Method not allowed (stateless server: POST only).', { Allow: 'POST, OPTIONS', ...cors });
39
+ const m = /^Bearer\s+(\S+)$/i.exec(String(req.headers.authorization || ''));
40
+ if (!m || !KEY_RE.test(m[1])) return rpcError(res, 401, 'Send your Blog Automation API key: Authorization: Bearer ba_live_…', { 'WWW-Authenticate': 'Bearer realm="blog-automation"', ...cors });
41
+ let body;
42
+ try {
43
+ const chunks = []; let size = 0;
44
+ for await (const c of req) { size += c.length; if (size > maxBody) return rpcError(res, 413, 'Request too large.', cors); chunks.push(c); }
45
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null');
46
+ } catch { return rpcError(res, 400, 'Invalid JSON.', cors); }
47
+ for (const [k, v] of Object.entries(cors)) res.setHeader(k, v);
48
+ const server = buildServer({ apiKey: m[1], base, fetch });
49
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
50
+ res.on('close', () => { transport.close().catch(() => {}); server.close().catch(() => {}); });
51
+ try {
52
+ await server.connect(transport);
53
+ await transport.handleRequest(req, res, body);
54
+ } catch (e) {
55
+ log(`mcp request failed: ${e?.message || e}`); // never the key
56
+ if (!res.headersSent) rpcError(res, 500, 'Internal error.');
57
+ }
58
+ });
59
+ }
package/src/tools.js ADDED
@@ -0,0 +1,119 @@
1
+ // The MCP tools. Each maps to one Customer API endpoint (app repo: server/routes/ext.js). Results are JSON text + structuredContent.
2
+ import { z } from 'zod';
3
+ import { explain } from './api.js';
4
+
5
+ const ok = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], structuredContent: data });
6
+ const fail = (e) => ({ isError: true, content: [{ type: 'text', text: e?.user ? e.message : explain(e) }] });
7
+ const run = (fn) => async (args, extra) => { try { return ok(await fn(args || {}, extra || {})); } catch (e) { return fail(e); } };
8
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
9
+ if (signal?.aborted) return reject(Object.assign(new Error('Cancelled.'), { user: true }));
10
+ const t = setTimeout(resolve, ms);
11
+ signal?.addEventListener('abort', () => { clearTimeout(t); reject(Object.assign(new Error('Cancelled.'), { user: true })); }, { once: true });
12
+ });
13
+ const small = (a) => { if (a) delete a.content; return a; }; // keep answers small – get_article returns the HTML
14
+ /** tuning for tests */
15
+ export const WAIT = { totalMs: 50000, everyMs: 3000 };
16
+ const clean = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== ''));
17
+
18
+ const id = z.number().int().positive();
19
+ const limit = z.number().int().min(1).max(100).optional().describe('1–100, default 25');
20
+ const cursor = z.string().optional().describe('nextCursor of the previous page');
21
+ const READ = { readOnlyHint: true, openWorldHint: true };
22
+ const ARTICLE_STATES = ['idea', 'draft', 'review', 'approved', 'scheduled', 'publishing', 'published', 'failed', 'archived'];
23
+
24
+ /** register all tools on an McpServer, using the API client `api` */
25
+ export function registerTools(server, api) {
26
+ server.registerTool('list_ideas', {
27
+ title: 'List ideas',
28
+ description: 'List the topic ideas on the Blog Automation idea board, newest first. Paginate with nextCursor.',
29
+ inputSchema: { status: z.enum(['new', 'planned', 'used', 'discarded']).optional(), siteId: id.optional(), limit, cursor },
30
+ annotations: READ,
31
+ }, run((a) => api.get('/ideas', clean(a))));
32
+
33
+ server.registerTool('create_idea', {
34
+ title: 'Create an idea',
35
+ description: 'Add a topic idea to the idea board (no tokens used). Automations and "write_article" with ideaId can turn it into an article later.',
36
+ inputSchema: {
37
+ title: z.string().min(1).max(300), keyword: z.string().max(300).optional().describe('focus keyword'), notes: z.string().max(2000).optional(),
38
+ audience: z.string().max(300).optional(), priority: z.number().int().min(1).max(3).optional().describe('1 = high … 3 = low'),
39
+ language: z.string().regex(/^[a-z]{2}$/).optional().describe('e.g. de, en'), siteId: id.optional(),
40
+ },
41
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
42
+ }, run((a) => api.post('/ideas', clean(a), { idempotent: true })));
43
+
44
+ server.registerTool('write_article', {
45
+ title: 'Write an article with AI',
46
+ description: 'Write a complete blog article with AI (title, HTML, SEO fields, optional featured image). USES TOKENS of the Blog Automation account – check token_balance first and confirm with the user. Give a topic or keyword, or ideaId of an idea (needs scope ideas:write). The article is saved as a draft; publishing is a separate step (publish_article). Waits up to about 50 seconds; if the AI is still writing it returns a jobId – then call job_status later.',
47
+ inputSchema: {
48
+ topic: z.string().max(300).optional(), keyword: z.string().max(300).optional(), ideaId: id.optional(), templateId: z.string().optional().describe('e.g. b:howto – see list_templates'),
49
+ language: z.string().regex(/^[a-z]{2}$/).optional(), length: z.enum(['short', 'medium', 'long']).optional(), tone: z.string().max(100).optional(),
50
+ audience: z.string().max(300).optional(), siteId: id.optional().describe('target WordPress site – see list_sites'), withImage: z.boolean().optional().describe('also create a featured image (more tokens)'),
51
+ notes: z.string().max(2000).optional().describe('extra instructions for the AI'),
52
+ },
53
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
54
+ }, run(async (a, extra) => {
55
+ if (!a.topic && !a.keyword && !a.ideaId) throw Object.assign(new Error('Give a topic, a keyword or an ideaId.'), { user: true });
56
+ // async mode: 202 { job } – writing can take minutes; wait ~50 s, then hand back the job id (job_status)
57
+ const r = await api.post('/articles/generate', { ...clean(a), async: true }, { idempotent: true });
58
+ if (!r.job) { small(r.article); return r; } // app answered synchronously
59
+ let job = r.job;
60
+ const until = Date.now() + WAIT.totalMs;
61
+ while (job.status === 'running' && Date.now() < until) {
62
+ await sleep(WAIT.everyMs, extra.signal);
63
+ job = await api.get(`/jobs/${job.id}`);
64
+ }
65
+ if (job.status === 'done') return { done: true, jobId: job.id, article: small(job.article), tokens: job.tokens };
66
+ if (job.status === 'failed') throw Object.assign(new Error(`Writing the article failed: ${job.error?.message || job.error?.error || 'unknown error'} (job ${job.id}).`), { user: true });
67
+ return { done: false, jobId: job.id, status: job.status, hint: `The AI is still writing. Call job_status with id ${job.id} in a minute.` };
68
+ }));
69
+
70
+ server.registerTool('job_status', {
71
+ title: 'AI writing job status',
72
+ description: 'State of an AI writing job started by write_article: running, done (with the article) or failed (with the error).',
73
+ inputSchema: { id }, annotations: READ,
74
+ }, run(async (a) => { const j = await api.get(`/jobs/${a.id}`); small(j.article); return j; }));
75
+
76
+ server.registerTool('list_articles', {
77
+ title: 'List articles',
78
+ description: 'List articles, newest first, without the HTML. Filter by status (one or more of: idea, draft, review, approved, scheduled, publishing, published, failed, archived), site or title text.',
79
+ inputSchema: { status: z.array(z.enum(ARTICLE_STATES)).optional(), siteId: id.optional(), q: z.string().max(80).optional().describe('search in titles'), limit, cursor },
80
+ annotations: READ,
81
+ }, run((a) => api.get('/articles', clean({ ...a, status: a.status?.length ? a.status.join(',') : undefined }))));
82
+
83
+ server.registerTool('get_article', {
84
+ title: 'Get an article',
85
+ description: 'Read one article with its HTML content, SEO fields and publishing state.',
86
+ inputSchema: { id }, annotations: READ,
87
+ }, run((a) => api.get(`/articles/${a.id}`)));
88
+
89
+ server.registerTool('article_status', {
90
+ title: 'Article status',
91
+ description: 'Publishing status of an article: status, scheduled time, WordPress link, last error and the publishing job.',
92
+ inputSchema: { id }, annotations: READ,
93
+ }, run((a) => api.get(`/articles/${a.id}/status`)));
94
+
95
+ server.registerTool('publish_article', {
96
+ title: 'Publish or schedule an article',
97
+ description: 'Publish an article to its WordPress site now, or – with "at" – add it to the editorial calendar: an ISO 8601 time or "next" for the next free slot of the site. Approves a draft in the same step (needs scope articles:publish). This makes content public – confirm with the user first.',
98
+ inputSchema: { id, at: z.string().optional().describe('ISO 8601 date-time or "next"; empty = publish now'), siteId: id.optional() },
99
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
100
+ }, run((a) => (a.at ? api.post(`/articles/${a.id}/schedule`, clean({ at: a.at, siteId: a.siteId })) : api.post(`/articles/${a.id}/publish`, clean({ siteId: a.siteId })))));
101
+
102
+ server.registerTool('token_balance', {
103
+ title: 'Token balance',
104
+ description: 'Token balance of the Blog Automation account, open lots with expiry and the price of each AI action.',
105
+ inputSchema: {}, annotations: READ,
106
+ }, run(() => api.get('/tokens')));
107
+
108
+ server.registerTool('list_sites', {
109
+ title: 'List WordPress sites',
110
+ description: 'The connected WordPress sites with connection status, language and time zone (use the id as siteId).',
111
+ inputSchema: { limit, cursor }, annotations: READ,
112
+ }, run((a) => api.get('/sites', clean(a))));
113
+
114
+ server.registerTool('list_templates', {
115
+ title: 'List article templates',
116
+ description: 'Built-in (id b:…) and own article templates (use the id as templateId in write_article).',
117
+ inputSchema: { lang: z.enum(['en', 'de']).optional() }, annotations: READ,
118
+ }, run((a) => api.get('/templates', clean(a))));
119
+ }