@sage-protocol/sage-plugin 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/README.md +12 -8
- package/index.js +549 -571
- package/index.test.js +464 -422
- package/mcp.integration.test.js +245 -122
- package/package.json +16 -16
- package/rlm-feedback.e2e.test.js +295 -0
- package/rlm.e2e.test.js +149 -0
- package/test-utils.js +287 -0
package/mcp.integration.test.js
CHANGED
|
@@ -1,129 +1,252 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
|
|
3
|
+
const TIMEOUT = 20_000;
|
|
4
|
+
|
|
3
5
|
function createMcpClient(proc) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
6
|
+
const decoder = new TextDecoder();
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
const pending = new Map();
|
|
9
|
+
|
|
10
|
+
let closed = false;
|
|
11
|
+
let closeErr;
|
|
12
|
+
let buf = "";
|
|
13
|
+
|
|
14
|
+
(async () => {
|
|
15
|
+
try {
|
|
16
|
+
for await (const chunk of proc.stdout) {
|
|
17
|
+
buf += decoder.decode(chunk);
|
|
18
|
+
const lines = buf.split("\n");
|
|
19
|
+
buf = lines.pop() ?? "";
|
|
20
|
+
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
if (!line.trim()) continue;
|
|
23
|
+
|
|
24
|
+
let msg;
|
|
25
|
+
try {
|
|
26
|
+
msg = JSON.parse(line);
|
|
27
|
+
} catch {
|
|
28
|
+
// Ignore malformed lines (stdout should be JSON-RPC, but be resilient).
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (msg && msg.id != null) {
|
|
33
|
+
const key = String(msg.id);
|
|
34
|
+
const waiter = pending.get(key);
|
|
35
|
+
if (waiter) {
|
|
36
|
+
pending.delete(key);
|
|
37
|
+
if (msg.error) waiter.reject(new Error(msg.error.message || "MCP error"));
|
|
38
|
+
else waiter.resolve(msg.result);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
closed = true;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
closed = true;
|
|
46
|
+
closeErr = e;
|
|
47
|
+
} finally {
|
|
48
|
+
// Fail any outstanding requests.
|
|
49
|
+
const stderr = await new Response(proc.stderr).text().catch(() => "");
|
|
50
|
+
for (const { reject } of pending.values()) {
|
|
51
|
+
reject(
|
|
52
|
+
new Error(
|
|
53
|
+
`MCP process ended before response. stderr:\n${stderr || "<empty>"}${closeErr ? `\nstdout reader error: ${closeErr}` : ""}`,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
pending.clear();
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
request(method, params) {
|
|
63
|
+
if (closed) {
|
|
64
|
+
throw new Error("MCP client is closed");
|
|
65
|
+
}
|
|
66
|
+
const id = `${Date.now()}-${Math.random()}`;
|
|
67
|
+
proc.stdin.write(
|
|
68
|
+
encoder.encode(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`),
|
|
69
|
+
);
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
pending.set(String(id), { resolve, reject });
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
notify(method, params) {
|
|
75
|
+
if (closed) return;
|
|
76
|
+
proc.stdin.write(encoder.encode(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`));
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
82
|
+
import { tmpdir } from "node:os";
|
|
83
|
+
import { join } from "node:path";
|
|
84
|
+
|
|
85
|
+
function makeSageProcess(env = {}) {
|
|
86
|
+
const sageBin = process.env.SAGE_BIN || new URL("../target/debug/sage", import.meta.url).pathname;
|
|
87
|
+
return Bun.spawn([sageBin, "mcp", "start"], {
|
|
88
|
+
stdin: "pipe",
|
|
89
|
+
stdout: "pipe",
|
|
90
|
+
stderr: "pipe",
|
|
91
|
+
env: { ...process.env, ...env },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function initMcp(client) {
|
|
96
|
+
const init = await client.request("initialize", {
|
|
97
|
+
protocolVersion: "2024-11-05",
|
|
98
|
+
capabilities: {},
|
|
99
|
+
clientInfo: { name: "sage-plugin-test", version: "0.0.0" },
|
|
100
|
+
});
|
|
101
|
+
client.notify("notifications/initialized", {});
|
|
102
|
+
return init;
|
|
84
103
|
}
|
|
85
104
|
|
|
86
105
|
describe("sage-plugin integration: CLI <-> MCP", () => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
106
|
+
it(
|
|
107
|
+
"sage mcp start initializes and exposes native tools",
|
|
108
|
+
async () => {
|
|
109
|
+
const proc = makeSageProcess();
|
|
110
|
+
const client = createMcpClient(proc);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const init = await initMcp(client);
|
|
114
|
+
expect(init).toBeTruthy();
|
|
115
|
+
|
|
116
|
+
const toolsList = await client.request("tools/list", {});
|
|
117
|
+
expect(Array.isArray(toolsList?.tools)).toBe(true);
|
|
118
|
+
expect(toolsList.tools.length).toBeGreaterThan(0);
|
|
119
|
+
|
|
120
|
+
const hasProjectContext = toolsList.tools.some((t) => t.name === "get_project_context");
|
|
121
|
+
expect(hasProjectContext).toBe(true);
|
|
122
|
+
|
|
123
|
+
const callRes = await client.request("tools/call", {
|
|
124
|
+
name: "get_project_context",
|
|
125
|
+
arguments: {},
|
|
126
|
+
});
|
|
127
|
+
expect(callRes).toBeTruthy();
|
|
128
|
+
expect(callRes.isError || false).toBe(false);
|
|
129
|
+
} finally {
|
|
130
|
+
proc.kill("SIGTERM");
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
TIMEOUT,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
it(
|
|
137
|
+
"get_prompt tool schema includes vars parameter",
|
|
138
|
+
async () => {
|
|
139
|
+
const proc = makeSageProcess();
|
|
140
|
+
const client = createMcpClient(proc);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await initMcp(client);
|
|
144
|
+
|
|
145
|
+
const toolsList = await client.request("tools/list", {});
|
|
146
|
+
const getPrompt = toolsList.tools.find((t) => t.name === "get_prompt");
|
|
147
|
+
expect(getPrompt).toBeTruthy();
|
|
148
|
+
expect(getPrompt.inputSchema.properties.vars).toBeTruthy();
|
|
149
|
+
expect(getPrompt.inputSchema.properties.vars.type).toBe("object");
|
|
150
|
+
expect(getPrompt.description).toContain("variables");
|
|
151
|
+
} finally {
|
|
152
|
+
proc.kill("SIGTERM");
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
TIMEOUT,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
it(
|
|
159
|
+
"get_prompt interpolates vars in behavior prompt content",
|
|
160
|
+
async () => {
|
|
161
|
+
// Create a temp data dir with a behavior-type library
|
|
162
|
+
// sage resolves: $XDG_DATA_HOME/sage/libraries/
|
|
163
|
+
const tempDir = join(tmpdir(), `sage-test-${Date.now()}`);
|
|
164
|
+
const libDir = join(tempDir, "sage", "libraries");
|
|
165
|
+
mkdirSync(libDir, { recursive: true });
|
|
166
|
+
|
|
167
|
+
const manifest = {
|
|
168
|
+
version: "3.0.0",
|
|
169
|
+
library: {
|
|
170
|
+
name: "test-behaviors",
|
|
171
|
+
description: "Test behavior templates",
|
|
172
|
+
},
|
|
173
|
+
prompts: [
|
|
174
|
+
{
|
|
175
|
+
key: "viral-thread",
|
|
176
|
+
name: "Viral Thread Generator",
|
|
177
|
+
type: "behavior",
|
|
178
|
+
category: "viral-engagement",
|
|
179
|
+
tags: ["viral", "behavior"],
|
|
180
|
+
content:
|
|
181
|
+
"{{number}} {{adjective}} {{topic}} tips:\n\nMost people get {{common_thing}} wrong.",
|
|
182
|
+
variables: [
|
|
183
|
+
{
|
|
184
|
+
name: "number",
|
|
185
|
+
description: "How many tips",
|
|
186
|
+
default: "5",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "adjective",
|
|
190
|
+
description: "Descriptor",
|
|
191
|
+
default: "Essential",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: "topic",
|
|
195
|
+
description: "Subject matter",
|
|
196
|
+
required: true,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: "common_thing",
|
|
200
|
+
default: "the basics",
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
writeFileSync(join(libDir, "test-behaviors.json"), JSON.stringify(manifest, null, 2));
|
|
208
|
+
|
|
209
|
+
// sage resolves data_dir from $XDG_DATA_HOME/sage
|
|
210
|
+
const proc = makeSageProcess({ XDG_DATA_HOME: tempDir });
|
|
211
|
+
const client = createMcpClient(proc);
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await initMcp(client);
|
|
215
|
+
|
|
216
|
+
// Call get_prompt with vars
|
|
217
|
+
const callRes = await client.request("tools/call", {
|
|
218
|
+
name: "get_prompt",
|
|
219
|
+
arguments: {
|
|
220
|
+
key: "viral-thread",
|
|
221
|
+
library: "test-behaviors",
|
|
222
|
+
vars: { topic: "MCP", number: "10" },
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(callRes).toBeTruthy();
|
|
227
|
+
expect(callRes.isError || false).toBe(false);
|
|
228
|
+
|
|
229
|
+
// Parse the response content
|
|
230
|
+
const text = callRes.content?.map((c) => c.text ?? "").join("\n");
|
|
231
|
+
const result = JSON.parse(text);
|
|
232
|
+
|
|
233
|
+
expect(result.found).toBe(true);
|
|
234
|
+
expect(result.prompt.content).toContain("10");
|
|
235
|
+
expect(result.prompt.content).toContain("MCP");
|
|
236
|
+
expect(result.prompt.content).toContain("Essential"); // default for adjective
|
|
237
|
+
expect(result.prompt.content).toContain("the basics"); // default for common_thing
|
|
238
|
+
expect(result.prompt.content).not.toContain("{{");
|
|
239
|
+
|
|
240
|
+
// Variables metadata should be exposed
|
|
241
|
+
expect(result.prompt.variables).toBeTruthy();
|
|
242
|
+
expect(result.prompt.variables.length).toBe(4);
|
|
243
|
+
} finally {
|
|
244
|
+
proc.kill("SIGTERM");
|
|
245
|
+
try {
|
|
246
|
+
rmSync(tempDir, { recursive: true });
|
|
247
|
+
} catch {}
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
TIMEOUT,
|
|
251
|
+
);
|
|
129
252
|
});
|
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
2
|
+
"name": "@sage-protocol/sage-plugin",
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"description": "OpenCode plugin for Sage: capture + suggest in one module",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"lint": "bunx biome check .",
|
|
13
|
+
"test": "bun test --no-bail"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@biomejs/biome": "^1.7.3"
|
|
17
|
+
}
|
|
18
18
|
}
|