@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.
- package/CHANGELOG.md +118 -0
- package/LICENSE +682 -0
- package/README.md +117 -0
- package/SECURITY.md +107 -0
- package/dist/app.js +88 -0
- package/dist/buildServer.js +146 -0
- package/dist/contracts/registerBlurtTool.js +53 -0
- package/dist/contracts/toolRegistry.js +384 -0
- package/dist/resources/blurtResource.js +82 -0
- package/dist/server-stdio.js +37 -0
- package/dist/server.js +35 -0
- package/dist/tools/claimRewards.js +48 -0
- package/dist/tools/comment.js +58 -0
- package/dist/tools/compareAccounts.js +50 -0
- package/dist/tools/fetch.js +91 -0
- package/dist/tools/follow.js +39 -0
- package/dist/tools/getAccount.js +80 -0
- package/dist/tools/getAccountHistory.js +109 -0
- package/dist/tools/getAccountNotifications.js +40 -0
- package/dist/tools/getAccountPosts.js +130 -0
- package/dist/tools/getAccountRelationships.js +34 -0
- package/dist/tools/getAccountSubscriptions.js +50 -0
- package/dist/tools/getAccountWitnessVotes.js +46 -0
- package/dist/tools/getBlurtPrice.js +43 -0
- package/dist/tools/getChainStatus.js +94 -0
- package/dist/tools/getCommunity.js +75 -0
- package/dist/tools/getDelegations.js +37 -0
- package/dist/tools/getPendingRewards.js +53 -0
- package/dist/tools/getPost.js +88 -0
- package/dist/tools/getPostReblogs.js +29 -0
- package/dist/tools/getPostVotes.js +78 -0
- package/dist/tools/getPublications.js +109 -0
- package/dist/tools/getReferrals.js +39 -0
- package/dist/tools/getVoteValue.js +67 -0
- package/dist/tools/getWitness.js +46 -0
- package/dist/tools/listCommunities.js +90 -0
- package/dist/tools/listWitnesses.js +48 -0
- package/dist/tools/lookupAccounts.js +30 -0
- package/dist/tools/mute.js +39 -0
- package/dist/tools/post.js +42 -0
- package/dist/tools/readNotifications.js +35 -0
- package/dist/tools/reblog.js +39 -0
- package/dist/tools/search.js +189 -0
- package/dist/tools/subscribeCommunity.js +39 -0
- package/dist/tools/upvote.js +48 -0
- package/dist/utils/blurtUri.js +61 -0
- package/dist/utils/loadEnv.js +21 -0
- package/dist/utils/logger.js +63 -0
- package/dist/utils/price.js +21 -0
- package/dist/utils/rpc.js +126 -0
- package/dist/utils/signer.js +350 -0
- package/docs/adr/0001-neutral-infrastructure.md +50 -0
- package/docs/architecture.md +62 -0
- package/docs/cache-policy.md +42 -0
- package/docs/clients.md +78 -0
- package/docs/deployment.md +102 -0
- package/docs/development.md +51 -0
- package/docs/install-snippets.md +236 -0
- package/docs/load-testing.md +51 -0
- package/docs/operations.md +56 -0
- package/docs/release-provenance.md +63 -0
- package/docs/tools.generated.md +89 -0
- package/docs/tools.md +102 -0
- package/docs/usage.md +157 -0
- package/docs/write-operations.md +223 -0
- package/package.json +77 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Logger
|
|
2
|
+
import logger from "../utils/logger.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
|
|
5
|
+
export function registerSearchTool(server) {
|
|
6
|
+
registerBlurtTool(server, "search", {
|
|
7
|
+
title: "Search Blurt resources",
|
|
8
|
+
description: `Turn a free-form query into Blurt resource links, which you then open with the fetch tool. Use this as an entry point ` +
|
|
9
|
+
`when you have a loose query and don't know the exact tool or URI; it returns resource_link items (no data itself — call ` +
|
|
10
|
+
`fetch on a returned URI to get the data). For specific needs, the dedicated tools (get-account, get-publications, get-post, ` +
|
|
11
|
+
`get-account-posts) are usually more direct.\n\n` +
|
|
12
|
+
`Supported query patterns:\n\n` +
|
|
13
|
+
`Accounts:\n` +
|
|
14
|
+
` - @username → account\n` +
|
|
15
|
+
` - history:@username → account history (limit respected)\n\n` +
|
|
16
|
+
`Ranked posts (Nexus getRankedPosts):\n` +
|
|
17
|
+
` - posts:tag → trending posts for a tag\n` +
|
|
18
|
+
` - posts tag → trending posts for a tag (space allowed)\n` +
|
|
19
|
+
` - posts/<sort>/<tag> → posts by sort (trending|hot|created|promoted|payout|payout_comments|muted)\n` +
|
|
20
|
+
` - posts:<sort>:<tag> → same as above (colon allowed)\n\n` +
|
|
21
|
+
`Single post / discussion (Nexus getPost/getDiscussion):\n` +
|
|
22
|
+
` - post author/permlink → the post; fetch will return full discussion JSON\n` +
|
|
23
|
+
` - author/permlink → shorthand for the same\n\n` +
|
|
24
|
+
`Account posts (Nexus getAccountPosts):\n` +
|
|
25
|
+
` - account-posts/<sort>/<account> sort in (blog|feed|posts|comments|replies|payout)\n\n` +
|
|
26
|
+
`Notes:\n` +
|
|
27
|
+
` - Whitespace and case are ignored in most patterns.\n` +
|
|
28
|
+
` - If nothing matches, query is interpreted as a Blurt username.`,
|
|
29
|
+
// ChatGPT expects a "query" parameter
|
|
30
|
+
annotations: { readOnlyHint: true },
|
|
31
|
+
inputSchema: {
|
|
32
|
+
query: z.string().min(1).describe("Free-form search string following the documented patterns"),
|
|
33
|
+
limit: z.number().int().min(1).max(1000).default(20).describe("Max items to return for list resources"),
|
|
34
|
+
},
|
|
35
|
+
}, async ({ query, limit }) => {
|
|
36
|
+
logger.debug(`SEARCH called with query="${query}", limit=${limit}`);
|
|
37
|
+
const content = [];
|
|
38
|
+
// --- Normalize --------------------------------------------------------
|
|
39
|
+
const raw = query.trim();
|
|
40
|
+
const lower = raw.toLowerCase();
|
|
41
|
+
const squashed = lower.replace(/\s+/g, ""); // remove spaces for patterns like "posts: tag"
|
|
42
|
+
// helpers
|
|
43
|
+
const push = (uri, name, description) => {
|
|
44
|
+
content.push({
|
|
45
|
+
type: "resource_link",
|
|
46
|
+
uri,
|
|
47
|
+
name,
|
|
48
|
+
mimeType: "application/json",
|
|
49
|
+
description,
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
// constants
|
|
53
|
+
const rankedSorts = new Set([
|
|
54
|
+
"trending",
|
|
55
|
+
"hot",
|
|
56
|
+
"created",
|
|
57
|
+
"promoted",
|
|
58
|
+
"payout",
|
|
59
|
+
"payout_comments",
|
|
60
|
+
"muted",
|
|
61
|
+
]);
|
|
62
|
+
const acctPostSorts = new Set([
|
|
63
|
+
"blog",
|
|
64
|
+
"feed",
|
|
65
|
+
"posts",
|
|
66
|
+
"comments",
|
|
67
|
+
"replies",
|
|
68
|
+
"payout",
|
|
69
|
+
]);
|
|
70
|
+
const sortAlias = { new: "created", newest: "created" };
|
|
71
|
+
// ---------------------------------------------------------------------
|
|
72
|
+
// 1) Single post (explicit): post author/permlink
|
|
73
|
+
// ---------------------------------------------------------------------
|
|
74
|
+
// `(?!s)` so that "posts/..." is NOT treated as a single post "post/..."
|
|
75
|
+
let m = lower.match(/^post(?!s)[:\s]*@?([a-z0-9_.\-]+)\/+([a-z0-9_.\-]+)/);
|
|
76
|
+
if (m) {
|
|
77
|
+
const author = m[1];
|
|
78
|
+
const permlink = m[2];
|
|
79
|
+
logger.info(`SEARCH: single post → ${author}/${permlink}`);
|
|
80
|
+
push(`blurt://post/${encodeURIComponent(author)}/${encodeURIComponent(permlink)}?with_comments=true`, `Post ${author}/${permlink}`, `Blurt post with full discussion`);
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------
|
|
83
|
+
// 2) Account posts: account-posts/<sort>/<account>
|
|
84
|
+
// ---------------------------------------------------------------------
|
|
85
|
+
if (content.length === 0) {
|
|
86
|
+
m = lower.match(/^account[-_]?posts[:\/\s]+([a-z_]+)[:\/\s]+@?([a-z0-9_.\-]+)/);
|
|
87
|
+
if (m) {
|
|
88
|
+
let sort = m[1];
|
|
89
|
+
const account = m[2];
|
|
90
|
+
if (sortAlias[sort])
|
|
91
|
+
sort = sortAlias[sort];
|
|
92
|
+
if (acctPostSorts.has(sort)) {
|
|
93
|
+
logger.info(`SEARCH: account-posts → sort=${sort}, account=${account}`);
|
|
94
|
+
const params = new URLSearchParams();
|
|
95
|
+
params.set("limit", String(Math.min(1000, Math.max(1, limit))));
|
|
96
|
+
push(`blurt://account-posts/${encodeURIComponent(sort)}/${encodeURIComponent(account)}?${params.toString()}`, `Account posts @${account} (${sort})`, `Nexus getAccountPosts(${sort}) for @${account}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------
|
|
101
|
+
// 3) Ranked posts by tag: posts ...
|
|
102
|
+
// Accept: "posts:tag", "posts tag", "posts/<sort>/<tag>", "posts:<sort>:<tag>"
|
|
103
|
+
// ---------------------------------------------------------------------
|
|
104
|
+
if (content.length === 0) {
|
|
105
|
+
// Everything after the "posts" keyword and a separator (":", "/", or space).
|
|
106
|
+
const pm = lower.match(/^posts[:\/\s]+(.+)$/);
|
|
107
|
+
if (pm) {
|
|
108
|
+
const segs = pm[1].split(/[:\/\s]+/).map(s => s.trim()).filter(Boolean);
|
|
109
|
+
let sort = "trending";
|
|
110
|
+
let tag;
|
|
111
|
+
// "posts/<sort>/<tag>" only when the first segment is a known sort (or alias);
|
|
112
|
+
// otherwise the single segment is the tag and we default to trending.
|
|
113
|
+
if (segs.length >= 2 && (rankedSorts.has(segs[0]) || sortAlias[segs[0]])) {
|
|
114
|
+
sort = sortAlias[segs[0]] ?? segs[0];
|
|
115
|
+
tag = segs[1];
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
tag = segs[0];
|
|
119
|
+
}
|
|
120
|
+
if (tag) {
|
|
121
|
+
const params = new URLSearchParams();
|
|
122
|
+
params.set("limit", String(Math.min(100, Math.max(1, limit))));
|
|
123
|
+
logger.info(`SEARCH: posts sort=${sort} for tag #${tag}`);
|
|
124
|
+
push(`blurt://posts/${encodeURIComponent(sort)}/${encodeURIComponent(tag)}?${params.toString()}`, `Publications #${tag}${sort !== "trending" ? ` (${sort})` : ""}`, `Ranked posts (${sort}) for #${tag}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// ---------------------------------------------------------------------
|
|
129
|
+
// 4) Account history: history:@username or history @username
|
|
130
|
+
// ---------------------------------------------------------------------
|
|
131
|
+
if (content.length === 0) {
|
|
132
|
+
m = lower.match(/^history[:\s]*@?([a-z0-9_.\-]+)$/);
|
|
133
|
+
if (m) {
|
|
134
|
+
const username = m[1];
|
|
135
|
+
const params = new URLSearchParams();
|
|
136
|
+
params.set("limit", String(Math.min(1000, Math.max(1, limit))));
|
|
137
|
+
logger.info(`SEARCH: history resource for ${username}`);
|
|
138
|
+
push(`blurt://history/${encodeURIComponent(username)}?${params.toString()}`, `History @${username}`, `Latest operations for @${username}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// ---------------------------------------------------------------------
|
|
142
|
+
// 5) Account: @username or account-nalexadre (common fallbacks)
|
|
143
|
+
// ---------------------------------------------------------------------
|
|
144
|
+
if (content.length === 0) {
|
|
145
|
+
m = lower.match(/^@([a-z0-9_.\-]+)$/);
|
|
146
|
+
if (!m) {
|
|
147
|
+
m = lower.match(/^account[-_]([a-z0-9_.\-]+)$/);
|
|
148
|
+
}
|
|
149
|
+
if (m) {
|
|
150
|
+
const username = m[1];
|
|
151
|
+
logger.info(`SEARCH: account resource for ${username}`);
|
|
152
|
+
push(`blurt://account/${encodeURIComponent(username)}`, `Account @${username}`, `Blurt account ${username}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// ---------------------------------------------------------------------
|
|
156
|
+
// New step: Tag query starting with #
|
|
157
|
+
// ---------------------------------------------------------------------
|
|
158
|
+
if (content.length === 0 && raw.startsWith("#")) {
|
|
159
|
+
const tag = raw.slice(1).trim();
|
|
160
|
+
if (tag.length > 0) {
|
|
161
|
+
const params = new URLSearchParams();
|
|
162
|
+
params.set("limit", String(Math.min(100, Math.max(1, limit))));
|
|
163
|
+
logger.info(`SEARCH: tag query for #${tag}`);
|
|
164
|
+
push(`blurt://posts/trending/${encodeURIComponent(tag)}?${params.toString()}`, `Publications #${tag}`, `Trending posts for #${tag}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// ---------------------------------------------------------------------
|
|
168
|
+
// 6) Single post (shorthand): author/permlink (no leading keyword)
|
|
169
|
+
// Require permlink length >= 6
|
|
170
|
+
// ---------------------------------------------------------------------
|
|
171
|
+
if (content.length === 0 && /^(?:@?[a-z0-9_.\-]+)\/(?:[a-z0-9_.\-]{6,})$/.test(lower)) {
|
|
172
|
+
const [authorRaw, permlinkRaw] = raw.replace(/^@/, "").split("/");
|
|
173
|
+
const author = authorRaw.trim();
|
|
174
|
+
const permlink = permlinkRaw.trim();
|
|
175
|
+
logger.info(`SEARCH: shorthand post → ${author}/${permlink}`);
|
|
176
|
+
push(`blurt://post/${encodeURIComponent(author)}/${encodeURIComponent(permlink)}?with_comments=true`, `Post ${author}/${permlink}`, `Blurt post with full discussion`);
|
|
177
|
+
}
|
|
178
|
+
// ---------------------------------------------------------------------
|
|
179
|
+
// 7) Fallback → interpret as username
|
|
180
|
+
// ---------------------------------------------------------------------
|
|
181
|
+
if (content.length === 0) {
|
|
182
|
+
const username = lower.replace(/^@/, "");
|
|
183
|
+
logger.info(`SEARCH: fallback → interpreting as username "${username}"`);
|
|
184
|
+
push(`blurt://account/${encodeURIComponent(username)}`, `Account @${username}`, `Blurt account ${username}`);
|
|
185
|
+
}
|
|
186
|
+
logger.debug(`SEARCH returning ${content.length} resource_links`);
|
|
187
|
+
return { content };
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import logger from "../utils/logger.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { subscribeCommunity, 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-subscribe-community (Blurt — WRITE) — subscribe/unsubscribe a community. */
|
|
8
|
+
export function registerSubscribeCommunity(server, client, ctx) {
|
|
9
|
+
registerBlurtTool(server, "blurt-subscribe-community", {
|
|
10
|
+
title: "Subscribe to or leave a Blurt community",
|
|
11
|
+
description: "WRITE operation (custom_json `community`, signed with the local posting key): subscribe the " +
|
|
12
|
+
"configured account to a Blurt community, or unsubscribe from it. Use this when the user asks to " +
|
|
13
|
+
"join/follow or leave a community. Parameters: community (the community name/id, e.g. 'blurt-192372' " +
|
|
14
|
+
"— from list-communities), action ('subscribe' or 'unsubscribe', default 'subscribe'), dry_run " +
|
|
15
|
+
"(default follows BLURT_DRY_RUN_DEFAULT; false when unset). Available only on the local stdio server; rate-limited.",
|
|
16
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
17
|
+
inputSchema: {
|
|
18
|
+
community: z.string().min(1).describe("Community name/id (e.g., 'blurt-192372')"),
|
|
19
|
+
action: z.enum(["subscribe", "unsubscribe"]).default("subscribe")
|
|
20
|
+
.describe("'subscribe' to join the community, 'unsubscribe' to leave (default 'subscribe')"),
|
|
21
|
+
dry_run: z.boolean().default(dryRunDefault()).describe("Preview only, without broadcasting"),
|
|
22
|
+
},
|
|
23
|
+
}, async ({ community, action, dry_run }) => {
|
|
24
|
+
logger.debug(`blurt-subscribe-community: ${action} ${community} (dry_run=${dry_run})`);
|
|
25
|
+
try {
|
|
26
|
+
if (!dry_run && !withinCap("subscribe-community", CAP_MAX, CAP_WINDOW_MS)) {
|
|
27
|
+
return { content: [{ type: "text", text: `Rate limit reached for subscribe-community (max ${CAP_MAX}/hour).` }], isError: true };
|
|
28
|
+
}
|
|
29
|
+
const result = await subscribeCommunity(client, ctx, { community, action, dryRun: dry_run });
|
|
30
|
+
if (result.done)
|
|
31
|
+
logger.info(`${action} community ${community} (tx ${result.tx_id}).`);
|
|
32
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
logger.error(`[blurt-subscribe-community] Error: ${e.message}`);
|
|
36
|
+
return { content: [{ type: "text", text: `Error trying to ${action} ${community}` }], isError: true };
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Logger
|
|
2
|
+
import logger from "../utils/logger.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { upvote, withinCap, dryRunDefault } from "../utils/signer.js";
|
|
5
|
+
import { registerBlurtTool } from "../contracts/registerBlurtTool.js";
|
|
6
|
+
const CAP_MAX = 30;
|
|
7
|
+
const CAP_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
8
|
+
/**
|
|
9
|
+
* Tool: blurt-upvote (Blurt — WRITE)
|
|
10
|
+
*
|
|
11
|
+
* Signs a `vote` with the local posting key. Only registered on the local stdio
|
|
12
|
+
* server when a validated posting key is present (never over HTTP).
|
|
13
|
+
*/
|
|
14
|
+
export function registerUpvote(server, client, ctx) {
|
|
15
|
+
registerBlurtTool(server, "blurt-upvote", {
|
|
16
|
+
title: "Upvote a Blurt post or comment",
|
|
17
|
+
description: "WRITE operation (signs a `vote` with the local posting key): upvote a Blurt post or comment as " +
|
|
18
|
+
"the configured account. Use this when the user asks to upvote/like a specific post. Identify the " +
|
|
19
|
+
"target by author + permlink. Parameters: author, permlink, weight (vote strength in percent, " +
|
|
20
|
+
"1-100, default 100), dry_run (default follows BLURT_DRY_RUN_DEFAULT; false when unset — when true, previews the vote WITHOUT broadcasting). " +
|
|
21
|
+
"Only the configured account votes. Available only on the local stdio server; rate-limited.",
|
|
22
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
23
|
+
inputSchema: {
|
|
24
|
+
author: z.string().min(1).describe("Author of the post/comment to upvote"),
|
|
25
|
+
permlink: z.string().min(1).describe("Permlink of the post/comment to upvote"),
|
|
26
|
+
weight: z.number().int().min(1).max(100).default(100).describe("Vote strength in percent (1-100)"),
|
|
27
|
+
dry_run: z.boolean().default(dryRunDefault()).describe("Preview only, without broadcasting"),
|
|
28
|
+
},
|
|
29
|
+
}, async ({ author, permlink, weight, dry_run }) => {
|
|
30
|
+
logger.debug(`blurt-upvote: ${author}/${permlink} @ ${weight}% (dry_run=${dry_run})`);
|
|
31
|
+
try {
|
|
32
|
+
if (!dry_run && !withinCap("upvote", CAP_MAX, CAP_WINDOW_MS)) {
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: "text", text: `Rate limit reached for upvote (max ${CAP_MAX}/hour).` }],
|
|
35
|
+
isError: true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const result = await upvote(client, ctx, { author, permlink, weight, dryRun: dry_run });
|
|
39
|
+
if (result.voted)
|
|
40
|
+
logger.info(`Upvoted ${author}/${permlink} @ ${weight}% (tx ${result.tx_id}).`);
|
|
41
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
logger.error(`[blurt-upvote] Error: ${e.message}`);
|
|
45
|
+
return { content: [{ type: "text", text: `Error upvoting ${author}/${permlink}` }], isError: true };
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Parsing/mapping helpers for Blurt resource URIs (blurt://…) and shorthand ids.
|
|
2
|
+
// Extracted from the fetch tool so they can be unit-tested without any network access.
|
|
3
|
+
export function parseBlurtUri(raw) {
|
|
4
|
+
const u = new URL(raw);
|
|
5
|
+
if (u.protocol !== "blurt:")
|
|
6
|
+
throw new Error(`Unsupported URI scheme: ${u.protocol}`);
|
|
7
|
+
const kind = u.hostname;
|
|
8
|
+
const parts = u.pathname.split("/").filter(Boolean);
|
|
9
|
+
const limitParam = u.searchParams.get("limit");
|
|
10
|
+
const limit = limitParam ? Number(limitParam) : undefined;
|
|
11
|
+
const opsParam = u.searchParams.get("ops");
|
|
12
|
+
const ops = opsParam ? opsParam.split(",").map(s => s.trim()).filter(Boolean) : undefined;
|
|
13
|
+
const sort = u.searchParams.get("sort") ?? undefined;
|
|
14
|
+
const observer = u.searchParams.has("observer") ? u.searchParams.get("observer") : undefined;
|
|
15
|
+
const with_comments = u.searchParams.get("with_comments") === "true";
|
|
16
|
+
if (kind === "account")
|
|
17
|
+
return { kind, username: parts[0] };
|
|
18
|
+
if (kind === "history")
|
|
19
|
+
return { kind, username: parts[0], limit, ops };
|
|
20
|
+
if (kind === "posts") {
|
|
21
|
+
// URI shape: blurt://posts/<sort>/<tag> (sort in path, fallback to ?sort=)
|
|
22
|
+
const [sortPart, tagPart] = parts;
|
|
23
|
+
return { kind, sort: sortPart ?? sort, tag: tagPart, limit };
|
|
24
|
+
}
|
|
25
|
+
if (kind === "account-posts") {
|
|
26
|
+
const [sortPart, accountPart] = parts;
|
|
27
|
+
return { kind, sort: sortPart, account: accountPart, limit, observer };
|
|
28
|
+
}
|
|
29
|
+
if (kind === "post") {
|
|
30
|
+
const [author, permlink] = parts;
|
|
31
|
+
return { kind, author, permlink, with_comments };
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`Unknown blurt resource: ${kind}`);
|
|
34
|
+
}
|
|
35
|
+
export function idToBlurtUri(id) {
|
|
36
|
+
if (id.startsWith("@")) {
|
|
37
|
+
const username = id.slice(1);
|
|
38
|
+
return `blurt://account/${encodeURIComponent(username)}`;
|
|
39
|
+
}
|
|
40
|
+
if (id.startsWith("#")) {
|
|
41
|
+
const tag = id.slice(1);
|
|
42
|
+
return `blurt://posts/trending/${encodeURIComponent(tag)}`;
|
|
43
|
+
}
|
|
44
|
+
if (id.startsWith("account-")) {
|
|
45
|
+
const username = id.slice("account-".length);
|
|
46
|
+
return `blurt://account/${encodeURIComponent(username)}`;
|
|
47
|
+
}
|
|
48
|
+
if (id.startsWith("history-")) {
|
|
49
|
+
const username = id.slice("history-".length);
|
|
50
|
+
return `blurt://history/${encodeURIComponent(username)}`;
|
|
51
|
+
}
|
|
52
|
+
if (id.startsWith("posts-")) {
|
|
53
|
+
const rest = id.slice("posts-".length); // ex: "trending-blurtography"
|
|
54
|
+
const [by, ...tagParts] = rest.split("-");
|
|
55
|
+
const tag = tagParts.join("-");
|
|
56
|
+
return `blurt://posts/${encodeURIComponent(by)}/${encodeURIComponent(tag)}`;
|
|
57
|
+
}
|
|
58
|
+
if (id.startsWith("blurt://"))
|
|
59
|
+
return id; // already a URI
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Load environment variables from a controlled location, independent of the
|
|
2
|
+
// process working directory. This lets the stdio server — which a desktop app
|
|
3
|
+
// launches from an arbitrary cwd — read OUR .env (or a file pointed to by
|
|
4
|
+
// BLURT_ENV_FILE) instead of requiring secrets in the launcher config.
|
|
5
|
+
//
|
|
6
|
+
// Resolution:
|
|
7
|
+
// - BLURT_ENV_FILE (if set) → that exact path (ideally outside the repo), else
|
|
8
|
+
// - the project root .env (next to this package), regardless of cwd.
|
|
9
|
+
//
|
|
10
|
+
// Precedence: variables already present in the real environment (e.g. injected
|
|
11
|
+
// by a launcher's `env` block) are NOT overridden by the file.
|
|
12
|
+
//
|
|
13
|
+
// Import this module FIRST, before anything that reads process.env.
|
|
14
|
+
import { config } from "dotenv";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { dirname, resolve } from "node:path";
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url)); // dist/utils (or src/utils under tsx)
|
|
18
|
+
const envPath = process.env.BLURT_ENV_FILE ?? resolve(here, "../../.env");
|
|
19
|
+
// `quiet` is essential: the stdio server uses stdout for the MCP protocol, and
|
|
20
|
+
// dotenv otherwise prints an "injecting env …" line there, which corrupts it.
|
|
21
|
+
config({ path: envPath, quiet: true });
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import process from "process";
|
|
2
|
+
const levels = {
|
|
3
|
+
debug: 0,
|
|
4
|
+
info: 1,
|
|
5
|
+
warn: 2,
|
|
6
|
+
error: 3,
|
|
7
|
+
};
|
|
8
|
+
let currentLevel = process.env.LOG_LEVEL || "info";
|
|
9
|
+
// --- Helpers ---
|
|
10
|
+
function shouldLog(level) {
|
|
11
|
+
return levels[level] >= levels[currentLevel];
|
|
12
|
+
}
|
|
13
|
+
function formatHuman(level, message) {
|
|
14
|
+
const timestamp = new Date().toISOString();
|
|
15
|
+
const color = level === "debug"
|
|
16
|
+
? "\x1b[36m" // cyan
|
|
17
|
+
: level === "info"
|
|
18
|
+
? "\x1b[32m" // green
|
|
19
|
+
: level === "warn"
|
|
20
|
+
? "\x1b[33m" // yellow
|
|
21
|
+
: "\x1b[31m"; // red
|
|
22
|
+
const reset = "\x1b[0m";
|
|
23
|
+
return `[${timestamp}] ${color}[${level.toUpperCase()}]${reset} ${message}\n`;
|
|
24
|
+
}
|
|
25
|
+
function formatJson(level, message) {
|
|
26
|
+
return (JSON.stringify({
|
|
27
|
+
ts: new Date().toISOString(),
|
|
28
|
+
level,
|
|
29
|
+
msg: message,
|
|
30
|
+
pid: process.pid,
|
|
31
|
+
}) + "\n");
|
|
32
|
+
}
|
|
33
|
+
function log(level, message) {
|
|
34
|
+
if (!shouldLog(level))
|
|
35
|
+
return;
|
|
36
|
+
const formatted = process.env.NODE_ENV === "production"
|
|
37
|
+
? formatJson(level, message)
|
|
38
|
+
: formatHuman(level, message);
|
|
39
|
+
process.stderr.write(formatted);
|
|
40
|
+
}
|
|
41
|
+
// --- API publique ---
|
|
42
|
+
export function setLogLevel(level) {
|
|
43
|
+
currentLevel = level;
|
|
44
|
+
}
|
|
45
|
+
export function debug(message) {
|
|
46
|
+
log("debug", message);
|
|
47
|
+
}
|
|
48
|
+
export function info(message) {
|
|
49
|
+
log("info", message);
|
|
50
|
+
}
|
|
51
|
+
export function warn(message) {
|
|
52
|
+
log("warn", message);
|
|
53
|
+
}
|
|
54
|
+
export function error(message) {
|
|
55
|
+
log("error", message);
|
|
56
|
+
}
|
|
57
|
+
export default {
|
|
58
|
+
debug,
|
|
59
|
+
info,
|
|
60
|
+
warn,
|
|
61
|
+
error,
|
|
62
|
+
setLogLevel,
|
|
63
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Shared BLURT price helper. Fetches the public price feed and caches it briefly
|
|
2
|
+
// so that the price/chain-status/vote-value tools don't hammer the external API.
|
|
3
|
+
const PRICE_URL = process.env.BLURT_PRICE_URL ?? "https://api.blurt.blog/price_info";
|
|
4
|
+
const TTL_MS = 60_000;
|
|
5
|
+
let cache = null;
|
|
6
|
+
export async function getBlurtPrice() {
|
|
7
|
+
if (cache && Date.now() - cache.ts < TTL_MS)
|
|
8
|
+
return cache.data;
|
|
9
|
+
const res = await fetch(PRICE_URL, { signal: AbortSignal.timeout(5000) });
|
|
10
|
+
if (!res.ok)
|
|
11
|
+
throw new Error(`price feed HTTP ${res.status}`);
|
|
12
|
+
const json = (await res.json());
|
|
13
|
+
const data = {
|
|
14
|
+
price_usd: Number(json.price_usd),
|
|
15
|
+
price_btc: Number(json.price_btc),
|
|
16
|
+
source: PRICE_URL,
|
|
17
|
+
fetched_at: new Date().toISOString(),
|
|
18
|
+
};
|
|
19
|
+
cache = { data, ts: Date.now() };
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Dynamic RPC node selection backed by @beblurt/blurt-nodes-checker.
|
|
2
|
+
//
|
|
3
|
+
// The checker continuously scores the configured Blurt RPC nodes (latency, chain
|
|
4
|
+
// lag, version, reliability) and we keep the shared dblurt client pointed at the
|
|
5
|
+
// healthy nodes, best-first — so a slow, stale, forked or offline node is avoided
|
|
6
|
+
// instead of failing a request. dblurt's own failover then covers transient blips
|
|
7
|
+
// within that healthy set.
|
|
8
|
+
import { Client } from "@beblurt/dblurt";
|
|
9
|
+
import { BlurtNodesChecker } from "@beblurt/blurt-nodes-checker";
|
|
10
|
+
import logger from "./logger.js";
|
|
11
|
+
import { BLURT_CLIENT_OPTIONS, networkOptions } from "../buildServer.js";
|
|
12
|
+
/** Default public Blurt RPC nodes. The checker prunes/ranks these at runtime. */
|
|
13
|
+
export const DEFAULT_RPC_URLS = [
|
|
14
|
+
"https://rpc.blurt.blog",
|
|
15
|
+
"https://blurt-rpc.saboin.com",
|
|
16
|
+
"https://rpc.beblurt.com",
|
|
17
|
+
"https://blurtrpc.dagobert.uk",
|
|
18
|
+
"https://rpc.drakernoise.com",
|
|
19
|
+
];
|
|
20
|
+
// Node statuses we are willing to use, in order of preference. Everything else
|
|
21
|
+
// (error, stale, forked, outdated, unknown, deactivated) is excluded.
|
|
22
|
+
const STATUS_RANK = {
|
|
23
|
+
online: 0,
|
|
24
|
+
experimental: 1,
|
|
25
|
+
degraded: 2,
|
|
26
|
+
throttled: 3,
|
|
27
|
+
};
|
|
28
|
+
function configuredUrls() {
|
|
29
|
+
const env = process.env.BLURT_RPC_URLS;
|
|
30
|
+
const urls = env ? env.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_RPC_URLS;
|
|
31
|
+
return urls.length ? urls : DEFAULT_RPC_URLS;
|
|
32
|
+
}
|
|
33
|
+
/** Keep only healthy nodes, ranked best-first (score, then status, then latency). */
|
|
34
|
+
function rankHealthyUrls(nodes) {
|
|
35
|
+
return nodes
|
|
36
|
+
.filter((n) => !n.deactivated && n.status in STATUS_RANK)
|
|
37
|
+
.sort((a, b) => b.score - a.score ||
|
|
38
|
+
STATUS_RANK[a.status] - STATUS_RANK[b.status] ||
|
|
39
|
+
(a.latency_avg ?? a.duration ?? Infinity) - (b.latency_avg ?? b.duration ?? Infinity))
|
|
40
|
+
.map((n) => n.url);
|
|
41
|
+
}
|
|
42
|
+
let client;
|
|
43
|
+
let currentUrls = [];
|
|
44
|
+
let checker;
|
|
45
|
+
let loggedNetwork = false;
|
|
46
|
+
export function rpcReadiness() {
|
|
47
|
+
const configured = configuredUrls();
|
|
48
|
+
return {
|
|
49
|
+
configured_nodes: configured.length,
|
|
50
|
+
active_nodes: currentUrls.length || configured.length,
|
|
51
|
+
node_checker_running: Boolean(checker),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Base client options merged with the optional testnet override. Mainnet (no
|
|
56
|
+
* override) returns the base options unchanged. Logs the active network once.
|
|
57
|
+
*/
|
|
58
|
+
function clientOptions() {
|
|
59
|
+
const net = networkOptions();
|
|
60
|
+
if (!loggedNetwork) {
|
|
61
|
+
loggedNetwork = true;
|
|
62
|
+
if (net.chainId || net.addressPrefix) {
|
|
63
|
+
if (!net.chainId || !net.addressPrefix) {
|
|
64
|
+
logger.warn("Partial network override: set BOTH BLURT_CHAIN_ID and BLURT_ADDRESS_PREFIX " +
|
|
65
|
+
"for a testnet (the missing one falls back to the mainnet default).");
|
|
66
|
+
}
|
|
67
|
+
logger.info(`Using custom Blurt network (chainId=${net.chainId ?? "default"}, ` +
|
|
68
|
+
`addressPrefix=${net.addressPrefix ?? "default"}).`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { ...BLURT_CLIENT_OPTIONS, ...net };
|
|
72
|
+
}
|
|
73
|
+
/** The shared dblurt client, pointed at the currently-healthy RPC nodes. */
|
|
74
|
+
export function getBlurtClient() {
|
|
75
|
+
if (!client) {
|
|
76
|
+
currentUrls = configuredUrls();
|
|
77
|
+
client = new Client(currentUrls, clientOptions());
|
|
78
|
+
}
|
|
79
|
+
return client;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A stable Client handle that always delegates to the current shared client, so
|
|
83
|
+
* a consumer that captures it once still follows the node checker's repointing.
|
|
84
|
+
* Used by the long-lived stdio server (the HTTP path calls getBlurtClient() per
|
|
85
|
+
* request and doesn't need this).
|
|
86
|
+
*/
|
|
87
|
+
export const liveBlurtClient = new Proxy({}, {
|
|
88
|
+
get(_target, prop) {
|
|
89
|
+
return getBlurtClient()[prop];
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
/**
|
|
93
|
+
* Start the background node checker. It periodically re-scores the configured
|
|
94
|
+
* nodes and repoints the shared client at the healthy ones (best-first) whenever
|
|
95
|
+
* the ranking changes. Call once, from the server entrypoint (not from tests).
|
|
96
|
+
*/
|
|
97
|
+
export function startNodeChecker() {
|
|
98
|
+
if (checker)
|
|
99
|
+
return;
|
|
100
|
+
const urls = configuredUrls();
|
|
101
|
+
getBlurtClient(); // ensure a client exists immediately, before the first check
|
|
102
|
+
if (urls.length < 2) {
|
|
103
|
+
logger.info("Only one RPC node configured — skipping the node checker.");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
checker = new BlurtNodesChecker(urls, { timeout: 5000, interval: 120000 });
|
|
107
|
+
checker.message.subscribe((nodes) => {
|
|
108
|
+
const ranked = rankHealthyUrls(nodes);
|
|
109
|
+
if (ranked.length === 0) {
|
|
110
|
+
logger.warn("Node checker: no healthy RPC node found — keeping current client.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (ranked.join(",") !== currentUrls.join(",")) {
|
|
114
|
+
logger.info(`Node checker: RPC nodes updated -> ${ranked.join(", ")}`);
|
|
115
|
+
currentUrls = ranked;
|
|
116
|
+
client = new Client(ranked, clientOptions());
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
checker.start().catch((e) => logger.error(`Node checker failed to start: ${e.message}`));
|
|
120
|
+
logger.info(`Node checker started over ${urls.length} RPC nodes.`);
|
|
121
|
+
}
|
|
122
|
+
/** Stop the background node checker (e.g. for graceful shutdown). */
|
|
123
|
+
export function stopNodeChecker() {
|
|
124
|
+
checker?.stop();
|
|
125
|
+
checker = undefined;
|
|
126
|
+
}
|