@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.
- package/.github/workflows/ci.yml +30 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +60 -11
- package/openclaw.plugin.json +9 -0
- package/package.json +1 -1
- package/src/index.ts +222 -35
- package/src/mcp-bridge.test.ts +150 -0
- package/src/mcp-bridge.ts +12 -1
|
@@ -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"
|
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
|
|
9
|
-
- **
|
|
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
|
-
|
|
79
|
-
-
|
|
80
|
-
-
|
|
81
|
-
-
|
|
82
|
-
-
|
|
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
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
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
|
|
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,
|
|
294
|
+
const fields: Record<string, TSchema> = {};
|
|
203
295
|
|
|
204
296
|
for (const [key, prop] of Object.entries(properties)) {
|
|
205
|
-
const
|
|
206
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
399
|
-
|
|
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
|
|
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
|
-
|
|
599
|
-
|
|
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
|
};
|
package/src/mcp-bridge.test.ts
CHANGED
|
@@ -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:
|
|
147
|
+
clientInfo: { name: "openclaw-sage-plugin", version: this.clientVersion },
|
|
137
148
|
})) as { serverInfo?: { name?: string } };
|
|
138
149
|
|
|
139
150
|
this.notify("notifications/initialized", {});
|