@kaleidorg/mind 0.5.1 → 0.6.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/dist/autonomy/index.d.ts +21 -0
- package/dist/autonomy/index.d.ts.map +1 -0
- package/dist/autonomy/index.js +16 -0
- package/dist/autonomy/index.js.map +1 -0
- package/dist/autonomy/prompt.d.ts +21 -0
- package/dist/autonomy/prompt.d.ts.map +1 -0
- package/dist/autonomy/prompt.js +37 -0
- package/dist/autonomy/prompt.js.map +1 -0
- package/dist/autonomy/risk.d.ts +53 -0
- package/dist/autonomy/risk.d.ts.map +1 -0
- package/dist/autonomy/risk.js +74 -0
- package/dist/autonomy/risk.js.map +1 -0
- package/dist/autonomy/run-state.d.ts +39 -0
- package/dist/autonomy/run-state.d.ts.map +1 -0
- package/dist/autonomy/run-state.js +118 -0
- package/dist/autonomy/run-state.js.map +1 -0
- package/dist/autonomy/scheduler.d.ts +18 -0
- package/dist/autonomy/scheduler.d.ts.map +1 -0
- package/dist/autonomy/scheduler.js +113 -0
- package/dist/autonomy/scheduler.js.map +1 -0
- package/dist/autonomy/task-store.d.ts +44 -0
- package/dist/autonomy/task-store.d.ts.map +1 -0
- package/dist/autonomy/task-store.js +139 -0
- package/dist/autonomy/task-store.js.map +1 -0
- package/dist/autonomy/types.d.ts +164 -0
- package/dist/autonomy/types.d.ts.map +1 -0
- package/dist/autonomy/types.js +20 -0
- package/dist/autonomy/types.js.map +1 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +12 -0
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/knowledge/bitcoin-copilot.js +2 -2
- package/dist/knowledge/bitcoin-copilot.js.map +1 -1
- package/dist/qvac/index.d.ts +1 -1
- package/dist/qvac/index.d.ts.map +1 -1
- package/dist/qvac/index.js.map +1 -1
- package/dist/qvac/parse.d.ts +18 -0
- package/dist/qvac/parse.d.ts.map +1 -1
- package/dist/qvac/parse.js +1 -0
- package/dist/qvac/parse.js.map +1 -1
- package/dist/qvac/provider.d.ts +16 -0
- package/dist/qvac/provider.d.ts.map +1 -1
- package/dist/qvac/provider.js +17 -1
- package/dist/qvac/provider.js.map +1 -1
- package/dist/qvac/stream.d.ts +16 -0
- package/dist/qvac/stream.d.ts.map +1 -1
- package/dist/qvac/stream.js +21 -1
- package/dist/qvac/stream.js.map +1 -1
- package/dist/recipe/buy-asset-channel.d.ts +1 -1
- package/dist/recipe/buy-asset-channel.d.ts.map +1 -1
- package/dist/recipe/buy-asset-channel.js +4 -3
- package/dist/recipe/buy-asset-channel.js.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.d.ts +1 -1
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.js +5 -4
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +38 -0
- package/dist/recipe/runner.js.map +1 -1
- package/dist/tools/mcp.d.ts +19 -0
- package/dist/tools/mcp.d.ts.map +1 -1
- package/dist/tools/mcp.js +51 -9
- package/dist/tools/mcp.js.map +1 -1
- package/package.json +2 -1
- package/skills/channel-manager/SKILL.md +59 -0
- package/skills/dca/SKILL.md +48 -0
- package/skills/kaleido-lsps/SKILL.md +12 -12
- package/skills/kaleido-trading/SKILL.md +1 -1
- package/skills/liquidity-optimizer/SKILL.md +91 -0
- package/skills/merchant-finder/SKILL.md +1 -1
- package/skills/portfolio-manager/SKILL.md +67 -0
- package/skills/rgb-lightning-node/SKILL.md +3 -3
- package/skills/wallet-assistant/SKILL.md +1 -1
- package/src/autonomy/autonomy.test.ts +348 -0
- package/src/autonomy/index.ts +50 -0
- package/src/autonomy/prompt.ts +48 -0
- package/src/autonomy/risk.ts +139 -0
- package/src/autonomy/run-state.ts +144 -0
- package/src/autonomy/scheduler.ts +120 -0
- package/src/autonomy/task-store.ts +167 -0
- package/src/autonomy/types.ts +186 -0
- package/src/funnel.mind.test.ts +390 -0
- package/src/funnel.ts +14 -0
- package/src/index.ts +41 -0
- package/src/knowledge/bitcoin-copilot.ts +2 -2
- package/src/qvac/index.ts +1 -0
- package/src/qvac/parse.ts +20 -0
- package/src/qvac/provider.test.ts +17 -0
- package/src/qvac/provider.ts +37 -1
- package/src/qvac/stream.test.ts +25 -0
- package/src/qvac/stream.ts +38 -1
- package/src/recipe/buy-asset-channel.test.ts +5 -0
- package/src/recipe/buy-asset-channel.ts +6 -3
- package/src/recipe/kaleidoswap-atomic.test.ts +3 -3
- package/src/recipe/kaleidoswap-atomic.ts +5 -4
- package/src/recipe/recipe.test.ts +16 -0
- package/src/recipe/runner.ts +41 -0
- package/src/tools/mcp.live.test.ts +116 -0
- package/src/tools/mcp.parse.test.ts +37 -0
- package/src/tools/mcp.ts +55 -9
package/src/qvac/stream.ts
CHANGED
|
@@ -27,10 +27,31 @@ export interface StreamHandlers {
|
|
|
27
27
|
onToken?: (token: string) => void;
|
|
28
28
|
/** The model's `<think>` reasoning, streamed separately. */
|
|
29
29
|
onThinking?: (token: string) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Cap the `<think>` reasoning at this many tokens. The cap is on TOKENS, not
|
|
32
|
+
* wall-clock seconds — tok/s varies by model and hardware, so a time budget is
|
|
33
|
+
* unreliable; the SDK has no numeric reasoning budget (`reasoning_budget` is
|
|
34
|
+
* only on/off), so we count thinking tokens and stop the run once they exceed
|
|
35
|
+
* this. Omit for unlimited reasoning.
|
|
36
|
+
*/
|
|
37
|
+
maxThinkingTokens?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Fires once, the moment the thinking budget is exceeded, so the host can
|
|
40
|
+
* cancel the in-flight run (the SDK keeps generating otherwise). consumeRun
|
|
41
|
+
* stops forwarding deltas after this.
|
|
42
|
+
*/
|
|
43
|
+
onThinkingBudgetExceeded?: () => void;
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
export interface ConsumedTurn extends ParsedTurn {
|
|
33
47
|
requestId: string;
|
|
48
|
+
/** True when the run was stopped because `<think>` hit `maxThinkingTokens`. */
|
|
49
|
+
thinkingBudgetExceeded?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Rough token estimate (~4 chars/token) — same heuristic the context budget uses. */
|
|
53
|
+
function approxTokens(chars: number): number {
|
|
54
|
+
return Math.ceil(chars / 4);
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
/**
|
|
@@ -43,14 +64,30 @@ export async function consumeRun(
|
|
|
43
64
|
handlers: StreamHandlers = {},
|
|
44
65
|
): Promise<ConsumedTurn> {
|
|
45
66
|
let streamed = '';
|
|
67
|
+
let thinkingChars = 0;
|
|
68
|
+
let budgetExceeded = false;
|
|
46
69
|
for await (const event of run.events) {
|
|
47
70
|
if (event.type === 'contentDelta' && typeof event.text === 'string') {
|
|
48
71
|
streamed += event.text;
|
|
49
72
|
handlers.onToken?.(event.text);
|
|
50
73
|
} else if (event.type === 'thinkingDelta' && typeof event.text === 'string') {
|
|
51
74
|
handlers.onThinking?.(event.text);
|
|
75
|
+
if (handlers.maxThinkingTokens !== undefined && !budgetExceeded) {
|
|
76
|
+
thinkingChars += event.text.length;
|
|
77
|
+
if (approxTokens(thinkingChars) >= handlers.maxThinkingTokens) {
|
|
78
|
+
budgetExceeded = true;
|
|
79
|
+
handlers.onThinkingBudgetExceeded?.();
|
|
80
|
+
// Stop forwarding; the host cancels the run, so `final` resolves
|
|
81
|
+
// (stopReason 'cancelled') with whatever was produced so far.
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
52
85
|
}
|
|
53
86
|
}
|
|
54
87
|
const final = await run.final;
|
|
55
|
-
return {
|
|
88
|
+
return {
|
|
89
|
+
...finalToTurn(final, streamed),
|
|
90
|
+
requestId: run.requestId,
|
|
91
|
+
thinkingBudgetExceeded: budgetExceeded,
|
|
92
|
+
};
|
|
56
93
|
}
|
|
@@ -54,6 +54,11 @@ describe('extractBuyAsset (deterministic Tier-0)', () => {
|
|
|
54
54
|
it('handles comma grouping in the amount', () => {
|
|
55
55
|
expect(extractBuyAsset('buy 1,000 usdt')).toEqual({ asset: 'USDT', asset_amount: 1000 });
|
|
56
56
|
});
|
|
57
|
+
it('parses an article/filler between the verb and amount ("buy a 100 usdt channel")', () => {
|
|
58
|
+
expect(extractBuyAsset('buy a 100 usdt channel')).toEqual({ asset: 'USDT', asset_amount: 100 });
|
|
59
|
+
expect(extractBuyAsset('get a 100 usdt inbound channel')).toEqual({ asset: 'USDT', asset_amount: 100 });
|
|
60
|
+
expect(extractBuyAsset('buy and sell 100 usdt')).toBeNull(); // "and" is not filler
|
|
61
|
+
});
|
|
57
62
|
it('null for a swap (a named source asset ⇒ swap owns it)', () => {
|
|
58
63
|
expect(extractBuyAsset('buy 0.001 btc with usdt')).toBeNull();
|
|
59
64
|
expect(extractBuyAsset('swap 10 usdt for btc')).toBeNull();
|
|
@@ -48,13 +48,16 @@ const num = (s?: string): number | undefined => {
|
|
|
48
48
|
/** Thousands separators, locale-independent (deterministic for tests). */
|
|
49
49
|
const commas = (n: number): string => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
50
50
|
|
|
51
|
-
/** "buy 100 usdt" / "get me 50 xaut" / "
|
|
51
|
+
/** "buy 100 usdt" / "get me 50 xaut" / "buy a 100 usdt channel" / "purchase 10 xaut". */
|
|
52
52
|
export function extractBuyAsset(text: string): Record<string, unknown> | null {
|
|
53
53
|
const t = text.trim();
|
|
54
54
|
if (NOT_BUY.test(t) || HAS_SOURCE.test(t)) return null;
|
|
55
55
|
if (!RGB_ASSET.test(t)) return null;
|
|
56
|
-
// buy/get/want/acquire/purchase [me] <amount> <asset>
|
|
57
|
-
|
|
56
|
+
// buy/get/want/acquire/purchase [me|a|an|some|new]* <amount> <asset>
|
|
57
|
+
// Filler words (the article in "buy A 100 usdt channel") must not break extraction.
|
|
58
|
+
const m = t.match(
|
|
59
|
+
/\b(?:buy|get|acquire|want|purchase|onboard|need)\b(?:\s+(?:me|a|an|some|new)\b)*\s+([\d.,]+)\s*([a-z]+)/i,
|
|
60
|
+
);
|
|
58
61
|
if (!m) return null;
|
|
59
62
|
const asset = normAsset(m[2]);
|
|
60
63
|
const amount = num(m[1]);
|
|
@@ -44,7 +44,7 @@ function buildStubs(captured: { name: string; args: any }[]) {
|
|
|
44
44
|
]),
|
|
45
45
|
new InProcessToolSource('rln', [
|
|
46
46
|
tool('rln_get_node_info', { pubkey: '03c31dae' }),
|
|
47
|
-
tool('
|
|
47
|
+
tool('rln_atomic_taker', { ok: true }, /* spend */ true),
|
|
48
48
|
]),
|
|
49
49
|
]);
|
|
50
50
|
}
|
|
@@ -130,7 +130,7 @@ describe('kaleidoswapAtomicRecipe — full chain', () => {
|
|
|
130
130
|
'kaleidoswap_get_quote',
|
|
131
131
|
'kaleidoswap_atomic_init',
|
|
132
132
|
'rln_get_node_info',
|
|
133
|
-
'
|
|
133
|
+
'rln_atomic_taker',
|
|
134
134
|
'kaleidoswap_atomic_execute',
|
|
135
135
|
]);
|
|
136
136
|
});
|
|
@@ -157,7 +157,7 @@ describe('kaleidoswapAtomicRecipe — full chain', () => {
|
|
|
157
157
|
provider: refusingProvider, tools, onConfirm: async () => ({ approved: true }),
|
|
158
158
|
slots: { from_asset: 'USDT', to_asset: 'BTC', amount: 10, amount_side: 'from' },
|
|
159
159
|
});
|
|
160
|
-
const whitelist = captured.find((c) => c.name === '
|
|
160
|
+
const whitelist = captured.find((c) => c.name === 'rln_atomic_taker')!;
|
|
161
161
|
expect(whitelist.args).toEqual({ swapstring: 'SWAP/abc/def' });
|
|
162
162
|
const exe = captured.find((c) => c.name === 'kaleidoswap_atomic_execute')!;
|
|
163
163
|
expect(exe.args).toEqual({
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* ↓ [ONE confirmation gate — shows the real quote numbers]
|
|
13
13
|
* kaleidoswap_atomic_init ← MAKER locks the swap → swapstring, payment_hash
|
|
14
14
|
* rln_get_node_info ← NODE read pubkey (= taker_pubkey)
|
|
15
|
-
*
|
|
15
|
+
* rln_atomic_taker ← NODE whitelist the swapstring (taker accepts)
|
|
16
16
|
* kaleidoswap_atomic_execute ← MAKER settle (final)
|
|
17
17
|
*
|
|
18
18
|
* `forceModelExtract` ensures the model is always consulted for slot parsing
|
|
@@ -122,10 +122,11 @@ export const kaleidoswapAtomicRecipe: Recipe = {
|
|
|
122
122
|
as: 'node',
|
|
123
123
|
args: () => ({}),
|
|
124
124
|
},
|
|
125
|
-
// 4. NODE:
|
|
126
|
-
//
|
|
125
|
+
// 4. NODE: the taker whitelists the maker's swapstring (accept the swap).
|
|
126
|
+
// Exposed by kaleido-mcp as `rln_atomic_taker` (calls rln.whitelistSwap).
|
|
127
|
+
// Ungated — covered by the single confirm above.
|
|
127
128
|
{
|
|
128
|
-
tool: '
|
|
129
|
+
tool: 'rln_atomic_taker',
|
|
129
130
|
as: 'whitelist',
|
|
130
131
|
args: (ctx) => {
|
|
131
132
|
const init = ctx.results.init as InitResult | undefined;
|
|
@@ -72,6 +72,22 @@ describe('runRecipe — pay a contact', () => {
|
|
|
72
72
|
expect(sent).toHaveLength(0);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
it('never reports a failed wallet result as sent', async () => {
|
|
76
|
+
const tools = new ToolRegistry([new InProcessToolSource('wallet', [
|
|
77
|
+
{ name: 'resolve_contact', description: '', parameters: { type: 'object', properties: {} }, handler: async ({ name }) => ({ name, ln_address: `${name}@kaleidoswap.com` }) },
|
|
78
|
+
{ name: 'fiat_to_sats', description: '', parameters: { type: 'object', properties: {} }, handler: async ({ amount }) => ({ sats: Math.round(Number(amount) * 1000) }) },
|
|
79
|
+
{ name: 'send_payment', description: '', parameters: { type: 'object', properties: {} }, requiresConfirmation: true, handler: async () => ({ success: false, message: 'insufficient balance' }) },
|
|
80
|
+
])]);
|
|
81
|
+
const res = await runRecipe(paymentsRecipe, 'pay bob 3 eur', {
|
|
82
|
+
provider: approve,
|
|
83
|
+
tools,
|
|
84
|
+
onConfirm: async () => ({ approved: true }),
|
|
85
|
+
});
|
|
86
|
+
expect(res.status).toBe('error');
|
|
87
|
+
expect(res.text).toContain('insufficient balance');
|
|
88
|
+
expect(res.text).not.toContain('Sent');
|
|
89
|
+
});
|
|
90
|
+
|
|
75
91
|
it('falls back to ONE LLM extraction when regex misses', async () => {
|
|
76
92
|
const sent: any[] = [];
|
|
77
93
|
const tools = stubTools({ send: (a) => sent.push(a) });
|
package/src/recipe/runner.ts
CHANGED
|
@@ -29,6 +29,43 @@ export interface RunRecipeOptions {
|
|
|
29
29
|
signal?: AbortSignal;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function toolFailure(result: unknown): string | null {
|
|
33
|
+
// A plain-string result (non-JSON MCP text, or a tool that returns prose):
|
|
34
|
+
// flag obvious error text so a failed action isn't reported as success.
|
|
35
|
+
if (typeof result === 'string') {
|
|
36
|
+
const s = result.trim();
|
|
37
|
+
return /^(error|failed|failure|exception)\b\s*[:\-]?/i.test(s) ? s : null;
|
|
38
|
+
}
|
|
39
|
+
if (!result || typeof result !== 'object') return null;
|
|
40
|
+
const r = result as Record<string, unknown>;
|
|
41
|
+
if (typeof r.error === 'string' && r.error.trim()) return r.error;
|
|
42
|
+
if (r.success === false || r.ok === false) {
|
|
43
|
+
return String(r.message ?? r.reason ?? 'The wallet action failed.');
|
|
44
|
+
}
|
|
45
|
+
const status = String(r.status ?? r.state ?? '').toLowerCase();
|
|
46
|
+
if (['error', 'failed', 'failure', 'rejected'].includes(status)) {
|
|
47
|
+
return String(r.message ?? r.reason ?? `The wallet returned status "${status}".`);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function failedResult(
|
|
53
|
+
recipe: Recipe,
|
|
54
|
+
ctx: RecipeContext,
|
|
55
|
+
inferences: number,
|
|
56
|
+
message: string,
|
|
57
|
+
): RecipeResult {
|
|
58
|
+
return {
|
|
59
|
+
recipe: recipe.name,
|
|
60
|
+
slots: ctx.slots,
|
|
61
|
+
results: ctx.results,
|
|
62
|
+
text: `Couldn't complete that: ${message}`,
|
|
63
|
+
status: 'error',
|
|
64
|
+
error: message,
|
|
65
|
+
inferences,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
32
69
|
/** Extract the recipe's slots — deterministic regex first, else ONE LLM call. */
|
|
33
70
|
export async function extractSlots(
|
|
34
71
|
provider: LLMProvider,
|
|
@@ -187,6 +224,8 @@ export async function runRecipe(recipe: Recipe, text: string, opts: RunRecipeOpt
|
|
|
187
224
|
const result = await opts.tools.execute(step.tool, args);
|
|
188
225
|
ctx.results[step.as ?? step.tool] = result;
|
|
189
226
|
opts.onStep?.(step.tool, args, result);
|
|
227
|
+
const failure = toolFailure(result);
|
|
228
|
+
if (failure) return failedResult(recipe, ctx, inferences, failure);
|
|
190
229
|
}
|
|
191
230
|
|
|
192
231
|
// Final action.
|
|
@@ -195,6 +234,8 @@ export async function runRecipe(recipe: Recipe, text: string, opts: RunRecipeOpt
|
|
|
195
234
|
const finalResult = await opts.tools.execute(recipe.final.tool, finalArgs);
|
|
196
235
|
ctx.results[recipe.final.as ?? recipe.final.tool] = finalResult;
|
|
197
236
|
opts.onStep?.(recipe.final.tool, finalArgs, finalResult);
|
|
237
|
+
const failure = toolFailure(finalResult);
|
|
238
|
+
if (failure) return failedResult(recipe, ctx, inferences, failure);
|
|
198
239
|
|
|
199
240
|
const out = recipe.summary?.(ctx, finalResult) ?? 'Done.';
|
|
200
241
|
return { recipe: recipe.name, slots: ctx.slots, results: ctx.results, final: finalResult, text: out, status: 'done', inferences };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live MCP integration — regression guard for the "tool-less desktop chat" bug.
|
|
3
|
+
*
|
|
4
|
+
* The desktop agent (desktop-app/src-tauri/src/mind.rs → apps/provider
|
|
5
|
+
* connectMcpIfConfigured) wires tools EXACTLY the way this test does: spawn
|
|
6
|
+
* `node <kaleido-mcp>/dist/index.js` over stdio with RLN_NODE_URL pointing at
|
|
7
|
+
* the user's RGB-Lightning node, then listTools()/execute(). When that wiring
|
|
8
|
+
* breaks, the registry is empty, the model goes "tool-less", and it NARRATES
|
|
9
|
+
* tool calls it can never run ("Could you use the kaleidoswap_get_quote tool?")
|
|
10
|
+
* instead of returning real data — the exact 2026-06 symptom.
|
|
11
|
+
*
|
|
12
|
+
* This drives that chain end-to-end against a REAL running node and asserts the
|
|
13
|
+
* tools both EXIST (not tool-less) and EXECUTE (return live node data). A unit
|
|
14
|
+
* test can't catch this: the bug is in process/env wiring, not pure logic.
|
|
15
|
+
*
|
|
16
|
+
* Auto-skips unless (a) kaleido-mcp/dist is built and (b) an RLN node answers,
|
|
17
|
+
* so it's a no-op in CI and a real check on a dev box with a node up. Run it
|
|
18
|
+
* explicitly against a node with:
|
|
19
|
+
* RLN_NODE_URL=http://localhost:3001 pnpm --filter @kaleidorg/mind test:live
|
|
20
|
+
*/
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { dirname, resolve } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { McpToolSource } from './mcp.js';
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
// $KALEIDO_MCP_PATH override (what mind.rs sets), else the sibling repo's build.
|
|
29
|
+
const MCP_ENTRY =
|
|
30
|
+
process.env.KALEIDO_MCP_PATH ??
|
|
31
|
+
resolve(here, '../../../../../kaleido-mcp/dist/index.js');
|
|
32
|
+
const NODE_URL = (process.env.RLN_NODE_URL ?? 'http://localhost:3001').replace(/\/+$/, '');
|
|
33
|
+
|
|
34
|
+
/** Probe the RLN node directly so we can (a) gate the suite and (b) compare the
|
|
35
|
+
* MCP tool's output to ground truth pulled straight from the node. */
|
|
36
|
+
async function fetchNodePubkey(): Promise<string | null> {
|
|
37
|
+
try {
|
|
38
|
+
const r = await fetch(`${NODE_URL}/nodeinfo`, { signal: AbortSignal.timeout(4000) });
|
|
39
|
+
if (!r.ok) return null;
|
|
40
|
+
const j = (await r.json()) as { pubkey?: string };
|
|
41
|
+
return typeof j.pubkey === 'string' && j.pubkey.length > 0 ? j.pubkey : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const hasDist = existsSync(MCP_ENTRY);
|
|
48
|
+
const livePubkey = hasDist ? await fetchNodePubkey() : null;
|
|
49
|
+
const RUN = hasDist && !!livePubkey;
|
|
50
|
+
|
|
51
|
+
if (!RUN) {
|
|
52
|
+
const why = !hasDist ? `no built MCP at ${MCP_ENTRY}` : `no RLN node at ${NODE_URL}`;
|
|
53
|
+
// eslint-disable-next-line no-console
|
|
54
|
+
console.warn(`[mcp.live] skipping live MCP integration — ${why}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe.skipIf(!RUN)('MCP live integration (real RLN node)', () => {
|
|
58
|
+
let src: McpToolSource;
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
src = new McpToolSource({
|
|
62
|
+
id: 'kaleido-test',
|
|
63
|
+
transport: {
|
|
64
|
+
kind: 'stdio',
|
|
65
|
+
command: 'node',
|
|
66
|
+
args: [MCP_ENTRY],
|
|
67
|
+
// Mirror the provider: inherit env, force the node URL, allow no WDK seed
|
|
68
|
+
// (rln_*/kaleidoswap_* register regardless; only spark_*/wdk_* need it).
|
|
69
|
+
env: {
|
|
70
|
+
...process.env,
|
|
71
|
+
RLN_NODE_URL: NODE_URL,
|
|
72
|
+
WDK_SEED: process.env.WDK_SEED ?? '',
|
|
73
|
+
} as Record<string, string>,
|
|
74
|
+
},
|
|
75
|
+
timeoutMs: 30_000,
|
|
76
|
+
});
|
|
77
|
+
await src.connect();
|
|
78
|
+
}, 45_000);
|
|
79
|
+
|
|
80
|
+
afterAll(async () => {
|
|
81
|
+
await src?.close();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('exposes a non-empty tool registry (the model is NOT tool-less)', () => {
|
|
85
|
+
const tools = src.listTools();
|
|
86
|
+
expect(tools.length).toBeGreaterThan(0);
|
|
87
|
+
// The exact tools the agent narrated when it couldn't call them.
|
|
88
|
+
expect(src.has('rln_get_node_info')).toBe(true);
|
|
89
|
+
expect(src.has('rln_get_balances')).toBe(true);
|
|
90
|
+
expect(src.has('kaleidoswap_get_quote')).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('preserves the confirmation gate on known spend tools', () => {
|
|
94
|
+
const spend = src.listTools().find((tool) => tool.name === 'rln_pay_invoice');
|
|
95
|
+
if (spend) expect(spend.requiresConfirmation).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rln_get_node_info EXECUTES against the node (returns the live pubkey)', async () => {
|
|
99
|
+
const out = await src.execute('rln_get_node_info', {});
|
|
100
|
+
const text = typeof out === 'string' ? out : JSON.stringify(out);
|
|
101
|
+
// Real execution returns the node's actual identity — not a narrated promise.
|
|
102
|
+
expect(text).toContain(livePubkey!);
|
|
103
|
+
}, 30_000);
|
|
104
|
+
|
|
105
|
+
it('rln_get_balances EXECUTES against the node (returns live balance fields)', async () => {
|
|
106
|
+
const out = await src.execute('rln_get_balances', {});
|
|
107
|
+
const text = typeof out === 'string' ? out : JSON.stringify(out);
|
|
108
|
+
const parsed = JSON.parse(text) as {
|
|
109
|
+
lightning_balance_sat?: number;
|
|
110
|
+
btc_onchain?: Record<string, number>;
|
|
111
|
+
};
|
|
112
|
+
expect(parsed).toHaveProperty('lightning_balance_sat');
|
|
113
|
+
expect(typeof parsed.lightning_balance_sat).toBe('number');
|
|
114
|
+
expect(parsed).toHaveProperty('btc_onchain');
|
|
115
|
+
}, 30_000);
|
|
116
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** parseMcpResult — JSON parsing + isError handling for MCP tool results. */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { parseMcpResult } from './mcp.js';
|
|
5
|
+
|
|
6
|
+
describe('parseMcpResult', () => {
|
|
7
|
+
it('parses JSON text content into an object (so recipes thread real fields)', () => {
|
|
8
|
+
const res = { content: [{ type: 'text', text: '{"rfq_id":"abc","total_sat":1500}' }] };
|
|
9
|
+
expect(parseMcpResult(res)).toEqual({ rfq_id: 'abc', total_sat: 1500 });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('surfaces isError as an {error} object (so a failed spend is not "success")', () => {
|
|
13
|
+
const res = { isError: true, content: [{ type: 'text', text: 'insufficient funds' }] };
|
|
14
|
+
expect(parseMcpResult(res)).toEqual({ error: 'insufficient funds' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('errors with no text still produce an {error} object', () => {
|
|
18
|
+
expect(parseMcpResult({ isError: true, content: [] })).toEqual({
|
|
19
|
+
error: 'The tool reported an error.',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('passes non-JSON prose through unchanged', () => {
|
|
24
|
+
const res = { content: [{ type: 'text', text: 'Bitcoin is digital cash.' }] };
|
|
25
|
+
expect(parseMcpResult(res)).toBe('Bitcoin is digital cash.');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns the content array when there is no text block', () => {
|
|
29
|
+
const res = { content: [{ type: 'image', data: 'x' }] };
|
|
30
|
+
expect(parseMcpResult(res)).toEqual([{ type: 'image', data: 'x' }]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('joins multiple text blocks before parsing', () => {
|
|
34
|
+
const res = { content: [{ type: 'text', text: '{"a":1,' }, { type: 'text', text: '"b":2}' }] };
|
|
35
|
+
expect(parseMcpResult(res)).toEqual({ a: 1, b: 2 });
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/tools/mcp.ts
CHANGED
|
@@ -20,6 +20,18 @@
|
|
|
20
20
|
|
|
21
21
|
import type { ToolDef } from '../types.js';
|
|
22
22
|
import type { ToolSource } from './source.js';
|
|
23
|
+
import { isKaleidoswapSpendTool } from '../kaleidoswap/contract.js';
|
|
24
|
+
import { isLsps1SpendTool } from '../lsps1/contract.js';
|
|
25
|
+
import { isSpendTool } from '../wallet/contract.js';
|
|
26
|
+
|
|
27
|
+
function toolRequiresConfirmation(name: string, description: string): boolean {
|
|
28
|
+
return (
|
|
29
|
+
isSpendTool(name) ||
|
|
30
|
+
isKaleidoswapSpendTool(name) ||
|
|
31
|
+
isLsps1SpendTool(name) ||
|
|
32
|
+
/\bSPEND\b.*\bconfirm/i.test(description)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
23
35
|
|
|
24
36
|
export type McpTransport =
|
|
25
37
|
| { kind: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
|
|
@@ -30,10 +42,47 @@ export interface McpToolSourceOptions {
|
|
|
30
42
|
transport: McpTransport;
|
|
31
43
|
/** Optional allowlist — only expose these tool names if provided. */
|
|
32
44
|
allow?: string[];
|
|
45
|
+
/** Optional prefix denylist applied after discovery (for host-specific rails). */
|
|
46
|
+
denyPrefixes?: string[];
|
|
33
47
|
/** Per-call timeout (ms). Default 60_000. */
|
|
34
48
|
timeoutMs?: number;
|
|
35
49
|
}
|
|
36
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Normalize an MCP `callTool` result into a structured value.
|
|
53
|
+
*
|
|
54
|
+
* Two fixes vs. returning the raw text content:
|
|
55
|
+
* - `isError` (the MCP failure signal) becomes an `{ error }` object, so callers
|
|
56
|
+
* — the recipe runner's `toolFailure`, the agent — treat it as a FAILURE
|
|
57
|
+
* instead of a successful result. Without this the agent claimed a spend had
|
|
58
|
+
* succeeded when the wallet actually rejected it.
|
|
59
|
+
* - JSON text is PARSED, so recipes thread real fields (rfq_id, total_sat,
|
|
60
|
+
* order_id) and any failure fields (error/status) are visible. A raw string
|
|
61
|
+
* hid both — the quote's rfq_id never reached the create call, and the canned
|
|
62
|
+
* success summary fired regardless. Non-JSON text passes through unchanged;
|
|
63
|
+
* the engine re-stringifies objects when feeding the model.
|
|
64
|
+
*
|
|
65
|
+
* Exported for unit testing.
|
|
66
|
+
*/
|
|
67
|
+
export function parseMcpResult(res: unknown): unknown {
|
|
68
|
+
const r = res as { content?: Array<{ type?: string; text?: string }>; isError?: boolean } | null;
|
|
69
|
+
const text = Array.isArray(r?.content)
|
|
70
|
+
? r!.content
|
|
71
|
+
.filter((c) => c?.type === 'text')
|
|
72
|
+
.map((c) => c?.text ?? '')
|
|
73
|
+
.join('\n')
|
|
74
|
+
: '';
|
|
75
|
+
if (r?.isError) return { error: text || 'The tool reported an error.' };
|
|
76
|
+
if (text) {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(text);
|
|
79
|
+
} catch {
|
|
80
|
+
return text;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return Array.isArray(r?.content) ? r!.content : res;
|
|
84
|
+
}
|
|
85
|
+
|
|
37
86
|
export class McpToolSource implements ToolSource {
|
|
38
87
|
readonly id: string;
|
|
39
88
|
private readonly opts: McpToolSourceOptions;
|
|
@@ -71,12 +120,15 @@ export class McpToolSource implements ToolSource {
|
|
|
71
120
|
|
|
72
121
|
const listed = await this.client.listTools();
|
|
73
122
|
const allow = this.opts.allow ? new Set(this.opts.allow) : null;
|
|
123
|
+
const denied = this.opts.denyPrefixes ?? [];
|
|
74
124
|
this.tools = (listed.tools ?? [])
|
|
75
125
|
.filter((t: any) => !allow || allow.has(t.name))
|
|
126
|
+
.filter((t: any) => !denied.some((prefix) => t.name.startsWith(prefix)))
|
|
76
127
|
.map((t: any) => ({
|
|
77
128
|
name: t.name,
|
|
78
129
|
description: t.description ?? '',
|
|
79
130
|
parameters: t.inputSchema ?? { type: 'object', properties: {} },
|
|
131
|
+
requiresConfirmation: toolRequiresConfirmation(t.name, t.description ?? ''),
|
|
80
132
|
}));
|
|
81
133
|
}
|
|
82
134
|
|
|
@@ -95,15 +147,9 @@ export class McpToolSource implements ToolSource {
|
|
|
95
147
|
undefined,
|
|
96
148
|
{ timeout: this.opts.timeoutMs ?? 60_000 },
|
|
97
149
|
);
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.filter((c: any) => c.type === 'text')
|
|
102
|
-
.map((c: any) => c.text)
|
|
103
|
-
.join('\n');
|
|
104
|
-
return text || res.content;
|
|
105
|
-
}
|
|
106
|
-
return res;
|
|
150
|
+
// Parse JSON + surface isError so recipes/agent get structured results and
|
|
151
|
+
// real failures (not an opaque string that hid both). See parseMcpResult.
|
|
152
|
+
return parseMcpResult(res);
|
|
107
153
|
}
|
|
108
154
|
|
|
109
155
|
async close(): Promise<void> {
|