@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,39 @@
1
+ import logger from "../utils/logger.js";
2
+ import { z } from "zod";
3
+ import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
4
+ /** Tool: get-referrals (Blurt) — accounts referred by a referrer. */
5
+ export function registerGetReferrals(server, client) {
6
+ registerBlurtTool(server, "get-referrals", {
7
+ title: "Get a referrer's affiliated Blurt accounts",
8
+ description: "List the Blurt accounts that signed up through a given referrer (the beBlurt referral/affiliate " +
9
+ "system), plus the total count. Use this when the user asks 'who did X refer', 'how many accounts " +
10
+ "did X bring', or to measure a referrer's onboarding impact. Parameters: referrer (the referrer " +
11
+ "account name), limit (1-100, default 50). Returns: total (the referrer's total affiliated accounts) " +
12
+ "and a list of referred accounts, each with account name, campaign_id and creation date (most recent " +
13
+ "first).",
14
+ annotations: { readOnlyHint: true },
15
+ inputSchema: {
16
+ referrer: z.string().min(3).describe("Referrer account name (e.g., 'beblurt')"),
17
+ limit: z.number().int().min(1).max(100).default(50).describe("Max referred accounts to return"),
18
+ },
19
+ }, async ({ referrer, limit }) => {
20
+ logger.debug(`get-referrals: ${referrer}, limit=${limit}`);
21
+ try {
22
+ const [accounts, count] = await Promise.all([
23
+ client.nexus.referralAccounts({ referrer, campaign_id: null, limit }),
24
+ client.nexus.referralAccountsCount({ referrer }),
25
+ ]);
26
+ const summary = {
27
+ referrer,
28
+ total: count.count,
29
+ returned: accounts.length,
30
+ accounts,
31
+ };
32
+ return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
33
+ }
34
+ catch (e) {
35
+ logger.error(`[get-referrals] Error: ${e.message}`);
36
+ return { content: [{ type: "text", text: `Error retrieving referrals for ${referrer}` }], isError: true };
37
+ }
38
+ });
39
+ }
@@ -0,0 +1,67 @@
1
+ // Logger
2
+ import logger from "../utils/logger.js";
3
+ import { z } from "zod";
4
+ import { getBlurtPrice } from "../utils/price.js";
5
+ import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
6
+ /**
7
+ * Tool: get-vote-value (Blurt — estimated upvote value)
8
+ *
9
+ * Purpose
10
+ * Estimate how much an account's upvote is worth, at a given voting weight,
11
+ * in BLURT and (best-effort) USD.
12
+ *
13
+ * Parameters (inputSchema)
14
+ * - username (string, required) — voter account
15
+ * - weight (number, 1..100, default 100) — voting weight in percent
16
+ *
17
+ * Behavior
18
+ * Combines client.tools.getAccountMana, getDynamicGlobalProperties and the
19
+ * post reward fund via client.tools.getAccountVoteValue. The estimate assumes
20
+ * a fresh vote on an empty post past the reverse-auction window (full value).
21
+ *
22
+ * Returns
23
+ * JSON: { account, weight_percent, current_mana_percent, vote_value_blurt, vote_value_usd, price_usd }
24
+ */
25
+ export function registerGetVoteValue(server, client) {
26
+ registerBlurtTool(server, "get-vote-value", {
27
+ title: "Estimate a Blurt upvote value",
28
+ description: "Estimate how much an account's upvote is worth right now, in BLURT and USD. " +
29
+ "Use this when the user asks 'how much is my (or someone's) vote worth' — useful for curators sizing their impact " +
30
+ "or authors estimating the support a vote brings. " +
31
+ "Parameters: username (the voter), weight (voting strength in percent, 1-100, default 100). " +
32
+ "The estimate assumes a vote on a fresh post past the reverse-auction window, so it reflects the account's current " +
33
+ "full voting power at that weight. Returns: account, weight_percent, current_mana_percent (remaining voting power, " +
34
+ "100% = fully charged), vote_value_blurt, vote_value_usd (best-effort, null if the price feed is down) and the " +
35
+ "price_usd used. Caveat: the value scales with the account's mana, which depletes with each vote and regenerates " +
36
+ "over ~5 days, so it varies over time — treat it as a current snapshot, not a fixed figure.",
37
+ annotations: { readOnlyHint: true },
38
+ inputSchema: {
39
+ username: z.string().min(3).describe("Voter account (e.g., 'nalexadre')"),
40
+ weight: z.number().int().min(1).max(100).default(100)
41
+ .describe("Voting weight in percent (1-100)"),
42
+ },
43
+ }, async ({ username, weight }) => {
44
+ logger.debug(`get-vote-value: ${username} @ ${weight}%`);
45
+ try {
46
+ const vote = await client.read.estimateVoteValue(username, weight);
47
+ const price = await getBlurtPrice().catch((e) => {
48
+ logger.warn(`get-vote-value: price feed unavailable (${e.message})`);
49
+ return null;
50
+ });
51
+ const summary = {
52
+ ...vote,
53
+ vote_value_usd: price ? Number((vote.vote_value_blurt * price.price_usd).toFixed(5)) : null,
54
+ price_usd: price?.price_usd ?? null,
55
+ };
56
+ logger.info(`Vote value for ${username}@${weight}%: ${vote.vote_value_blurt} BLURT`);
57
+ return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
58
+ }
59
+ catch (e) {
60
+ logger.error(`[get-vote-value] Error: ${e.stack}`);
61
+ return {
62
+ content: [{ type: "text", text: `Error estimating vote value for ${username}` }],
63
+ isError: true,
64
+ };
65
+ }
66
+ });
67
+ }
@@ -0,0 +1,46 @@
1
+ // Logger
2
+ import logger from "../utils/logger.js";
3
+ import { z } from "zod";
4
+ import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
5
+ /**
6
+ * Tool: get-witness (Blurt — single witness details + health)
7
+ */
8
+ export function registerGetWitness(server, client) {
9
+ registerBlurtTool(server, "get-witness", {
10
+ title: "Get Blurt witness details",
11
+ description: "Get details and a health read for a single Blurt witness (block producer) by account name. " +
12
+ "Use this when the user asks about a specific witness — its support, reliability, node version, " +
13
+ "or whether it is up to date and producing blocks. Parameter: username. Returns: vote weight " +
14
+ "(approx BLURT backing), lifetime missed blocks, running node version, last confirmed block and " +
15
+ "how far behind the chain head it is, whether block production is enabled (a null signing key " +
16
+ "means disabled), the configured chain properties (fees, block size), and a short health summary. " +
17
+ "Use list-witnesses to see the ranked set.",
18
+ annotations: { readOnlyHint: true },
19
+ inputSchema: {
20
+ username: z.string().min(3).describe("Witness account name (e.g., 'nalexadre')"),
21
+ },
22
+ }, async ({ username }) => {
23
+ logger.debug(`get-witness: ${username}`);
24
+ try {
25
+ const model = await client.read.getWitnessSummary(username);
26
+ // Simple health read remains MCP-local presentation.
27
+ const health = [];
28
+ if (!model.enabled)
29
+ health.push("block production DISABLED (null signing key)");
30
+ else if (model.blocks_behind_head > 1200)
31
+ health.push(`possibly inactive: ${model.blocks_behind_head} blocks behind head`);
32
+ else
33
+ health.push("actively confirming blocks");
34
+ const summary = {
35
+ ...model,
36
+ vote_weight_blurt: Math.round(model.vote_weight_blurt),
37
+ health: health.join("; "),
38
+ };
39
+ return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
40
+ }
41
+ catch (e) {
42
+ logger.error(`[get-witness] Error: ${e.stack}`);
43
+ return { content: [{ type: "text", text: `Error retrieving witness ${username}` }], isError: true };
44
+ }
45
+ });
46
+ }
@@ -0,0 +1,90 @@
1
+ // Logger
2
+ import logger from "../utils/logger.js";
3
+ import { z } from "zod";
4
+ import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
5
+ /**
6
+ * Tool: list-communities (Blurt — Nexus communities directory)
7
+ *
8
+ * Purpose
9
+ * List Blurt communities, sorted by rank, recency or subscriber count, with
10
+ * optional text search and pagination.
11
+ *
12
+ * Parameters (inputSchema)
13
+ * - sort (enum, default "rank") — "rank" | "new" | "subs"
14
+ * - query (string | null, optional) — text search on community name/title
15
+ * - limit (number, 1..100, default 20)
16
+ * - last (string | null, optional) — pagination cursor (last community name)
17
+ * - observer (string | null, optional) — observer account (subscription context)
18
+ *
19
+ * Behavior
20
+ * Calls: client.nexus.listCommunities({ last, limit, query, sort, observer })
21
+ *
22
+ * Returns
23
+ * - Human-readable summary (name, title, subscribers, authors, pending payout)
24
+ * - Full JSON of the NexusCommunity[] for deeper analysis
25
+ */
26
+ export function registerListCommunities(server, client) {
27
+ registerBlurtTool(server, "list-communities", {
28
+ title: "List Blurt communities",
29
+ description: "List Blurt communities — topic-based groups where users publish and moderate posts — via the Nexus L2 API. " +
30
+ "Use this for community discovery: e.g. 'what are the most popular or active communities', or to find a community " +
31
+ "by name/topic before calling get-community or filtering posts by it. " +
32
+ "Parameters: sort ('rank' = curated activity ranking, 'new' = recently created, 'subs' = most subscribers; default 'rank'), " +
33
+ "query (optional text filter on name/title), limit (1-100, default 20), " +
34
+ "last (pagination cursor: pass the last community 'name' from the previous page to get the next one), " +
35
+ "observer (optional account, adds the caller's subscription context). " +
36
+ "Returns a readable summary (name, title, subscribers, author count, pending posts and payout) plus the full JSON array. " +
37
+ "Note: a community 'name' looks like 'blurt-192372' and is the id used by get-community and as a post category.",
38
+ annotations: { readOnlyHint: true },
39
+ inputSchema: {
40
+ sort: z.enum(["rank", "new", "subs"]).default("rank")
41
+ .describe("Sort order: rank, new, or subs (subscriber count)"),
42
+ query: z.string().nullable().optional()
43
+ .describe("Optional text search on community name/title"),
44
+ limit: z.number().int().min(1).max(100).default(20)
45
+ .describe("Number of communities to return (max 100)"),
46
+ last: z.string().nullable().optional()
47
+ .describe("Pagination cursor: last community name from a previous page"),
48
+ observer: z.string().nullable().optional()
49
+ .describe("Observer account (affects subscription context)"),
50
+ },
51
+ }, async ({ sort, query, limit, last, observer }) => {
52
+ logger.debug(`list-communities: sort=${sort}, query=${query}, limit=${limit}, last=${last}, observer=${observer}`);
53
+ try {
54
+ const communities = await client.nexus.listCommunities({
55
+ last: last ?? undefined,
56
+ limit,
57
+ query: query ?? null,
58
+ sort,
59
+ observer: observer ?? null,
60
+ });
61
+ if (!communities || communities.length === 0) {
62
+ logger.warn(`No communities found (sort=${sort}, query=${query})`);
63
+ return { content: [{ type: "text", text: "No communities found." }] };
64
+ }
65
+ const summary = communities
66
+ .map((c, i) => {
67
+ return `${i + 1}. ${c.name} — ${c.title}
68
+ - subscribers: ${c.subscribers}
69
+ - authors: ${c.num_authors}
70
+ - pending posts: ${c.num_pending}
71
+ - pending payout: ${c.sum_pending} BLURT
72
+ - lang: ${c.lang}${c.is_nsfw ? " (NSFW)" : ""}`;
73
+ })
74
+ .join("\n\n");
75
+ return {
76
+ content: [
77
+ { type: "text", text: `Communities (${sort}, limit=${limit})\n${summary}` },
78
+ { type: "text", text: JSON.stringify(communities, null, 2) },
79
+ ],
80
+ };
81
+ }
82
+ catch (e) {
83
+ logger.error(`[list-communities] Error: ${e.stack}`);
84
+ return {
85
+ content: [{ type: "text", text: "Error retrieving communities." }],
86
+ isError: true,
87
+ };
88
+ }
89
+ });
90
+ }
@@ -0,0 +1,48 @@
1
+ // Logger
2
+ import logger from "../utils/logger.js";
3
+ import { z } from "zod";
4
+ import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
5
+ /**
6
+ * Tool: list-witnesses (Blurt — top witnesses by vote)
7
+ */
8
+ export function registerListWitnesses(server, client) {
9
+ registerBlurtTool(server, "list-witnesses", {
10
+ title: "List Blurt witnesses",
11
+ description: "List Blurt witnesses (block producers) ranked by vote weight. Use this for governance " +
12
+ "questions: 'who are the top witnesses', 'who produces blocks', or to inspect the consensus " +
13
+ "set. Parameter: limit (1-100, default 20). For each witness returns: rank, owner, approximate " +
14
+ "vote weight in BLURT (the Blurt Power backing its votes), lifetime missed blocks, running node " +
15
+ "version, last confirmed block, whether it is actively producing (a disabled witness uses a null " +
16
+ "signing key), and its URL. Use get-witness for a single-witness deep dive.",
17
+ annotations: { readOnlyHint: true },
18
+ inputSchema: {
19
+ limit: z.number().int().min(1).max(100).default(20)
20
+ .describe("Number of witnesses to return, ranked by votes (max 100)"),
21
+ },
22
+ }, async ({ limit }) => {
23
+ logger.debug(`list-witnesses: limit=${limit}`);
24
+ try {
25
+ const witnesses = await client.read.listWitnessSummaries(limit);
26
+ if (!witnesses || witnesses.length === 0) {
27
+ return { content: [{ type: "text", text: "No witnesses found." }] };
28
+ }
29
+ const summary = witnesses
30
+ .map((w) => `${w.rank}. ${w.owner}${w.enabled ? "" : " (DISABLED)"}
31
+ - vote weight: ${Math.round(w.vote_weight_blurt).toLocaleString("en-US")} BLURT
32
+ - missed blocks: ${w.missed_blocks_lifetime}
33
+ - version: ${w.running_version}
34
+ - last confirmed block: ${w.last_confirmed_block_num}
35
+ - url: ${w.url}`)
36
+ .join("\n\n");
37
+ return {
38
+ content: [
39
+ { type: "text", text: `Top ${witnesses.length} Blurt witnesses by vote\n${summary}` },
40
+ ],
41
+ };
42
+ }
43
+ catch (e) {
44
+ logger.error(`[list-witnesses] Error: ${e.stack}`);
45
+ return { content: [{ type: "text", text: "Error retrieving witnesses." }], isError: true };
46
+ }
47
+ });
48
+ }
@@ -0,0 +1,30 @@
1
+ import logger from "../utils/logger.js";
2
+ import { z } from "zod";
3
+ import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
4
+ /** Tool: lookup-accounts (Blurt) — autocomplete account names by prefix. */
5
+ export function registerLookupAccounts(server, client) {
6
+ registerBlurtTool(server, "lookup-accounts", {
7
+ title: "Look up Blurt account names by prefix",
8
+ description: "Find Blurt account names that start with a given prefix (alphabetical autocomplete). Use this when " +
9
+ "the user gives a partial or uncertain account name, wants to discover accounts by prefix, or to " +
10
+ "confirm an exact username before another call. Parameters: prefix (the starting characters), limit " +
11
+ "(1-100, default 20). Returns: the matching account names in alphabetical order. This only matches " +
12
+ "the start of the name; it is not a full-text search.",
13
+ annotations: { readOnlyHint: true },
14
+ inputSchema: {
15
+ prefix: z.string().min(1).describe("Start of the account name (e.g., 'nale')"),
16
+ limit: z.number().int().min(1).max(100).default(20).describe("Max names to return"),
17
+ },
18
+ }, async ({ prefix, limit }) => {
19
+ logger.debug(`lookup-accounts: ${prefix}, limit=${limit}`);
20
+ try {
21
+ const names = await client.condenser.lookupAccounts(prefix.toLowerCase(), limit);
22
+ const summary = { prefix, count: names.length, accounts: names };
23
+ return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
24
+ }
25
+ catch (e) {
26
+ logger.error(`[lookup-accounts] Error: ${e.message}`);
27
+ return { content: [{ type: "text", text: `Error looking up accounts for '${prefix}'` }], isError: true };
28
+ }
29
+ });
30
+ }
@@ -0,0 +1,39 @@
1
+ import logger from "../utils/logger.js";
2
+ import { z } from "zod";
3
+ import { mute, withinCap, dryRunDefault } from "../utils/signer.js";
4
+ import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
5
+ const CAP_MAX = 30;
6
+ const CAP_WINDOW_MS = 60 * 60 * 1000;
7
+ /** Tool: blurt-mute (Blurt — WRITE) — mute/unmute an account. */
8
+ export function registerMute(server, client, ctx) {
9
+ registerBlurtTool(server, "blurt-mute", {
10
+ title: "Mute or unmute a Blurt account",
11
+ description: "WRITE operation (custom_json `follow` with 'ignore', signed with the local posting key): mute " +
12
+ "(ignore) or unmute another Blurt account for the configured account, so its content is hidden in " +
13
+ "compatible frontends. Use this when the user asks to mute/ignore or unmute someone. Parameters: " +
14
+ "account, action ('mute' or 'unmute', default 'mute'), dry_run (default follows BLURT_DRY_RUN_DEFAULT; false when unset). Available only on " +
15
+ "the local stdio server; rate-limited.",
16
+ annotations: { readOnlyHint: false, destructiveHint: false },
17
+ inputSchema: {
18
+ account: z.string().min(3).describe("Account to mute/unmute"),
19
+ action: z.enum(["mute", "unmute"]).default("mute")
20
+ .describe("'mute' to ignore the account's content, 'unmute' to undo (default 'mute')"),
21
+ dry_run: z.boolean().default(dryRunDefault()).describe("Preview only, without broadcasting"),
22
+ },
23
+ }, async ({ account, action, dry_run }) => {
24
+ logger.debug(`blurt-mute: ${action} ${account} (dry_run=${dry_run})`);
25
+ try {
26
+ if (!dry_run && !withinCap("mute", CAP_MAX, CAP_WINDOW_MS)) {
27
+ return { content: [{ type: "text", text: `Rate limit reached for mute (max ${CAP_MAX}/hour).` }], isError: true };
28
+ }
29
+ const result = await mute(client, ctx, { account, action, dryRun: dry_run });
30
+ if (result.done)
31
+ logger.info(`${action} @${account} (tx ${result.tx_id}).`);
32
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
33
+ }
34
+ catch (e) {
35
+ logger.error(`[blurt-mute] Error: ${e.message}`);
36
+ return { content: [{ type: "text", text: `Error trying to ${action} ${account}` }], isError: true };
37
+ }
38
+ });
39
+ }
@@ -0,0 +1,42 @@
1
+ import logger from "../utils/logger.js";
2
+ import { z } from "zod";
3
+ import { post, withinCap, dryRunDefault } from "../utils/signer.js";
4
+ import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
5
+ const CAP_MAX = 10;
6
+ const CAP_WINDOW_MS = 60 * 60 * 1000;
7
+ /** Tool: blurt-post (Blurt — WRITE) — publish a top-level post. */
8
+ export function registerPost(server, client, ctx) {
9
+ registerBlurtTool(server, "blurt-post", {
10
+ title: "Publish a new Blurt post",
11
+ description: "WRITE operation (signs a top-level `comment` with the local posting key): publish a new blog post " +
12
+ "as the configured account. Use this when the user asks to publish / create a post (not a reply — " +
13
+ "for replies use blurt-comment). Parameters: title, body (markdown), tags (1-8 lowercase tags; the " +
14
+ "first is the post's main category), dry_run (default follows BLURT_DRY_RUN_DEFAULT; false when unset — when true, previews the exact post " +
15
+ "WITHOUT broadcasting). A unique permlink is generated from the title, the body is tagged with the " +
16
+ "blurt-mcp app and a short attribution footer, and only the configured account posts. Available only " +
17
+ "on the local stdio server; rate-limited. Posts are public and permanent.",
18
+ annotations: { readOnlyHint: false, destructiveHint: false },
19
+ inputSchema: {
20
+ title: z.string().min(1).max(255).describe("Post title"),
21
+ body: z.string().min(1).describe("Post body (markdown)"),
22
+ tags: z.array(z.string().min(1)).min(1).max(8)
23
+ .describe("1-8 tags; the first is the main category (e.g., ['blurt','intro'])"),
24
+ dry_run: z.boolean().default(dryRunDefault()).describe("Preview the final post only, without broadcasting"),
25
+ },
26
+ }, async ({ title, body, tags, dry_run }) => {
27
+ logger.debug(`blurt-post: "${title}" tags=${tags.join(",")} (dry_run=${dry_run})`);
28
+ try {
29
+ if (!dry_run && !withinCap("post", CAP_MAX, CAP_WINDOW_MS)) {
30
+ return { content: [{ type: "text", text: `Rate limit reached for post (max ${CAP_MAX}/hour).` }], isError: true };
31
+ }
32
+ const result = await post(client, ctx, { title, body, tags, dryRun: dry_run });
33
+ if (result.posted)
34
+ logger.info(`Published @${result.author}/${result.permlink} (tx ${result.tx_id}).`);
35
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
36
+ }
37
+ catch (e) {
38
+ logger.error(`[blurt-post] Error: ${e.message}`);
39
+ return { content: [{ type: "text", text: `Error publishing post "${title}"` }], isError: true };
40
+ }
41
+ });
42
+ }
@@ -0,0 +1,35 @@
1
+ import logger from "../utils/logger.js";
2
+ import { z } from "zod";
3
+ import { readNotifications, withinCap, dryRunDefault } from "../utils/signer.js";
4
+ import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
5
+ const CAP_MAX = 30;
6
+ const CAP_WINDOW_MS = 60 * 60 * 1000;
7
+ /** Tool: blurt-read-notifications (Blurt — WRITE) — mark notifications as read. */
8
+ export function registerReadNotifications(server, client, ctx) {
9
+ registerBlurtTool(server, "blurt-read-notifications", {
10
+ title: "Mark Blurt notifications as read",
11
+ description: "WRITE operation (custom_json `notify` setLastRead, signed with the local posting key): mark the " +
12
+ "configured account's notifications as read up to now. Use this when the user asks to clear / mark " +
13
+ "their notifications as read. Pair with get-account-notifications to read them first. Parameter: " +
14
+ "dry_run (default follows BLURT_DRY_RUN_DEFAULT; false when unset). Available only on the local stdio server; rate-limited.",
15
+ annotations: { readOnlyHint: false, destructiveHint: false },
16
+ inputSchema: {
17
+ dry_run: z.boolean().default(dryRunDefault()).describe("Preview only, without broadcasting"),
18
+ },
19
+ }, async ({ dry_run }) => {
20
+ logger.debug(`blurt-read-notifications (dry_run=${dry_run})`);
21
+ try {
22
+ if (!dry_run && !withinCap("read-notifications", CAP_MAX, CAP_WINDOW_MS)) {
23
+ return { content: [{ type: "text", text: `Rate limit reached for read-notifications (max ${CAP_MAX}/hour).` }], isError: true };
24
+ }
25
+ const result = await readNotifications(client, ctx, { dryRun: dry_run });
26
+ if (result.done)
27
+ logger.info(`Marked notifications read for @${result.account} (tx ${result.tx_id}).`);
28
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
29
+ }
30
+ catch (e) {
31
+ logger.error(`[blurt-read-notifications] Error: ${e.message}`);
32
+ return { content: [{ type: "text", text: "Error marking notifications as read." }], isError: true };
33
+ }
34
+ });
35
+ }
@@ -0,0 +1,39 @@
1
+ import logger from "../utils/logger.js";
2
+ import { z } from "zod";
3
+ import { reblog, withinCap, dryRunDefault } from "../utils/signer.js";
4
+ import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
5
+ const CAP_MAX = 30;
6
+ const CAP_WINDOW_MS = 60 * 60 * 1000;
7
+ /** Tool: blurt-reblog (Blurt — WRITE) — reblog (re-share) a post. */
8
+ export function registerReblog(server, client, ctx) {
9
+ registerBlurtTool(server, "blurt-reblog", {
10
+ title: "Reblog (re-share) a Blurt post",
11
+ description: "WRITE operation (custom_json `reblog`, signed with the local posting key): reblog (re-share) a Blurt " +
12
+ "post to the configured account's blog, so its followers see it. Use this when the user asks to " +
13
+ "reblog/re-share a post. Identify the post by author + permlink. Parameters: author, permlink, undo " +
14
+ "(default false — set true to remove a previous reblog), dry_run (default follows BLURT_DRY_RUN_DEFAULT; false when unset). Available only on " +
15
+ "the local stdio server; rate-limited.",
16
+ annotations: { readOnlyHint: false, destructiveHint: false },
17
+ inputSchema: {
18
+ author: z.string().min(1).describe("Author of the post to reblog"),
19
+ permlink: z.string().min(1).describe("Permlink of the post to reblog"),
20
+ undo: z.boolean().default(false).describe("Remove a previous reblog instead of adding one"),
21
+ dry_run: z.boolean().default(dryRunDefault()).describe("Preview only, without broadcasting"),
22
+ },
23
+ }, async ({ author, permlink, undo, dry_run }) => {
24
+ logger.debug(`blurt-reblog: ${author}/${permlink} undo=${undo} (dry_run=${dry_run})`);
25
+ try {
26
+ if (!dry_run && !withinCap("reblog", CAP_MAX, CAP_WINDOW_MS)) {
27
+ return { content: [{ type: "text", text: `Rate limit reached for reblog (max ${CAP_MAX}/hour).` }], isError: true };
28
+ }
29
+ const result = await reblog(client, ctx, { author, permlink, undo, dryRun: dry_run });
30
+ if (result.done)
31
+ logger.info(`Reblog ${author}/${permlink} undo=${undo} (tx ${result.tx_id}).`);
32
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
33
+ }
34
+ catch (e) {
35
+ logger.error(`[blurt-reblog] Error: ${e.message}`);
36
+ return { content: [{ type: "text", text: `Error reblogging ${author}/${permlink}` }], isError: true };
37
+ }
38
+ });
39
+ }