@blurt-blockchain/blurt-mcp-server 0.4.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/LICENSE +682 -0
  3. package/README.md +117 -0
  4. package/SECURITY.md +107 -0
  5. package/dist/app.js +88 -0
  6. package/dist/buildServer.js +146 -0
  7. package/dist/contracts/registerBlurtTool.js +53 -0
  8. package/dist/contracts/toolRegistry.js +384 -0
  9. package/dist/resources/blurtResource.js +82 -0
  10. package/dist/server-stdio.js +37 -0
  11. package/dist/server.js +35 -0
  12. package/dist/tools/claimRewards.js +48 -0
  13. package/dist/tools/comment.js +58 -0
  14. package/dist/tools/compareAccounts.js +50 -0
  15. package/dist/tools/fetch.js +91 -0
  16. package/dist/tools/follow.js +39 -0
  17. package/dist/tools/getAccount.js +80 -0
  18. package/dist/tools/getAccountHistory.js +109 -0
  19. package/dist/tools/getAccountNotifications.js +40 -0
  20. package/dist/tools/getAccountPosts.js +130 -0
  21. package/dist/tools/getAccountRelationships.js +34 -0
  22. package/dist/tools/getAccountSubscriptions.js +50 -0
  23. package/dist/tools/getAccountWitnessVotes.js +46 -0
  24. package/dist/tools/getBlurtPrice.js +43 -0
  25. package/dist/tools/getChainStatus.js +94 -0
  26. package/dist/tools/getCommunity.js +75 -0
  27. package/dist/tools/getDelegations.js +37 -0
  28. package/dist/tools/getPendingRewards.js +53 -0
  29. package/dist/tools/getPost.js +88 -0
  30. package/dist/tools/getPostReblogs.js +29 -0
  31. package/dist/tools/getPostVotes.js +78 -0
  32. package/dist/tools/getPublications.js +109 -0
  33. package/dist/tools/getReferrals.js +39 -0
  34. package/dist/tools/getVoteValue.js +67 -0
  35. package/dist/tools/getWitness.js +46 -0
  36. package/dist/tools/listCommunities.js +90 -0
  37. package/dist/tools/listWitnesses.js +48 -0
  38. package/dist/tools/lookupAccounts.js +30 -0
  39. package/dist/tools/mute.js +39 -0
  40. package/dist/tools/post.js +42 -0
  41. package/dist/tools/readNotifications.js +35 -0
  42. package/dist/tools/reblog.js +39 -0
  43. package/dist/tools/search.js +189 -0
  44. package/dist/tools/subscribeCommunity.js +39 -0
  45. package/dist/tools/upvote.js +48 -0
  46. package/dist/utils/blurtUri.js +61 -0
  47. package/dist/utils/loadEnv.js +21 -0
  48. package/dist/utils/logger.js +63 -0
  49. package/dist/utils/price.js +21 -0
  50. package/dist/utils/rpc.js +126 -0
  51. package/dist/utils/signer.js +350 -0
  52. package/docs/adr/0001-neutral-infrastructure.md +50 -0
  53. package/docs/architecture.md +62 -0
  54. package/docs/cache-policy.md +42 -0
  55. package/docs/clients.md +78 -0
  56. package/docs/deployment.md +102 -0
  57. package/docs/development.md +51 -0
  58. package/docs/install-snippets.md +236 -0
  59. package/docs/load-testing.md +51 -0
  60. package/docs/operations.md +56 -0
  61. package/docs/release-provenance.md +63 -0
  62. package/docs/tools.generated.md +89 -0
  63. package/docs/tools.md +102 -0
  64. package/docs/usage.md +157 -0
  65. package/docs/write-operations.md +223 -0
  66. package/package.json +77 -0
@@ -0,0 +1,384 @@
1
+ import { z } from "zod";
2
+ export const TOOL_RESULT_CONTRACT = "blurt-mcp.tool-result.v1";
3
+ export const TOOL_RESULT_OUTPUT_SCHEMA = {
4
+ data: z.unknown().describe("Tool-specific result payload. The exact shape is tool-specific until v1 schemas are finalized."),
5
+ meta: z.object({
6
+ contract: z.literal(TOOL_RESULT_CONTRACT),
7
+ tool: z.string(),
8
+ sources: z.array(z.enum(["layer1", "nexus", "external", "mixed", "mcp"])),
9
+ retrieved_at: z.string().describe("ISO-8601 timestamp when the MCP server produced this result."),
10
+ freshness: z.object({
11
+ cache: z.enum(["none", "short", "medium", "external"]),
12
+ ttl_seconds: z.number().nullable(),
13
+ }),
14
+ confidence: z.enum(["high", "medium", "low"]),
15
+ caveats: z.array(z.string()),
16
+ }),
17
+ };
18
+ const read = "read";
19
+ const writeLocal = "write-local";
20
+ const v1 = "v1-candidate";
21
+ export const TOOL_REGISTRY = {
22
+ "get-account": {
23
+ name: "get-account",
24
+ title: "Get account profile and wallet summary",
25
+ description: "Structured account identity, wallet, stake, witness vote count and rewards summary.",
26
+ source: "mixed",
27
+ safety: read,
28
+ cache: "short",
29
+ stability: v1,
30
+ examples: ["Show me @nalexadre's Blurt account and wallet."],
31
+ caveats: ["Combines Layer 1 account state with Nexus profile data."],
32
+ },
33
+ "get-account-history": {
34
+ name: "get-account-history",
35
+ title: "Get raw account operation history",
36
+ description: "Recent Layer 1 operations for an account, optionally filtered by operation type.",
37
+ source: "layer1",
38
+ safety: read,
39
+ cache: "none",
40
+ stability: v1,
41
+ examples: ["Show the latest 10 transfer operations for @beblurt."],
42
+ },
43
+ "get-account-posts": {
44
+ name: "get-account-posts",
45
+ title: "Get account-related posts",
46
+ description: "Nexus account posts, blog, comments, replies, feed or payout items.",
47
+ source: "nexus",
48
+ safety: read,
49
+ cache: "short",
50
+ stability: v1,
51
+ examples: ["Show @nalexadre's latest posts."],
52
+ },
53
+ "get-post": {
54
+ name: "get-post",
55
+ title: "Get post or discussion",
56
+ description: "Nexus post summary or full discussion tree for an author/permlink.",
57
+ source: "nexus",
58
+ safety: read,
59
+ cache: "short",
60
+ stability: v1,
61
+ examples: ["Open nalexadre/how-ai-can-help-curation-on-blurt-1759501073717."],
62
+ },
63
+ "get-publications": {
64
+ name: "get-publications",
65
+ title: "Get ranked publications",
66
+ description: "Nexus ranked posts for discovery by sort/tag.",
67
+ source: "nexus",
68
+ safety: read,
69
+ cache: "short",
70
+ stability: v1,
71
+ examples: ["What is trending under #blurt?"],
72
+ },
73
+ "get-blurt-price": {
74
+ name: "get-blurt-price",
75
+ title: "Get BLURT market price",
76
+ description: "Current BLURT USD/BTC price from the configured public price feed.",
77
+ source: "external",
78
+ safety: read,
79
+ cache: "external",
80
+ stability: v1,
81
+ examples: ["What is the current BLURT price?"],
82
+ caveats: ["External price feed is best-effort and may be temporarily unavailable."],
83
+ },
84
+ "get-chain-status": {
85
+ name: "get-chain-status",
86
+ title: "Get chain and market status",
87
+ description: "Network, supply, reward and market dashboard for Blurt.",
88
+ source: "mixed",
89
+ safety: read,
90
+ cache: "short",
91
+ stability: v1,
92
+ examples: ["Give me a Blurt network health snapshot."],
93
+ },
94
+ "list-communities": {
95
+ name: "list-communities",
96
+ title: "List communities",
97
+ description: "Nexus community discovery by rank, newness or subscribers.",
98
+ source: "nexus",
99
+ safety: read,
100
+ cache: "medium",
101
+ stability: v1,
102
+ examples: ["What are the most popular Blurt communities?"],
103
+ },
104
+ "get-community": {
105
+ name: "get-community",
106
+ title: "Get community details",
107
+ description: "Nexus details for a single Blurt community.",
108
+ source: "nexus",
109
+ safety: read,
110
+ cache: "medium",
111
+ stability: v1,
112
+ examples: ["Show details for community blurt-101010."],
113
+ },
114
+ "get-post-votes": {
115
+ name: "get-post-votes",
116
+ title: "Get post votes",
117
+ description: "Layer 1 active votes on a post/comment ranked by rshares.",
118
+ source: "layer1",
119
+ safety: read,
120
+ cache: "short",
121
+ stability: v1,
122
+ examples: ["Who were the biggest voters on this post?"],
123
+ },
124
+ "get-vote-value": {
125
+ name: "get-vote-value",
126
+ title: "Estimate account vote value",
127
+ description: "Estimated current upvote value for an account at a given weight.",
128
+ source: "mixed",
129
+ safety: read,
130
+ cache: "short",
131
+ stability: v1,
132
+ examples: ["How much is a 100% vote from @nalexadre worth?"],
133
+ caveats: ["Vote value depends on current mana and reward-pool state."],
134
+ },
135
+ "list-witnesses": {
136
+ name: "list-witnesses",
137
+ title: "List witnesses",
138
+ description: "Layer 1 witness ranking and production metadata.",
139
+ source: "layer1",
140
+ safety: read,
141
+ cache: "short",
142
+ stability: v1,
143
+ examples: ["List the top 10 Blurt witnesses."],
144
+ },
145
+ "get-witness": {
146
+ name: "get-witness",
147
+ title: "Get witness health",
148
+ description: "Layer 1 witness details and health summary for one account.",
149
+ source: "layer1",
150
+ safety: read,
151
+ cache: "short",
152
+ stability: v1,
153
+ examples: ["Is @nalexadre's witness healthy?"],
154
+ },
155
+ "get-account-witness-votes": {
156
+ name: "get-account-witness-votes",
157
+ title: "Get account witness votes",
158
+ description: "Witnesses supported by an account and any witness proxy.",
159
+ source: "layer1",
160
+ safety: read,
161
+ cache: "short",
162
+ stability: v1,
163
+ examples: ["Which witnesses does @nalexadre vote for?"],
164
+ },
165
+ "get-account-relationships": {
166
+ name: "get-account-relationships",
167
+ title: "Get social relationship counts",
168
+ description: "Follower/following counts and samples for an account.",
169
+ source: "layer1",
170
+ safety: read,
171
+ cache: "short",
172
+ stability: v1,
173
+ examples: ["Who follows @nalexadre?"],
174
+ },
175
+ "get-pending-rewards": {
176
+ name: "get-pending-rewards",
177
+ title: "Get unclaimed rewards",
178
+ description: "Pending reward balances currently claimable by an account.",
179
+ source: "mixed",
180
+ safety: read,
181
+ cache: "short",
182
+ stability: v1,
183
+ examples: ["How much can @nalexadre claim right now?"],
184
+ },
185
+ "get-account-subscriptions": {
186
+ name: "get-account-subscriptions",
187
+ title: "Get community subscriptions",
188
+ description: "Communities an account is subscribed to and its role in them.",
189
+ source: "nexus",
190
+ safety: read,
191
+ cache: "medium",
192
+ stability: v1,
193
+ examples: ["Which communities does @nalexadre belong to?"],
194
+ },
195
+ "compare-accounts": {
196
+ name: "compare-accounts",
197
+ title: "Compare accounts",
198
+ description: "Side-by-side account metrics for stake, reach, output and rewards.",
199
+ source: "mixed",
200
+ safety: read,
201
+ cache: "short",
202
+ stability: v1,
203
+ examples: ["Compare @nalexadre, @megadrive and @beblurt."],
204
+ },
205
+ "get-referrals": {
206
+ name: "get-referrals",
207
+ title: "Get referred accounts",
208
+ description: "beBlurt referral accounts and total count for a referrer.",
209
+ source: "nexus",
210
+ safety: read,
211
+ cache: "medium",
212
+ stability: v1,
213
+ examples: ["How many accounts did @beblurt refer?"],
214
+ },
215
+ "get-account-notifications": {
216
+ name: "get-account-notifications",
217
+ title: "Get notifications",
218
+ description: "Nexus notifications and unread count for an account.",
219
+ source: "nexus",
220
+ safety: read,
221
+ cache: "short",
222
+ stability: v1,
223
+ examples: ["Do I have recent Blurt notifications?"],
224
+ },
225
+ "get-post-reblogs": {
226
+ name: "get-post-reblogs",
227
+ title: "Get post reblogs",
228
+ description: "Accounts that reblogged a post.",
229
+ source: "layer1",
230
+ safety: read,
231
+ cache: "short",
232
+ stability: v1,
233
+ examples: ["Who reblogged this post?"],
234
+ },
235
+ "get-delegations": {
236
+ name: "get-delegations",
237
+ title: "Get outgoing delegations",
238
+ description: "Outgoing Blurt Power delegations for an account.",
239
+ source: "layer1",
240
+ safety: read,
241
+ cache: "short",
242
+ stability: v1,
243
+ examples: ["Who does @beblurt delegate to?"],
244
+ },
245
+ "lookup-accounts": {
246
+ name: "lookup-accounts",
247
+ title: "Lookup account names",
248
+ description: "Alphabetical account-name autocomplete by prefix.",
249
+ source: "layer1",
250
+ safety: read,
251
+ cache: "medium",
252
+ stability: v1,
253
+ examples: ["Find accounts starting with nale."],
254
+ },
255
+ search: {
256
+ name: "search",
257
+ title: "Search Blurt resources",
258
+ description: "Map a free-form query to blurt:// resource links.",
259
+ source: "mcp",
260
+ safety: read,
261
+ cache: "none",
262
+ stability: v1,
263
+ examples: ["Search for @nalexadre or posts:blurt."],
264
+ },
265
+ fetch: {
266
+ name: "fetch",
267
+ title: "Fetch Blurt resource",
268
+ description: "Resolve a blurt:// URI or shorthand id to raw JSON.",
269
+ source: "mixed",
270
+ safety: read,
271
+ cache: "short",
272
+ stability: v1,
273
+ examples: ["Fetch blurt://account/nalexadre."],
274
+ },
275
+ "blurt-claim-rewards": {
276
+ name: "blurt-claim-rewards",
277
+ title: "Claim rewards locally",
278
+ description: "Claim pending rewards using the configured local posting key.",
279
+ source: "layer1",
280
+ safety: writeLocal,
281
+ cache: "none",
282
+ stability: v1,
283
+ examples: ["Claim my pending rewards after dry-run preview."],
284
+ },
285
+ "blurt-upvote": {
286
+ name: "blurt-upvote",
287
+ title: "Upvote locally",
288
+ description: "Broadcast a local posting-key vote operation.",
289
+ source: "layer1",
290
+ safety: writeLocal,
291
+ cache: "none",
292
+ stability: v1,
293
+ examples: ["Upvote this post at 25% after dry-run."],
294
+ },
295
+ "blurt-comment": {
296
+ name: "blurt-comment",
297
+ title: "Comment locally",
298
+ description: "Broadcast a local posting-key comment/reply operation.",
299
+ source: "layer1",
300
+ safety: writeLocal,
301
+ cache: "none",
302
+ stability: v1,
303
+ examples: ["Publish this reviewed reply."],
304
+ },
305
+ "blurt-post": {
306
+ name: "blurt-post",
307
+ title: "Post locally",
308
+ description: "Broadcast a local posting-key top-level post operation.",
309
+ source: "layer1",
310
+ safety: writeLocal,
311
+ cache: "none",
312
+ stability: v1,
313
+ examples: ["Publish this reviewed post."],
314
+ },
315
+ "blurt-follow": {
316
+ name: "blurt-follow",
317
+ title: "Follow locally",
318
+ description: "Broadcast a local posting-key follow/unfollow custom_json.",
319
+ source: "layer1",
320
+ safety: writeLocal,
321
+ cache: "none",
322
+ stability: v1,
323
+ examples: ["Follow this account after preview."],
324
+ },
325
+ "blurt-mute": {
326
+ name: "blurt-mute",
327
+ title: "Mute locally",
328
+ description: "Broadcast a local posting-key mute/unmute custom_json.",
329
+ source: "layer1",
330
+ safety: writeLocal,
331
+ cache: "none",
332
+ stability: v1,
333
+ examples: ["Mute this account after preview."],
334
+ },
335
+ "blurt-subscribe-community": {
336
+ name: "blurt-subscribe-community",
337
+ title: "Subscribe to community locally",
338
+ description: "Broadcast a local posting-key community subscription custom_json.",
339
+ source: "layer1",
340
+ safety: writeLocal,
341
+ cache: "none",
342
+ stability: v1,
343
+ examples: ["Subscribe me to this community after preview."],
344
+ },
345
+ "blurt-reblog": {
346
+ name: "blurt-reblog",
347
+ title: "Reblog locally",
348
+ description: "Broadcast a local posting-key reblog/undo custom_json.",
349
+ source: "layer1",
350
+ safety: writeLocal,
351
+ cache: "none",
352
+ stability: v1,
353
+ examples: ["Reblog this post after preview."],
354
+ },
355
+ "blurt-read-notifications": {
356
+ name: "blurt-read-notifications",
357
+ title: "Mark notifications read locally",
358
+ description: "Broadcast a local posting-key notification read marker.",
359
+ source: "layer1",
360
+ safety: writeLocal,
361
+ cache: "none",
362
+ stability: v1,
363
+ examples: ["Mark notifications as read after I review them."],
364
+ },
365
+ };
366
+ export const READ_TOOL_NAMES = Object.values(TOOL_REGISTRY)
367
+ .filter((tool) => tool.safety === "read")
368
+ .map((tool) => tool.name)
369
+ .sort();
370
+ export const WRITE_TOOL_NAMES = Object.values(TOOL_REGISTRY)
371
+ .filter((tool) => tool.safety === "write-local")
372
+ .map((tool) => tool.name)
373
+ .sort();
374
+ export function toolSources(tool) {
375
+ return Array.isArray(tool.source) ? [...tool.source] : [tool.source];
376
+ }
377
+ export function ttlSeconds(cache) {
378
+ switch (cache) {
379
+ case "short": return 60;
380
+ case "medium": return 300;
381
+ case "external": return 60;
382
+ case "none": return null;
383
+ }
384
+ }
@@ -0,0 +1,82 @@
1
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { utils as blurtUtils } from "@beblurt/dblurt";
3
+ function jsonContents(uri, data) {
4
+ return {
5
+ contents: [
6
+ {
7
+ uri: uri.href,
8
+ mimeType: "application/json",
9
+ text: JSON.stringify(data, null, 2),
10
+ },
11
+ ],
12
+ };
13
+ }
14
+ export function registerBlurtResources(server, client) {
15
+ // 1) Account: blurt://account/{username}
16
+ server.registerResource("blurt-account", new ResourceTemplate("blurt://account/{username}", { list: undefined }), {
17
+ title: "Blurt Account",
18
+ description: "Blurt account information (JSON).",
19
+ mimeType: "application/json",
20
+ }, async (uri, { username }) => {
21
+ const [account] = await client.condenser.getAccounts([username]);
22
+ if (!account) {
23
+ throw Object.assign(new Error("Resource not found"), { code: -32002 });
24
+ }
25
+ return jsonContents(uri, account);
26
+ });
27
+ // 2) History: blurt://history/{username}?limit=&ops=vote,comment
28
+ server.registerResource("blurt-history", new ResourceTemplate("blurt://history/{username}", { list: undefined }), {
29
+ title: "Blurt Account History",
30
+ description: "Blurt account history, filterable by operation types via ?ops=.",
31
+ mimeType: "application/json",
32
+ }, async (uri, { username }) => {
33
+ const u = new URL(uri.href);
34
+ const limit = Math.max(1, Math.min(1000, Number(u.searchParams.get("limit") ?? 50)));
35
+ const opsCsv = u.searchParams.get("ops") ?? "";
36
+ let bitmask;
37
+ if (opsCsv) {
38
+ const opEnum = blurtUtils.operationOrders;
39
+ const selected = opsCsv
40
+ .split(",")
41
+ .map((s) => s.trim())
42
+ .filter(Boolean)
43
+ .map((name) => opEnum[name])
44
+ .filter((v) => typeof v === "number");
45
+ bitmask = blurtUtils.makeBitMaskFilter(selected);
46
+ }
47
+ const history = await client.condenser.getAccountHistory(username, -1, limit, bitmask);
48
+ return jsonContents(uri, history);
49
+ });
50
+ // 3) Publications: blurt://posts/{by}/{tag}?limit=
51
+ // by ∈ trending|hot|created|promoted|payout|payout_comments|muted (Nexus ranked posts)
52
+ server.registerResource("blurt-posts", new ResourceTemplate("blurt://posts/{by}/{tag}", { list: undefined }), {
53
+ title: "Blurt Publications",
54
+ description: "Ranked posts (Nexus getRankedPosts) by sort and tag. Parameters: {by}/{tag}?limit=",
55
+ mimeType: "application/json",
56
+ }, async (uri, { by, tag }) => {
57
+ const u = new URL(uri.href);
58
+ const limit = Math.max(1, Math.min(100, Number(u.searchParams.get("limit") ?? 20)));
59
+ // Aligned with the `fetch` tool and `get-publications`: single Nexus data source.
60
+ const posts = await client.nexus.getRankedPosts({
61
+ sort: by,
62
+ limit,
63
+ tag: tag ?? null,
64
+ });
65
+ return jsonContents(uri, posts);
66
+ });
67
+ // 4) Post / Discussion: blurt://post/{author}/{permlink}?with_comments=true
68
+ server.registerResource("blurt-post", new ResourceTemplate("blurt://post/{author}/{permlink}", { list: undefined }), {
69
+ title: "Blurt Post or Discussion",
70
+ description: "Single Blurt post or full discussion via ?with_comments=true (JSON).",
71
+ mimeType: "application/json",
72
+ }, async (uri, { author, permlink }) => {
73
+ const u = new URL(uri.href);
74
+ const withComments = u.searchParams.get("with_comments") === "true";
75
+ if (withComments) {
76
+ const discussion = await client.nexus.getDiscussion(author, permlink);
77
+ return jsonContents(uri, discussion);
78
+ }
79
+ const post = await client.nexus.getPost({ author: author, permlink: permlink });
80
+ return jsonContents(uri, post);
81
+ });
82
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import "./utils/loadEnv.js"; // must run before anything reads process.env
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import logger from "./utils/logger.js";
5
+ import { buildServer } from "./buildServer.js";
6
+ import { liveBlurtClient, startNodeChecker } from "./utils/rpc.js";
7
+ import { createWriteContext } from "./utils/signer.js";
8
+ /**
9
+ * Local stdio entrypoint: run the Blurt MCP server as a child process for desktop
10
+ * AI apps that launch a "local MCP server" via a command (e.g. Claude Desktop's
11
+ * claude_desktop_config.json) — no HTTP server, no bridge.
12
+ *
13
+ * The MCP protocol uses stdout; all logs go to stderr (see utils/logger.ts), so
14
+ * they never corrupt the stream.
15
+ */
16
+ async function main() {
17
+ // Keep the client pointed at the healthiest RPC nodes (liveBlurtClient follows it).
18
+ startNodeChecker();
19
+ // Enable write tools only if a valid posting key is configured. If a key is
20
+ // present but invalid (wrong format/authority), refuse to start (fail closed).
21
+ let write;
22
+ try {
23
+ write = (await createWriteContext(liveBlurtClient)) ?? undefined;
24
+ }
25
+ catch (e) {
26
+ logger.error(`Refusing to start — ${e.message}`);
27
+ process.exit(1);
28
+ }
29
+ const server = buildServer(liveBlurtClient, { write });
30
+ const transport = new StdioServerTransport();
31
+ await server.connect(transport);
32
+ logger.info(`Blurt MCP server running on stdio${write ? " (write enabled)" : " (read-only)"}`);
33
+ }
34
+ main().catch((err) => {
35
+ logger.error("Fatal stdio server error: " + err.stack);
36
+ process.exit(1);
37
+ });
package/dist/server.js ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import "./utils/loadEnv.js"; // must run before anything reads process.env
3
+ import logger from "./utils/logger.js";
4
+ import { createApp } from "./app.js";
5
+ import { liveBlurtClient, startNodeChecker } from "./utils/rpc.js";
6
+ import { assertNoKeyForHttp, createWriteContext } from "./utils/signer.js";
7
+ async function main() {
8
+ // The HTTP server stays read-only by default. If a posting key is present,
9
+ // assertNoKeyForHttp either refuses startup or returns true only after the
10
+ // operator sets the exact unsafe override value.
11
+ let write;
12
+ try {
13
+ const unsafeHttpSigning = assertNoKeyForHttp();
14
+ if (unsafeHttpSigning) {
15
+ write = (await createWriteContext(liveBlurtClient)) ?? undefined;
16
+ if (!write) {
17
+ throw new Error("Unsafe HTTP signing override is set, but no valid write context could be created.");
18
+ }
19
+ }
20
+ }
21
+ catch (e) {
22
+ logger.error(e.message);
23
+ process.exit(1);
24
+ }
25
+ const PORT = process.env.PORT || 3000;
26
+ createApp({ write }).listen(PORT, () => {
27
+ logger.info(`MCP Stateless Streamable HTTP server running on http://localhost:${PORT}/mcp${write ? " (UNSAFE HTTP WRITE ENABLED)" : ""}`);
28
+ // Continuously score the RPC nodes and keep the client on the healthiest ones.
29
+ startNodeChecker();
30
+ });
31
+ }
32
+ main().catch((err) => {
33
+ logger.error("Fatal HTTP server error: " + err.stack);
34
+ process.exit(1);
35
+ });
@@ -0,0 +1,48 @@
1
+ // Logger
2
+ import logger from "../utils/logger.js";
3
+ import { z } from "zod";
4
+ import { claimRewards, withinCap, dryRunDefault } from "../utils/signer.js";
5
+ import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
6
+ const CAP_MAX = 6;
7
+ const CAP_WINDOW_MS = 60 * 60 * 1000; // 1 hour
8
+ /**
9
+ * Tool: blurt-claim-rewards (Blurt — WRITE)
10
+ *
11
+ * Signs a claim_reward_balance with the local posting key. Only registered on the
12
+ * local stdio server by default when a validated posting key is present.
13
+ */
14
+ export function registerClaimRewards(server, client, ctx) {
15
+ registerBlurtTool(server, "blurt-claim-rewards", {
16
+ title: "Claim pending Blurt rewards",
17
+ description: "WRITE operation (signs a claim_reward_balance with the local posting key): claim the configured " +
18
+ "account's pending author/curation rewards, moving them from the reward pool into its balance and " +
19
+ "Blurt Power. Use this when the user asks to claim/collect their pending rewards. It only ever acts on " +
20
+ "the configured account (no parameter targets another account), and is available only on the local " +
21
+ "stdio server. Parameter: dry_run (default follows BLURT_DRY_RUN_DEFAULT; false when unset) — when true, previews the pending amounts WITHOUT " +
22
+ "broadcasting. Returns the pending amounts and, when executed, the transaction id. " +
23
+ "Note: rate-limited; the posting key cannot move funds, only claim/social actions.",
24
+ annotations: { readOnlyHint: false, destructiveHint: false },
25
+ inputSchema: {
26
+ dry_run: z.boolean().default(dryRunDefault()).describe("Preview only, without broadcasting the transaction"),
27
+ },
28
+ }, async ({ dry_run }) => {
29
+ logger.debug(`blurt-claim-rewards called (dry_run=${dry_run})`);
30
+ try {
31
+ if (!dry_run && !withinCap("claim-rewards", CAP_MAX, CAP_WINDOW_MS)) {
32
+ return {
33
+ content: [{ type: "text", text: `Rate limit reached for claim-rewards (max ${CAP_MAX}/hour).` }],
34
+ isError: true,
35
+ };
36
+ }
37
+ const result = await claimRewards(client, ctx, dry_run);
38
+ if (result.claimed)
39
+ logger.info(`Claimed rewards for @${result.account} (tx ${result.tx_id}).`);
40
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
41
+ }
42
+ catch (e) {
43
+ // Never include the key or signed payload in errors.
44
+ logger.error(`[blurt-claim-rewards] Error: ${e.message}`);
45
+ return { content: [{ type: "text", text: "Error claiming rewards." }], isError: true };
46
+ }
47
+ });
48
+ }
@@ -0,0 +1,58 @@
1
+ // Logger
2
+ import logger from "../utils/logger.js";
3
+ import { z } from "zod";
4
+ import { comment, withinCap, dryRunDefault } from "../utils/signer.js";
5
+ import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
6
+ const CAP_MAX = 10;
7
+ const CAP_WINDOW_MS = 60 * 60 * 1000; // 1 hour
8
+ /**
9
+ * Tool: blurt-comment (Blurt — WRITE)
10
+ *
11
+ * Signs a `comment` (reply) with the local posting key. Only registered on the
12
+ * local stdio server by default when a validated posting key is present.
13
+ *
14
+ * The comment is stamped with `json_metadata.app = "blurt-mcp/<version>"` and a
15
+ * `via [Blurt-MCP](…)` footer is appended to the body.
16
+ */
17
+ export function registerComment(server, client, ctx) {
18
+ registerBlurtTool(server, "blurt-comment", {
19
+ title: "Comment on (reply to) a Blurt post",
20
+ description: "WRITE operation (signs a `comment` with the local posting key): post a reply to a Blurt post or " +
21
+ "comment as the configured account. Use this when the user asks to comment on / reply to a specific " +
22
+ "post. Identify the parent by parent_author + parent_permlink. Parameters: parent_author, " +
23
+ "parent_permlink, body (the markdown comment text), dry_run (default follows BLURT_DRY_RUN_DEFAULT; false when unset — when true, previews the " +
24
+ "exact body that would be posted WITHOUT broadcasting). A unique permlink is generated automatically, " +
25
+ "the body is tagged with the blurt-mcp app and a short attribution footer, and only the configured " +
26
+ "account posts. Available only on the local stdio server; rate-limited. Posts are public and permanent.",
27
+ annotations: { readOnlyHint: false, destructiveHint: false },
28
+ inputSchema: {
29
+ parent_author: z.string().min(1).describe("Author of the post/comment being replied to"),
30
+ parent_permlink: z.string().min(1).describe("Permlink of the post/comment being replied to"),
31
+ body: z.string().min(1).describe("The comment text (markdown)"),
32
+ dry_run: z.boolean().default(dryRunDefault()).describe("Preview the final body only, without broadcasting"),
33
+ },
34
+ }, async ({ parent_author, parent_permlink, body, dry_run }) => {
35
+ logger.debug(`blurt-comment: reply to ${parent_author}/${parent_permlink} (dry_run=${dry_run})`);
36
+ try {
37
+ if (!dry_run && !withinCap("comment", CAP_MAX, CAP_WINDOW_MS)) {
38
+ return {
39
+ content: [{ type: "text", text: `Rate limit reached for comment (max ${CAP_MAX}/hour).` }],
40
+ isError: true,
41
+ };
42
+ }
43
+ const result = await comment(client, ctx, {
44
+ parentAuthor: parent_author,
45
+ parentPermlink: parent_permlink,
46
+ body,
47
+ dryRun: dry_run,
48
+ });
49
+ if (result.posted)
50
+ logger.info(`Commented on ${parent_author}/${parent_permlink} (tx ${result.tx_id}).`);
51
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
52
+ }
53
+ catch (e) {
54
+ logger.error(`[blurt-comment] Error: ${e.message}`);
55
+ return { content: [{ type: "text", text: `Error commenting on ${parent_author}/${parent_permlink}` }], isError: true };
56
+ }
57
+ });
58
+ }