@velocms/mcp 0.1.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/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/client.d.ts +70 -0
- package/dist/client.js +157 -0
- package/dist/env.d.ts +24 -0
- package/dist/env.js +30 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +59 -0
- package/dist/tools.d.ts +28 -0
- package/dist/tools.js +375 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.js +7 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VeloCMS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# @velocms/mcp
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server for [VeloCMS](https://velocms.org) —
|
|
4
|
+
draft, edit, publish, and manage your blog (posts, media, comments, members,
|
|
5
|
+
site settings) from Claude Code, Claude Desktop, Cursor, or any other MCP
|
|
6
|
+
client.
|
|
7
|
+
|
|
8
|
+
It's a thin, typed wrapper around the VeloCMS Public API
|
|
9
|
+
(`https://<your-blog>/api/v1`, documented at
|
|
10
|
+
[`/api/openapi`](https://velocms.org/api/openapi) on any VeloCMS site): every
|
|
11
|
+
tool maps to one real API call, with Zod-validated inputs and readable
|
|
12
|
+
errors — no scraping, no browser automation, just the platform's own
|
|
13
|
+
supported REST API.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
| Tool | What it does |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `list_posts` | List posts, paginated, optionally filtered by status (`draft`/`published`) |
|
|
20
|
+
| `get_post` | Fetch a single post by ID, including its full content and SEO fields |
|
|
21
|
+
| `create_post` | Create a new post (defaults to `draft`) |
|
|
22
|
+
| `update_post` | Partially update an existing post — only the fields you pass change |
|
|
23
|
+
| `delete_post` | Permanently delete a post |
|
|
24
|
+
| `publish_post` | Set a post's status to `published` (stamps `published_at`) |
|
|
25
|
+
| `unpublish_post` | Set a post's status back to `draft` |
|
|
26
|
+
| `list_media` | List the media library, paginated, optionally filtered by MIME type |
|
|
27
|
+
| `list_comments` | List comments, paginated, optionally filtered by post or moderation status |
|
|
28
|
+
| `moderate_comment` | Set a comment's status to `approved`, `pending`, or `spam` |
|
|
29
|
+
| `list_members` | List subscribers/readers (emails are masked, e.g. `u***@example.com`) |
|
|
30
|
+
| `get_site_settings` | Read the blog's name, description, and feature flags |
|
|
31
|
+
|
|
32
|
+
Every tool ships a description an LLM can read to figure out when and how to
|
|
33
|
+
use it — that's the point of MCP, not just a REST proxy with extra steps.
|
|
34
|
+
See [Tool reference](#tool-reference) below for full argument lists.
|
|
35
|
+
|
|
36
|
+
## Setup
|
|
37
|
+
|
|
38
|
+
### 1. Get a VeloCMS API key
|
|
39
|
+
|
|
40
|
+
In your VeloCMS dashboard: **Settings → API Keys** → create a key with the
|
|
41
|
+
scopes you need (`posts:read`, `posts:write`, `media:read`, `comments:read`,
|
|
42
|
+
`comments:moderate`, `members:read`, `site-settings:read`, etc.). API access
|
|
43
|
+
requires the **Pro plan or higher**.
|
|
44
|
+
|
|
45
|
+
### 2. Configure your MCP client
|
|
46
|
+
|
|
47
|
+
You don't need to clone this repo — `npx` fetches and runs it on demand.
|
|
48
|
+
|
|
49
|
+
#### Claude Code
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
claude mcp add velocms \
|
|
53
|
+
--env VELOCMS_SITE_URL=https://myblog.velocms.org \
|
|
54
|
+
--env VELOCMS_API_KEY=velo_your_64_char_hex_key_here \
|
|
55
|
+
-- npx -y -p @velocms/mcp velocms-mcp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
MCP servers register their tools at session start, so restart your Claude
|
|
59
|
+
Code session after adding this.
|
|
60
|
+
|
|
61
|
+
#### Claude Desktop
|
|
62
|
+
|
|
63
|
+
Add to your `claude_desktop_config.json` (Settings → Developer → Edit
|
|
64
|
+
Config):
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"mcpServers": {
|
|
69
|
+
"velocms": {
|
|
70
|
+
"command": "npx",
|
|
71
|
+
"args": ["-y", "-p", "@velocms/mcp", "velocms-mcp"],
|
|
72
|
+
"env": {
|
|
73
|
+
"VELOCMS_SITE_URL": "https://myblog.velocms.org",
|
|
74
|
+
"VELOCMS_API_KEY": "velo_your_64_char_hex_key_here"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Restart Claude Desktop after editing.
|
|
82
|
+
|
|
83
|
+
#### Cursor / any other MCP client
|
|
84
|
+
|
|
85
|
+
Same shape as above — point the client's MCP config at
|
|
86
|
+
`npx -y -p @velocms/mcp velocms-mcp` with `VELOCMS_SITE_URL` and
|
|
87
|
+
`VELOCMS_API_KEY` set in its `env`. The server speaks standard MCP over
|
|
88
|
+
stdio, so any client that supports stdio MCP servers works.
|
|
89
|
+
|
|
90
|
+
### 3. Local install (contributing / running from source)
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
git clone https://github.com/VeloCMS/velocms-mcp.git
|
|
94
|
+
cd velocms-mcp
|
|
95
|
+
npm install
|
|
96
|
+
npm run build
|
|
97
|
+
cp .env.example .env
|
|
98
|
+
# edit .env and set VELOCMS_SITE_URL + VELOCMS_API_KEY
|
|
99
|
+
VELOCMS_SITE_URL=... VELOCMS_API_KEY=... node dist/index.js
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Config reference
|
|
103
|
+
|
|
104
|
+
| Env var | Required | Description |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `VELOCMS_SITE_URL` | yes | Your blog's base URL — a `*.velocms.org` subdomain or a bound custom domain. No trailing slash needed. |
|
|
107
|
+
| `VELOCMS_API_KEY` | yes | An API key from `/admin/settings → API Keys`. Requires Pro plan or higher. Never logged. |
|
|
108
|
+
|
|
109
|
+
If either is missing, the server prints a clear message to stderr and exits
|
|
110
|
+
immediately (`process.exit(1)`) — it never starts half-configured.
|
|
111
|
+
|
|
112
|
+
## Tool reference
|
|
113
|
+
|
|
114
|
+
Argument names are camelCase; the server maps them to the API's snake_case
|
|
115
|
+
JSON fields for you.
|
|
116
|
+
|
|
117
|
+
**`list_posts`** — `page?`, `perPage?` (max 100), `status?` (`draft` |
|
|
118
|
+
`published`).
|
|
119
|
+
|
|
120
|
+
**`get_post`** — `id` (required).
|
|
121
|
+
|
|
122
|
+
**`create_post`** — `title` (required, ≤255 chars), `slug?`, `contentHtml?`,
|
|
123
|
+
`contentJson?` (TipTap ProseMirror document — prefer `contentHtml` unless
|
|
124
|
+
you need this), `excerpt?` (≤500), `status?` (`draft` default | `published`),
|
|
125
|
+
`tags?` (string array), `seoTitle?` (≤60), `seoDescription?` (≤160).
|
|
126
|
+
|
|
127
|
+
**`update_post`** — `id` (required) + any of the `create_post` fields
|
|
128
|
+
(all optional here). At least one field besides `id` is required — the tool
|
|
129
|
+
rejects a no-op call before making any network request.
|
|
130
|
+
|
|
131
|
+
**`delete_post`** — `id` (required). Permanent.
|
|
132
|
+
|
|
133
|
+
**`publish_post`** / **`unpublish_post`** — `id` (required). Shorthand for
|
|
134
|
+
`update_post({ status: "published" })` / `update_post({ status: "draft" })`.
|
|
135
|
+
|
|
136
|
+
**`list_media`** — `page?`, `perPage?`, `type?` (MIME type prefix, e.g.
|
|
137
|
+
`"image"`).
|
|
138
|
+
|
|
139
|
+
**`list_comments`** — `page?`, `perPage?`, `postId?`, `status?` (`approved`
|
|
140
|
+
| `pending` | `spam`).
|
|
141
|
+
|
|
142
|
+
**`moderate_comment`** — `id` (required), `status` (required: `approved` |
|
|
143
|
+
`pending` | `spam`).
|
|
144
|
+
|
|
145
|
+
**`list_members`** — `page?`, `perPage?`, `tier?` (`free` | `paid`).
|
|
146
|
+
|
|
147
|
+
**`get_site_settings`** — no arguments.
|
|
148
|
+
|
|
149
|
+
## Error handling model
|
|
150
|
+
|
|
151
|
+
The VeloCMS API returns a consistent JSON error envelope:
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{ "error": { "code": "RATE_LIMITED", "message": "...", "details": {} } }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
This client surfaces that message directly (never a raw stack trace),
|
|
158
|
+
enriched with actionable hints:
|
|
159
|
+
|
|
160
|
+
- **401** (`UNAUTHORIZED`) — the message tells you to check `VELOCMS_API_KEY`.
|
|
161
|
+
- **403** (`PLAN_UPGRADE_REQUIRED`) — the message points at `/admin/billing`.
|
|
162
|
+
- **403** (`INVALID_SCOPE`/`FORBIDDEN`) — the message tells you to check the
|
|
163
|
+
key's scopes.
|
|
164
|
+
- **429** (`RATE_LIMITED`) — the `Retry-After` response header (seconds) is
|
|
165
|
+
parsed and included in the error message, and returned as
|
|
166
|
+
`retryAfterSeconds` if you're calling the client library directly.
|
|
167
|
+
- **Any other non-2xx** — the API's own `code` + `message` are surfaced as-is.
|
|
168
|
+
|
|
169
|
+
Rate limits are plan-based (Pro: 30/min, 1,000/hr · Business: 120/min,
|
|
170
|
+
5,000/hr · Agency: 300/min, 20,000/hr) — this server does not retry
|
|
171
|
+
automatically; it fails fast with the wait time so an interactive tool call
|
|
172
|
+
never blocks silently.
|
|
173
|
+
|
|
174
|
+
## Security
|
|
175
|
+
|
|
176
|
+
- Your API key is a **tenant-scoped** credential — it can only reach the
|
|
177
|
+
one blog it was issued for, and only the endpoints its scopes allow.
|
|
178
|
+
- The key is read once from the environment and never logged, echoed, or
|
|
179
|
+
included in any tool output.
|
|
180
|
+
- Member emails returned by `list_members` are masked by the API itself
|
|
181
|
+
(e.g. `u***@example.com`) — this server never sees or handles unmasked
|
|
182
|
+
member PII.
|
|
183
|
+
- Encrypted tenant settings (Stripe keys, AI provider keys) are excluded
|
|
184
|
+
from `get_site_settings` by the API — there is no way to read them
|
|
185
|
+
through this server.
|
|
186
|
+
|
|
187
|
+
## Notes on this MCP server
|
|
188
|
+
|
|
189
|
+
- **`moderate_comment`'s body field is `status`, not `action`** —
|
|
190
|
+
`PATCH /api/v1/comments/{id}/moderate` takes `{ "status": "approved" |
|
|
191
|
+
"pending" | "spam" }` per the API's own schema, so the tool's input is
|
|
192
|
+
named to match.
|
|
193
|
+
- **`get_post` and `get_site_settings` return the record directly** (not
|
|
194
|
+
wrapped in `{ "data": ... }`) — only the write endpoints (`create_post`,
|
|
195
|
+
`update_post`, `moderate_comment`) wrap their response, matching the API's
|
|
196
|
+
own inconsistency here (documented in `openapi.yaml`).
|
|
197
|
+
- There is currently no `upload_media` tool — `POST /api/v1/media` takes
|
|
198
|
+
`multipart/form-data`, which doesn't map cleanly onto typical MCP client
|
|
199
|
+
transports. `list_media` is available for referencing media already
|
|
200
|
+
uploaded through the dashboard. Contributions welcome if you need this.
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
npm install
|
|
206
|
+
npm run typecheck # tsc --noEmit
|
|
207
|
+
npm test # vitest — all tests run against a mocked fetch, no live API calls
|
|
208
|
+
npm run build # emits dist/
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The test suite covers: the `Authorization: Bearer` header on every request,
|
|
212
|
+
each tool's exact method/URL/body mapping, error-code mapping for
|
|
213
|
+
401/403/429/500 responses, and the missing-env-var fail-fast path. No test
|
|
214
|
+
in this repository makes a real network call.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT — see [LICENSE](LICENSE).
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal REST client for the VeloCMS Public API (`public/openapi.yaml` in the
|
|
3
|
+
* main VeloCMS repository is the source of truth for every shape used here).
|
|
4
|
+
*
|
|
5
|
+
* Auth: `Authorization: Bearer <api-key>` — verbatim format from
|
|
6
|
+
* `src/lib/api/middleware.ts` `withApiAuth()` (missing/invalid header ->
|
|
7
|
+
* `401 UNAUTHORIZED`; see that file's step 1).
|
|
8
|
+
*
|
|
9
|
+
* Base path: `${siteUrl}/api/v1`. Every endpoint requires a Pro-or-higher
|
|
10
|
+
* plan and the scope listed for it in `openapi.yaml`.
|
|
11
|
+
*
|
|
12
|
+
* Error envelope (`openapi.yaml` `#/components/schemas/ApiError`):
|
|
13
|
+
*
|
|
14
|
+
* { "error": { "code": "...", "message": "...", "details"?: {...} } }
|
|
15
|
+
*
|
|
16
|
+
* Rate limiting (`openapi.yaml` `#/components/responses/RateLimited`,
|
|
17
|
+
* `middleware.ts` steps 4-5): a `429` response carries a `Retry-After` header
|
|
18
|
+
* (seconds until the window resets). This client does not retry
|
|
19
|
+
* automatically — it surfaces the wait time in the thrown error's message
|
|
20
|
+
* and `retryAfterSeconds` so the calling agent can decide what to do next.
|
|
21
|
+
*/
|
|
22
|
+
export interface VeloCmsClientOptions {
|
|
23
|
+
/** Tenant site base URL, e.g. `https://myblog.velocms.org` or a bound custom domain. */
|
|
24
|
+
siteUrl: string;
|
|
25
|
+
/** API key from the VeloCMS admin → Settings → API Keys (format: `velo_<64-hex>`). */
|
|
26
|
+
apiKey: string;
|
|
27
|
+
/** Injectable fetch implementation — used by tests, defaults to global `fetch`. */
|
|
28
|
+
fetchImpl?: typeof fetch;
|
|
29
|
+
/** Request timeout in ms (default 30s). */
|
|
30
|
+
requestTimeoutMs?: number;
|
|
31
|
+
}
|
|
32
|
+
/** `openapi.yaml` `ApiError.error.code` enum, plus a local fallback for unrecognized/absent codes. */
|
|
33
|
+
export type VeloCmsApiErrorCode = "UNAUTHORIZED" | "INVALID_SCOPE" | "RATE_LIMITED" | "QUOTA_EXCEEDED" | "NOT_FOUND" | "VALIDATION_ERROR" | "FORBIDDEN" | "INTERNAL_ERROR" | "PLAN_UPGRADE_REQUIRED" | "HTTPS_REQUIRED" | "UNKNOWN_ERROR";
|
|
34
|
+
/**
|
|
35
|
+
* Thrown for every non-2xx response. Carries the API's own error code +
|
|
36
|
+
* message (never a raw stack trace) plus the HTTP status and, on 429s, the
|
|
37
|
+
* `Retry-After` value in seconds.
|
|
38
|
+
*/
|
|
39
|
+
export declare class VeloCmsApiError extends Error {
|
|
40
|
+
readonly code: VeloCmsApiErrorCode;
|
|
41
|
+
readonly status: number | null;
|
|
42
|
+
readonly details?: Record<string, unknown>;
|
|
43
|
+
readonly retryAfterSeconds?: number;
|
|
44
|
+
constructor(message: string, opts?: {
|
|
45
|
+
code?: VeloCmsApiErrorCode;
|
|
46
|
+
status?: number | null;
|
|
47
|
+
details?: Record<string, unknown>;
|
|
48
|
+
retryAfterSeconds?: number;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export type QueryParams = Record<string, string | number | boolean | undefined>;
|
|
52
|
+
export type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
|
|
53
|
+
export declare class VeloCmsClient {
|
|
54
|
+
private readonly baseUrl;
|
|
55
|
+
private readonly apiKey;
|
|
56
|
+
private readonly fetchImpl;
|
|
57
|
+
private readonly requestTimeoutMs;
|
|
58
|
+
constructor(opts: VeloCmsClientOptions);
|
|
59
|
+
private buildUrl;
|
|
60
|
+
/**
|
|
61
|
+
* Sends one request against `${siteUrl}/api/v1${path}`. Resolves with the
|
|
62
|
+
* parsed JSON body on 2xx (or `undefined` for a 204), throws
|
|
63
|
+
* `VeloCmsApiError` on any non-2xx response or network failure/timeout.
|
|
64
|
+
*/
|
|
65
|
+
request<T>(method: HttpMethod, path: string, opts?: {
|
|
66
|
+
query?: QueryParams;
|
|
67
|
+
body?: unknown;
|
|
68
|
+
}): Promise<T>;
|
|
69
|
+
private buildError;
|
|
70
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal REST client for the VeloCMS Public API (`public/openapi.yaml` in the
|
|
3
|
+
* main VeloCMS repository is the source of truth for every shape used here).
|
|
4
|
+
*
|
|
5
|
+
* Auth: `Authorization: Bearer <api-key>` — verbatim format from
|
|
6
|
+
* `src/lib/api/middleware.ts` `withApiAuth()` (missing/invalid header ->
|
|
7
|
+
* `401 UNAUTHORIZED`; see that file's step 1).
|
|
8
|
+
*
|
|
9
|
+
* Base path: `${siteUrl}/api/v1`. Every endpoint requires a Pro-or-higher
|
|
10
|
+
* plan and the scope listed for it in `openapi.yaml`.
|
|
11
|
+
*
|
|
12
|
+
* Error envelope (`openapi.yaml` `#/components/schemas/ApiError`):
|
|
13
|
+
*
|
|
14
|
+
* { "error": { "code": "...", "message": "...", "details"?: {...} } }
|
|
15
|
+
*
|
|
16
|
+
* Rate limiting (`openapi.yaml` `#/components/responses/RateLimited`,
|
|
17
|
+
* `middleware.ts` steps 4-5): a `429` response carries a `Retry-After` header
|
|
18
|
+
* (seconds until the window resets). This client does not retry
|
|
19
|
+
* automatically — it surfaces the wait time in the thrown error's message
|
|
20
|
+
* and `retryAfterSeconds` so the calling agent can decide what to do next.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
23
|
+
function isApiErrorBody(value) {
|
|
24
|
+
if (typeof value !== "object" || value === null || !("error" in value))
|
|
25
|
+
return false;
|
|
26
|
+
const err = value.error;
|
|
27
|
+
if (typeof err !== "object" || err === null)
|
|
28
|
+
return false;
|
|
29
|
+
const { code, message } = err;
|
|
30
|
+
return typeof code === "string" && typeof message === "string";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Thrown for every non-2xx response. Carries the API's own error code +
|
|
34
|
+
* message (never a raw stack trace) plus the HTTP status and, on 429s, the
|
|
35
|
+
* `Retry-After` value in seconds.
|
|
36
|
+
*/
|
|
37
|
+
export class VeloCmsApiError extends Error {
|
|
38
|
+
code;
|
|
39
|
+
status;
|
|
40
|
+
details;
|
|
41
|
+
retryAfterSeconds;
|
|
42
|
+
constructor(message, opts = {}) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "VeloCmsApiError";
|
|
45
|
+
this.code = opts.code ?? "UNKNOWN_ERROR";
|
|
46
|
+
this.status = opts.status ?? null;
|
|
47
|
+
this.details = opts.details;
|
|
48
|
+
this.retryAfterSeconds = opts.retryAfterSeconds;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export class VeloCmsClient {
|
|
52
|
+
baseUrl;
|
|
53
|
+
apiKey;
|
|
54
|
+
fetchImpl;
|
|
55
|
+
requestTimeoutMs;
|
|
56
|
+
constructor(opts) {
|
|
57
|
+
if (!opts.siteUrl) {
|
|
58
|
+
throw new Error("VeloCmsClient requires siteUrl (e.g. https://myblog.velocms.org). Set VELOCMS_SITE_URL.");
|
|
59
|
+
}
|
|
60
|
+
if (!opts.apiKey) {
|
|
61
|
+
throw new Error("VeloCmsClient requires apiKey. Get one from your VeloCMS dashboard: " +
|
|
62
|
+
"/admin/settings -> API Keys (Pro plan or higher required). Set VELOCMS_API_KEY.");
|
|
63
|
+
}
|
|
64
|
+
this.baseUrl = opts.siteUrl.replace(/\/+$/, "");
|
|
65
|
+
this.apiKey = opts.apiKey;
|
|
66
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
67
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
68
|
+
}
|
|
69
|
+
buildUrl(path, query) {
|
|
70
|
+
const url = new URL(`${this.baseUrl}/api/v1${path}`);
|
|
71
|
+
if (query) {
|
|
72
|
+
for (const [key, value] of Object.entries(query)) {
|
|
73
|
+
if (value !== undefined)
|
|
74
|
+
url.searchParams.set(key, String(value));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return url.toString();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Sends one request against `${siteUrl}/api/v1${path}`. Resolves with the
|
|
81
|
+
* parsed JSON body on 2xx (or `undefined` for a 204), throws
|
|
82
|
+
* `VeloCmsApiError` on any non-2xx response or network failure/timeout.
|
|
83
|
+
*/
|
|
84
|
+
async request(method, path, opts = {}) {
|
|
85
|
+
const url = this.buildUrl(path, opts.query);
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
|
|
88
|
+
let response;
|
|
89
|
+
try {
|
|
90
|
+
response = await this.fetchImpl(url, {
|
|
91
|
+
method,
|
|
92
|
+
headers: {
|
|
93
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
94
|
+
...(opts.body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
95
|
+
},
|
|
96
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
102
|
+
throw new VeloCmsApiError(`Request to ${path} timed out after ${this.requestTimeoutMs}ms.`, { code: "INTERNAL_ERROR" });
|
|
103
|
+
}
|
|
104
|
+
throw new VeloCmsApiError(`Network error calling the VeloCMS API (${path}): ` +
|
|
105
|
+
`${err instanceof Error ? err.message : String(err)}. ` +
|
|
106
|
+
"Check that VELOCMS_SITE_URL is correct and reachable.", { code: "INTERNAL_ERROR" });
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
}
|
|
111
|
+
if (response.status === 204) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const rawText = await response.text();
|
|
115
|
+
let json;
|
|
116
|
+
if (rawText) {
|
|
117
|
+
try {
|
|
118
|
+
json = JSON.parse(rawText);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
json = undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
throw this.buildError(response, json, path);
|
|
126
|
+
}
|
|
127
|
+
return json;
|
|
128
|
+
}
|
|
129
|
+
buildError(response, json, path) {
|
|
130
|
+
const body = isApiErrorBody(json) ? json : undefined;
|
|
131
|
+
const code = body?.error.code ?? "UNKNOWN_ERROR";
|
|
132
|
+
let message = body?.error.message ?? `VeloCMS API returned HTTP ${response.status} for ${path}.`;
|
|
133
|
+
const details = body?.error.details;
|
|
134
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
135
|
+
const retryAfterParsed = retryAfterHeader !== null ? Number(retryAfterHeader) : NaN;
|
|
136
|
+
const retryAfterSeconds = Number.isFinite(retryAfterParsed) ? retryAfterParsed : undefined;
|
|
137
|
+
if (response.status === 401) {
|
|
138
|
+
message +=
|
|
139
|
+
" Check that VELOCMS_API_KEY is set and valid (get one from /admin/settings -> API Keys).";
|
|
140
|
+
}
|
|
141
|
+
else if (response.status === 403) {
|
|
142
|
+
message +=
|
|
143
|
+
code === "PLAN_UPGRADE_REQUIRED"
|
|
144
|
+
? " The VeloCMS Public API requires a Pro plan or higher — upgrade at /admin/billing."
|
|
145
|
+
: " Check that VELOCMS_API_KEY has the scope required for this operation.";
|
|
146
|
+
}
|
|
147
|
+
else if (response.status === 429 && retryAfterSeconds !== undefined) {
|
|
148
|
+
message += ` Retry after ${retryAfterSeconds} second(s).`;
|
|
149
|
+
}
|
|
150
|
+
return new VeloCmsApiError(message, {
|
|
151
|
+
code,
|
|
152
|
+
status: response.status,
|
|
153
|
+
details,
|
|
154
|
+
retryAfterSeconds,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure env-resolution helper — kept separate from `index.ts` (which has a
|
|
3
|
+
* `#!/usr/bin/env node` shebang and starts the stdio server as a side effect
|
|
4
|
+
* on import) so the fail-fast "missing env var" behavior is unit-testable
|
|
5
|
+
* without spawning a subprocess or calling `process.exit`.
|
|
6
|
+
*/
|
|
7
|
+
export interface VeloCmsEnvConfig {
|
|
8
|
+
siteUrl: string;
|
|
9
|
+
apiKey: string;
|
|
10
|
+
}
|
|
11
|
+
export type VeloCmsEnvResolution = {
|
|
12
|
+
ok: true;
|
|
13
|
+
config: VeloCmsEnvConfig;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
missing: string[];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Reads `VELOCMS_SITE_URL` + `VELOCMS_API_KEY` from the given environment
|
|
20
|
+
* (defaults to `process.env`). Never logs the resolved values.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveEnvConfig(env?: NodeJS.ProcessEnv): VeloCmsEnvResolution;
|
|
23
|
+
/** Human-readable message for the missing-env-var fail-fast path (stderr only, never stdout). */
|
|
24
|
+
export declare function formatMissingEnvMessage(missing: string[]): string;
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure env-resolution helper — kept separate from `index.ts` (which has a
|
|
3
|
+
* `#!/usr/bin/env node` shebang and starts the stdio server as a side effect
|
|
4
|
+
* on import) so the fail-fast "missing env var" behavior is unit-testable
|
|
5
|
+
* without spawning a subprocess or calling `process.exit`.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Reads `VELOCMS_SITE_URL` + `VELOCMS_API_KEY` from the given environment
|
|
9
|
+
* (defaults to `process.env`). Never logs the resolved values.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveEnvConfig(env = process.env) {
|
|
12
|
+
const siteUrl = env.VELOCMS_SITE_URL;
|
|
13
|
+
const apiKey = env.VELOCMS_API_KEY;
|
|
14
|
+
const missing = [];
|
|
15
|
+
if (!siteUrl)
|
|
16
|
+
missing.push("VELOCMS_SITE_URL");
|
|
17
|
+
if (!apiKey)
|
|
18
|
+
missing.push("VELOCMS_API_KEY");
|
|
19
|
+
if (missing.length > 0 || !siteUrl || !apiKey) {
|
|
20
|
+
return { ok: false, missing };
|
|
21
|
+
}
|
|
22
|
+
return { ok: true, config: { siteUrl, apiKey } };
|
|
23
|
+
}
|
|
24
|
+
/** Human-readable message for the missing-env-var fail-fast path (stderr only, never stdout). */
|
|
25
|
+
export function formatMissingEnvMessage(missing) {
|
|
26
|
+
return (`[velocms-mcp] Missing required environment variable(s): ${missing.join(", ")}.\n` +
|
|
27
|
+
" VELOCMS_SITE_URL — your blog's base URL, e.g. https://myblog.velocms.org (or a bound custom domain)\n" +
|
|
28
|
+
" VELOCMS_API_KEY — an API key from your VeloCMS dashboard: /admin/settings -> API Keys (Pro plan or higher)\n" +
|
|
29
|
+
"Set both in your MCP client config or shell environment. See README.md for setup.");
|
|
30
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* velocms-mcp — MCP stdio server for the VeloCMS Public API.
|
|
4
|
+
*
|
|
5
|
+
* Registers every tool from `./tools.js` against a single `VeloCmsClient`
|
|
6
|
+
* instance. Run directly (`node dist/index.js`) or via the `velocms-mcp` /
|
|
7
|
+
* `npx @velocms/mcp` bin entry — register it in an MCP client's config (see
|
|
8
|
+
* README.md).
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* velocms-mcp — MCP stdio server for the VeloCMS Public API.
|
|
4
|
+
*
|
|
5
|
+
* Registers every tool from `./tools.js` against a single `VeloCmsClient`
|
|
6
|
+
* instance. Run directly (`node dist/index.js`) or via the `velocms-mcp` /
|
|
7
|
+
* `npx @velocms/mcp` bin entry — register it in an MCP client's config (see
|
|
8
|
+
* README.md).
|
|
9
|
+
*/
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { VeloCmsApiError, VeloCmsClient } from "./client.js";
|
|
13
|
+
import { formatMissingEnvMessage, resolveEnvConfig } from "./env.js";
|
|
14
|
+
import { tools } from "./tools.js";
|
|
15
|
+
const SERVER_NAME = "velocms-mcp";
|
|
16
|
+
const SERVER_VERSION = "0.1.0";
|
|
17
|
+
function formatError(err) {
|
|
18
|
+
if (err instanceof VeloCmsApiError) {
|
|
19
|
+
const statusPart = err.status !== null ? `, HTTP ${err.status}` : "";
|
|
20
|
+
const parts = [`VeloCMS API error (${err.code}${statusPart}): ${err.message}`];
|
|
21
|
+
if (err.details)
|
|
22
|
+
parts.push(`Details: ${JSON.stringify(err.details)}`);
|
|
23
|
+
return parts.join("\n");
|
|
24
|
+
}
|
|
25
|
+
if (err instanceof Error)
|
|
26
|
+
return `Error: ${err.message}`;
|
|
27
|
+
return `Error: ${String(err)}`;
|
|
28
|
+
}
|
|
29
|
+
async function main() {
|
|
30
|
+
const resolution = resolveEnvConfig();
|
|
31
|
+
if (!resolution.ok) {
|
|
32
|
+
console.error(formatMissingEnvMessage(resolution.missing));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const client = new VeloCmsClient(resolution.config);
|
|
36
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
37
|
+
for (const [name, tool] of Object.entries(tools)) {
|
|
38
|
+
server.registerTool(name, {
|
|
39
|
+
title: tool.title,
|
|
40
|
+
description: tool.description,
|
|
41
|
+
inputSchema: tool.inputSchema,
|
|
42
|
+
}, async (args) => {
|
|
43
|
+
try {
|
|
44
|
+
const result = await tool.handler(client, (args ?? {}));
|
|
45
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
return { content: [{ type: "text", text: formatError(err) }], isError: true };
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const transport = new StdioServerTransport();
|
|
53
|
+
await server.connect(transport);
|
|
54
|
+
console.error(`[velocms-mcp] MCP server running on stdio (${Object.keys(tools).length} tools registered).`);
|
|
55
|
+
}
|
|
56
|
+
main().catch((err) => {
|
|
57
|
+
console.error(`[velocms-mcp] Fatal error: ${err instanceof Error ? err.message : String(err)}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registry for velocms-mcp. Each entry is `{ title, description,
|
|
3
|
+
* inputSchema, handler }`:
|
|
4
|
+
*
|
|
5
|
+
* - `inputSchema` is a Zod *raw shape* (a plain object of Zod schemas) —
|
|
6
|
+
* this is what `@modelcontextprotocol/sdk`'s `server.registerTool()`
|
|
7
|
+
* expects as its second argument's `inputSchema` field.
|
|
8
|
+
* - `handler(client, rawArgs)` re-validates `rawArgs` against
|
|
9
|
+
* `z.object(shape)` internally (belt-and-suspenders: the MCP SDK already
|
|
10
|
+
* validates against `inputSchema` before calling the handler, but parsing
|
|
11
|
+
* again here gives every handler a precisely-typed `args` value without
|
|
12
|
+
* needing an `any` escape hatch at the registry boundary — and it means
|
|
13
|
+
* `handler` can be called directly and safely from tests/CLI code with a
|
|
14
|
+
* plain object).
|
|
15
|
+
*
|
|
16
|
+
* Every request/response shape below is taken from `public/openapi.yaml` in
|
|
17
|
+
* the main VeloCMS repository (the canonical API contract) — see the
|
|
18
|
+
* per-tool comments for the exact operationId + line references used.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { VeloCmsClient } from "./client.js";
|
|
22
|
+
export interface ToolEntry {
|
|
23
|
+
title: string;
|
|
24
|
+
description: string;
|
|
25
|
+
inputSchema: z.ZodRawShape;
|
|
26
|
+
handler: (client: VeloCmsClient, rawArgs: Record<string, unknown>) => Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
export declare const tools: Record<string, ToolEntry>;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registry for velocms-mcp. Each entry is `{ title, description,
|
|
3
|
+
* inputSchema, handler }`:
|
|
4
|
+
*
|
|
5
|
+
* - `inputSchema` is a Zod *raw shape* (a plain object of Zod schemas) —
|
|
6
|
+
* this is what `@modelcontextprotocol/sdk`'s `server.registerTool()`
|
|
7
|
+
* expects as its second argument's `inputSchema` field.
|
|
8
|
+
* - `handler(client, rawArgs)` re-validates `rawArgs` against
|
|
9
|
+
* `z.object(shape)` internally (belt-and-suspenders: the MCP SDK already
|
|
10
|
+
* validates against `inputSchema` before calling the handler, but parsing
|
|
11
|
+
* again here gives every handler a precisely-typed `args` value without
|
|
12
|
+
* needing an `any` escape hatch at the registry boundary — and it means
|
|
13
|
+
* `handler` can be called directly and safely from tests/CLI code with a
|
|
14
|
+
* plain object).
|
|
15
|
+
*
|
|
16
|
+
* Every request/response shape below is taken from `public/openapi.yaml` in
|
|
17
|
+
* the main VeloCMS repository (the canonical API contract) — see the
|
|
18
|
+
* per-tool comments for the exact operationId + line references used.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Shared arg pieces
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
const postStatusEnum = z.enum(["draft", "published"]);
|
|
25
|
+
const commentStatusEnum = z.enum(["approved", "pending", "spam"]);
|
|
26
|
+
const memberTierEnum = z.enum(["free", "paid"]);
|
|
27
|
+
const pageArg = z
|
|
28
|
+
.number()
|
|
29
|
+
.int()
|
|
30
|
+
.min(1)
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Page number (1-based). Default: 1.");
|
|
33
|
+
const perPageArg = z
|
|
34
|
+
.number()
|
|
35
|
+
.int()
|
|
36
|
+
.min(1)
|
|
37
|
+
.max(100)
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("Records per page, max 100. Default: 20.");
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// list_posts — GET /api/v1/posts (openapi.yaml operationId: listPosts)
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
const listPostsShape = {
|
|
44
|
+
page: pageArg,
|
|
45
|
+
perPage: perPageArg,
|
|
46
|
+
status: postStatusEnum.optional().describe("Filter by post status (draft or published)."),
|
|
47
|
+
};
|
|
48
|
+
const listPostsSchema = z.object(listPostsShape);
|
|
49
|
+
async function handleListPosts(client, rawArgs) {
|
|
50
|
+
const args = listPostsSchema.parse(rawArgs);
|
|
51
|
+
return client.request("GET", "/posts", {
|
|
52
|
+
query: { page: args.page, per_page: args.perPage, status: args.status },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// get_post — GET /api/v1/posts/{id} (operationId: getPost)
|
|
57
|
+
// Response is the PostFull record DIRECTLY (not wrapped in `data`) —
|
|
58
|
+
// see openapi.yaml lines ~836-842.
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
const getPostShape = {
|
|
61
|
+
id: z.string().min(1).describe("PocketBase record ID of the post."),
|
|
62
|
+
};
|
|
63
|
+
const getPostSchema = z.object(getPostShape);
|
|
64
|
+
async function handleGetPost(client, rawArgs) {
|
|
65
|
+
const args = getPostSchema.parse(rawArgs);
|
|
66
|
+
return client.request("GET", `/posts/${encodeURIComponent(args.id)}`);
|
|
67
|
+
}
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// create_post — POST /api/v1/posts (operationId: createPost)
|
|
70
|
+
// Body: CreatePostBody. Response: 201 { data: PostFull }.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
const createPostShape = {
|
|
73
|
+
title: z.string().min(1).max(255).describe("Post title. Required."),
|
|
74
|
+
slug: z.string().min(1).max(255).optional().describe("Auto-generated from title if omitted."),
|
|
75
|
+
contentHtml: z.string().optional().describe("Post body as HTML."),
|
|
76
|
+
contentJson: z
|
|
77
|
+
.unknown()
|
|
78
|
+
.optional()
|
|
79
|
+
.describe("TipTap ProseMirror JSON document (arbitrary structure). Prefer contentHtml unless you " +
|
|
80
|
+
"specifically need to write a ProseMirror document."),
|
|
81
|
+
excerpt: z.string().max(500).optional(),
|
|
82
|
+
status: postStatusEnum
|
|
83
|
+
.optional()
|
|
84
|
+
.describe("draft (default) or published. Publishing here stamps published_at automatically — " +
|
|
85
|
+
"or create as draft and call publish_post later."),
|
|
86
|
+
tags: z.array(z.string()).optional(),
|
|
87
|
+
seoTitle: z.string().max(60).optional().describe("SEO meta title, max 60 chars."),
|
|
88
|
+
seoDescription: z.string().max(160).optional().describe("SEO meta description, max 160 chars."),
|
|
89
|
+
};
|
|
90
|
+
const createPostSchema = z.object(createPostShape);
|
|
91
|
+
async function handleCreatePost(client, rawArgs) {
|
|
92
|
+
const args = createPostSchema.parse(rawArgs);
|
|
93
|
+
const body = { title: args.title };
|
|
94
|
+
if (args.slug !== undefined)
|
|
95
|
+
body.slug = args.slug;
|
|
96
|
+
if (args.contentHtml !== undefined)
|
|
97
|
+
body.content_html = args.contentHtml;
|
|
98
|
+
if (args.contentJson !== undefined)
|
|
99
|
+
body.content_json = args.contentJson;
|
|
100
|
+
if (args.excerpt !== undefined)
|
|
101
|
+
body.excerpt = args.excerpt;
|
|
102
|
+
if (args.status !== undefined)
|
|
103
|
+
body.status = args.status;
|
|
104
|
+
if (args.tags !== undefined)
|
|
105
|
+
body.tags = args.tags;
|
|
106
|
+
if (args.seoTitle !== undefined)
|
|
107
|
+
body.seo_title = args.seoTitle;
|
|
108
|
+
if (args.seoDescription !== undefined)
|
|
109
|
+
body.seo_description = args.seoDescription;
|
|
110
|
+
const response = await client.request("POST", "/posts", { body });
|
|
111
|
+
return response.data;
|
|
112
|
+
}
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// update_post — PATCH /api/v1/posts/{id} (operationId: updatePost)
|
|
115
|
+
// Body: PatchPostBody (all fields optional). Response: 200 { data: PostFull }.
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
const updatePostShape = {
|
|
118
|
+
id: z.string().min(1).describe("PocketBase record ID of the post to update."),
|
|
119
|
+
title: z.string().min(1).max(255).optional(),
|
|
120
|
+
slug: z.string().min(1).max(255).optional(),
|
|
121
|
+
contentHtml: z.string().optional(),
|
|
122
|
+
contentJson: z.unknown().optional().describe("TipTap ProseMirror JSON document."),
|
|
123
|
+
excerpt: z.string().max(500).optional(),
|
|
124
|
+
status: postStatusEnum
|
|
125
|
+
.optional()
|
|
126
|
+
.describe("draft or published. Transitioning draft -> published stamps published_at automatically. " +
|
|
127
|
+
"Prefer publish_post/unpublish_post for a status-only change."),
|
|
128
|
+
tags: z.array(z.string()).optional(),
|
|
129
|
+
seoTitle: z.string().max(60).optional(),
|
|
130
|
+
seoDescription: z.string().max(160).optional(),
|
|
131
|
+
};
|
|
132
|
+
const updatePostSchema = z.object(updatePostShape);
|
|
133
|
+
const UPDATE_POST_FIELD_KEYS = [
|
|
134
|
+
"title",
|
|
135
|
+
"slug",
|
|
136
|
+
"contentHtml",
|
|
137
|
+
"contentJson",
|
|
138
|
+
"excerpt",
|
|
139
|
+
"status",
|
|
140
|
+
"tags",
|
|
141
|
+
"seoTitle",
|
|
142
|
+
"seoDescription",
|
|
143
|
+
];
|
|
144
|
+
async function handleUpdatePost(client, rawArgs) {
|
|
145
|
+
const args = updatePostSchema.parse(rawArgs);
|
|
146
|
+
const body = {};
|
|
147
|
+
if (args.title !== undefined)
|
|
148
|
+
body.title = args.title;
|
|
149
|
+
if (args.slug !== undefined)
|
|
150
|
+
body.slug = args.slug;
|
|
151
|
+
if (args.contentHtml !== undefined)
|
|
152
|
+
body.content_html = args.contentHtml;
|
|
153
|
+
if (args.contentJson !== undefined)
|
|
154
|
+
body.content_json = args.contentJson;
|
|
155
|
+
if (args.excerpt !== undefined)
|
|
156
|
+
body.excerpt = args.excerpt;
|
|
157
|
+
if (args.status !== undefined)
|
|
158
|
+
body.status = args.status;
|
|
159
|
+
if (args.tags !== undefined)
|
|
160
|
+
body.tags = args.tags;
|
|
161
|
+
if (args.seoTitle !== undefined)
|
|
162
|
+
body.seo_title = args.seoTitle;
|
|
163
|
+
if (args.seoDescription !== undefined)
|
|
164
|
+
body.seo_description = args.seoDescription;
|
|
165
|
+
const hasAnyField = UPDATE_POST_FIELD_KEYS.some((key) => args[key] !== undefined);
|
|
166
|
+
if (!hasAnyField) {
|
|
167
|
+
throw new Error("update_post: provide at least one field to change besides id (title, slug, contentHtml, " +
|
|
168
|
+
"contentJson, excerpt, status, tags, seoTitle, seoDescription).");
|
|
169
|
+
}
|
|
170
|
+
const response = await client.request("PATCH", `/posts/${encodeURIComponent(args.id)}`, { body });
|
|
171
|
+
return response.data;
|
|
172
|
+
}
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// delete_post — DELETE /api/v1/posts/{id} (operationId: deletePost) -> 204
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
const deletePostShape = {
|
|
177
|
+
id: z.string().min(1).describe("PocketBase record ID of the post to delete."),
|
|
178
|
+
};
|
|
179
|
+
const deletePostSchema = z.object(deletePostShape);
|
|
180
|
+
async function handleDeletePost(client, rawArgs) {
|
|
181
|
+
const args = deletePostSchema.parse(rawArgs);
|
|
182
|
+
await client.request("DELETE", `/posts/${encodeURIComponent(args.id)}`);
|
|
183
|
+
return { ok: true, id: args.id };
|
|
184
|
+
}
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// publish_post / unpublish_post — both PATCH /api/v1/posts/{id} with a
|
|
187
|
+
// status-only body (openapi.yaml PatchPostBody.status). Kept as separate
|
|
188
|
+
// tools from update_post because "publish this" / "take this down" are the
|
|
189
|
+
// two most common single-purpose actions an agent performs.
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
const publishPostShape = {
|
|
192
|
+
id: z.string().min(1).describe("PocketBase record ID of the post to publish."),
|
|
193
|
+
};
|
|
194
|
+
const publishPostSchema = z.object(publishPostShape);
|
|
195
|
+
async function setPostStatus(client, id, status) {
|
|
196
|
+
const response = await client.request("PATCH", `/posts/${encodeURIComponent(id)}`, { body: { status } });
|
|
197
|
+
return response.data;
|
|
198
|
+
}
|
|
199
|
+
async function handlePublishPost(client, rawArgs) {
|
|
200
|
+
const args = publishPostSchema.parse(rawArgs);
|
|
201
|
+
return setPostStatus(client, args.id, "published");
|
|
202
|
+
}
|
|
203
|
+
const unpublishPostShape = {
|
|
204
|
+
id: z.string().min(1).describe("PocketBase record ID of the post to unpublish."),
|
|
205
|
+
};
|
|
206
|
+
const unpublishPostSchema = z.object(unpublishPostShape);
|
|
207
|
+
async function handleUnpublishPost(client, rawArgs) {
|
|
208
|
+
const args = unpublishPostSchema.parse(rawArgs);
|
|
209
|
+
return setPostStatus(client, args.id, "draft");
|
|
210
|
+
}
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// list_media — GET /api/v1/media (operationId: listMedia)
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
const listMediaShape = {
|
|
215
|
+
page: pageArg,
|
|
216
|
+
perPage: perPageArg,
|
|
217
|
+
type: z.string().optional().describe("Filter by MIME type prefix (e.g. 'image')."),
|
|
218
|
+
};
|
|
219
|
+
const listMediaSchema = z.object(listMediaShape);
|
|
220
|
+
async function handleListMedia(client, rawArgs) {
|
|
221
|
+
const args = listMediaSchema.parse(rawArgs);
|
|
222
|
+
return client.request("GET", "/media", {
|
|
223
|
+
query: { page: args.page, per_page: args.perPage, type: args.type },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// list_comments — GET /api/v1/comments (operationId: listComments)
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
const listCommentsShape = {
|
|
230
|
+
page: pageArg,
|
|
231
|
+
perPage: perPageArg,
|
|
232
|
+
postId: z.string().optional().describe("Filter to comments on a specific post."),
|
|
233
|
+
status: commentStatusEnum.optional().describe("Filter by moderation status."),
|
|
234
|
+
};
|
|
235
|
+
const listCommentsSchema = z.object(listCommentsShape);
|
|
236
|
+
async function handleListComments(client, rawArgs) {
|
|
237
|
+
const args = listCommentsSchema.parse(rawArgs);
|
|
238
|
+
return client.request("GET", "/comments", {
|
|
239
|
+
query: { page: args.page, per_page: args.perPage, post_id: args.postId, status: args.status },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// moderate_comment — PATCH /api/v1/comments/{id}/moderate
|
|
244
|
+
// (operationId: moderateComment). Body field is `status` per openapi.yaml
|
|
245
|
+
// lines ~1109-1115 (NOT "action" — see README "Notes on this MCP server").
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
const moderateCommentShape = {
|
|
248
|
+
id: z.string().min(1).describe("PocketBase record ID of the comment to moderate."),
|
|
249
|
+
status: commentStatusEnum.describe("Moderation status to apply: approved, pending, or spam."),
|
|
250
|
+
};
|
|
251
|
+
const moderateCommentSchema = z.object(moderateCommentShape);
|
|
252
|
+
async function handleModerateComment(client, rawArgs) {
|
|
253
|
+
const args = moderateCommentSchema.parse(rawArgs);
|
|
254
|
+
const response = await client.request("PATCH", `/comments/${encodeURIComponent(args.id)}/moderate`, { body: { status: args.status } });
|
|
255
|
+
return response.data;
|
|
256
|
+
}
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// list_members — GET /api/v1/members (operationId: listMembers)
|
|
259
|
+
// Email local parts are masked by the API (e.g. u***@example.com) — this
|
|
260
|
+
// tool never sees or handles unmasked member emails.
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
const listMembersShape = {
|
|
263
|
+
page: pageArg,
|
|
264
|
+
perPage: perPageArg,
|
|
265
|
+
tier: memberTierEnum.optional().describe("Filter by subscription tier (free or paid)."),
|
|
266
|
+
};
|
|
267
|
+
const listMembersSchema = z.object(listMembersShape);
|
|
268
|
+
async function handleListMembers(client, rawArgs) {
|
|
269
|
+
const args = listMembersSchema.parse(rawArgs);
|
|
270
|
+
return client.request("GET", "/members", {
|
|
271
|
+
query: { page: args.page, per_page: args.perPage, tier: args.tier },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// get_site_settings — GET /api/v1/site-settings (operationId: getSiteSettings)
|
|
276
|
+
// Response is the SiteSettings record directly (not wrapped in `data`).
|
|
277
|
+
// Encrypted fields (member_stripe_*, ai_api_key) are excluded by the API.
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
const getSiteSettingsShape = {};
|
|
280
|
+
const getSiteSettingsSchema = z.object(getSiteSettingsShape);
|
|
281
|
+
async function handleGetSiteSettings(client, rawArgs) {
|
|
282
|
+
getSiteSettingsSchema.parse(rawArgs);
|
|
283
|
+
return client.request("GET", "/site-settings");
|
|
284
|
+
}
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Registry
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
export const tools = {
|
|
289
|
+
list_posts: {
|
|
290
|
+
title: "List posts",
|
|
291
|
+
description: "Lists blog posts for the authenticated tenant, paginated (default 20/page, max 100). " +
|
|
292
|
+
"Optionally filter by status (draft or published). Requires the posts:read API key scope.",
|
|
293
|
+
inputSchema: listPostsShape,
|
|
294
|
+
handler: handleListPosts,
|
|
295
|
+
},
|
|
296
|
+
get_post: {
|
|
297
|
+
title: "Get a post",
|
|
298
|
+
description: "Fetches a single post by its PocketBase record ID, including content_html/content_json " +
|
|
299
|
+
"and SEO fields. Requires the posts:read scope.",
|
|
300
|
+
inputSchema: getPostShape,
|
|
301
|
+
handler: handleGetPost,
|
|
302
|
+
},
|
|
303
|
+
create_post: {
|
|
304
|
+
title: "Create a post",
|
|
305
|
+
description: "Creates a new blog post. Defaults to status=draft — pass status=published to publish " +
|
|
306
|
+
"immediately (stamps published_at automatically), or create as a draft and call " +
|
|
307
|
+
"publish_post once you're ready. Requires the posts:write scope.",
|
|
308
|
+
inputSchema: createPostShape,
|
|
309
|
+
handler: handleCreatePost,
|
|
310
|
+
},
|
|
311
|
+
update_post: {
|
|
312
|
+
title: "Update a post",
|
|
313
|
+
description: "Partially updates an existing post — only the fields you pass are changed. Requires at " +
|
|
314
|
+
"least one field besides id. Requires the posts:write scope.",
|
|
315
|
+
inputSchema: updatePostShape,
|
|
316
|
+
handler: handleUpdatePost,
|
|
317
|
+
},
|
|
318
|
+
delete_post: {
|
|
319
|
+
title: "Delete a post",
|
|
320
|
+
description: "Permanently deletes a post by ID. Requires the posts:write scope.",
|
|
321
|
+
inputSchema: deletePostShape,
|
|
322
|
+
handler: handleDeletePost,
|
|
323
|
+
},
|
|
324
|
+
publish_post: {
|
|
325
|
+
title: "Publish a post",
|
|
326
|
+
description: "Sets a post's status to published (stamps published_at). Shorthand for " +
|
|
327
|
+
"update_post({ status: 'published' }). Requires the posts:write scope.",
|
|
328
|
+
inputSchema: publishPostShape,
|
|
329
|
+
handler: handlePublishPost,
|
|
330
|
+
},
|
|
331
|
+
unpublish_post: {
|
|
332
|
+
title: "Unpublish a post",
|
|
333
|
+
description: "Sets a post's status back to draft, taking it off the public site. Shorthand for " +
|
|
334
|
+
"update_post({ status: 'draft' }). Requires the posts:write scope.",
|
|
335
|
+
inputSchema: unpublishPostShape,
|
|
336
|
+
handler: handleUnpublishPost,
|
|
337
|
+
},
|
|
338
|
+
list_media: {
|
|
339
|
+
title: "List media",
|
|
340
|
+
description: "Lists items in the media library, paginated. Optionally filter by MIME type prefix " +
|
|
341
|
+
"(e.g. 'image'). Requires the media:read scope.",
|
|
342
|
+
inputSchema: listMediaShape,
|
|
343
|
+
handler: handleListMedia,
|
|
344
|
+
},
|
|
345
|
+
list_comments: {
|
|
346
|
+
title: "List comments",
|
|
347
|
+
description: "Lists comments, paginated. Optionally filter to a specific post (postId) and/or by " +
|
|
348
|
+
"moderation status (approved, pending, spam). Requires the comments:read scope.",
|
|
349
|
+
inputSchema: listCommentsShape,
|
|
350
|
+
handler: handleListComments,
|
|
351
|
+
},
|
|
352
|
+
moderate_comment: {
|
|
353
|
+
title: "Moderate a comment",
|
|
354
|
+
description: "Sets a comment's moderation status (approved, pending, or spam). Requires the " +
|
|
355
|
+
"comments:moderate scope.",
|
|
356
|
+
inputSchema: moderateCommentShape,
|
|
357
|
+
handler: handleModerateComment,
|
|
358
|
+
},
|
|
359
|
+
list_members: {
|
|
360
|
+
title: "List members",
|
|
361
|
+
description: "Lists reader/subscriber records, paginated. Email local parts are masked by the API " +
|
|
362
|
+
"(e.g. u***@example.com). Optionally filter by tier (free or paid). Requires the " +
|
|
363
|
+
"members:read scope.",
|
|
364
|
+
inputSchema: listMembersShape,
|
|
365
|
+
handler: handleListMembers,
|
|
366
|
+
},
|
|
367
|
+
get_site_settings: {
|
|
368
|
+
title: "Get site settings",
|
|
369
|
+
description: "Fetches the tenant's site configuration (name, description, logo, favicon, " +
|
|
370
|
+
"members/comments enabled flags). Encrypted fields (Stripe keys, AI API key) are always " +
|
|
371
|
+
"excluded by the API. Requires the site-settings:read scope.",
|
|
372
|
+
inputSchema: getSiteSettingsShape,
|
|
373
|
+
handler: handleGetSiteSettings,
|
|
374
|
+
},
|
|
375
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response DTOs for the VeloCMS Public API (`/api/v1`), mirrored from
|
|
3
|
+
* `public/openapi.yaml` in the main VeloCMS repository (components.schemas).
|
|
4
|
+
* Kept intentionally close to the wire shape (snake_case field names) so a
|
|
5
|
+
* tool's JSON output matches what `openapi.yaml` documents.
|
|
6
|
+
*/
|
|
7
|
+
export type PostStatus = "draft" | "published";
|
|
8
|
+
export interface PostSummary {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
slug: string;
|
|
12
|
+
status: PostStatus;
|
|
13
|
+
excerpt?: string | null;
|
|
14
|
+
tags?: string[];
|
|
15
|
+
published_at?: string | null;
|
|
16
|
+
created: string;
|
|
17
|
+
updated: string;
|
|
18
|
+
}
|
|
19
|
+
/** GET /api/v1/posts/{id} response shape (PostFull = PostSummary + body/SEO fields). */
|
|
20
|
+
export interface PostFull extends PostSummary {
|
|
21
|
+
content_html?: string;
|
|
22
|
+
content_json?: unknown;
|
|
23
|
+
seo_title?: string | null;
|
|
24
|
+
seo_description?: string | null;
|
|
25
|
+
}
|
|
26
|
+
/** Envelope every paginated list endpoint returns (openapi.yaml PaginatedMeta + items). */
|
|
27
|
+
export interface PaginatedList<T> {
|
|
28
|
+
page: number;
|
|
29
|
+
per_page: number;
|
|
30
|
+
total: number;
|
|
31
|
+
total_pages: number;
|
|
32
|
+
items: T[];
|
|
33
|
+
}
|
|
34
|
+
export interface MediaItem {
|
|
35
|
+
id: string;
|
|
36
|
+
filename: string;
|
|
37
|
+
url: string;
|
|
38
|
+
mime_type: string;
|
|
39
|
+
size: number;
|
|
40
|
+
width?: number | null;
|
|
41
|
+
height?: number | null;
|
|
42
|
+
alt?: string | null;
|
|
43
|
+
created: string;
|
|
44
|
+
}
|
|
45
|
+
export type CommentStatus = "approved" | "pending" | "spam";
|
|
46
|
+
export interface CommentRecord {
|
|
47
|
+
id: string;
|
|
48
|
+
post_id: string;
|
|
49
|
+
author_name: string;
|
|
50
|
+
author_email?: string | null;
|
|
51
|
+
body: string;
|
|
52
|
+
parent_id?: string | null;
|
|
53
|
+
status: CommentStatus;
|
|
54
|
+
created: string;
|
|
55
|
+
updated: string;
|
|
56
|
+
}
|
|
57
|
+
export type MemberTier = "free" | "paid";
|
|
58
|
+
export type MemberStatus = "active" | "cancelled" | "past_due";
|
|
59
|
+
export interface MemberSummary {
|
|
60
|
+
id: string;
|
|
61
|
+
/** Local part is masked by the API for privacy, e.g. `u***@example.com`. */
|
|
62
|
+
email: string;
|
|
63
|
+
tier: MemberTier;
|
|
64
|
+
status: MemberStatus;
|
|
65
|
+
created: string;
|
|
66
|
+
updated: string;
|
|
67
|
+
}
|
|
68
|
+
export interface SiteSettings {
|
|
69
|
+
id: string;
|
|
70
|
+
tenant_id: string;
|
|
71
|
+
site_name: string;
|
|
72
|
+
site_description?: string | null;
|
|
73
|
+
site_logo?: string | null;
|
|
74
|
+
site_favicon?: string | null;
|
|
75
|
+
members_enabled: boolean;
|
|
76
|
+
comments_enabled: boolean;
|
|
77
|
+
created: string;
|
|
78
|
+
updated: string;
|
|
79
|
+
}
|
|
80
|
+
/** POST/PATCH endpoints that wrap their record in `{ "data": ... }`. */
|
|
81
|
+
export interface ApiEnvelope<T> {
|
|
82
|
+
data: T;
|
|
83
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response DTOs for the VeloCMS Public API (`/api/v1`), mirrored from
|
|
3
|
+
* `public/openapi.yaml` in the main VeloCMS repository (components.schemas).
|
|
4
|
+
* Kept intentionally close to the wire shape (snake_case field names) so a
|
|
5
|
+
* tool's JSON output matches what `openapi.yaml` documents.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@velocms/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for VeloCMS — draft, publish, and manage your blog (posts, media, comments, members) from Claude Code, Claude Desktop, Cursor, or any MCP client.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "VeloCMS",
|
|
9
|
+
"url": "https://velocms.org"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"velocms",
|
|
13
|
+
"blog",
|
|
14
|
+
"cms",
|
|
15
|
+
"mcp",
|
|
16
|
+
"mcp-server",
|
|
17
|
+
"claude",
|
|
18
|
+
"publishing",
|
|
19
|
+
"seo"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://velocms.org",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/VeloCMS/velocms-mcp.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/VeloCMS/velocms-mcp/issues"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"bin": {
|
|
33
|
+
"velocms-mcp": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"main": "./dist/index.js",
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.json",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:watch": "vitest",
|
|
47
|
+
"start": "node dist/index.js",
|
|
48
|
+
"prepublishOnly": "npm run build"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
52
|
+
"zod": "^3.24.1"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^20.14.0",
|
|
56
|
+
"typescript": "^5.6.0",
|
|
57
|
+
"vitest": "^2.1.0"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public",
|
|
61
|
+
"registry": "https://registry.npmjs.org/"
|
|
62
|
+
}
|
|
63
|
+
}
|