@sage-protocol/openclaw-sage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +24 -0
- package/src/index.ts +172 -0
- package/src/mcp-bridge.ts +218 -0
- package/tsconfig.json +17 -0
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sage-protocol/openclaw-sage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sage MCP bridge plugin for OpenClaw — prompt libraries, skills, governance, and on-chain operations",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"openclaw": {
|
|
8
|
+
"id": "sage-mcp",
|
|
9
|
+
"displayName": "Sage Protocol",
|
|
10
|
+
"description": "MCP bridge for Sage prompt libraries, skills, and governance"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"test": "node --import tsx src/mcp-bridge.test.ts"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@sinclair/typebox": "^0.34.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"typescript": "^5.6.0",
|
|
21
|
+
"tsx": "^4.19.0"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT"
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
import { McpBridge, type McpToolDef } from "./mcp-bridge.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal type stubs for OpenClaw plugin API.
|
|
7
|
+
*
|
|
8
|
+
* OpenClaw's jiti runtime resolves "openclaw/plugin-sdk" at load time.
|
|
9
|
+
* These stubs keep the code compilable standalone.
|
|
10
|
+
*/
|
|
11
|
+
type PluginLogger = {
|
|
12
|
+
info: (msg: string) => void;
|
|
13
|
+
warn: (msg: string) => void;
|
|
14
|
+
error: (msg: string) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type PluginServiceContext = {
|
|
18
|
+
config: unknown;
|
|
19
|
+
workspaceDir?: string;
|
|
20
|
+
stateDir: string;
|
|
21
|
+
logger: PluginLogger;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type PluginApi = {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
logger: PluginLogger;
|
|
28
|
+
registerTool: (tool: unknown, opts?: { name?: string; optional?: boolean }) => void;
|
|
29
|
+
registerService: (service: {
|
|
30
|
+
id: string;
|
|
31
|
+
start: (ctx: PluginServiceContext) => void | Promise<void>;
|
|
32
|
+
stop?: (ctx: PluginServiceContext) => void | Promise<void>;
|
|
33
|
+
}) => void;
|
|
34
|
+
on: (hook: string, handler: (...args: unknown[]) => void | Promise<void>) => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Convert an MCP JSON Schema inputSchema into a TypeBox object schema
|
|
39
|
+
* that OpenClaw's tool system accepts.
|
|
40
|
+
*/
|
|
41
|
+
function mcpSchemaToTypebox(inputSchema?: Record<string, unknown>) {
|
|
42
|
+
if (!inputSchema || typeof inputSchema !== "object") {
|
|
43
|
+
return Type.Object({});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const properties = (inputSchema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
|
47
|
+
const required = new Set(
|
|
48
|
+
Array.isArray(inputSchema.required) ? (inputSchema.required as string[]) : [],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const fields: Record<string, unknown> = {};
|
|
52
|
+
|
|
53
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
54
|
+
const desc = typeof prop.description === "string" ? prop.description : undefined;
|
|
55
|
+
const opts = desc ? { description: desc } : {};
|
|
56
|
+
|
|
57
|
+
let field: unknown;
|
|
58
|
+
switch (prop.type) {
|
|
59
|
+
case "number":
|
|
60
|
+
case "integer":
|
|
61
|
+
field = Type.Number(opts);
|
|
62
|
+
break;
|
|
63
|
+
case "boolean":
|
|
64
|
+
field = Type.Boolean(opts);
|
|
65
|
+
break;
|
|
66
|
+
case "array":
|
|
67
|
+
field = Type.Array(Type.Unknown(), opts);
|
|
68
|
+
break;
|
|
69
|
+
case "object":
|
|
70
|
+
field = Type.Record(Type.String(), Type.Unknown(), opts);
|
|
71
|
+
break;
|
|
72
|
+
default:
|
|
73
|
+
field = Type.String(opts);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fields[key] = required.has(key) ? field : Type.Optional(field as any);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return Type.Object(fields as any, { additionalProperties: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toToolResult(mcpResult: unknown) {
|
|
83
|
+
const result = mcpResult as {
|
|
84
|
+
content?: Array<{ type: string; text?: string }>;
|
|
85
|
+
} | null;
|
|
86
|
+
|
|
87
|
+
const text =
|
|
88
|
+
result?.content
|
|
89
|
+
?.map((c) => c.text ?? "")
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join("\n") ?? JSON.stringify(mcpResult ?? {});
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: "text" as const, text }],
|
|
95
|
+
details: mcpResult,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Plugin Definition ────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
let bridge: McpBridge | null = null;
|
|
102
|
+
|
|
103
|
+
const plugin = {
|
|
104
|
+
id: "sage-mcp",
|
|
105
|
+
name: "Sage Protocol",
|
|
106
|
+
version: "0.1.0",
|
|
107
|
+
description:
|
|
108
|
+
"Sage MCP tools for prompt libraries, skills, governance, and on-chain operations",
|
|
109
|
+
|
|
110
|
+
register(api: PluginApi) {
|
|
111
|
+
bridge = new McpBridge("sage", ["mcp", "start"]);
|
|
112
|
+
|
|
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}`));
|
|
115
|
+
|
|
116
|
+
api.registerService({
|
|
117
|
+
id: "sage-mcp-bridge",
|
|
118
|
+
start: async (ctx) => {
|
|
119
|
+
ctx.logger.info("Starting Sage MCP bridge...");
|
|
120
|
+
try {
|
|
121
|
+
await bridge!.start();
|
|
122
|
+
ctx.logger.info("Sage MCP bridge ready");
|
|
123
|
+
|
|
124
|
+
const tools = await bridge!.listTools();
|
|
125
|
+
ctx.logger.info(`Discovered ${tools.length} MCP tools`);
|
|
126
|
+
|
|
127
|
+
for (const tool of tools) {
|
|
128
|
+
registerMcpTool(api, tool);
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
ctx.logger.error(
|
|
132
|
+
`Failed to start MCP bridge: ${err instanceof Error ? err.message : String(err)}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
stop: async (ctx) => {
|
|
137
|
+
ctx.logger.info("Stopping Sage MCP bridge...");
|
|
138
|
+
await bridge?.stop();
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
function registerMcpTool(api: PluginApi, tool: McpToolDef) {
|
|
145
|
+
const name = `sage_${tool.name}`;
|
|
146
|
+
const schema = mcpSchemaToTypebox(tool.inputSchema);
|
|
147
|
+
|
|
148
|
+
api.registerTool(
|
|
149
|
+
{
|
|
150
|
+
name,
|
|
151
|
+
label: `Sage: ${tool.name}`,
|
|
152
|
+
description: tool.description ?? `Sage MCP tool: ${tool.name}`,
|
|
153
|
+
parameters: schema,
|
|
154
|
+
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
155
|
+
if (!bridge) {
|
|
156
|
+
return toToolResult({ error: "MCP bridge not initialized" });
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const result = await bridge.callTool(tool.name, params);
|
|
160
|
+
return toToolResult(result);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return toToolResult({
|
|
163
|
+
error: err instanceof Error ? err.message : String(err),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{ name, optional: true },
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default plugin;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
|
|
5
|
+
|
|
6
|
+
/** MCP tool definition returned by tools/list */
|
|
7
|
+
export type McpToolDef = {
|
|
8
|
+
name: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
inputSchema?: Record<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** JSON-RPC request/response types */
|
|
14
|
+
type JsonRpcRequest = {
|
|
15
|
+
jsonrpc: "2.0";
|
|
16
|
+
id: string | number;
|
|
17
|
+
method: string;
|
|
18
|
+
params?: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type JsonRpcResponse = {
|
|
22
|
+
jsonrpc: "2.0";
|
|
23
|
+
id: string | number;
|
|
24
|
+
result?: unknown;
|
|
25
|
+
error?: { code: number; message: string; data?: unknown };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const MAX_RETRIES = 3;
|
|
29
|
+
const RESTART_DELAY_MS = 1000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Lightweight MCP stdio client.
|
|
33
|
+
*
|
|
34
|
+
* Spawns a child process that speaks JSON-RPC over stdin/stdout (MCP stdio transport).
|
|
35
|
+
* Provides methods to list tools and call them.
|
|
36
|
+
*/
|
|
37
|
+
export class McpBridge extends EventEmitter {
|
|
38
|
+
private proc: ChildProcess | null = null;
|
|
39
|
+
private rl: ReadlineInterface | null = null;
|
|
40
|
+
private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
41
|
+
private ready = false;
|
|
42
|
+
private retries = 0;
|
|
43
|
+
private stopped = false;
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
private command: string,
|
|
47
|
+
private args: string[],
|
|
48
|
+
) {
|
|
49
|
+
super();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async start(): Promise<void> {
|
|
53
|
+
this.stopped = false;
|
|
54
|
+
await this.spawn();
|
|
55
|
+
await this.initialize();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async stop(): Promise<void> {
|
|
59
|
+
this.stopped = true;
|
|
60
|
+
this.ready = false;
|
|
61
|
+
this.rejectAll("Bridge stopped");
|
|
62
|
+
if (this.rl) {
|
|
63
|
+
this.rl.close();
|
|
64
|
+
this.rl = null;
|
|
65
|
+
}
|
|
66
|
+
if (this.proc) {
|
|
67
|
+
this.proc.kill("SIGTERM");
|
|
68
|
+
this.proc = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async listTools(): Promise<McpToolDef[]> {
|
|
73
|
+
const result = (await this.request("tools/list", {})) as { tools?: McpToolDef[] };
|
|
74
|
+
return result?.tools ?? [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
78
|
+
const result = (await this.request("tools/call", { name, arguments: args })) as {
|
|
79
|
+
content?: Array<{ type: string; text?: string }>;
|
|
80
|
+
isError?: boolean;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (result?.isError) {
|
|
84
|
+
const text = result.content?.map((c) => c.text ?? "").join("\n") ?? "MCP tool error";
|
|
85
|
+
throw new Error(text);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── private ──────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
private spawn(): Promise<void> {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const proc = spawn(this.command, this.args, {
|
|
96
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
97
|
+
env: { ...process.env },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
proc.on("error", (err) => {
|
|
101
|
+
if (!this.stopped) {
|
|
102
|
+
this.handleCrash(err);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
proc.on("exit", (code) => {
|
|
107
|
+
if (!this.stopped && code !== 0) {
|
|
108
|
+
this.handleCrash(new Error(`MCP process exited with code ${code}`));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!proc.stdout || !proc.stdin) {
|
|
113
|
+
reject(new Error("Failed to open stdio pipes for MCP process"));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.proc = proc;
|
|
118
|
+
|
|
119
|
+
this.rl = createInterface({ input: proc.stdout });
|
|
120
|
+
this.rl.on("line", (line) => this.handleLine(line));
|
|
121
|
+
|
|
122
|
+
if (proc.stderr) {
|
|
123
|
+
const errRl = createInterface({ input: proc.stderr });
|
|
124
|
+
errRl.on("line", (line) => this.emit("log", line));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
resolve();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async initialize(): Promise<void> {
|
|
132
|
+
const result = (await this.request("initialize", {
|
|
133
|
+
protocolVersion: "2024-11-05",
|
|
134
|
+
capabilities: {},
|
|
135
|
+
clientInfo: { name: "openclaw-sage-plugin", version: "0.1.0" },
|
|
136
|
+
})) as { serverInfo?: { name?: string } };
|
|
137
|
+
|
|
138
|
+
this.notify("notifications/initialized", {});
|
|
139
|
+
this.ready = true;
|
|
140
|
+
this.retries = 0;
|
|
141
|
+
this.emit("ready", result?.serverInfo);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private request(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
if (!this.proc?.stdin?.writable) {
|
|
147
|
+
reject(new Error("MCP process not running"));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const id = randomUUID();
|
|
152
|
+
const req: JsonRpcRequest = { jsonrpc: "2.0", id, method, params };
|
|
153
|
+
|
|
154
|
+
this.pending.set(id, { resolve, reject });
|
|
155
|
+
this.proc.stdin.write(JSON.stringify(req) + "\n");
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private notify(method: string, params: Record<string, unknown>): void {
|
|
160
|
+
if (!this.proc?.stdin?.writable) return;
|
|
161
|
+
const msg = { jsonrpc: "2.0", method, params };
|
|
162
|
+
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private handleLine(line: string): void {
|
|
166
|
+
let msg: JsonRpcResponse;
|
|
167
|
+
try {
|
|
168
|
+
msg = JSON.parse(line);
|
|
169
|
+
} catch {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!msg.id) return;
|
|
174
|
+
|
|
175
|
+
const id = String(msg.id);
|
|
176
|
+
const pending = this.pending.get(id);
|
|
177
|
+
if (!pending) return;
|
|
178
|
+
|
|
179
|
+
this.pending.delete(id);
|
|
180
|
+
|
|
181
|
+
if (msg.error) {
|
|
182
|
+
pending.reject(new Error(`MCP error ${msg.error.code}: ${msg.error.message}`));
|
|
183
|
+
} else {
|
|
184
|
+
pending.resolve(msg.result);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async handleCrash(err: Error): Promise<void> {
|
|
189
|
+
this.ready = false;
|
|
190
|
+
this.rejectAll(`MCP process crashed: ${err.message}`);
|
|
191
|
+
|
|
192
|
+
if (this.retries >= MAX_RETRIES) {
|
|
193
|
+
this.emit("error", new Error(`MCP bridge failed after ${MAX_RETRIES} retries: ${err.message}`));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.retries++;
|
|
198
|
+
this.emit("log", `MCP process crashed, retry ${this.retries}/${MAX_RETRIES}...`);
|
|
199
|
+
|
|
200
|
+
await new Promise((r) => setTimeout(r, RESTART_DELAY_MS));
|
|
201
|
+
|
|
202
|
+
if (!this.stopped) {
|
|
203
|
+
try {
|
|
204
|
+
await this.spawn();
|
|
205
|
+
await this.initialize();
|
|
206
|
+
} catch (retryErr) {
|
|
207
|
+
this.handleCrash(retryErr instanceof Error ? retryErr : new Error(String(retryErr)));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private rejectAll(reason: string): void {
|
|
213
|
+
for (const [, { reject }] of this.pending) {
|
|
214
|
+
reject(new Error(reason));
|
|
215
|
+
}
|
|
216
|
+
this.pending.clear();
|
|
217
|
+
}
|
|
218
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"outDir": "dist",
|
|
12
|
+
"rootDir": "src",
|
|
13
|
+
"resolveJsonModule": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|