@sage-protocol/openclaw-sage 0.1.2 → 0.1.4

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,18 @@
1
+ name: release-please
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ permissions:
8
+ contents: write
9
+ pull-requests: write
10
+
11
+ jobs:
12
+ release-please:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: googleapis/release-please-action@v4
16
+ with:
17
+ config-file: release-please-config.json
18
+ manifest-file: .release-please-manifest.json
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.1.4"
3
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## [0.1.4](https://github.com/sage-protocol/openclaw-sage/compare/openclaw-sage-v0.1.3...openclaw-sage-v0.1.4) (2026-02-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * add support for external MCP servers from mcp-servers.toml ([d7e6283](https://github.com/sage-protocol/openclaw-sage/commit/d7e62836296fb6032e62b036b8a0900d1d384198))
9
+ * auto-inject context at agent start ([6417254](https://github.com/sage-protocol/openclaw-sage/commit/6417254168f6307d42b54880c07cb46e62832514))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * adding SOUL.md and rlm test ([2644710](https://github.com/sage-protocol/openclaw-sage/commit/26447107a4f40d675480f9d36b5bb9f058ed8e33))
15
+ * pass HOME and XDG env vars to sage subprocess for auth persistence ([653ff31](https://github.com/sage-protocol/openclaw-sage/commit/653ff3135996c1e82d916794d74fe61510a5a1fd))
16
+
17
+ ## [0.1.3](https://github.com/sage-protocol/openclaw-sage/compare/openclaw-sage-v0.1.2...openclaw-sage-v0.1.3) (2026-02-02)
18
+
19
+
20
+ ### Features
21
+
22
+ * add Sage capture hooks ([f8fab39](https://github.com/sage-protocol/openclaw-sage/commit/f8fab399860de55d1949c9358a443372f0617eb6))
23
+ * adding ci, release please and improvements ([c32f79a](https://github.com/sage-protocol/openclaw-sage/commit/c32f79a05ee9212d4e382c9131ec684c91706add))
24
+ * adding readme ([4aea6c9](https://github.com/sage-protocol/openclaw-sage/commit/4aea6c950e2dcf276ebcb492cfe70b6ac4cd138e))
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * fixing manifest naming ([28d6add](https://github.com/sage-protocol/openclaw-sage/commit/28d6add05e0b4e60b17993aaf73a23ef55ab1c94))
30
+ * fixing missing openclaw manifest ([5062ae7](https://github.com/sage-protocol/openclaw-sage/commit/5062ae789d2733a209ce2f0c63453779f73245fb))
31
+
32
+ ## Changelog
33
+
34
+ All notable changes to this package are documented here.
package/README.md CHANGED
@@ -1,6 +1,13 @@
1
- # @sage-protocol/openclaw-sage
1
+ # Sage Plugin (OpenClaw)
2
2
 
3
- Sage Protocol MCP bridge plugin for OpenClaw. Provides prompt libraries, skills, governance, and on-chain operations directly in OpenClaw sessions.
3
+ MCP bridge plugin that exposes all Sage Protocol tools inside OpenClaw. Spawns the sage MCP server as a child process and translates JSON-RPC calls into registered OpenClaw tools.
4
+
5
+ ## What It Does
6
+
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
10
+ - **Crash Recovery** - Automatically restarts the MCP subprocess on unexpected exits
4
11
 
5
12
  ## Install
6
13
 
@@ -8,11 +15,6 @@ Sage Protocol MCP bridge plugin for OpenClaw. Provides prompt libraries, skills,
8
15
  openclaw plugins install @sage-protocol/openclaw-sage
9
16
  ```
10
17
 
11
- ## Requirements
12
-
13
- - [Sage CLI](https://github.com/sage-protocol/sage-cli) installed and available on PATH
14
- - OpenClaw v0.1.0+
15
-
16
18
  ## Configuration
17
19
 
18
20
  The plugin auto-detects the `sage` binary from PATH. To override:
@@ -23,15 +25,44 @@ The plugin auto-detects the `sage` binary from PATH. To override:
23
25
  }
24
26
  ```
25
27
 
28
+ ### Auto-Inject / Auto-Suggest
29
+
30
+ This plugin uses OpenClaw's plugin hook API to inject context at the start of each agent run (`before_agent_start`).
31
+
32
+ Available config fields:
33
+
34
+ ```json
35
+ {
36
+ "autoInjectContext": true,
37
+ "autoSuggestSkills": true,
38
+ "suggestLimit": 3,
39
+ "minPromptLen": 12,
40
+ "maxPromptBytes": 16384
41
+ }
42
+ ```
43
+
44
+ ### Avoiding Double Injection
45
+
46
+ If you also enabled Sage's OpenClaw *internal hook* (installed by `sage init --openclaw`), both the hook and this plugin can inject Sage context.
47
+
48
+ - Recommended: keep the plugin injection on, and disable the internal hook injection via `SAGE_OPENCLAW_INJECT_CONTEXT=0` in your OpenClaw environment.
49
+
50
+ The internal hook exists mainly for bootstrap-file injection; the plugin is the preferred place for per-run injection and suggestions.
51
+
26
52
  ## What It Provides
27
53
 
28
- The plugin exposes Sage Protocol MCP tools inside OpenClaw:
54
+ Once loaded, all Sage MCP tools are available in OpenClaw:
55
+
56
+ - **Prompts & Libraries** - Search, list, create, and manage prompt libraries
57
+ - **Skills** - Discover and activate skills from Sage Protocol, GitHub, or local sources
58
+ - **Builder** - AI-powered prompt recommendations and synthesis
59
+ - **Governance** - List DAOs, view proposals, check voting power
60
+ - **Hub** - Start/stop additional MCP servers (memory, brave-search, github, etc.)
29
61
 
30
- - **Prompts & Libraries** — search, list, create, and manage prompt libraries
31
- - **Skills** — discover and activate skills from Sage Protocol, GitHub, or local sources
32
- - **Builder** AI-powered prompt recommendations and synthesis
33
- - **Governance** — list DAOs, view proposals, check voting power
34
- - **Hub** — start/stop additional MCP servers (memory, brave-search, github, etc.)
62
+ ## Requirements
63
+
64
+ - Sage CLI on PATH
65
+ - OpenClaw v0.1.0+
35
66
 
36
67
  ## Development
37
68
 
package/SOUL.md ADDED
@@ -0,0 +1,64 @@
1
+ # OpenClaw Agent SOUL
2
+
3
+ This file defines the default behavior for an OpenClaw agent operating in the Sage Protocol ecosystem.
4
+
5
+ ## Core Objective
6
+
7
+ Help the user get productive with Sage as fast as possible by reliably steering onboarding toward:
8
+
9
+ ```bash
10
+ sage init
11
+ sage agent quickstart
12
+ ```
13
+
14
+ ## When To Prompt For Onboarding
15
+
16
+ Prompt the user to run `sage init` and/or `sage agent quickstart` when any of the following is true:
17
+
18
+ - The user asks about setup, installation, configuration, MCP tools, skills, libraries, or OpenClaw integration.
19
+ - The session appears to be a first run (no prior mention of `sage init` / `sage agent quickstart`).
20
+ - The user hits auth/credits/wallet friction (examples: wallet not configured, SXXX tokens needed, IPFS credits missing, library sync failing).
21
+
22
+ ## How To Prompt (Non-Spammy)
23
+
24
+ - Ask once, early, and only repeat if the user is blocked by missing setup.
25
+ - Prefer one short message with a single actionable next step.
26
+ - Don't derail the task: if the user is already mid-task, suggest onboarding as a quick fix only when it unblocks progress.
27
+
28
+ ## Recommended Message Templates
29
+
30
+ If Sage is not initialized:
31
+
32
+ ```text
33
+ Run `sage init` to set up the Sage skill + MCP integration for your environment.
34
+ Then request SXXX tokens: `sage chat send global:agents "request tokens"`
35
+ Then claim IPFS credits: `sage agent quickstart`
36
+ ```
37
+
38
+ If Sage is initialized but the user needs tokens/credits:
39
+
40
+ ```text
41
+ Request SXXX tokens from the bot: `sage chat send global:agents "request tokens"`
42
+ Then claim IPFS credits: `sage agent quickstart`
43
+ ```
44
+
45
+ If the user is using OpenClaw and doesn't have MCP tools:
46
+
47
+ ```text
48
+ Install the OpenClaw MCP bridge: `openclaw plugins install @sage-protocol/openclaw-sage`.
49
+ Then run: `sage init` and `sage agent quickstart`.
50
+ ```
51
+
52
+ ## OpenClaw Skill Directory
53
+
54
+ OpenClaw loads skills from:
55
+
56
+ ```text
57
+ ~/.openclaw/workspace/skills/
58
+ ```
59
+
60
+ ## Guardrails
61
+
62
+ - Don't instruct `git push`, key export, or any destructive command unless the user explicitly asks.
63
+ - Don't ask the user to paste secrets (tokens, private keys). Use `sage wallet connect -w privy` (OAuth) or documented wallet flows.
64
+ - Prefer the unified `sage suggest ...` family for discovery, but onboarding comes first.
@@ -14,6 +14,26 @@
14
14
  "sageBinary": {
15
15
  "type": "string",
16
16
  "description": "Path to sage binary (default: auto-detect from PATH)"
17
+ },
18
+ "autoInjectContext": {
19
+ "type": "boolean",
20
+ "description": "Inject Sage tool context into the agent at start (default: true)"
21
+ },
22
+ "autoSuggestSkills": {
23
+ "type": "boolean",
24
+ "description": "Suggest relevant skills at agent start (default: true)"
25
+ },
26
+ "suggestLimit": {
27
+ "type": "number",
28
+ "description": "Max number of skill suggestions to include (default: 3)"
29
+ },
30
+ "minPromptLen": {
31
+ "type": "number",
32
+ "description": "Minimum prompt length before suggesting (default: 12)"
33
+ },
34
+ "maxPromptBytes": {
35
+ "type": "number",
36
+ "description": "Max prompt bytes forwarded to suggestion search (default: 16384)"
17
37
  }
18
38
  }
19
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sage-protocol/openclaw-sage",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",
@@ -14,11 +14,13 @@
14
14
  "test": "node --import tsx src/mcp-bridge.test.ts"
15
15
  },
16
16
  "dependencies": {
17
+ "@iarna/toml": "^2.2.5",
17
18
  "@sinclair/typebox": "^0.34.0"
18
19
  },
19
20
  "devDependencies": {
20
- "typescript": "^5.6.0",
21
- "tsx": "^4.19.0"
21
+ "@types/node": "^25.2.0",
22
+ "tsx": "^4.19.0",
23
+ "typescript": "^5.6.0"
22
24
  },
23
25
  "license": "MIT"
24
26
  }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "release-type": "node",
4
+ "include-v-in-tag": true,
5
+ "bump-minor-pre-major": true,
6
+ "bump-patch-for-minor-pre-major": true,
7
+ "packages": {
8
+ ".": {
9
+ "package-name": "@sage-protocol/openclaw-sage",
10
+ "changelog-path": "CHANGELOG.md"
11
+ }
12
+ }
13
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,37 @@
1
1
  import { Type } from "@sinclair/typebox";
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import TOML from "@iarna/toml";
2
6
 
3
7
  import { McpBridge, type McpToolDef } from "./mcp-bridge.js";
4
8
 
9
+ const SAGE_CONTEXT = `## Sage MCP Tools Available
10
+
11
+ You have access to Sage MCP tools for prompts, skills, and knowledge discovery.
12
+
13
+ ### Prompt Discovery
14
+ - \`search_prompts\` - Hybrid keyword + semantic search for prompts
15
+ - \`list_prompts\` - Browse prompts by source (local/onchain)
16
+ - \`get_prompt\` - Get full prompt content by key
17
+ - \`builder_recommend\` - AI-powered prompt suggestions based on intent
18
+
19
+ ### Skills
20
+ - \`search_skills\` / \`list_skills\` - Find available skills
21
+ - \`get_skill\` - Get skill details and content
22
+ - \`use_skill\` - Activate a skill (auto-provisions required MCP servers)
23
+
24
+ ### External Tools (via Hub)
25
+ - \`hub_list_servers\` - List available MCP servers (memory, github, brave, etc.)
26
+ - \`hub_start_server\` - Start an MCP server to gain access to its tools
27
+ - \`hub_status\` - Check which servers are currently running
28
+
29
+ ### Best Practices
30
+ 1. **Search before implementing** - Use \`search_prompts\` or \`builder_recommend\` to find existing solutions
31
+ 2. **Use skills for complex tasks** - Skills bundle prompts + MCP servers for specific workflows
32
+ 3. **Start additional servers as needed** - Use \`hub_start_server\` for memory, github, brave search, etc.
33
+ 4. **Check skill requirements** - Skills may require specific MCP servers; \`use_skill\` auto-provisions them`;
34
+
5
35
  /**
6
36
  * Minimal type stubs for OpenClaw plugin API.
7
37
  *
@@ -25,13 +55,104 @@ type PluginApi = {
25
55
  id: string;
26
56
  name: string;
27
57
  logger: PluginLogger;
58
+ pluginConfig?: Record<string, unknown>;
28
59
  registerTool: (tool: unknown, opts?: { name?: string; optional?: boolean }) => void;
29
60
  registerService: (service: {
30
61
  id: string;
31
62
  start: (ctx: PluginServiceContext) => void | Promise<void>;
32
63
  stop?: (ctx: PluginServiceContext) => void | Promise<void>;
33
64
  }) => void;
34
- on: (hook: string, handler: (...args: unknown[]) => void | Promise<void>) => void;
65
+ on: (hook: string, handler: (...args: unknown[]) => unknown | Promise<unknown>) => void;
66
+ };
67
+
68
+ function clampInt(raw: unknown, def: number, min: number, max: number): number {
69
+ const n = typeof raw === "string" && raw.trim() ? Number(raw) : Number(raw);
70
+ if (!Number.isFinite(n)) return def;
71
+ return Math.min(max, Math.max(min, Math.trunc(n)));
72
+ }
73
+
74
+ function truncateUtf8(s: string, maxBytes: number): string {
75
+ if (Buffer.byteLength(s, "utf8") <= maxBytes) return s;
76
+
77
+ let lo = 0;
78
+ let hi = s.length;
79
+ while (lo < hi) {
80
+ const mid = Math.ceil((lo + hi) / 2);
81
+ if (Buffer.byteLength(s.slice(0, mid), "utf8") <= maxBytes) lo = mid;
82
+ else hi = mid - 1;
83
+ }
84
+ return s.slice(0, lo);
85
+ }
86
+
87
+ function normalizePrompt(prompt: string, opts?: { maxBytes?: number }): string {
88
+ const trimmed = prompt.trim();
89
+ if (!trimmed) return "";
90
+ const maxBytes = clampInt(opts?.maxBytes, 16_384, 512, 65_536);
91
+ return truncateUtf8(trimmed, maxBytes);
92
+ }
93
+
94
+ function extractJsonFromMcpResult(result: unknown): unknown {
95
+ const anyResult = result as any;
96
+ if (!anyResult || typeof anyResult !== "object") return undefined;
97
+
98
+ // Sage MCP tools typically return { content: [{ type: 'text', text: '...json...' }], isError?: bool }
99
+ const text =
100
+ Array.isArray(anyResult.content) && anyResult.content.length
101
+ ? anyResult.content
102
+ .map((c: any) => (c && typeof c.text === "string" ? c.text : ""))
103
+ .filter(Boolean)
104
+ .join("\n")
105
+ : undefined;
106
+
107
+ if (!text) return undefined;
108
+ try {
109
+ return JSON.parse(text);
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ type SkillSearchResult = {
116
+ key?: string;
117
+ name?: string;
118
+ description?: string;
119
+ source?: string;
120
+ library?: string;
121
+ mcpServers?: string[];
122
+ };
123
+
124
+ function formatSkillSuggestions(results: SkillSearchResult[], limit: number): string {
125
+ const items = results
126
+ .filter((r) => r && typeof r.key === "string" && r.key.trim())
127
+ .slice(0, limit);
128
+ if (!items.length) return "";
129
+
130
+ const lines: string[] = [];
131
+ lines.push("## Suggested Skills");
132
+ lines.push("");
133
+ for (const r of items) {
134
+ const key = r.key!.trim();
135
+ const desc = typeof r.description === "string" ? r.description.trim() : "";
136
+ const origin = typeof r.library === "string" && r.library.trim() ? ` (from ${r.library.trim()})` : "";
137
+ const servers = Array.isArray(r.mcpServers) && r.mcpServers.length ? ` — requires: ${r.mcpServers.join(", ")}` : "";
138
+ lines.push(`- \`use_skill\` \`${key}\`${origin}${desc ? `: ${desc}` : ""}${servers}`);
139
+ }
140
+ return lines.join("\n");
141
+ }
142
+
143
+ /** Custom server configuration from mcp-servers.toml */
144
+ type CustomServerConfig = {
145
+ id: string;
146
+ name: string;
147
+ description?: string;
148
+ enabled: boolean;
149
+ source: {
150
+ type: "npx" | "node" | "binary";
151
+ package?: string;
152
+ path?: string;
153
+ };
154
+ extra_args?: string[];
155
+ env?: Record<string, string>;
35
156
  };
36
157
 
37
158
  /**
@@ -96,65 +217,229 @@ function toToolResult(mcpResult: unknown) {
96
217
  };
97
218
  }
98
219
 
220
+ /**
221
+ * Load custom server configurations from ~/.config/sage/mcp-servers.toml
222
+ */
223
+ function loadCustomServers(): CustomServerConfig[] {
224
+ const configPath = join(homedir(), ".config", "sage", "mcp-servers.toml");
225
+
226
+ if (!existsSync(configPath)) {
227
+ return [];
228
+ }
229
+
230
+ try {
231
+ const content = readFileSync(configPath, "utf8");
232
+ const config = TOML.parse(content) as {
233
+ custom?: Record<string, {
234
+ id: string;
235
+ name: string;
236
+ description?: string;
237
+ enabled: boolean;
238
+ source: { type: string; package?: string; path?: string };
239
+ extra_args?: string[];
240
+ env?: Record<string, string>;
241
+ }>;
242
+ };
243
+
244
+ if (!config.custom) {
245
+ return [];
246
+ }
247
+
248
+ return Object.values(config.custom)
249
+ .filter((s) => s.enabled)
250
+ .map((s) => ({
251
+ id: s.id,
252
+ name: s.name,
253
+ description: s.description,
254
+ enabled: s.enabled,
255
+ source: {
256
+ type: s.source.type as "npx" | "node" | "binary",
257
+ package: s.source.package,
258
+ path: s.source.path,
259
+ },
260
+ extra_args: s.extra_args,
261
+ env: s.env,
262
+ }));
263
+ } catch (err) {
264
+ console.error(`Failed to parse mcp-servers.toml: ${err}`);
265
+ return [];
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Create command and args for spawning an external server
271
+ */
272
+ function getServerCommand(server: CustomServerConfig): { command: string; args: string[] } {
273
+ switch (server.source.type) {
274
+ case "npx":
275
+ return {
276
+ command: "npx",
277
+ args: ["-y", server.source.package!, ...(server.extra_args || [])],
278
+ };
279
+ case "node":
280
+ return {
281
+ command: "node",
282
+ args: [server.source.path!, ...(server.extra_args || [])],
283
+ };
284
+ case "binary":
285
+ return {
286
+ command: server.source.path!,
287
+ args: server.extra_args || [],
288
+ };
289
+ default:
290
+ throw new Error(`Unknown source type: ${server.source.type}`);
291
+ }
292
+ }
293
+
99
294
  // ── Plugin Definition ────────────────────────────────────────────────────────
100
295
 
101
- let bridge: McpBridge | null = null;
296
+ let sageBridge: McpBridge | null = null;
297
+ const externalBridges: Map<string, McpBridge> = new Map();
102
298
 
103
299
  const plugin = {
104
300
  id: "openclaw-sage",
105
301
  name: "Sage Protocol",
106
- version: "0.1.2",
302
+ version: "0.2.0",
107
303
  description:
108
- "Sage MCP tools for prompt libraries, skills, governance, and on-chain operations",
304
+ "Sage MCP tools for prompt libraries, skills, governance, and on-chain operations (including external servers)",
109
305
 
110
306
  register(api: PluginApi) {
111
- bridge = new McpBridge("sage", ["mcp", "start"]);
307
+ const pluginCfg = api.pluginConfig ?? {};
308
+ const sageBinary = typeof pluginCfg.sageBinary === "string" && pluginCfg.sageBinary.trim()
309
+ ? pluginCfg.sageBinary.trim()
310
+ : "sage";
311
+
312
+ const autoInject = pluginCfg.autoInjectContext !== false;
313
+ const autoSuggest = pluginCfg.autoSuggestSkills !== false;
314
+ const suggestLimit = clampInt(pluginCfg.suggestLimit, 3, 1, 10);
315
+ const minPromptLen = clampInt(pluginCfg.minPromptLen, 12, 0, 500);
316
+ const maxPromptBytes = clampInt(pluginCfg.maxPromptBytes, 16_384, 512, 65_536);
112
317
 
113
- bridge.on("log", (line: string) => api.logger.info(`[sage-mcp] ${line}`));
114
- bridge.on("error", (err: Error) => api.logger.error(`[sage-mcp] ${err.message}`));
318
+ // Main sage MCP bridge - pass HOME to ensure auth state is found
319
+ sageBridge = new McpBridge(sageBinary, ["mcp", "start"], {
320
+ HOME: homedir(),
321
+ PATH: process.env.PATH || "",
322
+ USER: process.env.USER || "",
323
+ XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
324
+ XDG_DATA_HOME: process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"),
325
+ });
326
+ sageBridge.on("log", (line: string) => api.logger.info(`[sage-mcp] ${line}`));
327
+ sageBridge.on("error", (err: Error) => api.logger.error(`[sage-mcp] ${err.message}`));
115
328
 
116
329
  api.registerService({
117
330
  id: "sage-mcp-bridge",
118
331
  start: async (ctx) => {
119
332
  ctx.logger.info("Starting Sage MCP bridge...");
333
+
334
+ // Start the main sage bridge
120
335
  try {
121
- await bridge!.start();
336
+ await sageBridge!.start();
122
337
  ctx.logger.info("Sage MCP bridge ready");
123
338
 
124
- const tools = await bridge!.listTools();
125
- ctx.logger.info(`Discovered ${tools.length} MCP tools`);
339
+ const tools = await sageBridge!.listTools();
340
+ ctx.logger.info(`Discovered ${tools.length} internal MCP tools`);
126
341
 
127
342
  for (const tool of tools) {
128
- registerMcpTool(api, tool);
343
+ registerMcpTool(api, "sage", sageBridge!, tool);
129
344
  }
130
345
  } catch (err) {
131
346
  ctx.logger.error(
132
- `Failed to start MCP bridge: ${err instanceof Error ? err.message : String(err)}`,
347
+ `Failed to start sage MCP bridge: ${err instanceof Error ? err.message : String(err)}`,
133
348
  );
134
349
  }
350
+
351
+ // Load and start external servers
352
+ const customServers = loadCustomServers();
353
+ ctx.logger.info(`Found ${customServers.length} custom external servers`);
354
+
355
+ for (const server of customServers) {
356
+ try {
357
+ ctx.logger.info(`Starting external server: ${server.name} (${server.id})`);
358
+
359
+ const { command, args } = getServerCommand(server);
360
+ const bridge = new McpBridge(command, args, server.env);
361
+
362
+ bridge.on("log", (line: string) => ctx.logger.info(`[${server.id}] ${line}`));
363
+ bridge.on("error", (err: Error) => ctx.logger.error(`[${server.id}] ${err.message}`));
364
+
365
+ await bridge.start();
366
+ externalBridges.set(server.id, bridge);
367
+
368
+ const tools = await bridge.listTools();
369
+ ctx.logger.info(`[${server.id}] Discovered ${tools.length} tools`);
370
+
371
+ for (const tool of tools) {
372
+ registerMcpTool(api, server.id.replace(/-/g, "_"), bridge, tool);
373
+ }
374
+ } catch (err) {
375
+ ctx.logger.error(
376
+ `Failed to start ${server.name}: ${err instanceof Error ? err.message : String(err)}`,
377
+ );
378
+ }
379
+ }
135
380
  },
136
381
  stop: async (ctx) => {
137
- ctx.logger.info("Stopping Sage MCP bridge...");
138
- await bridge?.stop();
382
+ ctx.logger.info("Stopping Sage MCP bridges...");
383
+
384
+ // Stop external bridges
385
+ for (const [id, bridge] of externalBridges) {
386
+ ctx.logger.info(`Stopping ${id}...`);
387
+ await bridge.stop();
388
+ }
389
+ externalBridges.clear();
390
+
391
+ // Stop main sage bridge
392
+ await sageBridge?.stop();
139
393
  },
140
394
  });
395
+
396
+ // Auto-inject context and suggestions at agent start.
397
+ // This uses OpenClaw's plugin hook API (not internal hooks).
398
+ api.on("before_agent_start", async (event: any) => {
399
+ const prompt = normalizePrompt(typeof event?.prompt === "string" ? event.prompt : "", {
400
+ maxBytes: maxPromptBytes,
401
+ });
402
+ if (!prompt || prompt.length < minPromptLen) {
403
+ return autoInject ? { prependContext: SAGE_CONTEXT } : undefined;
404
+ }
405
+
406
+ let suggestBlock = "";
407
+ if (autoSuggest && sageBridge) {
408
+ try {
409
+ const raw = await sageBridge.callTool("search_skills", {
410
+ query: prompt,
411
+ source: "all",
412
+ limit: Math.max(20, suggestLimit),
413
+ });
414
+ const json = extractJsonFromMcpResult(raw) as any;
415
+ const results = Array.isArray(json?.results) ? (json.results as SkillSearchResult[]) : [];
416
+ suggestBlock = formatSkillSuggestions(results, suggestLimit);
417
+ } catch {
418
+ // Ignore suggestion failures; context injection should still work.
419
+ }
420
+ }
421
+
422
+ const parts: string[] = [];
423
+ if (autoInject) parts.push(SAGE_CONTEXT);
424
+ if (suggestBlock) parts.push(suggestBlock);
425
+
426
+ if (!parts.length) return undefined;
427
+ return { prependContext: parts.join("\n\n") };
428
+ });
141
429
  },
142
430
  };
143
431
 
144
- function registerMcpTool(api: PluginApi, tool: McpToolDef) {
145
- const name = `sage_${tool.name}`;
432
+ function registerMcpTool(api: PluginApi, prefix: string, bridge: McpBridge, tool: McpToolDef) {
433
+ const name = `${prefix}_${tool.name}`;
146
434
  const schema = mcpSchemaToTypebox(tool.inputSchema);
147
435
 
148
436
  api.registerTool(
149
437
  {
150
438
  name,
151
- label: `Sage: ${tool.name}`,
152
- description: tool.description ?? `Sage MCP tool: ${tool.name}`,
439
+ label: `${prefix}: ${tool.name}`,
440
+ description: tool.description ?? `MCP tool: ${prefix}/${tool.name}`,
153
441
  parameters: schema,
154
442
  execute: async (_toolCallId: string, params: Record<string, unknown>) => {
155
- if (!bridge) {
156
- return toToolResult({ error: "MCP bridge not initialized" });
157
- }
158
443
  try {
159
444
  const result = await bridge.callTool(tool.name, params);
160
445
  return toToolResult(result);
@@ -170,3 +455,10 @@ function registerMcpTool(api: PluginApi, tool: McpToolDef) {
170
455
  }
171
456
 
172
457
  export default plugin;
458
+
459
+ export const __test = {
460
+ SAGE_CONTEXT,
461
+ normalizePrompt,
462
+ extractJsonFromMcpResult,
463
+ formatSkillSuggestions,
464
+ };
@@ -0,0 +1,132 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolve } from "node:path";
4
+
5
+ import { McpBridge } from "./mcp-bridge.js";
6
+ import plugin from "./index.js";
7
+ import { __test } from "./index.js";
8
+
9
+ function addSageDebugBinToPath() {
10
+ // Ensure the `sage` binary used by the plugin resolves to this repo's build.
11
+ const binDir = resolve(new URL("..", import.meta.url).pathname, "..", "target", "debug");
12
+ const sep = process.platform === "win32" ? ";" : ":";
13
+ process.env.PATH = `${binDir}${sep}${process.env.PATH ?? ""}`;
14
+ return { binDir };
15
+ }
16
+
17
+ test("McpBridge can initialize, list tools, and call a native tool", async () => {
18
+ const sageBin = resolve(new URL("..", import.meta.url).pathname, "..", "target", "debug", "sage");
19
+ const bridge = new McpBridge(sageBin, ["mcp", "start"]);
20
+ await bridge.start();
21
+ try {
22
+ const tools = await bridge.listTools();
23
+ assert.ok(Array.isArray(tools));
24
+ assert.ok(tools.length > 0);
25
+
26
+ const hasProjectContext = tools.some((t) => t.name === "get_project_context");
27
+ assert.ok(hasProjectContext, "expected get_project_context tool to exist");
28
+
29
+ const result = await bridge.callTool("get_project_context", {});
30
+ assert.ok(result && typeof result === "object");
31
+ } finally {
32
+ await bridge.stop();
33
+ }
34
+ });
35
+
36
+ test("OpenClaw plugin registers MCP tools via sage mcp start", async () => {
37
+ addSageDebugBinToPath();
38
+
39
+ const registeredTools: string[] = [];
40
+ const services: Array<{ id: string; start: Function; stop?: Function }> = [];
41
+
42
+ const api = {
43
+ id: "t",
44
+ name: "t",
45
+ logger: {
46
+ info: (_: string) => {},
47
+ warn: (_: string) => {},
48
+ error: (_: string) => {},
49
+ },
50
+ registerTool: (tool: any) => {
51
+ if (tool?.name) registeredTools.push(tool.name);
52
+ },
53
+ registerService: (svc: any) => {
54
+ services.push(svc);
55
+ },
56
+ on: (_hook: string, _handler: any) => {},
57
+ };
58
+
59
+ plugin.register(api);
60
+ const svc = services.find((s) => s.id === "sage-mcp-bridge");
61
+ assert.ok(svc, "expected sage-mcp-bridge service to be registered");
62
+
63
+ await svc!.start({
64
+ config: {},
65
+ stateDir: "/tmp",
66
+ logger: api.logger,
67
+ });
68
+
69
+ // Tool names are prefixed with `sage_` in this plugin.
70
+ assert.ok(
71
+ registeredTools.some((n) => n.startsWith("sage_")),
72
+ "expected at least one sage_* tool",
73
+ );
74
+
75
+ if (svc!.stop) {
76
+ await svc!.stop({
77
+ config: {},
78
+ stateDir: "/tmp",
79
+ logger: api.logger,
80
+ });
81
+ }
82
+ });
83
+
84
+ test("OpenClaw plugin registers before_agent_start hook and returns prependContext", async () => {
85
+ const hooks: Record<string, any> = {};
86
+
87
+ const api = {
88
+ id: "t",
89
+ name: "t",
90
+ pluginConfig: {},
91
+ logger: {
92
+ info: (_: string) => {},
93
+ warn: (_: string) => {},
94
+ error: (_: string) => {},
95
+ },
96
+ registerTool: (_tool: any) => {},
97
+ registerService: (_svc: any) => {},
98
+ on: (hook: string, handler: any) => {
99
+ hooks[hook] = handler;
100
+ },
101
+ };
102
+
103
+ plugin.register(api as any);
104
+ assert.ok(typeof hooks.before_agent_start === "function", "expected before_agent_start hook");
105
+
106
+ const result = await hooks.before_agent_start({ prompt: "build an mcp server" });
107
+ assert.ok(result && typeof result === "object");
108
+ assert.ok(
109
+ typeof result.prependContext === "string" && result.prependContext.includes("Sage MCP Tools Available"),
110
+ "expected prependContext with Sage tool context",
111
+ );
112
+ });
113
+
114
+ test("formatSkillSuggestions formats stable markdown", () => {
115
+ const out = __test.formatSkillSuggestions(
116
+ [
117
+ {
118
+ key: "bug-bounty",
119
+ name: "Bug Bounty",
120
+ description: "Recon, scanning, API testing",
121
+ source: "installed",
122
+ mcpServers: ["zap"],
123
+ },
124
+ { key: "", name: "skip" },
125
+ ],
126
+ 3,
127
+ );
128
+
129
+ assert.ok(out.includes("## Suggested Skills"));
130
+ assert.ok(out.includes("`use_skill` `bug-bounty`"));
131
+ assert.ok(out.includes("requires: zap"));
132
+ });
package/src/mcp-bridge.ts CHANGED
@@ -45,6 +45,7 @@ export class McpBridge extends EventEmitter {
45
45
  constructor(
46
46
  private command: string,
47
47
  private args: string[],
48
+ private env?: Record<string, string>,
48
49
  ) {
49
50
  super();
50
51
  }
@@ -94,7 +95,7 @@ export class McpBridge extends EventEmitter {
94
95
  return new Promise((resolve, reject) => {
95
96
  const proc = spawn(this.command, this.args, {
96
97
  stdio: ["pipe", "pipe", "pipe"],
97
- env: { ...process.env },
98
+ env: { ...process.env, ...this.env },
98
99
  });
99
100
 
100
101
  proc.on("error", (err) => {
@@ -0,0 +1,255 @@
1
+ /**
2
+ * E2E Test: OpenClaw RLM Capture Path
3
+ *
4
+ * Validates the OpenClaw-specific capture flow:
5
+ * 1. Spawn sage MCP server with isolated HOME
6
+ * 2. Simulate message_received hook (sage capture hook prompt)
7
+ * 3. Simulate agent_end hook (sage capture hook response)
8
+ * 4. Verify captures landed via rlm_stats MCP tool
9
+ * 5. Run rlm_analyze_captures and verify stats update
10
+ */
11
+
12
+ import test from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { resolve } from "node:path";
15
+ import { mkdtempSync, existsSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { spawn, type ChildProcess } from "node:child_process";
18
+ import { createInterface } from "node:readline";
19
+ import { randomUUID } from "node:crypto";
20
+
21
+ // ── Helpers ──────────────────────────────────────────────────────────
22
+
23
+ const sageBin = resolve(new URL("..", import.meta.url).pathname, "..", "target", "debug", "sage");
24
+
25
+ function createIsolatedHome(): string {
26
+ return mkdtempSync(resolve(tmpdir(), "sage-openclaw-e2e-"));
27
+ }
28
+
29
+ function isolatedEnv(tmpHome: string): Record<string, string> {
30
+ return {
31
+ ...(process.env as Record<string, string>),
32
+ HOME: tmpHome,
33
+ XDG_CONFIG_HOME: resolve(tmpHome, ".config"),
34
+ XDG_DATA_HOME: resolve(tmpHome, ".local/share"),
35
+ SAGE_HOME: resolve(tmpHome, ".sage"),
36
+ };
37
+ }
38
+
39
+ type JsonRpcClient = {
40
+ request: (method: string, params: Record<string, unknown>) => Promise<unknown>;
41
+ notify: (method: string, params: Record<string, unknown>) => void;
42
+ };
43
+
44
+ function createMcpClient(proc: ChildProcess): JsonRpcClient {
45
+ const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
46
+
47
+ if (!proc.stdout) throw new Error("No stdout");
48
+
49
+ const rl = createInterface({ input: proc.stdout });
50
+ rl.on("line", (line) => {
51
+ let msg: any;
52
+ try {
53
+ msg = JSON.parse(line);
54
+ } catch {
55
+ return;
56
+ }
57
+ if (!msg?.id) return;
58
+ const id = String(msg.id);
59
+ const waiter = pending.get(id);
60
+ if (!waiter) return;
61
+ pending.delete(id);
62
+ if (msg.error) waiter.reject(new Error(msg.error.message || "MCP error"));
63
+ else waiter.resolve(msg.result);
64
+ });
65
+
66
+ return {
67
+ request(method, params) {
68
+ if (!proc.stdin?.writable) throw new Error("stdin not writable");
69
+ const id = randomUUID();
70
+ const req = { jsonrpc: "2.0", id, method, params };
71
+ proc.stdin.write(JSON.stringify(req) + "\n");
72
+ return new Promise((resolve, reject) => {
73
+ pending.set(id, { resolve, reject });
74
+ });
75
+ },
76
+ notify(method, params) {
77
+ if (!proc.stdin?.writable) return;
78
+ proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
79
+ },
80
+ };
81
+ }
82
+
83
+ async function callTool(
84
+ client: JsonRpcClient,
85
+ name: string,
86
+ args: Record<string, unknown> = {},
87
+ ): Promise<any> {
88
+ const result = (await client.request("tools/call", {
89
+ name,
90
+ arguments: args,
91
+ })) as any;
92
+
93
+ const text =
94
+ result?.content
95
+ ?.filter((c: any) => c.type === "text")
96
+ .map((c: any) => c.text)
97
+ .join("\n") ?? "";
98
+
99
+ try {
100
+ return JSON.parse(text);
101
+ } catch {
102
+ return text;
103
+ }
104
+ }
105
+
106
+ function runCaptureCli(
107
+ bin: string,
108
+ subArgs: string[],
109
+ env: Record<string, string>,
110
+ ): Promise<number | null> {
111
+ return new Promise((resolve) => {
112
+ const p = spawn(bin, subArgs, {
113
+ env,
114
+ stdio: ["ignore", "pipe", "pipe"],
115
+ });
116
+ p.on("close", (code) => resolve(code));
117
+ p.on("error", () => resolve(null));
118
+ });
119
+ }
120
+
121
+ // ── Tests ────────────────────────────────────────────────────────────
122
+
123
+ const TIMEOUT = 60_000;
124
+
125
+ test("OpenClaw capture flow: prompt + response -> rlm_stats", { timeout: TIMEOUT }, async () => {
126
+ const tmpHome = createIsolatedHome();
127
+ const env = isolatedEnv(tmpHome);
128
+
129
+ // Start MCP server
130
+ const proc = spawn(sageBin, ["mcp", "start"], {
131
+ stdio: ["pipe", "pipe", "pipe"],
132
+ env,
133
+ });
134
+
135
+ const client = createMcpClient(proc);
136
+
137
+ try {
138
+ // MCP handshake
139
+ const init = await client.request("initialize", {
140
+ protocolVersion: "2024-11-05",
141
+ capabilities: {},
142
+ clientInfo: { name: "openclaw-e2e-test", version: "0.0.0" },
143
+ });
144
+ assert.ok(init, "initialize should return a result");
145
+ client.notify("notifications/initialized", {});
146
+
147
+ // Baseline stats
148
+ const baselineStats = await callTool(client, "rlm_stats");
149
+ assert.ok(baselineStats, "rlm_stats should return a result");
150
+
151
+ // Inject captures mimicking OpenClaw's message_received + agent_end hooks
152
+ const captureEnv = {
153
+ ...env,
154
+ SAGE_SOURCE: "openclaw",
155
+ OPENCLAW: "1",
156
+ };
157
+
158
+ const prompts = [
159
+ {
160
+ prompt: "How to implement a plugin system in TypeScript?",
161
+ response: "Use dynamic imports, define a plugin interface, register plugins at startup.",
162
+ },
163
+ {
164
+ prompt: "Best practices for error handling in Node.js",
165
+ response:
166
+ "Use try-catch with async/await, create custom error classes, use error middleware.",
167
+ },
168
+ {
169
+ prompt: "How to write unit tests with vitest?",
170
+ response:
171
+ "Install vitest, create .test.ts files, use describe/it/expect, run with npx vitest.",
172
+ },
173
+ ];
174
+
175
+ for (const { prompt, response } of prompts) {
176
+ // Phase 1: capture prompt (simulates message_received hook)
177
+ const promptExit = await runCaptureCli(sageBin, ["capture", "hook", "prompt"], {
178
+ ...captureEnv,
179
+ PROMPT: prompt,
180
+ SAGE_SESSION_ID: "openclaw-e2e-session",
181
+ SAGE_MODEL: "gpt-4",
182
+ SAGE_PROVIDER: "openai",
183
+ SAGE_CAPTURE_ATTRIBUTES_JSON: JSON.stringify({
184
+ openclaw: {
185
+ hook: "message_received",
186
+ sessionId: "openclaw-e2e-session",
187
+ channel: "test",
188
+ },
189
+ }),
190
+ });
191
+ // Exit code check (may be non-zero if daemon socket not found, but file-based fallback works)
192
+ assert.ok(promptExit !== null, "prompt capture should not crash");
193
+
194
+ // Phase 2: capture response (simulates agent_end hook)
195
+ const responseExit = await runCaptureCli(sageBin, ["capture", "hook", "response"], {
196
+ ...captureEnv,
197
+ LAST_RESPONSE: response,
198
+ TOKENS_INPUT: "150",
199
+ TOKENS_OUTPUT: "75",
200
+ });
201
+ assert.ok(responseExit !== null, "response capture should not crash");
202
+ }
203
+
204
+ // Run analysis via MCP
205
+ const analysisResult = await callTool(client, "rlm_analyze_captures", {
206
+ goal: "improve developer productivity",
207
+ });
208
+ assert.ok(analysisResult, "rlm_analyze_captures should return a result");
209
+
210
+ // Check patterns
211
+ const patterns = await callTool(client, "rlm_list_patterns", {});
212
+ assert.ok(patterns, "rlm_list_patterns should return a result");
213
+
214
+ // Final stats should reflect some activity
215
+ const finalStats = await callTool(client, "rlm_stats");
216
+ assert.ok(finalStats, "final rlm_stats should return a result");
217
+ } finally {
218
+ proc.kill("SIGTERM");
219
+ }
220
+ });
221
+
222
+ test(
223
+ "OpenClaw capture with custom attributes preserves metadata",
224
+ { timeout: TIMEOUT },
225
+ async () => {
226
+ const tmpHome = createIsolatedHome();
227
+ const env = isolatedEnv(tmpHome);
228
+
229
+ const attrs = {
230
+ openclaw: {
231
+ hook: "message_received",
232
+ sessionId: "test-sess-123",
233
+ sessionKey: "key-456",
234
+ channel: "web",
235
+ senderId: "user-789",
236
+ },
237
+ };
238
+
239
+ // Run capture with rich OpenClaw attributes
240
+ const exitCode = await runCaptureCli(sageBin, ["capture", "hook", "prompt"], {
241
+ ...env,
242
+ SAGE_SOURCE: "openclaw",
243
+ OPENCLAW: "1",
244
+ PROMPT: "Test prompt with rich metadata",
245
+ SAGE_SESSION_ID: "test-sess-123",
246
+ SAGE_MODEL: "claude-3-opus",
247
+ SAGE_PROVIDER: "anthropic",
248
+ SAGE_WORKSPACE: "/workspace/project",
249
+ SAGE_CAPTURE_ATTRIBUTES_JSON: JSON.stringify(attrs),
250
+ });
251
+
252
+ // Should not crash (exit code may be non-zero if daemon not running, that's OK)
253
+ assert.ok(exitCode !== null, "capture with attributes should not crash");
254
+ },
255
+ );
package/tsconfig.json CHANGED
@@ -10,7 +10,8 @@
10
10
  "declaration": true,
11
11
  "outDir": "dist",
12
12
  "rootDir": "src",
13
- "resolveJsonModule": true
13
+ "resolveJsonModule": true,
14
+ "types": ["node"]
14
15
  },
15
16
  "include": ["src/**/*.ts"],
16
17
  "exclude": ["node_modules", "dist"]