@sage-protocol/openclaw-sage 0.1.5 → 0.1.6

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.
@@ -0,0 +1,30 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ typecheck-and-unit-test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: actions/setup-node@v4
16
+ with:
17
+ node-version: "22"
18
+
19
+ - name: Install dependencies
20
+ run: npm install
21
+
22
+ - name: TypeScript typecheck
23
+ run: npm run typecheck
24
+
25
+ - name: Run unit tests (no sage binary required)
26
+ run: node --import tsx src/mcp-bridge.test.ts
27
+ env:
28
+ # Unit tests that don't need the sage binary will pass;
29
+ # integration tests that need it will be skipped on CI
30
+ NODE_OPTIONS: "--test-only"
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.1.5"
2
+ ".": "0.1.6"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.6](https://github.com/sage-protocol/openclaw-sage/compare/openclaw-sage-v0.1.5...openclaw-sage-v0.1.6) (2026-02-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * P0-P2 fixes — version sync, schema conversion, env passthrough, status tool, error enrichment ([5e2d6f4](https://github.com/sage-protocol/openclaw-sage/commit/5e2d6f4ab468d53cf9bf36d504cde1b32ed801f3))
9
+
3
10
  ## [0.1.5](https://github.com/sage-protocol/openclaw-sage/compare/openclaw-sage-v0.1.4...openclaw-sage-v0.1.5) (2026-02-04)
4
11
 
5
12
 
package/README.md CHANGED
@@ -5,9 +5,12 @@ MCP bridge plugin that exposes all Sage Protocol tools inside OpenClaw. Spawns t
5
5
  ## What It Does
6
6
 
7
7
  - **MCP Tool Bridge** - Spawns `sage mcp start` and translates JSON-RPC tool calls into native OpenClaw tools
8
- - **Dynamic Registration** - Discovers available tools at startup and registers them with typed schemas
9
- - **RLM Capture** - Records prompt/response pairs for Sage's RLM feedback loop
8
+ - **Dynamic Registration** - Discovers 60+ tools at startup and registers them with typed schemas
9
+ - **Auto-Context Injection** - Injects Sage tool context and skill suggestions at agent start
10
+ - **Error Context** - Enriches error messages with actionable hints (wallet, auth, network, credits)
11
+ - **Injection Guard** - Optional prompt-injection scanning for fetched prompt content
10
12
  - **Crash Recovery** - Automatically restarts the MCP subprocess on unexpected exits
13
+ - **External Servers** - Loads additional MCP servers from `~/.config/sage/mcp-servers.toml`
11
14
 
12
15
  ## Install
13
16
 
@@ -21,10 +24,13 @@ The plugin auto-detects the `sage` binary from PATH. To override:
21
24
 
22
25
  ```json
23
26
  {
24
- "sageBinary": "/path/to/sage"
27
+ "sageBinary": "/path/to/sage",
28
+ "sageProfile": "testnet"
25
29
  }
26
30
  ```
27
31
 
32
+ The `sageProfile` field maps to `SAGE_PROFILE` and controls which network/wallet the CLI uses. The plugin also passes through these env vars when set: `SAGE_PROFILE`, `SAGE_PAY_TO_PIN`, `SAGE_IPFS_WORKER_URL`, `SAGE_API_URL`, `KEYSTORE_PASSWORD`.
33
+
28
34
  ### Auto-Inject / Auto-Suggest
29
35
 
30
36
  This plugin uses OpenClaw's plugin hook API to inject context at the start of each agent run (`before_agent_start`).
@@ -73,17 +79,60 @@ The internal hook exists mainly for bootstrap-file injection; the plugin is the
73
79
 
74
80
  ## What It Provides
75
81
 
76
- Once loaded, all Sage MCP tools are available in OpenClaw:
77
-
78
- - **Prompts & Libraries** - Search, list, create, and manage prompt libraries
79
- - **Skills** - Discover and activate skills from Sage Protocol, GitHub, or local sources
80
- - **Builder** - AI-powered prompt recommendations and synthesis
81
- - **Governance** - List DAOs, view proposals, check voting power
82
- - **Hub** - Start/stop additional MCP servers (memory, brave-search, github, etc.)
82
+ Once loaded, all Sage MCP tools are available in OpenClaw with a `sage_` prefix:
83
+
84
+ ### Prompts & Libraries
85
+ - `sage_search_prompts` - Hybrid keyword + semantic search
86
+ - `sage_list_prompts` - Browse prompts by source
87
+ - `sage_get_prompt` - Full prompt content
88
+ - `sage_quick_create_prompt` - Create new prompts
89
+ - `sage_list_libraries` - Local/on-chain libraries
90
+
91
+ ### Skills
92
+ - `sage_search_skills` / `sage_list_skills` - Find skills
93
+ - `sage_get_skill` - Skill details and content
94
+ - `sage_use_skill` - Activate a skill (auto-provisions MCP servers)
95
+ - `sage_sync_skills` - Sync from daemon
96
+
97
+ ### Builder
98
+ - `sage_builder_recommend` - AI-powered prompt suggestions
99
+ - `sage_builder_synthesize` - Synthesize from intent
100
+ - `sage_builder_vote` - Feedback on recommendations
101
+
102
+ ### Governance & DAOs
103
+ - `sage_list_subdaos` - List available DAOs
104
+ - `sage_list_proposals` / `sage_list_governance_proposals` - View proposals
105
+ - `sage_list_governance_votes` - Vote breakdown
106
+ - `sage_get_voting_power` - Voting power with NFT multipliers
107
+
108
+ ### Tips, Bounties & Marketplace
109
+ - `sage_list_tips` / `sage_list_tip_stats` - Tips activity and stats
110
+ - `sage_list_bounties` - Open/completed bounties
111
+ - `sage_list_bounty_library_additions` - Pending library merges
112
+
113
+ ### Chat & Social
114
+ - `sage_chat_list_rooms` / `sage_chat_send` / `sage_chat_history` - Real-time messaging
115
+
116
+ ### RLM (Recursive Language Model)
117
+ - `sage_rlm_stats` - Statistics and capture counts
118
+ - `sage_rlm_analyze_captures` - Analyze captured data
119
+ - `sage_rlm_list_patterns` - Discovered patterns
120
+
121
+ ### Memory & Knowledge Graph
122
+ - `sage_memory_create_entities` / `sage_memory_search_nodes` / `sage_memory_read_graph`
123
+
124
+ ### Hub (External MCP Servers)
125
+ - `sage_hub_list_servers` - List available MCP servers
126
+ - `sage_hub_start_server` - Start a server
127
+ - `sage_hub_stop_server` - Stop a server
128
+ - `sage_hub_status` - Check running servers
129
+
130
+ ### Plugin Meta
131
+ - `sage_status` - Bridge health, wallet, network, tool count
83
132
 
84
133
  ## Requirements
85
134
 
86
- - Sage CLI on PATH
135
+ - Sage CLI on PATH (v0.9.16+)
87
136
  - OpenClaw v0.1.0+
88
137
 
89
138
  ## Development
@@ -5,6 +5,11 @@
5
5
  "label": "Sage Binary Path",
6
6
  "placeholder": "sage",
7
7
  "help": "Path to sage CLI binary (auto-detected from PATH if empty)"
8
+ },
9
+ "sageProfile": {
10
+ "label": "Sage Profile",
11
+ "placeholder": "default",
12
+ "help": "Sage CLI profile name (e.g. testnet, mainnet). Maps to SAGE_PROFILE env var."
8
13
  }
9
14
  },
10
15
  "configSchema": {
@@ -15,6 +20,10 @@
15
20
  "type": "string",
16
21
  "description": "Path to sage binary (default: auto-detect from PATH)"
17
22
  },
23
+ "sageProfile": {
24
+ "type": "string",
25
+ "description": "Sage CLI profile name to use (default: from SAGE_PROFILE env or 'default')"
26
+ },
18
27
  "autoInjectContext": {
19
28
  "type": "boolean",
20
29
  "description": "Inject Sage tool context into the agent at start (default: true)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sage-protocol/openclaw-sage",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Sage MCP bridge plugin for OpenClaw — prompt libraries, skills, governance, and on-chain operations",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -1,32 +1,72 @@
1
- import { Type } from "@sinclair/typebox";
1
+ import { Type, type TSchema } from "@sinclair/typebox";
2
2
  import { readFileSync, existsSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { join } from "node:path";
4
+ import { join, resolve, dirname } from "node:path";
5
5
  import { createHash } from "node:crypto";
6
+ import { fileURLToPath } from "node:url";
6
7
  import TOML from "@iarna/toml";
7
8
 
8
9
  import { McpBridge, type McpToolDef } from "./mcp-bridge.js";
9
10
 
11
+ // Read version from package.json at module load time
12
+ const __dirname_compat = dirname(fileURLToPath(import.meta.url));
13
+ const PKG_VERSION: string = (() => {
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(resolve(__dirname_compat, "..", "package.json"), "utf8"));
16
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
17
+ } catch {
18
+ return "0.0.0";
19
+ }
20
+ })();
21
+
10
22
  const SAGE_CONTEXT = `## Sage MCP Tools Available
11
23
 
12
- You have access to Sage MCP tools for prompts, skills, and knowledge discovery.
24
+ You have access to Sage MCP tools for prompts, skills, governance, and on-chain operations.
13
25
 
14
26
  ### Prompt Discovery
15
27
  - \`search_prompts\` - Hybrid keyword + semantic search for prompts
16
28
  - \`list_prompts\` - Browse prompts by source (local/onchain)
17
29
  - \`get_prompt\` - Get full prompt content by key
18
30
  - \`builder_recommend\` - AI-powered prompt suggestions based on intent
31
+ - \`builder_synthesize\` - Create new prompts from a description
19
32
 
20
33
  ### Skills
21
34
  - \`search_skills\` / \`list_skills\` - Find available skills
22
35
  - \`get_skill\` - Get skill details and content
23
36
  - \`use_skill\` - Activate a skill (auto-provisions required MCP servers)
37
+ - \`sync_skills\` - Sync skills from daemon
38
+
39
+ ### Governance & DAOs
40
+ - \`list_subdaos\` - List available DAOs
41
+ - \`list_proposals\` / \`sage_list_governance_proposals\` - View proposals
42
+ - \`sage_list_governance_votes\` - View vote breakdown
43
+ - \`get_voting_power\` - Check voting power with NFT multipliers
44
+
45
+ ### Tips, Bounties & Marketplace
46
+ - \`sage_list_tips\` / \`sage_list_tip_stats\` - Tips activity and stats
47
+ - \`sage_list_bounties\` - Open/completed bounties
48
+ - \`sage_list_bounty_library_additions\` - Pending library merges
49
+
50
+ ### Chat & Social
51
+ - \`chat_list_rooms\` / \`chat_send\` / \`chat_history\` - Real-time messaging
52
+ - Social follow/unfollow (via CLI)
53
+
54
+ ### RLM (Recursive Language Model)
55
+ - \`rlm_stats\` - RLM statistics and capture counts
56
+ - \`rlm_analyze_captures\` - Analyze captured prompt/response pairs
57
+ - \`rlm_list_patterns\` - Show discovered patterns
58
+
59
+ ### Memory & Knowledge Graph
60
+ - \`memory_create_entities\` / \`memory_search_nodes\` / \`memory_read_graph\` - Knowledge graph ops
24
61
 
25
62
  ### External Tools (via Hub)
26
63
  - \`hub_list_servers\` - List available MCP servers (memory, github, brave, etc.)
27
64
  - \`hub_start_server\` - Start an MCP server to gain access to its tools
28
65
  - \`hub_status\` - Check which servers are currently running
29
66
 
67
+ ### Plugin Status
68
+ - \`sage_status\` - Check bridge health, connected network, wallet, and tool count
69
+
30
70
  ### Best Practices
31
71
  1. **Search before implementing** - Use \`search_prompts\` or \`builder_recommend\` to find existing solutions
32
72
  2. **Use skills for complex tasks** - Skills bundle prompts + MCP servers for specific workflows
@@ -185,6 +225,58 @@ type CustomServerConfig = {
185
225
  env?: Record<string, string>;
186
226
  };
187
227
 
228
+ /**
229
+ * Convert a single MCP JSON Schema property into a TypeBox type.
230
+ * Handles nested objects, typed arrays, and enums.
231
+ */
232
+ function jsonSchemaToTypebox(prop: Record<string, unknown>): TSchema {
233
+ const desc = typeof prop.description === "string" ? prop.description : undefined;
234
+ const opts: Record<string, unknown> = {};
235
+ if (desc) opts.description = desc;
236
+
237
+ // Enum support: string enums become Type.Union of Type.Literal
238
+ if (Array.isArray(prop.enum) && prop.enum.length > 0) {
239
+ const literals = prop.enum
240
+ .filter((v): v is string | number | boolean => ["string", "number", "boolean"].includes(typeof v))
241
+ .map((v) => Type.Literal(v));
242
+ if (literals.length > 0) {
243
+ return literals.length === 1 ? literals[0] : Type.Union(literals, opts);
244
+ }
245
+ }
246
+
247
+ switch (prop.type) {
248
+ case "number":
249
+ case "integer":
250
+ return Type.Number(opts);
251
+ case "boolean":
252
+ return Type.Boolean(opts);
253
+ case "array": {
254
+ // Typed array items
255
+ const items = prop.items as Record<string, unknown> | undefined;
256
+ const itemType = items && typeof items === "object" ? jsonSchemaToTypebox(items) : Type.Unknown();
257
+ return Type.Array(itemType, opts);
258
+ }
259
+ case "object": {
260
+ // Nested object with known properties
261
+ const nested = prop.properties as Record<string, Record<string, unknown>> | undefined;
262
+ if (nested && typeof nested === "object" && Object.keys(nested).length > 0) {
263
+ const nestedRequired = new Set(
264
+ Array.isArray(prop.required) ? (prop.required as string[]) : [],
265
+ );
266
+ const nestedFields: Record<string, TSchema> = {};
267
+ for (const [k, v] of Object.entries(nested)) {
268
+ const field = jsonSchemaToTypebox(v);
269
+ nestedFields[k] = nestedRequired.has(k) ? field : Type.Optional(field);
270
+ }
271
+ return Type.Object(nestedFields, { ...opts, additionalProperties: true });
272
+ }
273
+ return Type.Record(Type.String(), Type.Unknown(), opts);
274
+ }
275
+ default:
276
+ return Type.String(opts);
277
+ }
278
+ }
279
+
188
280
  /**
189
281
  * Convert an MCP JSON Schema inputSchema into a TypeBox object schema
190
282
  * that OpenClaw's tool system accepts.
@@ -199,35 +291,14 @@ function mcpSchemaToTypebox(inputSchema?: Record<string, unknown>) {
199
291
  Array.isArray(inputSchema.required) ? (inputSchema.required as string[]) : [],
200
292
  );
201
293
 
202
- const fields: Record<string, unknown> = {};
294
+ const fields: Record<string, TSchema> = {};
203
295
 
204
296
  for (const [key, prop] of Object.entries(properties)) {
205
- const desc = typeof prop.description === "string" ? prop.description : undefined;
206
- const opts = desc ? { description: desc } : {};
207
-
208
- let field: unknown;
209
- switch (prop.type) {
210
- case "number":
211
- case "integer":
212
- field = Type.Number(opts);
213
- break;
214
- case "boolean":
215
- field = Type.Boolean(opts);
216
- break;
217
- case "array":
218
- field = Type.Array(Type.Unknown(), opts);
219
- break;
220
- case "object":
221
- field = Type.Record(Type.String(), Type.Unknown(), opts);
222
- break;
223
- default:
224
- field = Type.String(opts);
225
- }
226
-
227
- fields[key] = required.has(key) ? field : Type.Optional(field as any);
297
+ const field = jsonSchemaToTypebox(prop);
298
+ fields[key] = required.has(key) ? field : Type.Optional(field);
228
299
  }
229
300
 
230
- return Type.Object(fields as any, { additionalProperties: true });
301
+ return Type.Object(fields, { additionalProperties: true });
231
302
  }
232
303
 
233
304
  function toToolResult(mcpResult: unknown) {
@@ -329,7 +400,7 @@ const externalBridges: Map<string, McpBridge> = new Map();
329
400
  const plugin = {
330
401
  id: "openclaw-sage",
331
402
  name: "Sage Protocol",
332
- version: "0.2.0",
403
+ version: PKG_VERSION,
333
404
  description:
334
405
  "Sage MCP tools for prompt libraries, skills, governance, and on-chain operations (including external servers)",
335
406
 
@@ -338,6 +409,9 @@ const plugin = {
338
409
  const sageBinary = typeof pluginCfg.sageBinary === "string" && pluginCfg.sageBinary.trim()
339
410
  ? pluginCfg.sageBinary.trim()
340
411
  : "sage";
412
+ const sageProfile = typeof pluginCfg.sageProfile === "string" && pluginCfg.sageProfile.trim()
413
+ ? pluginCfg.sageProfile.trim()
414
+ : undefined;
341
415
 
342
416
  const autoInject = pluginCfg.autoInjectContext !== false;
343
417
  const autoSuggest = pluginCfg.autoSuggestSkills !== false;
@@ -395,13 +469,29 @@ const plugin = {
395
469
  }
396
470
  };
397
471
 
398
- // Main sage MCP bridge - pass HOME to ensure auth state is found
399
- sageBridge = new McpBridge(sageBinary, ["mcp", "start"], {
472
+ // Build env for sage subprocess pass through auth/wallet state and profile config
473
+ const sageEnv: Record<string, string> = {
400
474
  HOME: homedir(),
401
475
  PATH: process.env.PATH || "",
402
476
  USER: process.env.USER || "",
403
477
  XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
404
478
  XDG_DATA_HOME: process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"),
479
+ };
480
+ // Pass through Sage-specific env vars when set
481
+ const passthroughVars = [
482
+ "SAGE_PROFILE", "SAGE_PAY_TO_PIN", "SAGE_IPFS_WORKER_URL",
483
+ "SAGE_IPFS_UPLOAD_TOKEN", "SAGE_API_URL", "SAGE_HOME",
484
+ "KEYSTORE_PASSWORD", "SAGE_PROMPT_GUARD_API_KEY",
485
+ ];
486
+ for (const key of passthroughVars) {
487
+ if (process.env[key]) sageEnv[key] = process.env[key]!;
488
+ }
489
+ // Config-level profile override takes precedence
490
+ if (sageProfile) sageEnv.SAGE_PROFILE = sageProfile;
491
+
492
+ // Main sage MCP bridge
493
+ sageBridge = new McpBridge(sageBinary, ["mcp", "start"], sageEnv, {
494
+ clientVersion: PKG_VERSION,
405
495
  });
406
496
  sageBridge.on("log", (line: string) => api.logger.info(`[sage-mcp] ${line}`));
407
497
  sageBridge.on("error", (err: Error) => api.logger.error(`[sage-mcp] ${err.message}`));
@@ -426,6 +516,9 @@ const plugin = {
426
516
  scanText,
427
517
  });
428
518
  }
519
+
520
+ // Register sage_status meta-tool for bridge health reporting
521
+ registerStatusTool(api, tools.length);
429
522
  } catch (err) {
430
523
  ctx.logger.error(
431
524
  `Failed to start sage MCP bridge: ${err instanceof Error ? err.message : String(err)}`,
@@ -535,6 +628,81 @@ const plugin = {
535
628
  },
536
629
  };
537
630
 
631
+ /** Map common error patterns to actionable hints */
632
+ function enrichErrorMessage(err: Error, toolName: string): string {
633
+ const msg = err.message;
634
+
635
+ // Wallet not configured
636
+ if (/wallet|signer|no.*account|not.*connected/i.test(msg)) {
637
+ return `${msg}\n\nHint: Run \`sage wallet connect\` to configure a wallet, or set KEYSTORE_PASSWORD for automated flows.`;
638
+ }
639
+ // Auth / token issues
640
+ if (/auth|unauthorized|403|401|token.*expired|challenge/i.test(msg)) {
641
+ return `${msg}\n\nHint: Run \`sage ipfs setup\` to refresh authentication, or check SAGE_IPFS_UPLOAD_TOKEN.`;
642
+ }
643
+ // Network / RPC failures
644
+ if (/rpc|network|timeout|ECONNREFUSED|ENOTFOUND|fetch.*failed/i.test(msg)) {
645
+ return `${msg}\n\nHint: Check your network connection. Set SAGE_PROFILE to switch between testnet/mainnet.`;
646
+ }
647
+ // MCP bridge not running
648
+ if (/not running|not initialized|bridge stopped/i.test(msg)) {
649
+ return `${msg}\n\nHint: The Sage MCP bridge may have crashed. Try restarting the plugin or running \`sage mcp start\` to verify the CLI works.`;
650
+ }
651
+ // Credits
652
+ if (/credits|insufficient.*balance|IPFS.*balance/i.test(msg)) {
653
+ return `${msg}\n\nHint: Run \`sage ipfs faucet\` (testnet) or purchase credits via \`sage wallet buy\`.`;
654
+ }
655
+
656
+ return msg;
657
+ }
658
+
659
+ function registerStatusTool(api: PluginApi, sageToolCount: number) {
660
+ api.registerTool(
661
+ {
662
+ name: "sage_status",
663
+ label: "Sage: status",
664
+ description: "Check Sage plugin health: bridge connection, tool count, network profile, and wallet status",
665
+ parameters: Type.Object({}),
666
+ execute: async () => {
667
+ const bridgeReady = sageBridge?.isReady() ?? false;
668
+ const externalCount = externalBridges.size;
669
+ const externalIds = Array.from(externalBridges.keys());
670
+
671
+ // Try to get wallet + network info from sage
672
+ let walletInfo = "unknown";
673
+ let networkInfo = "unknown";
674
+ if (bridgeReady && sageBridge) {
675
+ try {
676
+ const ctx = await sageBridge.callTool("get_project_context", {});
677
+ const json = extractJsonFromMcpResult(ctx) as any;
678
+ if (json?.wallet?.address) walletInfo = json.wallet.address;
679
+ if (json?.network) networkInfo = json.network;
680
+ } catch {
681
+ // Not critical — report what we can
682
+ }
683
+ }
684
+
685
+ const status = {
686
+ pluginVersion: PKG_VERSION,
687
+ bridgeConnected: bridgeReady,
688
+ sageToolCount,
689
+ externalServerCount: externalCount,
690
+ externalServers: externalIds,
691
+ wallet: walletInfo,
692
+ network: networkInfo,
693
+ profile: process.env.SAGE_PROFILE || "default",
694
+ };
695
+
696
+ return {
697
+ content: [{ type: "text" as const, text: JSON.stringify(status, null, 2) }],
698
+ details: status,
699
+ };
700
+ },
701
+ },
702
+ { name: "sage_status", optional: true },
703
+ );
704
+ }
705
+
538
706
  function registerMcpTool(
539
707
  api: PluginApi,
540
708
  prefix: string,
@@ -549,13 +717,26 @@ function registerMcpTool(
549
717
  const name = `${prefix}_${tool.name}`;
550
718
  const schema = mcpSchemaToTypebox(tool.inputSchema);
551
719
 
720
+ // Extract category from tool annotations if available
721
+ const category = typeof tool.annotations?.category === "string"
722
+ ? tool.annotations.category
723
+ : undefined;
724
+ const label = category
725
+ ? `${prefix}: ${category} / ${tool.name}`
726
+ : `${prefix}: ${tool.name}`;
727
+
552
728
  api.registerTool(
553
729
  {
554
730
  name,
555
- label: `${prefix}: ${tool.name}`,
731
+ label,
556
732
  description: tool.description ?? `MCP tool: ${prefix}/${tool.name}`,
557
733
  parameters: schema,
558
734
  execute: async (_toolCallId: string, params: Record<string, unknown>) => {
735
+ if (!bridge.isReady()) {
736
+ return toToolResult({
737
+ error: "MCP bridge not connected. The sage subprocess may have crashed — try restarting the plugin.",
738
+ });
739
+ }
559
740
  try {
560
741
  const result = await bridge.callTool(tool.name, params);
561
742
 
@@ -595,9 +776,11 @@ function registerMcpTool(
595
776
 
596
777
  return toToolResult(result);
597
778
  } catch (err) {
598
- return toToolResult({
599
- error: err instanceof Error ? err.message : String(err),
600
- });
779
+ const enriched = enrichErrorMessage(
780
+ err instanceof Error ? err : new Error(String(err)),
781
+ tool.name,
782
+ );
783
+ return toToolResult({ error: enriched });
601
784
  }
602
785
  },
603
786
  },
@@ -608,8 +791,12 @@ function registerMcpTool(
608
791
  export default plugin;
609
792
 
610
793
  export const __test = {
794
+ PKG_VERSION,
611
795
  SAGE_CONTEXT,
612
796
  normalizePrompt,
613
797
  extractJsonFromMcpResult,
614
798
  formatSkillSuggestions,
799
+ mcpSchemaToTypebox,
800
+ jsonSchemaToTypebox,
801
+ enrichErrorMessage,
615
802
  };
@@ -1,6 +1,7 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { resolve } from "node:path";
4
+ import { readFileSync } from "node:fs";
4
5
 
5
6
  import { McpBridge } from "./mcp-bridge.js";
6
7
  import plugin from "./index.js";
@@ -14,11 +15,153 @@ function addSageDebugBinToPath() {
14
15
  return { binDir };
15
16
  }
16
17
 
18
+ // ── P0: Version consistency ──────────────────────────────────────────
19
+
20
+ test("PKG_VERSION matches package.json version", () => {
21
+ const pkgPath = resolve(new URL("..", import.meta.url).pathname, "package.json");
22
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
23
+ assert.equal(__test.PKG_VERSION, pkg.version, "PKG_VERSION should match package.json");
24
+ });
25
+
26
+ test("plugin.version matches package.json version", () => {
27
+ const pkgPath = resolve(new URL("..", import.meta.url).pathname, "package.json");
28
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
29
+ assert.equal(plugin.version, pkg.version, "plugin.version should match package.json");
30
+ });
31
+
32
+ // ── P1: Schema conversion ────────────────────────────────────────────
33
+
34
+ test("mcpSchemaToTypebox handles string properties", () => {
35
+ const schema = __test.mcpSchemaToTypebox({
36
+ type: "object",
37
+ properties: {
38
+ name: { type: "string", description: "A name" },
39
+ },
40
+ required: ["name"],
41
+ }) as any;
42
+ assert.ok(schema);
43
+ assert.equal(schema.type, "object");
44
+ assert.ok(schema.properties.name, "should have name property");
45
+ });
46
+
47
+ test("mcpSchemaToTypebox handles enum properties", () => {
48
+ const schema = __test.mcpSchemaToTypebox({
49
+ type: "object",
50
+ properties: {
51
+ vote: { type: "string", enum: ["for", "against", "abstain"], description: "Vote direction" },
52
+ },
53
+ required: ["vote"],
54
+ }) as any;
55
+ assert.ok(schema);
56
+ const voteField = schema.properties.vote;
57
+ assert.ok(voteField, "should have vote property");
58
+ // Union of literals produces anyOf
59
+ assert.ok(voteField.anyOf || voteField.const || voteField.enum,
60
+ "enum should produce union of literals or single literal");
61
+ });
62
+
63
+ test("mcpSchemaToTypebox handles typed arrays", () => {
64
+ const schema = __test.mcpSchemaToTypebox({
65
+ type: "object",
66
+ properties: {
67
+ tags: { type: "array", items: { type: "string" }, description: "Tags list" },
68
+ },
69
+ }) as any;
70
+ assert.ok(schema);
71
+ const tagsField = schema.properties.tags;
72
+ assert.ok(tagsField, "should have tags property");
73
+ });
74
+
75
+ test("mcpSchemaToTypebox handles nested objects", () => {
76
+ const schema = __test.mcpSchemaToTypebox({
77
+ type: "object",
78
+ properties: {
79
+ config: {
80
+ type: "object",
81
+ properties: {
82
+ timeout: { type: "number", description: "Timeout in ms" },
83
+ retry: { type: "boolean" },
84
+ },
85
+ required: ["timeout"],
86
+ },
87
+ },
88
+ }) as any;
89
+ assert.ok(schema);
90
+ const configField = schema.properties.config;
91
+ assert.ok(configField, "should have config property");
92
+ assert.ok(configField.properties?.timeout, "nested object should have timeout");
93
+ });
94
+
95
+ test("mcpSchemaToTypebox handles empty/missing schema gracefully", () => {
96
+ assert.ok(__test.mcpSchemaToTypebox(undefined));
97
+ assert.ok(__test.mcpSchemaToTypebox({}));
98
+ assert.ok(__test.mcpSchemaToTypebox({ type: "object" }));
99
+ });
100
+
101
+ test("jsonSchemaToTypebox handles single enum value as literal", () => {
102
+ const result = __test.jsonSchemaToTypebox({ type: "string", enum: ["only_value"] });
103
+ assert.ok(result);
104
+ assert.equal(result.const, "only_value");
105
+ });
106
+
107
+ // ── P2: Error enrichment ─────────────────────────────────────────────
108
+
109
+ test("enrichErrorMessage adds wallet hint for wallet errors", () => {
110
+ const err = new Error("No wallet connected");
111
+ const enriched = __test.enrichErrorMessage(err, "list_proposals");
112
+ assert.ok(enriched.includes("sage wallet connect"), "should suggest wallet connect");
113
+ });
114
+
115
+ test("enrichErrorMessage adds auth hint for auth errors", () => {
116
+ const err = new Error("401 Unauthorized: token expired");
117
+ const enriched = __test.enrichErrorMessage(err, "ipfs_upload");
118
+ assert.ok(enriched.includes("sage ipfs setup"), "should suggest ipfs setup");
119
+ });
120
+
121
+ test("enrichErrorMessage adds network hint for RPC errors", () => {
122
+ const err = new Error("ECONNREFUSED 127.0.0.1:8545");
123
+ const enriched = __test.enrichErrorMessage(err, "list_subdaos");
124
+ assert.ok(enriched.includes("SAGE_PROFILE"), "should mention SAGE_PROFILE");
125
+ });
126
+
127
+ test("enrichErrorMessage adds bridge hint for bridge errors", () => {
128
+ const err = new Error("MCP bridge not running");
129
+ const enriched = __test.enrichErrorMessage(err, "search_prompts");
130
+ assert.ok(enriched.includes("sage mcp start"), "should suggest mcp start");
131
+ });
132
+
133
+ test("enrichErrorMessage adds credits hint for balance errors", () => {
134
+ const err = new Error("Insufficient IPFS balance");
135
+ const enriched = __test.enrichErrorMessage(err, "ipfs_pin");
136
+ assert.ok(enriched.includes("sage ipfs faucet"), "should suggest faucet");
137
+ });
138
+
139
+ test("enrichErrorMessage passes through unknown errors", () => {
140
+ const err = new Error("Something unexpected");
141
+ const enriched = __test.enrichErrorMessage(err, "unknown_tool");
142
+ assert.equal(enriched, "Something unexpected");
143
+ });
144
+
145
+ // ── P2: SAGE_CONTEXT completeness ────────────────────────────────────
146
+
147
+ test("SAGE_CONTEXT includes all major tool categories", () => {
148
+ const ctx = __test.SAGE_CONTEXT;
149
+ assert.ok(ctx.includes("Governance & DAOs"), "should include Governance");
150
+ assert.ok(ctx.includes("Tips, Bounties"), "should include Tips/Bounties");
151
+ assert.ok(ctx.includes("Chat & Social"), "should include Chat");
152
+ assert.ok(ctx.includes("RLM"), "should include RLM");
153
+ assert.ok(ctx.includes("Memory"), "should include Memory");
154
+ assert.ok(ctx.includes("sage_status"), "should include status tool");
155
+ });
156
+
157
+ // ── Existing tests (integration — require sage binary) ───────────────
158
+
17
159
  test("McpBridge can initialize, list tools, and call a native tool", async () => {
18
160
  const sageBin = resolve(new URL("..", import.meta.url).pathname, "..", "target", "debug", "sage");
19
161
  const bridge = new McpBridge(sageBin, ["mcp", "start"]);
20
162
  await bridge.start();
21
163
  try {
164
+ assert.ok(bridge.isReady(), "bridge should be ready after start");
22
165
  const tools = await bridge.listTools();
23
166
  assert.ok(Array.isArray(tools));
24
167
  assert.ok(tools.length > 0);
@@ -30,6 +173,7 @@ test("McpBridge can initialize, list tools, and call a native tool", async () =>
30
173
  assert.ok(result && typeof result === "object");
31
174
  } finally {
32
175
  await bridge.stop();
176
+ assert.ok(!bridge.isReady(), "bridge should not be ready after stop");
33
177
  }
34
178
  });
35
179
 
@@ -72,6 +216,12 @@ test("OpenClaw plugin registers MCP tools via sage mcp start", async () => {
72
216
  "expected at least one sage_* tool",
73
217
  );
74
218
 
219
+ // sage_status meta-tool should be registered
220
+ assert.ok(
221
+ registeredTools.includes("sage_status"),
222
+ "expected sage_status meta-tool to be registered",
223
+ );
224
+
75
225
  if (svc!.stop) {
76
226
  await svc!.stop({
77
227
  config: {},
package/src/mcp-bridge.ts CHANGED
@@ -8,6 +8,8 @@ export type McpToolDef = {
8
8
  name: string;
9
9
  description?: string;
10
10
  inputSchema?: Record<string, unknown>;
11
+ /** Tool category (from Sage MCP registry) */
12
+ annotations?: Record<string, unknown>;
11
13
  };
12
14
 
13
15
  /** JSON-RPC request/response types */
@@ -42,12 +44,21 @@ export class McpBridge extends EventEmitter {
42
44
  private retries = 0;
43
45
  private stopped = false;
44
46
 
47
+ private clientVersion: string;
48
+
45
49
  constructor(
46
50
  private command: string,
47
51
  private args: string[],
48
52
  private env?: Record<string, string>,
53
+ opts?: { clientVersion?: string },
49
54
  ) {
50
55
  super();
56
+ this.clientVersion = opts?.clientVersion ?? "0.0.0";
57
+ }
58
+
59
+ /** Whether the bridge is connected and ready for requests */
60
+ isReady(): boolean {
61
+ return this.ready && this.proc !== null && !this.stopped;
51
62
  }
52
63
 
53
64
  async start(): Promise<void> {
@@ -133,7 +144,7 @@ export class McpBridge extends EventEmitter {
133
144
  const result = (await this.request("initialize", {
134
145
  protocolVersion: "2024-11-05",
135
146
  capabilities: {},
136
- clientInfo: { name: "openclaw-sage-plugin", version: "0.1.0" },
147
+ clientInfo: { name: "openclaw-sage-plugin", version: this.clientVersion },
137
148
  })) as { serverInfo?: { name?: string } };
138
149
 
139
150
  this.notify("notifications/initialized", {});