ask-refined-element-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 +235 -0
- package/dist/api.d.ts +101 -0
- package/dist/api.js +212 -0
- package/dist/api.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.js +162 -0
- package/dist/server.js.map +1 -0
- package/dist/tools.d.ts +53 -0
- package/dist/tools.js +668 -0
- package/dist/tools.js.map +1 -0
- package/dist/types.d.ts +104 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +53 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool logic for the Ask Refined Element MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Each exported function takes an {@link AskReClient} plus validated input and
|
|
5
|
+
* resolves to an MCP tool result ({ content, isError }). The logic is kept out
|
|
6
|
+
* of the transport wiring in server.ts so it can be unit-tested by driving a
|
|
7
|
+
* client backed by a mock fetch.
|
|
8
|
+
*/
|
|
9
|
+
const PREMIUM_PRICE_SATS = 100;
|
|
10
|
+
/** Reusable pointer to the free payment tooling — kept in one place. */
|
|
11
|
+
const LE_MCP = "github.com/refined-element/lightning-enable-mcp";
|
|
12
|
+
const LE_DOCS = "docs.lightningenable.com";
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Result helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
function ok(text) {
|
|
17
|
+
return { content: [{ type: "text", text }] };
|
|
18
|
+
}
|
|
19
|
+
function fail(text) {
|
|
20
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
21
|
+
}
|
|
22
|
+
/** Turns an unexpected outcome into helpful text instead of leaking a stack trace. */
|
|
23
|
+
function unexpected(outcome, action) {
|
|
24
|
+
if (outcome.kind === "network-error" || outcome.kind === "api-unavailable") {
|
|
25
|
+
return fail(outcome.message);
|
|
26
|
+
}
|
|
27
|
+
if (outcome.kind === "http-error") {
|
|
28
|
+
return fail(`The Ask Refined Element API returned HTTP ${outcome.status} while trying to ${action}${httpErrorDetail(outcome)}. This is unexpected; please try again shortly.`);
|
|
29
|
+
}
|
|
30
|
+
return fail(`Unexpected response while trying to ${action}.`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Short body excerpt for an unexpected HTTP error. HTML error pages (typical
|
|
34
|
+
* for 5xx/gateway failures on this host) are summarized with a one-line hint
|
|
35
|
+
* instead of dumping markup into the agent-facing message; plain-text/JSON
|
|
36
|
+
* bodies keep the 300-char excerpt.
|
|
37
|
+
*/
|
|
38
|
+
function httpErrorDetail(outcome) {
|
|
39
|
+
if (!outcome.text) {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
const contentType = outcome.headers.get("content-type")?.toLowerCase() ?? "";
|
|
43
|
+
const isHtml = contentType.includes("text/html") || outcome.text.trimStart().startsWith("<");
|
|
44
|
+
if (isHtml) {
|
|
45
|
+
return " — the response body was an HTML error page, not API output";
|
|
46
|
+
}
|
|
47
|
+
return ` — ${truncate(outcome.text, 300)}`;
|
|
48
|
+
}
|
|
49
|
+
function truncate(value, max) {
|
|
50
|
+
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
51
|
+
}
|
|
52
|
+
export async function searchKnowledge(client, input) {
|
|
53
|
+
const query = input.query?.trim();
|
|
54
|
+
if (!query) {
|
|
55
|
+
return fail("Please provide a non-empty `query` to search the knowledge pack.");
|
|
56
|
+
}
|
|
57
|
+
const outcome = await client.searchKnowledge(query);
|
|
58
|
+
if (outcome.kind !== "ok") {
|
|
59
|
+
return unexpected(outcome, "search the knowledge pack");
|
|
60
|
+
}
|
|
61
|
+
const body = (outcome.json ?? {});
|
|
62
|
+
let results = Array.isArray(body.results) ? body.results : [];
|
|
63
|
+
// Optional client-side narrowing (the API ranks; these just filter/cap).
|
|
64
|
+
if (input.topic) {
|
|
65
|
+
results = results.filter((r) => r.topic === input.topic);
|
|
66
|
+
}
|
|
67
|
+
if (input.audience) {
|
|
68
|
+
results = results.filter((r) => r.audience === input.audience);
|
|
69
|
+
}
|
|
70
|
+
const limit = clampLimit(input.limit);
|
|
71
|
+
const total = results.length;
|
|
72
|
+
results = results.slice(0, limit);
|
|
73
|
+
if (results.length === 0) {
|
|
74
|
+
const filterNote = input.topic || input.audience
|
|
75
|
+
? " (with the topic/audience filters applied)"
|
|
76
|
+
: "";
|
|
77
|
+
return ok(`No matches for "${query}"${filterNote}. Try broader terms, or drop the topic/audience filter. ` +
|
|
78
|
+
`The pack covers Xperience by Kentico upgrades, GEO (generative engine optimization), AI-driven development, Sentinel, and CMS architecture.`);
|
|
79
|
+
}
|
|
80
|
+
const header = `${total} match${total === 1 ? "" : "es"} for "${query}"` +
|
|
81
|
+
(total > results.length ? ` (showing the top ${results.length})` : "") +
|
|
82
|
+
":";
|
|
83
|
+
const blocks = results.map((r, i) => formatSearchHit(r, i + 1));
|
|
84
|
+
const footer = "\nHow to go deeper: read a blog article, FAQ, case study, or service page in full with " +
|
|
85
|
+
"get_article(slug). Fetch a free checklist with get_checklist(slug). " +
|
|
86
|
+
"Retrieve a [PREMIUM] item with get_paid_playbook(slug).";
|
|
87
|
+
return ok([header, "", blocks.join("\n\n"), footer].join("\n"));
|
|
88
|
+
}
|
|
89
|
+
function clampLimit(limit) {
|
|
90
|
+
if (typeof limit !== "number" || Number.isNaN(limit))
|
|
91
|
+
return 5;
|
|
92
|
+
return Math.max(1, Math.min(20, Math.trunc(limit)));
|
|
93
|
+
}
|
|
94
|
+
function formatSearchHit(r, n) {
|
|
95
|
+
const title = r.title ?? r.slug ?? "(untitled item)";
|
|
96
|
+
const badges = [];
|
|
97
|
+
if (r.type)
|
|
98
|
+
badges.push(r.type);
|
|
99
|
+
if (r.topic)
|
|
100
|
+
badges.push(`topic: ${r.topic}`);
|
|
101
|
+
if (r.audience)
|
|
102
|
+
badges.push(`audience: ${r.audience}`);
|
|
103
|
+
const lines = [];
|
|
104
|
+
lines.push(`${n}. ${title}${r.isPremium ? " [PREMIUM]" : ""}`);
|
|
105
|
+
if (badges.length)
|
|
106
|
+
lines.push(` ${badges.join(" · ")}`);
|
|
107
|
+
if (r.slug)
|
|
108
|
+
lines.push(` slug: ${r.slug}`);
|
|
109
|
+
if (r.summary)
|
|
110
|
+
lines.push(` ${r.summary}`);
|
|
111
|
+
// Only render a tool call-to-action when there is a real slug to call with —
|
|
112
|
+
// guidance pointing at get_paid_playbook(slug: "") would just burn a round-trip.
|
|
113
|
+
if (r.isPremium) {
|
|
114
|
+
const price = r.premium?.priceSats ?? PREMIUM_PRICE_SATS;
|
|
115
|
+
if (r.slug) {
|
|
116
|
+
lines.push(` → Premium (${price} sats). Full body via get_paid_playbook(slug: "${r.slug}").`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
lines.push(` → Premium (${price} sats).`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else if (isChecklist(r) && r.slug) {
|
|
123
|
+
lines.push(` → Free checklist. Full steps via get_checklist(slug: "${r.slug}").`);
|
|
124
|
+
}
|
|
125
|
+
else if (isArticle(r) && r.slug) {
|
|
126
|
+
lines.push(` → Read the full article via get_article(slug: "${r.slug}").`);
|
|
127
|
+
}
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
}
|
|
130
|
+
function isChecklist(r) {
|
|
131
|
+
return (r.type ?? "").toLowerCase().includes("checklist");
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* An "article-like" hit: a blog article, FAQ, case study, or service page whose
|
|
135
|
+
* full body is fetched via get_article(slug). Detected by type label OR by the
|
|
136
|
+
* hit carrying a `url` (a reference to the full page) instead of an inline body.
|
|
137
|
+
*/
|
|
138
|
+
function isArticle(r) {
|
|
139
|
+
const t = (r.type ?? "").toLowerCase();
|
|
140
|
+
if (t.includes("article") ||
|
|
141
|
+
t.includes("blog") ||
|
|
142
|
+
t.includes("faq") ||
|
|
143
|
+
t.includes("case") ||
|
|
144
|
+
t.includes("service")) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
return Boolean(r.url);
|
|
148
|
+
}
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Tool 2: get_checklist
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
export async function getChecklist(client, slug) {
|
|
153
|
+
const clean = slug?.trim();
|
|
154
|
+
if (!clean) {
|
|
155
|
+
return fail("Please provide a checklist `slug`, e.g. geo-readiness-checklist-basic.");
|
|
156
|
+
}
|
|
157
|
+
const outcome = await client.getChecklist(clean);
|
|
158
|
+
if (outcome.kind === "ok") {
|
|
159
|
+
// A 200 whose body is empty or not JSON must become helpful text, never a
|
|
160
|
+
// TypeError from dereferencing a null body.
|
|
161
|
+
if (outcome.json === null || typeof outcome.json !== "object") {
|
|
162
|
+
return fail(`The Ask Refined Element API returned an empty or non-JSON body (HTTP ${outcome.status}) ` +
|
|
163
|
+
`for the checklist "${clean}". This is unexpected; please try again shortly.`);
|
|
164
|
+
}
|
|
165
|
+
return ok(formatChecklist(outcome.json, clean));
|
|
166
|
+
}
|
|
167
|
+
if (outcome.kind === "http-error" && outcome.status === 402) {
|
|
168
|
+
const challenge = (outcome.json ?? {});
|
|
169
|
+
const price = challenge.priceSats ?? PREMIUM_PRICE_SATS;
|
|
170
|
+
return ok(`"${clean}" is a premium checklist (${price} sats) and is not served free here.\n\n` +
|
|
171
|
+
`Retrieve it through the L402-paid endpoint instead:\n` +
|
|
172
|
+
` get_paid_playbook(slug: "${clean}")\n\n` +
|
|
173
|
+
`That tool returns a Lightning invoice to pay, then delivers the full checklist once the payment credential verifies.`);
|
|
174
|
+
}
|
|
175
|
+
if (outcome.kind === "http-error" && outcome.status === 404) {
|
|
176
|
+
return fail(`No free checklist found with slug "${clean}". ` +
|
|
177
|
+
`Use search_refined_element_knowledge to find valid slugs (free checklists are marked as such).`);
|
|
178
|
+
}
|
|
179
|
+
return unexpected(outcome, `fetch the checklist "${clean}"`);
|
|
180
|
+
}
|
|
181
|
+
function formatChecklist(body, slug) {
|
|
182
|
+
const title = body.title ?? slug;
|
|
183
|
+
const meta = [];
|
|
184
|
+
if (body.topic)
|
|
185
|
+
meta.push(`topic: ${body.topic}`);
|
|
186
|
+
if (body.audience)
|
|
187
|
+
meta.push(`audience: ${body.audience}`);
|
|
188
|
+
const lines = [title];
|
|
189
|
+
if (meta.length)
|
|
190
|
+
lines.push(meta.join(" · "));
|
|
191
|
+
if (body.summary) {
|
|
192
|
+
lines.push("");
|
|
193
|
+
lines.push(body.summary);
|
|
194
|
+
}
|
|
195
|
+
const steps = Array.isArray(body.steps) ? body.steps : [];
|
|
196
|
+
if (steps.length) {
|
|
197
|
+
lines.push("");
|
|
198
|
+
lines.push("Steps:");
|
|
199
|
+
steps.forEach((step, i) => lines.push(`${i + 1}. ${formatStep(step)}`));
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
lines.push("");
|
|
203
|
+
lines.push("(This checklist has no steps listed.)");
|
|
204
|
+
}
|
|
205
|
+
return lines.join("\n");
|
|
206
|
+
}
|
|
207
|
+
function formatStep(step) {
|
|
208
|
+
if (typeof step === "string")
|
|
209
|
+
return step.trim();
|
|
210
|
+
const heading = step.title ?? step.action ?? step.step ?? "";
|
|
211
|
+
const detail = step.text ?? step.detail ?? step.description ?? "";
|
|
212
|
+
if (heading && detail)
|
|
213
|
+
return `${heading.trim()} — ${detail.trim()}`;
|
|
214
|
+
return (heading || detail).trim();
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Header-safe charset for L402 credential parts: base64, base64url, or hex.
|
|
218
|
+
* Anything outside this set would make `fetch` throw a header TypeError.
|
|
219
|
+
*/
|
|
220
|
+
const CREDENTIAL_CHARSET = /^[A-Za-z0-9+/=_-]+$/;
|
|
221
|
+
/**
|
|
222
|
+
* Normalizes an agent-supplied credential part: strips ALL internal whitespace
|
|
223
|
+
* and newlines (line-wrap artifacts from copying out of chat) so a visually
|
|
224
|
+
* correct macaroon/preimage still forms a valid Authorization header.
|
|
225
|
+
*/
|
|
226
|
+
function normalizeCredential(value) {
|
|
227
|
+
if (!value) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
const collapsed = value.replace(/\s+/g, "");
|
|
231
|
+
return collapsed.length > 0 ? collapsed : undefined;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Shared L402 credential resolution used by every L402-gated tool. Normalizes
|
|
235
|
+
* both parts, rejects a half-supplied pair BEFORE any network call, and
|
|
236
|
+
* validates the header-safe charset (a stray character would make fetch throw a
|
|
237
|
+
* header TypeError that must never masquerade as a network failure).
|
|
238
|
+
*
|
|
239
|
+
* Returns `{ credential }` (possibly `undefined` when neither part was given,
|
|
240
|
+
* i.e. the first, unauthenticated call) or `{ error }` — a ready-to-return
|
|
241
|
+
* failure ToolResult.
|
|
242
|
+
*/
|
|
243
|
+
function resolveL402Credential(rawMacaroon, rawPreimage, ctx) {
|
|
244
|
+
const macaroon = normalizeCredential(rawMacaroon);
|
|
245
|
+
const preimage = normalizeCredential(rawPreimage);
|
|
246
|
+
if ((macaroon && !preimage) || (!macaroon && preimage)) {
|
|
247
|
+
return {
|
|
248
|
+
error: fail("An L402 credential needs BOTH the `macaroon` (from the 402 challenge) and the payment `preimage` " +
|
|
249
|
+
`(proof you settled the invoice). ${ctx.firstCallHint}`),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (!macaroon || !preimage) {
|
|
253
|
+
return {};
|
|
254
|
+
}
|
|
255
|
+
const invalidParts = [
|
|
256
|
+
CREDENTIAL_CHARSET.test(macaroon) ? null : "macaroon",
|
|
257
|
+
CREDENTIAL_CHARSET.test(preimage) ? null : "preimage",
|
|
258
|
+
].filter((part) => part !== null);
|
|
259
|
+
if (invalidParts.length > 0) {
|
|
260
|
+
return {
|
|
261
|
+
error: fail(`Your ${invalidParts.join(" and ")} contains invalid characters — an L402 credential is ` +
|
|
262
|
+
`base64/base64url or hex only. Copy it exactly from the payment challenge (and the preimage ` +
|
|
263
|
+
`exactly as your wallet returned it), then call ${ctx.retryToolName} again.`),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return { credential: { macaroon, preimage } };
|
|
267
|
+
}
|
|
268
|
+
export async function getPaidPlaybook(client, input) {
|
|
269
|
+
const slug = input.slug?.trim();
|
|
270
|
+
if (!slug) {
|
|
271
|
+
return fail("Please provide a premium `slug`. Discover premium items with " +
|
|
272
|
+
"search_refined_element_knowledge — they are marked [PREMIUM] and include the slug to pass here.");
|
|
273
|
+
}
|
|
274
|
+
const cred = resolveL402Credential(input.macaroon, input.preimage, {
|
|
275
|
+
firstCallHint: "Call get_paid_playbook with only the slug first to obtain the challenge.",
|
|
276
|
+
retryToolName: "get_paid_playbook",
|
|
277
|
+
});
|
|
278
|
+
if (cred.error) {
|
|
279
|
+
return cred.error;
|
|
280
|
+
}
|
|
281
|
+
const hasCredential = Boolean(cred.credential);
|
|
282
|
+
const outcome = await client.getPlaybook(slug, cred.credential);
|
|
283
|
+
// Paid content delivered.
|
|
284
|
+
if (outcome.kind === "ok") {
|
|
285
|
+
// A 200 with an empty or non-JSON body must never throw — this is the paid
|
|
286
|
+
// path, and a user who just paid must get guidance, not a TypeError.
|
|
287
|
+
if (outcome.json === null || typeof outcome.json !== "object") {
|
|
288
|
+
return fail(`The Ask Refined Element API returned an empty or non-JSON body (HTTP ${outcome.status}) ` +
|
|
289
|
+
`instead of the premium item "${slug}". This is unexpected.` +
|
|
290
|
+
(hasCredential
|
|
291
|
+
? ` If you already paid, your macaroon and preimage remain valid — call get_paid_playbook ` +
|
|
292
|
+
`again with the same slug, macaroon, and preimage. Do NOT pay again.`
|
|
293
|
+
: ` Please try again shortly.`));
|
|
294
|
+
}
|
|
295
|
+
return ok(formatPlaybook(outcome.json, slug));
|
|
296
|
+
}
|
|
297
|
+
if (outcome.kind === "http-error" && outcome.status === 404) {
|
|
298
|
+
return fail(`No premium item found with slug "${slug}". ` +
|
|
299
|
+
`Use search_refined_element_knowledge to discover the current premium items — ` +
|
|
300
|
+
`they are marked [PREMIUM] and include the slug to pass here.`);
|
|
301
|
+
}
|
|
302
|
+
// 402: either the first (unauthenticated) call, or a credential that failed to verify.
|
|
303
|
+
if (outcome.kind === "http-error" && outcome.status === 402) {
|
|
304
|
+
const challenge = readChallenge(outcome);
|
|
305
|
+
const verificationFailed = hasCredential;
|
|
306
|
+
return ok(formatChallenge(slug, challenge, verificationFailed));
|
|
307
|
+
}
|
|
308
|
+
return unexpected(outcome, `fetch the premium item "${slug}"`);
|
|
309
|
+
}
|
|
310
|
+
function readChallenge(outcome) {
|
|
311
|
+
const body = (outcome.json ?? {});
|
|
312
|
+
// The invoice/macaroon can also arrive via the WWW-Authenticate header.
|
|
313
|
+
const wwwAuth = outcome.headers.get("www-authenticate") ?? "";
|
|
314
|
+
const macaroon = body.macaroon ?? extractL402Field(wwwAuth, "macaroon");
|
|
315
|
+
const invoice = body.invoice ?? extractL402Field(wwwAuth, "invoice");
|
|
316
|
+
return {
|
|
317
|
+
error: body.error,
|
|
318
|
+
priceSats: body.priceSats,
|
|
319
|
+
macaroon,
|
|
320
|
+
invoice,
|
|
321
|
+
endpoint: body.endpoint,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Pulls a field out of a `WWW-Authenticate: L402 …` header. Handles both the
|
|
326
|
+
* quoted form (macaroon="…") and the unquoted form (macaroon=…, terminated by
|
|
327
|
+
* a comma, whitespace, or the end of the header).
|
|
328
|
+
*/
|
|
329
|
+
function extractL402Field(header, field) {
|
|
330
|
+
const quoted = header.match(new RegExp(`(?:^|[\\s,])${field}\\s*=\\s*"([^"]*)"`, "i"));
|
|
331
|
+
if (quoted) {
|
|
332
|
+
return quoted[1];
|
|
333
|
+
}
|
|
334
|
+
const unquoted = header.match(new RegExp(`(?:^|[\\s,])${field}\\s*=\\s*([^",\\s]+)`, "i"));
|
|
335
|
+
return unquoted ? unquoted[1] : undefined;
|
|
336
|
+
}
|
|
337
|
+
function formatPlaybook(body, slug) {
|
|
338
|
+
const title = body.title ?? slug;
|
|
339
|
+
const meta = [];
|
|
340
|
+
if (body.topic)
|
|
341
|
+
meta.push(`topic: ${body.topic}`);
|
|
342
|
+
if (body.audience)
|
|
343
|
+
meta.push(`audience: ${body.audience}`);
|
|
344
|
+
const lines = [`${title} (premium — paid)`];
|
|
345
|
+
if (meta.length)
|
|
346
|
+
lines.push(meta.join(" · "));
|
|
347
|
+
if (body.summary) {
|
|
348
|
+
lines.push("");
|
|
349
|
+
lines.push(body.summary);
|
|
350
|
+
}
|
|
351
|
+
if (body.content) {
|
|
352
|
+
lines.push("");
|
|
353
|
+
lines.push(body.content);
|
|
354
|
+
}
|
|
355
|
+
return lines.join("\n");
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Shared L402 challenge presenter used by every L402-gated tool so the
|
|
359
|
+
* payment-instruction formatting (the "L402 CHALLENGE" block + "HOW TO PAY AND
|
|
360
|
+
* UNLOCK" steps) is identical everywhere. Callers supply the price, the
|
|
361
|
+
* invoice/macaroon, the tool-specific opening line, the retry tool name (used
|
|
362
|
+
* for the "(no invoice returned — retry X)" placeholders), and the step-3
|
|
363
|
+
* lines describing how to call back.
|
|
364
|
+
*/
|
|
365
|
+
function renderL402Challenge(params) {
|
|
366
|
+
const invoice = params.invoice ?? `(no invoice returned — retry ${params.retryToolName})`;
|
|
367
|
+
const macaroon = params.macaroon ?? `(no macaroon returned — retry ${params.retryToolName})`;
|
|
368
|
+
return [
|
|
369
|
+
params.opening,
|
|
370
|
+
"",
|
|
371
|
+
"L402 CHALLENGE",
|
|
372
|
+
` price: ${params.price} sats`,
|
|
373
|
+
` invoice: ${invoice}`,
|
|
374
|
+
` macaroon: ${macaroon}`,
|
|
375
|
+
"",
|
|
376
|
+
"HOW TO PAY AND UNLOCK",
|
|
377
|
+
` 1. Pay the Lightning invoice above. The easiest path: the free Lightning Enable MCP`,
|
|
378
|
+
` server's pay_invoice tool (${LE_MCP}). Wallet setup is via NWC or Strike —`,
|
|
379
|
+
` see ${LE_DOCS}. Any Lightning wallet that returns a payment preimage works.`,
|
|
380
|
+
` 2. Keep the payment preimage the wallet returns (proof of settlement).`,
|
|
381
|
+
...params.step3,
|
|
382
|
+
].join("\n");
|
|
383
|
+
}
|
|
384
|
+
function formatChallenge(slug, challenge, verificationFailed) {
|
|
385
|
+
const price = challenge.priceSats ?? PREMIUM_PRICE_SATS;
|
|
386
|
+
const opening = verificationFailed
|
|
387
|
+
? `Payment could not be verified for "${slug}" — the macaroon/preimage did not settle the invoice. ` +
|
|
388
|
+
`Here is a fresh L402 challenge; pay this one and retry with its credential.`
|
|
389
|
+
: `"${slug}" is premium (${price} sats), gated with L402 (HTTP 402 Payment Required). ` +
|
|
390
|
+
`Pay the Lightning invoice below, then call this tool again with the macaroon and your payment preimage.`;
|
|
391
|
+
return renderL402Challenge({
|
|
392
|
+
opening,
|
|
393
|
+
price,
|
|
394
|
+
invoice: challenge.invoice,
|
|
395
|
+
macaroon: challenge.macaroon,
|
|
396
|
+
retryToolName: "get_paid_playbook",
|
|
397
|
+
step3: [
|
|
398
|
+
` 3. Call get_paid_playbook again with the SAME slug plus:`,
|
|
399
|
+
` macaroon: the value above`,
|
|
400
|
+
` preimage: the preimage from step 2`,
|
|
401
|
+
` The tool sends "Authorization: L402 <macaroon>:<preimage>" and returns the full item.`,
|
|
402
|
+
],
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
export async function listArticles(client, input) {
|
|
406
|
+
const limit = clampArticleLimit(input.limit);
|
|
407
|
+
const outcome = await client.listArticles(limit);
|
|
408
|
+
if (outcome.kind !== "ok") {
|
|
409
|
+
return unexpected(outcome, "list blog articles");
|
|
410
|
+
}
|
|
411
|
+
if (outcome.json === null || typeof outcome.json !== "object") {
|
|
412
|
+
return fail(`The Ask Refined Element API returned an empty or non-JSON body (HTTP ${outcome.status}) ` +
|
|
413
|
+
`while listing articles. This is unexpected; please try again shortly.`);
|
|
414
|
+
}
|
|
415
|
+
const body = outcome.json;
|
|
416
|
+
const articles = Array.isArray(body.articles) ? body.articles : [];
|
|
417
|
+
if (articles.length === 0) {
|
|
418
|
+
return ok("No blog articles are published yet. Try search_refined_element_knowledge for guidance " +
|
|
419
|
+
"items (checklists, playbooks), or check back soon.");
|
|
420
|
+
}
|
|
421
|
+
const header = `${articles.length} recent article${articles.length === 1 ? "" : "s"} from the Refined Element blog:`;
|
|
422
|
+
const blocks = articles.map((a, i) => formatArticleSummary(a, i + 1));
|
|
423
|
+
const footer = "\nRead any of these in full with get_article(slug).";
|
|
424
|
+
return ok([header, "", blocks.join("\n\n"), footer].join("\n"));
|
|
425
|
+
}
|
|
426
|
+
function clampArticleLimit(limit) {
|
|
427
|
+
if (typeof limit !== "number" || Number.isNaN(limit))
|
|
428
|
+
return 10;
|
|
429
|
+
return Math.max(1, Math.min(50, Math.trunc(limit)));
|
|
430
|
+
}
|
|
431
|
+
function formatArticleSummary(a, n) {
|
|
432
|
+
const title = a.title ?? a.slug ?? "(untitled article)";
|
|
433
|
+
const lines = [`${n}. ${title}`];
|
|
434
|
+
if (a.publishedDate)
|
|
435
|
+
lines.push(` published: ${formatDate(a.publishedDate)}`);
|
|
436
|
+
if (a.summary)
|
|
437
|
+
lines.push(` ${oneLine(a.summary)}`);
|
|
438
|
+
if (a.url)
|
|
439
|
+
lines.push(` url: ${a.url}`);
|
|
440
|
+
if (a.slug)
|
|
441
|
+
lines.push(` → Full text via get_article(slug: "${a.slug}").`);
|
|
442
|
+
return lines.join("\n");
|
|
443
|
+
}
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// Tool 5: get_article
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
export async function getArticle(client, slug) {
|
|
448
|
+
const clean = slug?.trim();
|
|
449
|
+
if (!clean) {
|
|
450
|
+
return fail("Please provide an article `slug`. Discover article slugs with list_articles or " +
|
|
451
|
+
"search_refined_element_knowledge.");
|
|
452
|
+
}
|
|
453
|
+
const outcome = await client.getArticle(clean);
|
|
454
|
+
if (outcome.kind === "ok") {
|
|
455
|
+
if (outcome.json === null || typeof outcome.json !== "object") {
|
|
456
|
+
return fail(`The Ask Refined Element API returned an empty or non-JSON body (HTTP ${outcome.status}) ` +
|
|
457
|
+
`for the article "${clean}". This is unexpected; please try again shortly.`);
|
|
458
|
+
}
|
|
459
|
+
return ok(formatArticle(outcome.json, clean));
|
|
460
|
+
}
|
|
461
|
+
if (outcome.kind === "http-error" && outcome.status === 404) {
|
|
462
|
+
return fail(`No article found with slug "${clean}". ` +
|
|
463
|
+
`Use list_articles or search_refined_element_knowledge to find valid article slugs.`);
|
|
464
|
+
}
|
|
465
|
+
return unexpected(outcome, `fetch the article "${clean}"`);
|
|
466
|
+
}
|
|
467
|
+
function formatArticle(body, slug) {
|
|
468
|
+
const title = body.title ?? slug;
|
|
469
|
+
const lines = [title];
|
|
470
|
+
if (body.publishedDate)
|
|
471
|
+
lines.push(`published: ${formatDate(body.publishedDate)}`);
|
|
472
|
+
if (body.summary) {
|
|
473
|
+
lines.push("");
|
|
474
|
+
lines.push(body.summary.trim());
|
|
475
|
+
}
|
|
476
|
+
const text = body.contentHtml ? htmlToText(body.contentHtml) : "";
|
|
477
|
+
if (text) {
|
|
478
|
+
lines.push("");
|
|
479
|
+
lines.push(text);
|
|
480
|
+
}
|
|
481
|
+
if (body.url) {
|
|
482
|
+
lines.push("");
|
|
483
|
+
lines.push(`Source: ${body.url}`);
|
|
484
|
+
}
|
|
485
|
+
return lines.join("\n");
|
|
486
|
+
}
|
|
487
|
+
export async function requestConsultation(client, input) {
|
|
488
|
+
const name = input.name?.trim();
|
|
489
|
+
const email = input.email?.trim();
|
|
490
|
+
const message = input.message?.trim();
|
|
491
|
+
const company = input.company?.trim() || undefined;
|
|
492
|
+
if (!name) {
|
|
493
|
+
return fail("Please provide your `name` so Mike knows who is reaching out.");
|
|
494
|
+
}
|
|
495
|
+
if (!email) {
|
|
496
|
+
return fail("Please provide an `email` so Mike can reply.");
|
|
497
|
+
}
|
|
498
|
+
if (!message) {
|
|
499
|
+
return fail("Please provide a `message`. Mike reads these personally — include enough context " +
|
|
500
|
+
"(what you're building, your Kentico version, timeline) for him to act on it.");
|
|
501
|
+
}
|
|
502
|
+
// Client-side email shape check to save a round-trip; the API does the real
|
|
503
|
+
// validation.
|
|
504
|
+
if (!isEmailShape(email)) {
|
|
505
|
+
return fail(`"${email}" does not look like an email address — it needs an "@" with text on both sides. ` +
|
|
506
|
+
`Fix it and try again.`);
|
|
507
|
+
}
|
|
508
|
+
const body = { name, email, message, ...(company ? { company } : {}) };
|
|
509
|
+
const priority = input.priority === true;
|
|
510
|
+
const outcome = priority
|
|
511
|
+
? await client.submitPriorityContact(body)
|
|
512
|
+
: await client.submitContact(body);
|
|
513
|
+
if (outcome.kind === "ok") {
|
|
514
|
+
if (priority) {
|
|
515
|
+
return ok(`Priority noted — thanks, ${name}. Your message is flagged priority so Mike Rahel sees the urgency; ` +
|
|
516
|
+
`he reads every consultation request personally and will reply to ${email} directly.`);
|
|
517
|
+
}
|
|
518
|
+
return ok(`Thanks, ${name} — your message is in. Mike Rahel reads every consultation request personally, ` +
|
|
519
|
+
`so you'll hear back from him directly. The more context you gave, the faster he can help.\n\n` +
|
|
520
|
+
`Want Mike to see it flagged as urgent? Call request_consultation again with priority: true (still free).`);
|
|
521
|
+
}
|
|
522
|
+
const shaped = contactErrorOrNull(outcome);
|
|
523
|
+
if (shaped)
|
|
524
|
+
return shaped;
|
|
525
|
+
return unexpected(outcome, priority ? "send your priority consultation message" : "send your consultation message");
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Shared shaping for the contact endpoints' error states that both lanes handle
|
|
529
|
+
* identically: 400 validation and 429 rate-limited (plus network/api-unavailable).
|
|
530
|
+
* Returns a ready-to-return failure, or `null` when the caller should handle the
|
|
531
|
+
* outcome itself (200, 402, or an unexpected status).
|
|
532
|
+
*/
|
|
533
|
+
function contactErrorOrNull(outcome) {
|
|
534
|
+
if (outcome.kind === "network-error" || outcome.kind === "api-unavailable") {
|
|
535
|
+
return fail(outcome.message);
|
|
536
|
+
}
|
|
537
|
+
if (outcome.kind === "http-error") {
|
|
538
|
+
if (outcome.status === 400) {
|
|
539
|
+
return fail(formatValidationError(outcome));
|
|
540
|
+
}
|
|
541
|
+
if (outcome.status === 429) {
|
|
542
|
+
return fail(formatRateLimited(outcome));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
function formatValidationError(outcome) {
|
|
548
|
+
const detail = validationDetail(outcome);
|
|
549
|
+
return (`The Ask Refined Element API rejected your consultation request (HTTP 400 — validation)` +
|
|
550
|
+
(detail ? `: ${detail}` : ".") +
|
|
551
|
+
` Check your name, email, and message, then try again.`);
|
|
552
|
+
}
|
|
553
|
+
function validationDetail(outcome) {
|
|
554
|
+
const body = outcome.json;
|
|
555
|
+
if (body && typeof body === "object") {
|
|
556
|
+
const b = body;
|
|
557
|
+
if (typeof b.message === "string" && b.message)
|
|
558
|
+
return b.message;
|
|
559
|
+
if (typeof b.error === "string" && b.error)
|
|
560
|
+
return b.error;
|
|
561
|
+
if (b.errors !== undefined) {
|
|
562
|
+
return typeof b.errors === "string" ? b.errors : JSON.stringify(b.errors);
|
|
563
|
+
}
|
|
564
|
+
return "";
|
|
565
|
+
}
|
|
566
|
+
// Non-JSON body: keep a short excerpt, but never dump an HTML error page.
|
|
567
|
+
if (outcome.text) {
|
|
568
|
+
const contentType = outcome.headers.get("content-type")?.toLowerCase() ?? "";
|
|
569
|
+
const isHtml = contentType.includes("text/html") || outcome.text.trimStart().startsWith("<");
|
|
570
|
+
if (!isHtml)
|
|
571
|
+
return truncate(outcome.text, 300);
|
|
572
|
+
}
|
|
573
|
+
return "";
|
|
574
|
+
}
|
|
575
|
+
function formatRateLimited(outcome) {
|
|
576
|
+
const retryAfter = outcome.headers.get("retry-after");
|
|
577
|
+
const wait = retryAfter
|
|
578
|
+
? ` Retry after ${retryAfter} second${retryAfter === "1" ? "" : "s"}.`
|
|
579
|
+
: " Please wait a bit before retrying.";
|
|
580
|
+
return (`Too many requests — the consultation endpoint is rate-limited (HTTP 429).${wait} ` +
|
|
581
|
+
`Your message was not submitted; try again shortly.`);
|
|
582
|
+
}
|
|
583
|
+
/** Simple client-side email shape check (contains-@ + length). Real validation is the API's job. */
|
|
584
|
+
function isEmailShape(email) {
|
|
585
|
+
if (email.length < 3 || email.length > 254)
|
|
586
|
+
return false;
|
|
587
|
+
const at = email.indexOf("@");
|
|
588
|
+
// Need an "@" with at least one char before and after it, and only one "@".
|
|
589
|
+
if (at <= 0 || at === email.length - 1)
|
|
590
|
+
return false;
|
|
591
|
+
return email.indexOf("@", at + 1) === -1;
|
|
592
|
+
}
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
// Shared formatting helpers
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
/** Renders an ISO-ish date as its calendar day (YYYY-MM-DD); passes other strings through. */
|
|
597
|
+
function formatDate(value) {
|
|
598
|
+
const trimmed = value.trim();
|
|
599
|
+
const match = trimmed.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
600
|
+
return match ? match[1] : trimmed;
|
|
601
|
+
}
|
|
602
|
+
/** Collapses all internal whitespace/newlines to single spaces for one-line rendering. */
|
|
603
|
+
function oneLine(value) {
|
|
604
|
+
return value.replace(/\s+/g, " ").trim();
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Converts an HTML article body to readable plain text / lightweight Markdown:
|
|
608
|
+
* headings become `#`-prefixed lines, list items become `-` bullets, links
|
|
609
|
+
* become `text (href)`, and paragraph/`<br>` boundaries become blank lines.
|
|
610
|
+
* Everything else is stripped and common HTML entities are decoded. Anchors are
|
|
611
|
+
* resolved BEFORE headings/list items so links survive inside them.
|
|
612
|
+
*/
|
|
613
|
+
export function htmlToText(html) {
|
|
614
|
+
let s = html;
|
|
615
|
+
// Drop script/style blocks wholesale.
|
|
616
|
+
s = s.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, "");
|
|
617
|
+
// Links first, so their text+href survive inside headings and list items.
|
|
618
|
+
s = s.replace(/<a\b[^>]*?href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_m, href, inner) => {
|
|
619
|
+
const label = stripTags(inner).trim();
|
|
620
|
+
if (!label)
|
|
621
|
+
return href;
|
|
622
|
+
return href ? `${label} (${href})` : label;
|
|
623
|
+
});
|
|
624
|
+
// Headings → Markdown atx headings.
|
|
625
|
+
s = s.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_m, level, inner) => {
|
|
626
|
+
const hashes = "#".repeat(Number(level));
|
|
627
|
+
return `\n\n${hashes} ${stripTags(inner).trim()}\n\n`;
|
|
628
|
+
});
|
|
629
|
+
// List items → "- " bullets; list containers → surrounding blank lines.
|
|
630
|
+
s = s.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_m, inner) => `\n- ${stripTags(inner).trim()}`);
|
|
631
|
+
s = s.replace(/<\/(ul|ol)>/gi, "\n\n");
|
|
632
|
+
s = s.replace(/<(ul|ol)\b[^>]*>/gi, "\n");
|
|
633
|
+
// Block boundaries → blank line; <br> → newline.
|
|
634
|
+
s = s.replace(/<\/(p|div|section|article|header|footer|blockquote|tr|h[1-6])>/gi, "\n\n");
|
|
635
|
+
s = s.replace(/<br\s*\/?>/gi, "\n");
|
|
636
|
+
// Strip anything left, decode entities, tidy whitespace.
|
|
637
|
+
s = stripTags(s);
|
|
638
|
+
s = decodeEntities(s);
|
|
639
|
+
s = s.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
640
|
+
return s;
|
|
641
|
+
}
|
|
642
|
+
function stripTags(s) {
|
|
643
|
+
return s.replace(/<[^>]+>/g, "");
|
|
644
|
+
}
|
|
645
|
+
function decodeEntities(s) {
|
|
646
|
+
return s
|
|
647
|
+
.replace(/ /gi, " ")
|
|
648
|
+
.replace(/—/gi, "—")
|
|
649
|
+
.replace(/–/gi, "–")
|
|
650
|
+
.replace(/…/gi, "…")
|
|
651
|
+
.replace(/"/gi, '"')
|
|
652
|
+
.replace(/�*39;/g, "'")
|
|
653
|
+
.replace(/'/gi, "'")
|
|
654
|
+
.replace(/</gi, "<")
|
|
655
|
+
.replace(/>/gi, ">")
|
|
656
|
+
.replace(/&#x([0-9a-f]+);/gi, (_m, code) => safeCodePoint(parseInt(code, 16)))
|
|
657
|
+
.replace(/&#(\d+);/g, (_m, code) => safeCodePoint(Number(code)))
|
|
658
|
+
.replace(/&/gi, "&");
|
|
659
|
+
}
|
|
660
|
+
function safeCodePoint(code) {
|
|
661
|
+
try {
|
|
662
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : "";
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
return "";
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
//# sourceMappingURL=tools.js.map
|