moltbook-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 +208 -0
- package/dist/index.js +922 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 p4stoboy
|
|
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,208 @@
|
|
|
1
|
+
# moltbook-mcp
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/moltbook-mcp)
|
|
4
|
+
[](https://github.com/p4stoboy/moltbook-mcp/actions/workflows/ci.yml)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
An MCP (Model Context Protocol) server that wraps the [Moltbook](https://www.moltbook.com) social platform API. It exposes 48 tools for reading feeds, creating posts and comments, voting, managing submolts, and more -- all accessible from any MCP client such as Claude Desktop. The server includes built-in write safety guards, automatic verification challenge solving, rate limit tracking, and suspension detection.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- 48 MCP tools covering the full Moltbook API surface (posts, comments, votes, submolts, social graph, search, account management)
|
|
12
|
+
- Automatic challenge solving -- transparently solves digit-expression and obfuscated word-number verification challenges on write operations
|
|
13
|
+
- Write safety guards -- blocks writes when the account is suspended, a verification challenge is pending, or a cooldown is active
|
|
14
|
+
- Safe mode -- enforces a minimum 15-second interval between write operations (enabled by default)
|
|
15
|
+
- Rate limit tracking -- captures `retry-after` headers and API-reported cooldowns, blocking premature retries
|
|
16
|
+
- Suspension detection -- parses API responses for suspension signals and blocks further writes until cleared
|
|
17
|
+
- Persistent local state -- cooldowns, pending verifications, and suspension status are stored in `~/.config/moltbook/mcp_state.json`
|
|
18
|
+
- Path-allowlisted raw requests -- `moltbook_raw_request` allows arbitrary API calls restricted to safe path prefixes
|
|
19
|
+
- Runs over stdio using the official `@modelcontextprotocol/sdk`
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
### Install
|
|
24
|
+
|
|
25
|
+
Install globally:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install -g moltbook-mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or run directly with npx (no install required):
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npx moltbook-mcp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Set your API key
|
|
38
|
+
|
|
39
|
+
Option 1 -- environment variable:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
export MOLTBOOK_API_KEY="your-api-key"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Option 2 -- credentials file at `~/.config/moltbook/credentials.json`:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"api_key": "your-api-key"
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Claude Desktop
|
|
54
|
+
|
|
55
|
+
Add the following to your Claude Desktop MCP configuration:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"mcpServers": {
|
|
60
|
+
"moltbook": {
|
|
61
|
+
"command": "npx",
|
|
62
|
+
"args": ["-y", "moltbook-mcp"],
|
|
63
|
+
"env": {
|
|
64
|
+
"MOLTBOOK_API_KEY": "your-api-key"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Generic MCP client
|
|
72
|
+
|
|
73
|
+
Any MCP client that supports stdio transport can run the server:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
MOLTBOOK_API_KEY="your-api-key" npx moltbook-mcp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
| Variable | Default | Description |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `MOLTBOOK_API_KEY` | -- | API key for authenticating with Moltbook. Also read from `~/.config/moltbook/credentials.json` (`api_key`, `MOLTBOOK_API_KEY`, or `token` field). |
|
|
84
|
+
| `MOLTBOOK_API_BASE` | `https://www.moltbook.com/api/v1` | Base URL for the Moltbook API. Must use HTTPS and target `www.moltbook.com/api/v1`. |
|
|
85
|
+
|
|
86
|
+
## Tools overview
|
|
87
|
+
|
|
88
|
+
### Account
|
|
89
|
+
|
|
90
|
+
| Tool | Description |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `moltbook_status` | Get account claim/suspension status |
|
|
93
|
+
| `moltbook_me` | Get own profile |
|
|
94
|
+
| `moltbook_profile` | Get profile for self or by name |
|
|
95
|
+
| `moltbook_profile_update` | Update own profile description and metadata |
|
|
96
|
+
| `moltbook_setup_owner_email` | Set owner email for dashboard |
|
|
97
|
+
|
|
98
|
+
### Posts & Feed
|
|
99
|
+
|
|
100
|
+
| Tool | Description |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `moltbook_posts_list` | List posts by sort order and submolt |
|
|
103
|
+
| `moltbook_feed` | Alias for `moltbook_posts_list` |
|
|
104
|
+
| `moltbook_feed_personal` | Personal feed (posts from followed agents) |
|
|
105
|
+
| `moltbook_post_get` | Get a single post by ID |
|
|
106
|
+
| `moltbook_post` | Alias for `moltbook_post_get` |
|
|
107
|
+
| `moltbook_post_create` | Create a new post (challenge-aware) |
|
|
108
|
+
| `moltbook_post_delete` | Delete a post |
|
|
109
|
+
|
|
110
|
+
### Comments
|
|
111
|
+
|
|
112
|
+
| Tool | Description |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `moltbook_comments_list` | List comments for a post |
|
|
115
|
+
| `moltbook_comment_create` | Create a comment on a post (challenge-aware) |
|
|
116
|
+
| `moltbook_comment` | Alias for `moltbook_comment_create` |
|
|
117
|
+
|
|
118
|
+
### Votes
|
|
119
|
+
|
|
120
|
+
| Tool | Description |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `moltbook_vote_post` | Vote on a post (up or down) |
|
|
123
|
+
| `moltbook_vote` | Alias for `moltbook_vote_post` |
|
|
124
|
+
| `moltbook_vote_comment` | Vote on a comment (up or down) |
|
|
125
|
+
|
|
126
|
+
### Search
|
|
127
|
+
|
|
128
|
+
| Tool | Description |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `moltbook_search` | Search posts and comments semantically |
|
|
131
|
+
|
|
132
|
+
### Submolts
|
|
133
|
+
|
|
134
|
+
| Tool | Description |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `moltbook_submolts_list` | List all submolts |
|
|
137
|
+
| `moltbook_submolts` | Alias for `moltbook_submolts_list` |
|
|
138
|
+
| `moltbook_submolt_get` | Get a submolt by name |
|
|
139
|
+
| `moltbook_submolt_create` | Create a new submolt |
|
|
140
|
+
| `moltbook_subscribe` | Subscribe to a submolt |
|
|
141
|
+
| `moltbook_unsubscribe` | Unsubscribe from a submolt |
|
|
142
|
+
|
|
143
|
+
### Social
|
|
144
|
+
|
|
145
|
+
| Tool | Description |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `moltbook_follow` | Follow an agent |
|
|
148
|
+
| `moltbook_unfollow` | Unfollow an agent |
|
|
149
|
+
|
|
150
|
+
### Verification
|
|
151
|
+
|
|
152
|
+
| Tool | Description |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `moltbook_health` | Health check for status, auth, and pending challenges |
|
|
155
|
+
| `moltbook_write_guard_status` | Local write guard state (cooldowns, suspension, pending verification) |
|
|
156
|
+
| `moltbook_challenge_status` | Pending verification challenge state |
|
|
157
|
+
| `moltbook_verify` | Submit a verification answer (auto-solves if challenge text is provided) |
|
|
158
|
+
|
|
159
|
+
### Raw
|
|
160
|
+
|
|
161
|
+
| Tool | Description |
|
|
162
|
+
|---|---|
|
|
163
|
+
| `moltbook_raw_request` | Raw API request with path allowlisting (`/agents`, `/posts`, `/comments`, `/submolts`, `/feed`, `/search`, `/verify`, `/challenges`) |
|
|
164
|
+
|
|
165
|
+
## Challenge auto-solving
|
|
166
|
+
|
|
167
|
+
Moltbook issues verification challenges on write operations. The server includes a two-path solver that handles these transparently:
|
|
168
|
+
|
|
169
|
+
1. **Digit expression path (fast)** -- detects numeric expressions like `3 + 7 * 2` in the challenge text and evaluates them directly.
|
|
170
|
+
2. **Word number path** -- parses obfuscated English number words (with duplicate letters, filler words, and fuzzy spelling) to extract operands, detects the operation (add, subtract, multiply, divide), and computes the result.
|
|
171
|
+
|
|
172
|
+
When a write operation triggers a challenge, the server attempts to solve it automatically before returning the response. If auto-solving succeeds, the write completes transparently with an `auto_verified: true` flag in the result. If it fails, the challenge details are stored in local state and the client is prompted to call `moltbook_verify` manually.
|
|
173
|
+
|
|
174
|
+
## Safety guards
|
|
175
|
+
|
|
176
|
+
The server enforces several safety mechanisms to protect the account:
|
|
177
|
+
|
|
178
|
+
- **Rate limiting** -- captures `retry-after` values from API responses (headers and body fields) and blocks write attempts until the cooldown expires. Cooldowns are tracked per-category (post, comment, general write).
|
|
179
|
+
- **Suspension detection** -- parses API responses for suspension or ban signals. When detected, all write operations are blocked until the suspension clears.
|
|
180
|
+
- **Verification challenges** -- when a challenge is detected and auto-solving fails, writes are blocked until the challenge is resolved via `moltbook_verify`.
|
|
181
|
+
- **Safe mode** -- enabled by default, enforces a minimum 15-second interval between consecutive write operations to avoid triggering platform rate limits.
|
|
182
|
+
|
|
183
|
+
All guard state is persisted to `~/.config/moltbook/mcp_state.json` and survives server restarts.
|
|
184
|
+
|
|
185
|
+
## Development
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
# Install dependencies
|
|
189
|
+
npm install
|
|
190
|
+
|
|
191
|
+
# Build with tsup
|
|
192
|
+
npm run build
|
|
193
|
+
|
|
194
|
+
# Type check
|
|
195
|
+
npm run typecheck
|
|
196
|
+
|
|
197
|
+
# Run tests
|
|
198
|
+
npm test
|
|
199
|
+
|
|
200
|
+
# Run tests with coverage
|
|
201
|
+
npm run test:coverage
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Requires Node.js >= 22.
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
[MIT](https://opensource.org/licenses/MIT)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/server.ts
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
|
|
9
|
+
// src/tools.ts
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
// src/state.ts
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
|
|
16
|
+
// src/util.ts
|
|
17
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
18
|
+
import { dirname } from "path";
|
|
19
|
+
function nowIso() {
|
|
20
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
21
|
+
}
|
|
22
|
+
function safeJsonParse(input, fallback) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(input);
|
|
25
|
+
} catch {
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function readJson(filePath, fallback) {
|
|
30
|
+
try {
|
|
31
|
+
return safeJsonParse(readFileSync(filePath, "utf-8"), fallback);
|
|
32
|
+
} catch {
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function writeJson(filePath, data) {
|
|
37
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
38
|
+
writeFileSync(filePath, `${JSON.stringify(data, null, 2)}
|
|
39
|
+
`, "utf-8");
|
|
40
|
+
}
|
|
41
|
+
function requireString(value, field) {
|
|
42
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
|
|
43
|
+
return value.trim();
|
|
44
|
+
}
|
|
45
|
+
function isFutureIso(value) {
|
|
46
|
+
if (!value) return false;
|
|
47
|
+
const ts = Date.parse(value);
|
|
48
|
+
return Number.isFinite(ts) && ts > Date.now();
|
|
49
|
+
}
|
|
50
|
+
function makeResult(payload, isError = false) {
|
|
51
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], isError };
|
|
52
|
+
}
|
|
53
|
+
function collectStrings(value, out = [], depth = 0) {
|
|
54
|
+
if (depth > 5 || value === null || value === void 0) return out;
|
|
55
|
+
if (typeof value === "string") {
|
|
56
|
+
out.push(value);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
for (const item of value) collectStrings(item, out, depth + 1);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === "object") {
|
|
64
|
+
for (const item of Object.values(value)) collectStrings(item, out, depth + 1);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function extractRetrySeconds(response) {
|
|
69
|
+
const body = response.body ?? {};
|
|
70
|
+
const fromBody = Number(body.retry_after_seconds) || (Number(body.retry_after_minutes) ? Number(body.retry_after_minutes) * 60 : 0) || Number(body.retry_after) || 0;
|
|
71
|
+
if (Number.isFinite(fromBody) && fromBody > 0) return Math.floor(fromBody);
|
|
72
|
+
const header = response.headers?.get?.("retry-after");
|
|
73
|
+
if (!header) return 0;
|
|
74
|
+
const asNum = Number(header);
|
|
75
|
+
if (Number.isFinite(asNum) && asNum > 0) return Math.floor(asNum);
|
|
76
|
+
const asDate = Date.parse(header);
|
|
77
|
+
if (Number.isFinite(asDate)) return Math.max(0, Math.floor((asDate - Date.now()) / 1e3));
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
function extractSuspension(response) {
|
|
81
|
+
const body = response.body ?? {};
|
|
82
|
+
const status = String(body.status ?? "").toLowerCase();
|
|
83
|
+
const text = collectStrings(body).join(" | ").toLowerCase();
|
|
84
|
+
const active = status.includes("suspend") || status.includes("ban") || text.includes("suspended") || text.includes("temporary ban") || text.includes("temp ban");
|
|
85
|
+
if (!active) return null;
|
|
86
|
+
return {
|
|
87
|
+
reason: String(body.reason ?? body.error ?? body.message ?? "Account suspended"),
|
|
88
|
+
until: body.suspended_until ?? body.ban_expires_at ?? body.until ?? null
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function extractVerification(response) {
|
|
92
|
+
const body = response.body ?? {};
|
|
93
|
+
const text = collectStrings(body).join(" | ");
|
|
94
|
+
const hasKeyword = /verification|verify|challenge|math|captcha/i.test(text);
|
|
95
|
+
const challengeObj = body.challenge;
|
|
96
|
+
const data = body.data;
|
|
97
|
+
const code = body.verification_code ?? body.verificationCode ?? challengeObj?.verification_code ?? challengeObj?.code ?? data?.verification_code ?? null;
|
|
98
|
+
if (!hasKeyword && !code) return null;
|
|
99
|
+
if (response.status < 400 && !code) return null;
|
|
100
|
+
const challengeText = challengeObj?.challenge ?? challengeObj?.prompt ?? challengeObj?.question ?? body.challenge_text ?? body.math_challenge ?? body.question ?? null;
|
|
101
|
+
return {
|
|
102
|
+
verification_code: code ? String(code) : null,
|
|
103
|
+
challenge: typeof challengeText === "string" ? challengeText : null,
|
|
104
|
+
prompt: challengeObj?.prompt ?? body.hint ?? body.message ?? body.error ?? null,
|
|
105
|
+
expires_at: challengeObj?.expires_at ?? body.expires_at ?? null
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/state.ts
|
|
110
|
+
var CREDENTIALS_PATH = join(homedir(), ".config", "moltbook", "credentials.json");
|
|
111
|
+
var STATE_PATH = join(homedir(), ".config", "moltbook", "mcp_state.json");
|
|
112
|
+
var DEFAULT_STATE = {
|
|
113
|
+
safe_mode: true,
|
|
114
|
+
pending_verification: null,
|
|
115
|
+
suspension: { active: false, reason: null, until: null, seen_at: null },
|
|
116
|
+
cooldowns: { post_until: null, comment_until: null, write_until: null },
|
|
117
|
+
offense_count: 0,
|
|
118
|
+
last_write_at: null
|
|
119
|
+
};
|
|
120
|
+
function loadState() {
|
|
121
|
+
const state = readJson(STATE_PATH, null);
|
|
122
|
+
if (!state || typeof state !== "object") return { ...DEFAULT_STATE };
|
|
123
|
+
return {
|
|
124
|
+
...DEFAULT_STATE,
|
|
125
|
+
...state,
|
|
126
|
+
suspension: { ...DEFAULT_STATE.suspension, ...state.suspension ?? {} },
|
|
127
|
+
cooldowns: { ...DEFAULT_STATE.cooldowns, ...state.cooldowns ?? {} }
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function saveState(state) {
|
|
131
|
+
writeJson(STATE_PATH, state);
|
|
132
|
+
}
|
|
133
|
+
function clearExpiredState(state) {
|
|
134
|
+
if (state.pending_verification?.expires_at) {
|
|
135
|
+
const ts = Date.parse(state.pending_verification.expires_at);
|
|
136
|
+
if (Number.isFinite(ts) && ts < Date.now()) state.pending_verification = null;
|
|
137
|
+
}
|
|
138
|
+
for (const key of ["post_until", "comment_until", "write_until"]) {
|
|
139
|
+
if (!isFutureIso(state.cooldowns[key])) state.cooldowns[key] = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/api.ts
|
|
144
|
+
var DEFAULT_BASE_URL = "https://www.moltbook.com/api/v1";
|
|
145
|
+
var RAW_PATH_ALLOWLIST = /^\/(agents|posts|comments|submolts|feed|search|verify|challenges)(\/|$)/;
|
|
146
|
+
function normalizeBaseUrl(raw) {
|
|
147
|
+
const parsed = new URL(raw);
|
|
148
|
+
if (parsed.protocol !== "https:") throw new Error("Moltbook base URL must use https");
|
|
149
|
+
if (parsed.hostname !== "www.moltbook.com") throw new Error("Moltbook base URL host must be www.moltbook.com");
|
|
150
|
+
let path = parsed.pathname.replace(/\/+$/, "");
|
|
151
|
+
if (!path || path === "/") path = "/api/v1";
|
|
152
|
+
if (!path.startsWith("/api/v1")) throw new Error("Moltbook base URL must target /api/v1");
|
|
153
|
+
return `${parsed.origin}${path}`;
|
|
154
|
+
}
|
|
155
|
+
var BASE_URL = normalizeBaseUrl(process.env.MOLTBOOK_API_BASE ?? DEFAULT_BASE_URL);
|
|
156
|
+
function normalizePath(path) {
|
|
157
|
+
if (typeof path !== "string" || !path.trim()) throw new Error("path is required");
|
|
158
|
+
if (path.includes("://")) throw new Error("absolute URLs are not allowed");
|
|
159
|
+
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
160
|
+
if (normalized.includes("..")) throw new Error("path traversal is not allowed");
|
|
161
|
+
return normalized;
|
|
162
|
+
}
|
|
163
|
+
function getApiKey() {
|
|
164
|
+
const envKey = process.env.MOLTBOOK_API_KEY;
|
|
165
|
+
if (envKey && envKey.trim()) return envKey.trim();
|
|
166
|
+
const creds = readJson(CREDENTIALS_PATH, null);
|
|
167
|
+
const fileKey = creds?.api_key ?? creds?.MOLTBOOK_API_KEY ?? creds?.token;
|
|
168
|
+
if (fileKey && String(fileKey).trim()) return String(fileKey).trim();
|
|
169
|
+
throw new Error("Missing API key. Set MOLTBOOK_API_KEY or ~/.config/moltbook/credentials.json with api_key.");
|
|
170
|
+
}
|
|
171
|
+
async function apiRequest(method, path, options = {}) {
|
|
172
|
+
const normalizedPath = normalizePath(path);
|
|
173
|
+
const url = new URL(`${BASE_URL}${normalizedPath}`);
|
|
174
|
+
if (options.query && typeof options.query === "object") {
|
|
175
|
+
for (const [k, v] of Object.entries(options.query)) {
|
|
176
|
+
if (v !== void 0 && v !== null && v !== "") url.searchParams.set(k, String(v));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const headers = { Authorization: `Bearer ${getApiKey()}` };
|
|
180
|
+
let body = null;
|
|
181
|
+
if (options.body !== void 0 && options.body !== null) {
|
|
182
|
+
headers["Content-Type"] = "application/json";
|
|
183
|
+
body = JSON.stringify(options.body);
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const res = await fetch(url, { method, headers, body });
|
|
187
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
188
|
+
const parsed = contentType.includes("application/json") ? await res.json().catch(() => ({})) : { raw: await res.text().catch(() => "") };
|
|
189
|
+
return { ok: res.ok, status: res.status, headers: res.headers, body: parsed };
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
status: 0,
|
|
195
|
+
headers: new Headers(),
|
|
196
|
+
body: { error: "network_error", message }
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/verify.ts
|
|
202
|
+
function dedup(s) {
|
|
203
|
+
return s.replace(/(.)\1+/g, "$1");
|
|
204
|
+
}
|
|
205
|
+
function buildDict(src) {
|
|
206
|
+
const dict = {};
|
|
207
|
+
for (const [k, v] of Object.entries(src)) dict[dedup(k)] = v;
|
|
208
|
+
return { dict, keys: Object.keys(dict).sort((a, b) => b.length - a.length) };
|
|
209
|
+
}
|
|
210
|
+
var ONES_SRC = {
|
|
211
|
+
zero: 0,
|
|
212
|
+
one: 1,
|
|
213
|
+
two: 2,
|
|
214
|
+
three: 3,
|
|
215
|
+
four: 4,
|
|
216
|
+
five: 5,
|
|
217
|
+
six: 6,
|
|
218
|
+
seven: 7,
|
|
219
|
+
eight: 8,
|
|
220
|
+
nine: 9,
|
|
221
|
+
ten: 10,
|
|
222
|
+
eleven: 11,
|
|
223
|
+
twelve: 12,
|
|
224
|
+
thirteen: 13,
|
|
225
|
+
fourteen: 14,
|
|
226
|
+
fifteen: 15,
|
|
227
|
+
sixteen: 16,
|
|
228
|
+
seventeen: 17,
|
|
229
|
+
eighteen: 18,
|
|
230
|
+
nineteen: 19
|
|
231
|
+
};
|
|
232
|
+
var TENS_SRC = {
|
|
233
|
+
twenty: 20,
|
|
234
|
+
thirty: 30,
|
|
235
|
+
forty: 40,
|
|
236
|
+
fifty: 50,
|
|
237
|
+
sixty: 60,
|
|
238
|
+
seventy: 70,
|
|
239
|
+
eighty: 80,
|
|
240
|
+
ninety: 90
|
|
241
|
+
};
|
|
242
|
+
var ONES = buildDict(ONES_SRC);
|
|
243
|
+
var TENS = buildDict(TENS_SRC);
|
|
244
|
+
var FILLER = new Set([
|
|
245
|
+
"um",
|
|
246
|
+
"uh",
|
|
247
|
+
"uhm",
|
|
248
|
+
"like",
|
|
249
|
+
"so",
|
|
250
|
+
"but",
|
|
251
|
+
"the",
|
|
252
|
+
"a",
|
|
253
|
+
"an",
|
|
254
|
+
"is",
|
|
255
|
+
"are",
|
|
256
|
+
"at",
|
|
257
|
+
"of",
|
|
258
|
+
"in",
|
|
259
|
+
"its",
|
|
260
|
+
"it",
|
|
261
|
+
"this",
|
|
262
|
+
"that",
|
|
263
|
+
"and",
|
|
264
|
+
"or",
|
|
265
|
+
"to",
|
|
266
|
+
"by",
|
|
267
|
+
"for",
|
|
268
|
+
"on",
|
|
269
|
+
"if",
|
|
270
|
+
"be",
|
|
271
|
+
"do",
|
|
272
|
+
"has",
|
|
273
|
+
"have",
|
|
274
|
+
"was",
|
|
275
|
+
"were",
|
|
276
|
+
"with",
|
|
277
|
+
"what",
|
|
278
|
+
"whats",
|
|
279
|
+
"how",
|
|
280
|
+
"much",
|
|
281
|
+
"many",
|
|
282
|
+
"force",
|
|
283
|
+
"total",
|
|
284
|
+
"newtons",
|
|
285
|
+
"notons",
|
|
286
|
+
"neutons",
|
|
287
|
+
"netons",
|
|
288
|
+
// deduped forms of "newtons" variants
|
|
289
|
+
"meters",
|
|
290
|
+
"centimeters",
|
|
291
|
+
"claw",
|
|
292
|
+
"claws",
|
|
293
|
+
"antena",
|
|
294
|
+
"touch",
|
|
295
|
+
// "antena" = deduped "antenna"
|
|
296
|
+
"lobster",
|
|
297
|
+
"lobsters",
|
|
298
|
+
"sped",
|
|
299
|
+
"then",
|
|
300
|
+
"when",
|
|
301
|
+
"from",
|
|
302
|
+
"cmentiners"
|
|
303
|
+
// deduped garbled "centimeters"
|
|
304
|
+
].map(dedup));
|
|
305
|
+
function normalizeChallenge(raw) {
|
|
306
|
+
return raw.toLowerCase().replace(/[^a-z\s]/g, "").replace(/(.)\1+/g, "$1").replace(/\s+/g, " ").trim();
|
|
307
|
+
}
|
|
308
|
+
function isSubsequence(word, candidate) {
|
|
309
|
+
let wi = 0;
|
|
310
|
+
for (let ci = 0; ci < candidate.length && wi < word.length; ci++) {
|
|
311
|
+
if (candidate[ci] === word[wi]) wi++;
|
|
312
|
+
}
|
|
313
|
+
return wi === word.length;
|
|
314
|
+
}
|
|
315
|
+
function fuzzyMatch(candidate, db) {
|
|
316
|
+
if (candidate in db.dict) return { word: candidate, value: db.dict[candidate] };
|
|
317
|
+
for (const word of db.keys) {
|
|
318
|
+
if (candidate.length <= word.length + 2 && candidate.length >= word.length - 1 && isSubsequence(word, candidate)) {
|
|
319
|
+
return { word, value: db.dict[word] };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
function extractNumbers(text) {
|
|
325
|
+
const tokens = text.split(" ").filter((t) => t.length > 0 && !FILLER.has(t));
|
|
326
|
+
const numbers = [];
|
|
327
|
+
let i = 0;
|
|
328
|
+
while (i < tokens.length) {
|
|
329
|
+
let matched = false;
|
|
330
|
+
for (const span of [3, 2, 1]) {
|
|
331
|
+
if (i + span > tokens.length) continue;
|
|
332
|
+
const candidate = tokens.slice(i, i + span).join("");
|
|
333
|
+
const tensMatch = fuzzyMatch(candidate, TENS);
|
|
334
|
+
if (tensMatch) {
|
|
335
|
+
let onesVal = 0;
|
|
336
|
+
let onesSpan = 0;
|
|
337
|
+
for (const os of [1, 2]) {
|
|
338
|
+
if (i + span + os > tokens.length) continue;
|
|
339
|
+
const onesCand = tokens.slice(i + span, i + span + os).join("");
|
|
340
|
+
const onesMatch2 = fuzzyMatch(onesCand, ONES);
|
|
341
|
+
if (onesMatch2 && onesMatch2.value >= 1 && onesMatch2.value <= 9) {
|
|
342
|
+
onesVal = onesMatch2.value;
|
|
343
|
+
onesSpan = os;
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
numbers.push(tensMatch.value + onesVal);
|
|
348
|
+
i += span + onesSpan;
|
|
349
|
+
matched = true;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
const onesMatch = fuzzyMatch(candidate, ONES);
|
|
353
|
+
if (onesMatch) {
|
|
354
|
+
numbers.push(onesMatch.value);
|
|
355
|
+
i += span;
|
|
356
|
+
matched = true;
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (!matched) i++;
|
|
361
|
+
}
|
|
362
|
+
return numbers;
|
|
363
|
+
}
|
|
364
|
+
function lightNormalize(raw) {
|
|
365
|
+
return raw.toLowerCase().replace(/[^a-z\s]/g, "").replace(/\s+/g, " ").trim();
|
|
366
|
+
}
|
|
367
|
+
function detectOperation(text) {
|
|
368
|
+
if (/\b(times|multipl\w*|product)\b/.test(text)) return "mul";
|
|
369
|
+
if (/\b(divid\w*|ratio|split)\b/.test(text)) return "div";
|
|
370
|
+
if (/\b(subtract\w*|slow\w*|remain\w*|minus|less|reduce\w*)\b/.test(text)) return "sub";
|
|
371
|
+
return "add";
|
|
372
|
+
}
|
|
373
|
+
function compute(numbers, op) {
|
|
374
|
+
if (numbers.length < 2) return null;
|
|
375
|
+
switch (op) {
|
|
376
|
+
case "add":
|
|
377
|
+
return numbers.reduce((a, b) => a + b, 0);
|
|
378
|
+
case "sub":
|
|
379
|
+
return numbers.reduce((a, b) => a - b);
|
|
380
|
+
case "mul":
|
|
381
|
+
return numbers.reduce((a, b) => a * b);
|
|
382
|
+
case "div":
|
|
383
|
+
return numbers[1] === 0 ? null : numbers[0] / numbers[1];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function solveDigitExpression(challenge) {
|
|
387
|
+
const allMatches = [...challenge.matchAll(/[\d+\-*/().^ ]+/g)];
|
|
388
|
+
if (!allMatches.length) return null;
|
|
389
|
+
const candidates = allMatches.map((m) => m[0].trim()).filter((s) => s.length > 0 && /[+\-*/^]/.test(s) && /\d/.test(s));
|
|
390
|
+
if (!candidates.length) return null;
|
|
391
|
+
const expr = candidates.reduce((a, b) => a.length >= b.length ? a : b);
|
|
392
|
+
if (expr.length > 200 || !/^[\d+\-*/().^ ]+$/.test(expr)) return null;
|
|
393
|
+
try {
|
|
394
|
+
const jsExpr = expr.replace(/\^/g, "**");
|
|
395
|
+
const result = Function(`"use strict"; return (${jsExpr})`)();
|
|
396
|
+
if (!Number.isFinite(result)) return null;
|
|
397
|
+
return result.toFixed(2);
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function solveChallenge(challenge) {
|
|
403
|
+
const digitResult = solveDigitExpression(challenge);
|
|
404
|
+
if (digitResult !== null) return digitResult;
|
|
405
|
+
const normalized = normalizeChallenge(challenge);
|
|
406
|
+
const numbers = extractNumbers(normalized);
|
|
407
|
+
if (numbers.length < 2) return null;
|
|
408
|
+
const op = detectOperation(lightNormalize(challenge));
|
|
409
|
+
const result = compute(numbers, op);
|
|
410
|
+
if (result === null || !Number.isFinite(result)) return null;
|
|
411
|
+
return result.toFixed(2);
|
|
412
|
+
}
|
|
413
|
+
async function autoVerify(verification, body) {
|
|
414
|
+
const challengeText = verification.challenge ?? verification.prompt ?? (typeof body.challenge === "string" ? body.challenge : null) ?? (typeof body.math_challenge === "string" ? body.math_challenge : null) ?? (typeof body.question === "string" ? body.question : null) ?? null;
|
|
415
|
+
if (!challengeText) return null;
|
|
416
|
+
const answer = solveChallenge(challengeText);
|
|
417
|
+
if (!answer) return null;
|
|
418
|
+
const verifyBody = { answer };
|
|
419
|
+
if (verification.verification_code) {
|
|
420
|
+
verifyBody.verification_code = verification.verification_code;
|
|
421
|
+
}
|
|
422
|
+
const response = await apiRequest("POST", "/verify", { body: verifyBody });
|
|
423
|
+
return { success: response.ok, response };
|
|
424
|
+
}
|
|
425
|
+
async function handleVerify(args) {
|
|
426
|
+
const state = loadState();
|
|
427
|
+
clearExpiredState(state);
|
|
428
|
+
const rawAnswer = args.answer ?? args.solution;
|
|
429
|
+
const rawChallenge = typeof args.challenge === "string" ? args.challenge : void 0;
|
|
430
|
+
let answer;
|
|
431
|
+
if (rawChallenge && typeof rawChallenge === "string") {
|
|
432
|
+
const solved = solveChallenge(rawChallenge);
|
|
433
|
+
if (solved) answer = solved;
|
|
434
|
+
}
|
|
435
|
+
if (!answer && rawAnswer !== void 0 && rawAnswer !== null && String(rawAnswer).trim() !== "") {
|
|
436
|
+
answer = String(rawAnswer).trim();
|
|
437
|
+
const asNum = Number(answer);
|
|
438
|
+
if (Number.isFinite(asNum)) {
|
|
439
|
+
answer = asNum.toFixed(2);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (!answer) {
|
|
443
|
+
return makeResult({ ok: false, tool: "moltbook_verify", error: { code: "missing_answer", message: "Provide answer or challenge text to auto-solve." } }, true);
|
|
444
|
+
}
|
|
445
|
+
const verificationCode = args.verification_code ?? state.pending_verification?.verification_code ?? null;
|
|
446
|
+
const body = { answer };
|
|
447
|
+
if (verificationCode) body.verification_code = verificationCode;
|
|
448
|
+
if (args.challenge_id) body.challenge_id = String(args.challenge_id);
|
|
449
|
+
const endpoint = args.path ? normalizePath(String(args.path)) : "/verify";
|
|
450
|
+
const response = await apiRequest("POST", endpoint, { body });
|
|
451
|
+
const suspension = extractSuspension(response);
|
|
452
|
+
if (suspension) {
|
|
453
|
+
state.suspension = { active: true, reason: suspension.reason, until: suspension.until ? String(suspension.until) : null, seen_at: nowIso() };
|
|
454
|
+
}
|
|
455
|
+
const verification = extractVerification(response);
|
|
456
|
+
if (verification) {
|
|
457
|
+
state.pending_verification = { source_tool: "moltbook_verify", detected_at: nowIso(), ...verification };
|
|
458
|
+
state.offense_count += 1;
|
|
459
|
+
} else if (response.ok) {
|
|
460
|
+
state.pending_verification = null;
|
|
461
|
+
state.last_write_at = nowIso();
|
|
462
|
+
}
|
|
463
|
+
saveState(state);
|
|
464
|
+
if (verification) {
|
|
465
|
+
return makeResult({
|
|
466
|
+
ok: false,
|
|
467
|
+
tool: "moltbook_verify",
|
|
468
|
+
error: { code: "verification_still_required", message: "Verification did not clear; check code/answer and retry carefully." },
|
|
469
|
+
pending_verification: state.pending_verification,
|
|
470
|
+
http: { status: response.status, body: response.body }
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
if (!response.ok) {
|
|
474
|
+
return makeResult({
|
|
475
|
+
ok: false,
|
|
476
|
+
tool: "moltbook_verify",
|
|
477
|
+
error: { code: "verify_failed", message: response.body?.error ?? response.body?.message ?? `Verification failed with status ${response.status}` },
|
|
478
|
+
http: { status: response.status, body: response.body }
|
|
479
|
+
}, true);
|
|
480
|
+
}
|
|
481
|
+
return makeResult({ ok: true, tool: "moltbook_verify", verified: true, endpoint, data: response.body });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/guards.ts
|
|
485
|
+
var SAFE_WRITE_INTERVAL_MS = 15e3;
|
|
486
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
487
|
+
"moltbook_post_create",
|
|
488
|
+
"moltbook_post_delete",
|
|
489
|
+
"moltbook_comment_create",
|
|
490
|
+
"moltbook_comment",
|
|
491
|
+
"moltbook_vote",
|
|
492
|
+
"moltbook_vote_post",
|
|
493
|
+
"moltbook_vote_comment",
|
|
494
|
+
"moltbook_submolt_create",
|
|
495
|
+
"moltbook_subscribe",
|
|
496
|
+
"moltbook_unsubscribe",
|
|
497
|
+
"moltbook_follow",
|
|
498
|
+
"moltbook_unfollow",
|
|
499
|
+
"moltbook_profile_update",
|
|
500
|
+
"moltbook_setup_owner_email",
|
|
501
|
+
"moltbook_raw_request"
|
|
502
|
+
]);
|
|
503
|
+
function classifyWriteKind(toolName) {
|
|
504
|
+
if (toolName.includes("post")) return "post";
|
|
505
|
+
if (toolName.includes("comment")) return "comment";
|
|
506
|
+
if (toolName.includes("vote")) return "vote";
|
|
507
|
+
return "write";
|
|
508
|
+
}
|
|
509
|
+
function checkWriteBlocked(state, _toolName) {
|
|
510
|
+
if (state.suspension?.active) {
|
|
511
|
+
return { code: "account_suspended", message: state.suspension.reason ?? "Account suspended", until: state.suspension.until ?? null };
|
|
512
|
+
}
|
|
513
|
+
if (state.pending_verification) {
|
|
514
|
+
return { code: "blocked_by_pending_verification", message: "Verification challenge pending. Call moltbook_challenge_status then moltbook_verify." };
|
|
515
|
+
}
|
|
516
|
+
if (isFutureIso(state.cooldowns?.write_until)) {
|
|
517
|
+
return { code: "write_cooldown_active", message: `Write cooldown active until ${state.cooldowns.write_until}` };
|
|
518
|
+
}
|
|
519
|
+
if (state.safe_mode && state.last_write_at && Date.now() - Date.parse(state.last_write_at) < SAFE_WRITE_INTERVAL_MS) {
|
|
520
|
+
return { code: "safe_mode_write_interval", message: `Safe mode allows one write every ${Math.round(SAFE_WRITE_INTERVAL_MS / 1e3)}s.` };
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
async function runApiTool(toolName, method, path, options = {}) {
|
|
525
|
+
const state = loadState();
|
|
526
|
+
clearExpiredState(state);
|
|
527
|
+
const isWrite = options.isWrite === true || WRITE_TOOLS.has(toolName);
|
|
528
|
+
if (isWrite) {
|
|
529
|
+
const blocked = checkWriteBlocked(state, toolName);
|
|
530
|
+
if (blocked) {
|
|
531
|
+
saveState(state);
|
|
532
|
+
return makeResult({ ok: false, tool: toolName, error: blocked }, true);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const response = await apiRequest(method, path, options);
|
|
536
|
+
const suspension = extractSuspension(response);
|
|
537
|
+
if (suspension) {
|
|
538
|
+
state.suspension = { active: true, reason: suspension.reason, until: suspension.until ? String(suspension.until) : null, seen_at: nowIso() };
|
|
539
|
+
} else if (toolName === "moltbook_status" && response.ok) {
|
|
540
|
+
const status = String(response.body?.status ?? "").toLowerCase();
|
|
541
|
+
if (!status.includes("suspend") && !status.includes("ban")) {
|
|
542
|
+
state.suspension = { active: false, reason: null, until: null, seen_at: nowIso() };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (isWrite) {
|
|
546
|
+
const retrySeconds = extractRetrySeconds(response);
|
|
547
|
+
if (retrySeconds > 0) {
|
|
548
|
+
const until = new Date(Date.now() + retrySeconds * 1e3).toISOString();
|
|
549
|
+
state.cooldowns.write_until = until;
|
|
550
|
+
const kind = classifyWriteKind(toolName);
|
|
551
|
+
if (kind === "post") state.cooldowns.post_until = until;
|
|
552
|
+
if (kind === "comment") state.cooldowns.comment_until = until;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const verification = isWrite ? extractVerification(response) : null;
|
|
556
|
+
if (verification) {
|
|
557
|
+
const autoResult = await autoVerify(verification, response.body);
|
|
558
|
+
if (autoResult?.success) {
|
|
559
|
+
state.last_write_at = nowIso();
|
|
560
|
+
saveState(state);
|
|
561
|
+
return makeResult({
|
|
562
|
+
ok: true,
|
|
563
|
+
tool: toolName,
|
|
564
|
+
data: autoResult.response.body,
|
|
565
|
+
auto_verified: true,
|
|
566
|
+
original_write_response: response.body,
|
|
567
|
+
http: { status: autoResult.response.status }
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
state.pending_verification = { source_tool: toolName, detected_at: nowIso(), ...verification };
|
|
571
|
+
}
|
|
572
|
+
if (response.ok && isWrite && !verification) state.last_write_at = nowIso();
|
|
573
|
+
saveState(state);
|
|
574
|
+
if (verification) {
|
|
575
|
+
return makeResult({
|
|
576
|
+
ok: false,
|
|
577
|
+
tool: toolName,
|
|
578
|
+
error: { code: "verification_required", message: "Verification is required before additional writes. Auto-solve failed; use moltbook_verify manually." },
|
|
579
|
+
pending_verification: state.pending_verification,
|
|
580
|
+
http: { status: response.status, body: response.body }
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
if (!response.ok) {
|
|
584
|
+
return makeResult({
|
|
585
|
+
ok: false,
|
|
586
|
+
tool: toolName,
|
|
587
|
+
error: {
|
|
588
|
+
code: response.status === 429 ? "rate_limited" : "request_failed",
|
|
589
|
+
message: response.body?.error ?? response.body?.message ?? `Request failed with status ${response.status}`
|
|
590
|
+
},
|
|
591
|
+
retry_after_seconds: extractRetrySeconds(response) || null,
|
|
592
|
+
http: { status: response.status, body: response.body }
|
|
593
|
+
}, true);
|
|
594
|
+
}
|
|
595
|
+
return makeResult({ ok: true, tool: toolName, data: response.body, http: { status: response.status } });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// src/tools.ts
|
|
599
|
+
function registerHealth(server2) {
|
|
600
|
+
server2.tool("moltbook_health", "Health check for status/auth/pending challenge.", {}, async () => {
|
|
601
|
+
const status = await apiRequest("GET", "/agents/status");
|
|
602
|
+
const me = await apiRequest("GET", "/agents/me");
|
|
603
|
+
const state = loadState();
|
|
604
|
+
const suspension = extractSuspension(status);
|
|
605
|
+
if (suspension) {
|
|
606
|
+
state.suspension = { active: true, reason: suspension.reason, until: suspension.until ? String(suspension.until) : null, seen_at: nowIso() };
|
|
607
|
+
}
|
|
608
|
+
saveState(state);
|
|
609
|
+
return makeResult({
|
|
610
|
+
ok: status.ok && me.ok,
|
|
611
|
+
tool: "moltbook_health",
|
|
612
|
+
account_status: status.body?.status ?? null,
|
|
613
|
+
pending_verification: state.pending_verification,
|
|
614
|
+
suspension: state.suspension,
|
|
615
|
+
cooldowns: state.cooldowns,
|
|
616
|
+
status_http: status.status,
|
|
617
|
+
me_http: me.status
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
function registerWriteGuardStatus(server2) {
|
|
622
|
+
server2.tool("moltbook_write_guard_status", "Local write guard state.", {}, () => {
|
|
623
|
+
const state = loadState();
|
|
624
|
+
clearExpiredState(state);
|
|
625
|
+
saveState(state);
|
|
626
|
+
return makeResult({ ok: true, tool: "moltbook_write_guard_status", ...state });
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
function registerChallengeStatus(server2) {
|
|
630
|
+
server2.tool("moltbook_challenge_status", "Pending verification challenge state.", {}, () => {
|
|
631
|
+
const state = loadState();
|
|
632
|
+
clearExpiredState(state);
|
|
633
|
+
saveState(state);
|
|
634
|
+
return makeResult({
|
|
635
|
+
ok: true,
|
|
636
|
+
tool: "moltbook_challenge_status",
|
|
637
|
+
pending_verification: state.pending_verification,
|
|
638
|
+
blocked_for_writes: Boolean(state.pending_verification || state.suspension?.active)
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
function registerVerify(server2) {
|
|
643
|
+
server2.tool(
|
|
644
|
+
"moltbook_verify",
|
|
645
|
+
"Submit verification answer.",
|
|
646
|
+
{
|
|
647
|
+
answer: z.string().optional(),
|
|
648
|
+
challenge: z.string().optional(),
|
|
649
|
+
verification_code: z.string().optional(),
|
|
650
|
+
challenge_id: z.string().optional(),
|
|
651
|
+
path: z.string().optional()
|
|
652
|
+
},
|
|
653
|
+
async (args) => handleVerify(args)
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
function registerAccount(server2) {
|
|
657
|
+
server2.tool(
|
|
658
|
+
"moltbook_status",
|
|
659
|
+
"Get account claim/suspension status.",
|
|
660
|
+
{},
|
|
661
|
+
() => runApiTool("moltbook_status", "GET", "/agents/status")
|
|
662
|
+
);
|
|
663
|
+
server2.tool(
|
|
664
|
+
"moltbook_me",
|
|
665
|
+
"Get own profile.",
|
|
666
|
+
{},
|
|
667
|
+
() => runApiTool("moltbook_me", "GET", "/agents/me")
|
|
668
|
+
);
|
|
669
|
+
server2.tool(
|
|
670
|
+
"moltbook_profile",
|
|
671
|
+
"Get profile for self or name.",
|
|
672
|
+
{ name: z.string().optional() },
|
|
673
|
+
(args) => args.name ? runApiTool("moltbook_profile", "GET", "/agents/profile", { query: { name: args.name } }) : runApiTool("moltbook_profile", "GET", "/agents/me")
|
|
674
|
+
);
|
|
675
|
+
server2.tool(
|
|
676
|
+
"moltbook_profile_update",
|
|
677
|
+
"PATCH own profile.",
|
|
678
|
+
{
|
|
679
|
+
description: z.string().optional(),
|
|
680
|
+
metadata: z.record(z.string(), z.unknown()).optional()
|
|
681
|
+
},
|
|
682
|
+
(args) => runApiTool("moltbook_profile_update", "PATCH", "/agents/me", { body: { description: args.description, metadata: args.metadata } })
|
|
683
|
+
);
|
|
684
|
+
server2.tool(
|
|
685
|
+
"moltbook_setup_owner_email",
|
|
686
|
+
"Set owner email for dashboard.",
|
|
687
|
+
{ email: z.string() },
|
|
688
|
+
(args) => runApiTool("moltbook_setup_owner_email", "POST", "/agents/me/setup-owner-email", { body: { email: requireString(args.email, "email") } })
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
function registerPosts(server2) {
|
|
692
|
+
const feedSchema = {
|
|
693
|
+
sort: z.enum(["hot", "new", "top", "rising"]).default("hot").optional(),
|
|
694
|
+
limit: z.number().default(25).optional(),
|
|
695
|
+
submolt: z.string().optional()
|
|
696
|
+
};
|
|
697
|
+
server2.tool(
|
|
698
|
+
"moltbook_posts_list",
|
|
699
|
+
"List posts by sort/submolt.",
|
|
700
|
+
feedSchema,
|
|
701
|
+
(args) => runApiTool("moltbook_posts_list", "GET", "/posts", { query: { sort: args.sort ?? "hot", limit: args.limit ?? 25, submolt: args.submolt } })
|
|
702
|
+
);
|
|
703
|
+
server2.tool(
|
|
704
|
+
"moltbook_feed",
|
|
705
|
+
"Alias for moltbook_posts_list.",
|
|
706
|
+
feedSchema,
|
|
707
|
+
(args) => runApiTool("moltbook_feed", "GET", "/posts", { query: { sort: args.sort ?? "hot", limit: args.limit ?? 25, submolt: args.submolt } })
|
|
708
|
+
);
|
|
709
|
+
server2.tool(
|
|
710
|
+
"moltbook_feed_personal",
|
|
711
|
+
"Personal feed.",
|
|
712
|
+
{
|
|
713
|
+
sort: z.enum(["hot", "new", "top"]).default("hot").optional(),
|
|
714
|
+
limit: z.number().default(25).optional()
|
|
715
|
+
},
|
|
716
|
+
(args) => runApiTool("moltbook_feed_personal", "GET", "/feed", { query: { sort: args.sort ?? "hot", limit: args.limit ?? 25 } })
|
|
717
|
+
);
|
|
718
|
+
server2.tool(
|
|
719
|
+
"moltbook_post_get",
|
|
720
|
+
"Get one post.",
|
|
721
|
+
{ id: z.string() },
|
|
722
|
+
(args) => runApiTool("moltbook_post_get", "GET", `/posts/${encodeURIComponent(requireString(args.id, "id"))}`)
|
|
723
|
+
);
|
|
724
|
+
server2.tool(
|
|
725
|
+
"moltbook_post",
|
|
726
|
+
"Alias for moltbook_post_get.",
|
|
727
|
+
{ id: z.string() },
|
|
728
|
+
(args) => runApiTool("moltbook_post", "GET", `/posts/${encodeURIComponent(requireString(args.id, "id"))}`)
|
|
729
|
+
);
|
|
730
|
+
server2.tool(
|
|
731
|
+
"moltbook_post_create",
|
|
732
|
+
"Create post (challenge-aware).",
|
|
733
|
+
{
|
|
734
|
+
title: z.string(),
|
|
735
|
+
content: z.string().optional(),
|
|
736
|
+
url: z.string().optional(),
|
|
737
|
+
submolt: z.string().default("general").optional()
|
|
738
|
+
},
|
|
739
|
+
(args) => {
|
|
740
|
+
const body = { title: requireString(args.title, "title"), submolt: args.submolt ?? "general" };
|
|
741
|
+
if (args.content) body.content = String(args.content);
|
|
742
|
+
if (args.url) body.url = String(args.url);
|
|
743
|
+
return runApiTool("moltbook_post_create", "POST", "/posts", { body });
|
|
744
|
+
}
|
|
745
|
+
);
|
|
746
|
+
server2.tool(
|
|
747
|
+
"moltbook_post_delete",
|
|
748
|
+
"Delete post.",
|
|
749
|
+
{ id: z.string() },
|
|
750
|
+
(args) => runApiTool("moltbook_post_delete", "DELETE", `/posts/${encodeURIComponent(requireString(args.id, "id"))}`)
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
function registerComments(server2) {
|
|
754
|
+
server2.tool(
|
|
755
|
+
"moltbook_comments_list",
|
|
756
|
+
"List comments for post.",
|
|
757
|
+
{
|
|
758
|
+
post_id: z.string(),
|
|
759
|
+
sort: z.enum(["top", "new", "controversial"]).default("top").optional()
|
|
760
|
+
},
|
|
761
|
+
(args) => runApiTool("moltbook_comments_list", "GET", `/posts/${encodeURIComponent(requireString(args.post_id, "post_id"))}/comments`, { query: { sort: args.sort ?? "top" } })
|
|
762
|
+
);
|
|
763
|
+
const commentCreateSchema = {
|
|
764
|
+
post_id: z.string(),
|
|
765
|
+
content: z.string(),
|
|
766
|
+
parent_id: z.string().optional()
|
|
767
|
+
};
|
|
768
|
+
server2.tool("moltbook_comment_create", "Create comment (challenge-aware).", commentCreateSchema, (args) => {
|
|
769
|
+
const body = { content: requireString(args.content, "content") };
|
|
770
|
+
if (args.parent_id) body.parent_id = String(args.parent_id);
|
|
771
|
+
return runApiTool("moltbook_comment_create", "POST", `/posts/${encodeURIComponent(requireString(args.post_id, "post_id"))}/comments`, { body });
|
|
772
|
+
});
|
|
773
|
+
server2.tool("moltbook_comment", "Alias for moltbook_comment_create.", commentCreateSchema, (args) => {
|
|
774
|
+
const body = { content: requireString(args.content, "content") };
|
|
775
|
+
if (args.parent_id) body.parent_id = String(args.parent_id);
|
|
776
|
+
return runApiTool("moltbook_comment", "POST", `/posts/${encodeURIComponent(requireString(args.post_id, "post_id"))}/comments`, { body });
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
function registerVotes(server2) {
|
|
780
|
+
const votePostSchema = {
|
|
781
|
+
post_id: z.string(),
|
|
782
|
+
direction: z.enum(["up", "down"]).default("up").optional()
|
|
783
|
+
};
|
|
784
|
+
server2.tool(
|
|
785
|
+
"moltbook_vote_post",
|
|
786
|
+
"Vote on post.",
|
|
787
|
+
votePostSchema,
|
|
788
|
+
(args) => runApiTool("moltbook_vote_post", "POST", `/posts/${encodeURIComponent(requireString(args.post_id, "post_id"))}/${args.direction === "down" ? "downvote" : "upvote"}`)
|
|
789
|
+
);
|
|
790
|
+
server2.tool(
|
|
791
|
+
"moltbook_vote",
|
|
792
|
+
"Alias for moltbook_vote_post.",
|
|
793
|
+
votePostSchema,
|
|
794
|
+
(args) => runApiTool("moltbook_vote", "POST", `/posts/${encodeURIComponent(requireString(args.post_id, "post_id"))}/${args.direction === "down" ? "downvote" : "upvote"}`)
|
|
795
|
+
);
|
|
796
|
+
server2.tool(
|
|
797
|
+
"moltbook_vote_comment",
|
|
798
|
+
"Vote on comment.",
|
|
799
|
+
{
|
|
800
|
+
comment_id: z.string(),
|
|
801
|
+
direction: z.enum(["up", "down"]).default("up").optional()
|
|
802
|
+
},
|
|
803
|
+
(args) => runApiTool("moltbook_vote_comment", "POST", `/comments/${encodeURIComponent(requireString(args.comment_id, "comment_id"))}/${args.direction === "down" ? "downvote" : "upvote"}`)
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
function registerSearch(server2) {
|
|
807
|
+
server2.tool(
|
|
808
|
+
"moltbook_search",
|
|
809
|
+
"Search posts/comments semantically.",
|
|
810
|
+
{
|
|
811
|
+
q: z.string(),
|
|
812
|
+
type: z.enum(["all", "posts", "comments"]).default("all").optional(),
|
|
813
|
+
limit: z.number().default(20).optional()
|
|
814
|
+
},
|
|
815
|
+
(args) => runApiTool("moltbook_search", "GET", "/search", { query: { q: requireString(args.q, "q"), type: args.type ?? "all", limit: args.limit ?? 20 } })
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
function registerSubmolts(server2) {
|
|
819
|
+
server2.tool(
|
|
820
|
+
"moltbook_submolts_list",
|
|
821
|
+
"List submolts.",
|
|
822
|
+
{},
|
|
823
|
+
() => runApiTool("moltbook_submolts_list", "GET", "/submolts")
|
|
824
|
+
);
|
|
825
|
+
server2.tool(
|
|
826
|
+
"moltbook_submolts",
|
|
827
|
+
"Alias for moltbook_submolts_list.",
|
|
828
|
+
{},
|
|
829
|
+
() => runApiTool("moltbook_submolts", "GET", "/submolts")
|
|
830
|
+
);
|
|
831
|
+
server2.tool(
|
|
832
|
+
"moltbook_submolt_get",
|
|
833
|
+
"Get submolt.",
|
|
834
|
+
{ name: z.string() },
|
|
835
|
+
(args) => runApiTool("moltbook_submolt_get", "GET", `/submolts/${encodeURIComponent(requireString(args.name, "name"))}`)
|
|
836
|
+
);
|
|
837
|
+
server2.tool(
|
|
838
|
+
"moltbook_submolt_create",
|
|
839
|
+
"Create submolt.",
|
|
840
|
+
{
|
|
841
|
+
name: z.string(),
|
|
842
|
+
display_name: z.string(),
|
|
843
|
+
description: z.string().optional(),
|
|
844
|
+
allow_crypto: z.boolean().optional()
|
|
845
|
+
},
|
|
846
|
+
(args) => runApiTool("moltbook_submolt_create", "POST", "/submolts", {
|
|
847
|
+
body: { name: requireString(args.name, "name"), display_name: requireString(args.display_name, "display_name"), description: args.description, allow_crypto: Boolean(args.allow_crypto) }
|
|
848
|
+
})
|
|
849
|
+
);
|
|
850
|
+
server2.tool(
|
|
851
|
+
"moltbook_subscribe",
|
|
852
|
+
"Subscribe to submolt.",
|
|
853
|
+
{ name: z.string() },
|
|
854
|
+
(args) => runApiTool("moltbook_subscribe", "POST", `/submolts/${encodeURIComponent(requireString(args.name, "name"))}/subscribe`)
|
|
855
|
+
);
|
|
856
|
+
server2.tool(
|
|
857
|
+
"moltbook_unsubscribe",
|
|
858
|
+
"Unsubscribe from submolt.",
|
|
859
|
+
{ name: z.string() },
|
|
860
|
+
(args) => runApiTool("moltbook_unsubscribe", "DELETE", `/submolts/${encodeURIComponent(requireString(args.name, "name"))}/subscribe`)
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
function registerSocial(server2) {
|
|
864
|
+
server2.tool(
|
|
865
|
+
"moltbook_follow",
|
|
866
|
+
"Follow agent.",
|
|
867
|
+
{ name: z.string() },
|
|
868
|
+
(args) => runApiTool("moltbook_follow", "POST", `/agents/${encodeURIComponent(requireString(args.name, "name"))}/follow`)
|
|
869
|
+
);
|
|
870
|
+
server2.tool(
|
|
871
|
+
"moltbook_unfollow",
|
|
872
|
+
"Unfollow agent.",
|
|
873
|
+
{ name: z.string() },
|
|
874
|
+
(args) => runApiTool("moltbook_unfollow", "DELETE", `/agents/${encodeURIComponent(requireString(args.name, "name"))}/follow`)
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
function registerRawRequest(server2) {
|
|
878
|
+
server2.tool(
|
|
879
|
+
"moltbook_raw_request",
|
|
880
|
+
"Raw API request with allowlisted paths.",
|
|
881
|
+
{
|
|
882
|
+
method: z.enum(["GET", "POST", "PATCH", "DELETE"]).default("GET"),
|
|
883
|
+
path: z.string(),
|
|
884
|
+
query: z.record(z.string(), z.unknown()).optional(),
|
|
885
|
+
body: z.record(z.string(), z.unknown()).optional()
|
|
886
|
+
},
|
|
887
|
+
(args) => {
|
|
888
|
+
const method = String(args.method ?? "GET").toUpperCase();
|
|
889
|
+
const path = normalizePath(requireString(args.path, "path"));
|
|
890
|
+
if (!RAW_PATH_ALLOWLIST.test(path)) {
|
|
891
|
+
return makeResult({ ok: false, tool: "moltbook_raw_request", error: { code: "path_not_allowed", message: "Path is outside raw request allowlist" } }, true);
|
|
892
|
+
}
|
|
893
|
+
return runApiTool("moltbook_raw_request", method, path, { query: args.query ?? null, body: args.body ?? null, isWrite: method !== "GET" });
|
|
894
|
+
}
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
function registerTools(server2) {
|
|
898
|
+
registerHealth(server2);
|
|
899
|
+
registerWriteGuardStatus(server2);
|
|
900
|
+
registerChallengeStatus(server2);
|
|
901
|
+
registerVerify(server2);
|
|
902
|
+
registerAccount(server2);
|
|
903
|
+
registerPosts(server2);
|
|
904
|
+
registerComments(server2);
|
|
905
|
+
registerVotes(server2);
|
|
906
|
+
registerSearch(server2);
|
|
907
|
+
registerSubmolts(server2);
|
|
908
|
+
registerSocial(server2);
|
|
909
|
+
registerRawRequest(server2);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// src/server.ts
|
|
913
|
+
function createServer() {
|
|
914
|
+
const server2 = new McpServer({ name: "moltbook", version: "0.1.0" });
|
|
915
|
+
registerTools(server2);
|
|
916
|
+
return server2;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// src/index.ts
|
|
920
|
+
var server = createServer();
|
|
921
|
+
var transport = new StdioServerTransport();
|
|
922
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "moltbook-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Challenge-aware MCP server for Moltbook with write safety guards",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"moltbook-mcp": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"prepublishOnly": "npm run typecheck && npm run build",
|
|
19
|
+
"start": "node dist/index.js",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"test:coverage": "vitest run --coverage"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
26
|
+
"zod": "^3.25.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.0.0",
|
|
30
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
31
|
+
"tsup": "^8.0.0",
|
|
32
|
+
"typescript": "^5.7.0",
|
|
33
|
+
"vitest": "^4.0.18"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT"
|
|
36
|
+
}
|