@ryuhq/sdk 0.0.5

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.
Files changed (61) hide show
  1. package/LICENSE +179 -0
  2. package/README.md +31 -0
  3. package/dist/agent.cjs +761 -0
  4. package/dist/agent.d.cts +3 -0
  5. package/dist/agent.d.ts +3 -0
  6. package/dist/agent.js +23 -0
  7. package/dist/chunk-GXHL5CO7.js +353 -0
  8. package/dist/chunk-KPKMMGVC.js +671 -0
  9. package/dist/chunk-ODFEUVPW.js +100 -0
  10. package/dist/cli.cjs +858 -0
  11. package/dist/cli.d.cts +1 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +454 -0
  14. package/dist/index-CEbS1SlS.d.cts +988 -0
  15. package/dist/index-DAxq7Y0R.d.ts +988 -0
  16. package/dist/index.cjs +1900 -0
  17. package/dist/index.d.cts +759 -0
  18. package/dist/index.d.ts +759 -0
  19. package/dist/index.js +771 -0
  20. package/dist/manifest.cjs +399 -0
  21. package/dist/manifest.d.cts +355 -0
  22. package/dist/manifest.d.ts +355 -0
  23. package/dist/manifest.js +38 -0
  24. package/package.json +56 -0
  25. package/src/agent/agent.ts +208 -0
  26. package/src/agent/index.ts +51 -0
  27. package/src/agent/loop.test.ts +261 -0
  28. package/src/agent/loop.ts +259 -0
  29. package/src/agent/model-call.ts +190 -0
  30. package/src/agent/query.ts +40 -0
  31. package/src/agent/tools.ts +295 -0
  32. package/src/builder.ts +473 -0
  33. package/src/cli/dev.test.ts +178 -0
  34. package/src/cli/dev.ts +425 -0
  35. package/src/cli.ts +390 -0
  36. package/src/contracts-lockstep.test.ts +77 -0
  37. package/src/generated/plugin-manifest.ts +1121 -0
  38. package/src/index.ts +141 -0
  39. package/src/manifest.test.ts +610 -0
  40. package/src/manifest.ts +589 -0
  41. package/src/mcp/bridge.test.ts +196 -0
  42. package/src/mcp/client.ts +253 -0
  43. package/src/mcp/fixture-server.ts +23 -0
  44. package/src/mcp/server.ts +351 -0
  45. package/src/model/client.test.ts +107 -0
  46. package/src/model/client.ts +179 -0
  47. package/src/model/gateway.ts +41 -0
  48. package/src/plugin/ryu-plugin.ts +191 -0
  49. package/src/runnable/agent.ts +338 -0
  50. package/src/runnable/app.ts +233 -0
  51. package/src/runnable/index.ts +61 -0
  52. package/src/runnable/primitives-hostapi.test.ts +73 -0
  53. package/src/runnable/primitives.test.ts +286 -0
  54. package/src/runnable/primitives.ts +610 -0
  55. package/src/runnable/runnable-types.ts +113 -0
  56. package/src/runnable/runnable.test.ts +397 -0
  57. package/src/runnable/skill.ts +60 -0
  58. package/src/runnable/tool.ts +260 -0
  59. package/src/runnable/turn-hook.test.ts +81 -0
  60. package/src/runnable/turn-hook.ts +191 -0
  61. package/src/runnable/workflow.ts +76 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * MCP client+server bridge round-trip tests.
3
+ *
4
+ * Criterion coverage:
5
+ * 1. SDK MCP client can `initialize` + `tools/list` + `tools/call` against a
6
+ * stdio MCP server matching the wire contract in client.rs.
7
+ * 2. SDK MCP server registers Runnables and serves them via tools/list +
8
+ * tools/call over stdio.
9
+ * 3. A round-trip test starts the SDK MCP server, lists tools, calls one
10
+ * Runnable tool, and asserts the result matches a direct run().
11
+ * 4. The bridge does NOT implement tool-approval or policy (left to
12
+ * chat/Gateway per #86) — documented in server.ts; no approval code here.
13
+ */
14
+
15
+ import { describe, expect, it } from "bun:test";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { callTool, listTools } from "./client.ts";
19
+ import type { SdkRunnable } from "./server.ts";
20
+ import { McpServer, unwrapContent } from "./server.ts";
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const FIXTURE = join(__dirname, "fixture-server.ts");
24
+
25
+ /** Command descriptor that spawns the fixture server via `bun run`. */
26
+ const fixtureCmd = { command: "bun", args: ["run", FIXTURE] };
27
+
28
+ // ── Criterion 1 + 2: client against SDK server ────────────────────────────────
29
+
30
+ describe("MCP client", () => {
31
+ it("lists tools from the fixture server", async () => {
32
+ const tools = await listTools(fixtureCmd);
33
+ expect(tools.length).toBeGreaterThan(0);
34
+ const greet = tools.find((t) => t.name === "greet");
35
+ expect(greet).toBeDefined();
36
+ expect(greet?.description).toContain("greeting");
37
+ });
38
+
39
+ it("calls a tool on the fixture server", async () => {
40
+ const result = await callTool(fixtureCmd, "greet", { name: "Ryu" });
41
+ // result is the raw MCP tools/call envelope
42
+ const value = unwrapContent(result);
43
+ expect(value).toEqual({ message: "Hello, Ryu!" });
44
+ });
45
+ });
46
+
47
+ // ── Criterion 3: round-trip — client result matches direct run() ──────────────
48
+
49
+ describe("MCP bridge round-trip", () => {
50
+ it("client callTool result matches direct runnable.run()", async () => {
51
+ // Define the same Runnable that the fixture server registers.
52
+ const runnable: SdkRunnable = {
53
+ name: "greet",
54
+ run: (args: unknown) => {
55
+ const a = args as { name?: string };
56
+ return Promise.resolve({ message: `Hello, ${a.name ?? "world"}!` });
57
+ },
58
+ };
59
+
60
+ const inputArgs = { name: "Bridge" };
61
+
62
+ // Direct run.
63
+ const directResult = await runnable.run(inputArgs);
64
+
65
+ // Via MCP round-trip through the fixture server.
66
+ const rawResult = await callTool(fixtureCmd, "greet", inputArgs);
67
+ const roundTripResult = unwrapContent(rawResult);
68
+
69
+ expect(roundTripResult).toEqual(directResult);
70
+ });
71
+ });
72
+
73
+ // ── McpServer.serve() in-process ──────────────────────────────────────────────
74
+
75
+ describe("McpServer.serve()", () => {
76
+ /**
77
+ * Helper: feed a sequence of JSON-RPC lines into an McpServer via piped
78
+ * streams and collect all output lines.
79
+ */
80
+ async function runInProcess(
81
+ server: McpServer,
82
+ lines: string[]
83
+ ): Promise<string[]> {
84
+ const { Readable, Writable } = await import("node:stream");
85
+
86
+ const inputLines = [...lines].map((l) => `${l}\n`).join("");
87
+
88
+ const input = Readable.from([inputLines]);
89
+ const outputLines: string[] = [];
90
+ const output = new Writable({
91
+ write(chunk, _enc, cb) {
92
+ const text: string = chunk.toString();
93
+ for (const line of text.split("\n")) {
94
+ const t = line.trim();
95
+ if (t) {
96
+ outputLines.push(t);
97
+ }
98
+ }
99
+ cb();
100
+ },
101
+ });
102
+
103
+ await server.serve(input, output);
104
+ return outputLines;
105
+ }
106
+
107
+ it("responds to initialize", async () => {
108
+ const server = new McpServer();
109
+ const outputs = await runInProcess(server, [
110
+ JSON.stringify({
111
+ jsonrpc: "2.0",
112
+ id: 1,
113
+ method: "initialize",
114
+ params: {
115
+ protocolVersion: "2024-11-05",
116
+ capabilities: {},
117
+ clientInfo: { name: "test", version: "0" },
118
+ },
119
+ }),
120
+ ]);
121
+
122
+ expect(outputs.length).toBeGreaterThan(0);
123
+ const resp = JSON.parse(outputs[0] as string);
124
+ expect(resp.id).toBe(1);
125
+ expect(resp.result?.protocolVersion).toBe("2024-11-05");
126
+ expect(resp.result?.capabilities?.tools).toBeDefined();
127
+ });
128
+
129
+ it("lists registered runnables via tools/list", async () => {
130
+ const server = new McpServer().register({
131
+ name: "ping",
132
+ description: "Ping tool",
133
+ run: async () => "pong",
134
+ });
135
+
136
+ const outputs = await runInProcess(server, [
137
+ JSON.stringify({
138
+ jsonrpc: "2.0",
139
+ id: 1,
140
+ method: "initialize",
141
+ params: { protocolVersion: "2024-11-05", capabilities: {} },
142
+ }),
143
+ JSON.stringify({
144
+ jsonrpc: "2.0",
145
+ method: "notifications/initialized",
146
+ params: {},
147
+ }),
148
+ JSON.stringify({
149
+ jsonrpc: "2.0",
150
+ id: 2,
151
+ method: "tools/list",
152
+ params: {},
153
+ }),
154
+ ]);
155
+
156
+ const listResp = outputs.map((l) => JSON.parse(l)).find((r) => r.id === 2);
157
+ expect(listResp).toBeDefined();
158
+ const tools = listResp.result?.tools as { name: string }[];
159
+ expect(tools.some((t) => t.name === "ping")).toBe(true);
160
+ });
161
+
162
+ it("calls a registered runnable via tools/call", async () => {
163
+ const server = new McpServer().register({
164
+ name: "add",
165
+ run: (args: unknown) => {
166
+ const a = args as { x: number; y: number };
167
+ return Promise.resolve(a.x + a.y);
168
+ },
169
+ });
170
+
171
+ const outputs = await runInProcess(server, [
172
+ JSON.stringify({
173
+ jsonrpc: "2.0",
174
+ id: 1,
175
+ method: "initialize",
176
+ params: { protocolVersion: "2024-11-05", capabilities: {} },
177
+ }),
178
+ JSON.stringify({
179
+ jsonrpc: "2.0",
180
+ method: "notifications/initialized",
181
+ params: {},
182
+ }),
183
+ JSON.stringify({
184
+ jsonrpc: "2.0",
185
+ id: 3,
186
+ method: "tools/call",
187
+ params: { name: "add", arguments: { x: 3, y: 4 } },
188
+ }),
189
+ ]);
190
+
191
+ const callResp = outputs.map((l) => JSON.parse(l)).find((r) => r.id === 3);
192
+ expect(callResp).toBeDefined();
193
+ const value = unwrapContent(callResp.result);
194
+ expect(value).toBe(7);
195
+ });
196
+ });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * SDK MCP stdio client — a TypeScript mirror of the wire contract in
3
+ * `apps/core/src/sidecar/mcp/client.rs`.
4
+ *
5
+ * This implements the same JSON-RPC 2.0 / newline-delimited transport:
6
+ * 1. Spawn the MCP server process.
7
+ * 2. Send `initialize` (protocolVersion "2024-11-05") and receive the result.
8
+ * 3. Send `notifications/initialized`.
9
+ * 4. Call `tools/list` or `tools/call` as needed.
10
+ * 5. Tear down the process.
11
+ *
12
+ * POLICY NOTE: this client does NOT implement tool-approval or request-level
13
+ * policy enforcement. Approval and policy live in the chat layer and the
14
+ * Gateway (per issue #86). Any policy that must run before a tool call must be
15
+ * wired upstream by the caller, not here.
16
+ */
17
+
18
+ import { spawn } from "node:child_process";
19
+ import { createInterface } from "node:readline";
20
+
21
+ /** MCP protocol version sent during `initialize`. Matches client.rs. */
22
+ export const MCP_PROTOCOL_VERSION = "2024-11-05";
23
+
24
+ /** Timeout (ms) waiting for a single JSON-RPC response. */
25
+ const RPC_TIMEOUT_MS = 60_000;
26
+
27
+ /** A tool entry from `tools/list`. */
28
+ export interface McpTool {
29
+ description?: string;
30
+ /** JSON Schema object for the tool's input arguments. */
31
+ inputSchema?: unknown;
32
+ name: string;
33
+ }
34
+
35
+ /** Command descriptor for spawning an MCP stdio server. */
36
+ export interface McpStdioCommand {
37
+ args?: string[];
38
+ command: string;
39
+ env?: Record<string, string>;
40
+ }
41
+
42
+ /** A line-delimited JSON value read from the server's stdout. */
43
+ interface JsonRpcFrame {
44
+ error?: { code: number; message: string; data?: unknown };
45
+ id?: number | null;
46
+ jsonrpc: "2.0";
47
+ method?: string;
48
+ params?: unknown;
49
+ result?: unknown;
50
+ }
51
+
52
+ /**
53
+ * Pending response waiter: each in-flight request registers a handler that
54
+ * receives the next line whose `id` matches.
55
+ */
56
+ interface ResponseWaiter {
57
+ id: number;
58
+ reject: (err: unknown) => void;
59
+ resolve: (result: unknown) => void;
60
+ timer: ReturnType<typeof setTimeout>;
61
+ }
62
+
63
+ /** A live connection to a spawned MCP stdio server. */
64
+ class McpConnection {
65
+ private readonly proc: ReturnType<typeof spawn>;
66
+ private nextId = 1;
67
+ private closed = false;
68
+ private readonly waiters: ResponseWaiter[] = [];
69
+
70
+ private constructor(proc: ReturnType<typeof spawn>) {
71
+ this.proc = proc;
72
+ }
73
+
74
+ /** Spawn the server and complete the MCP `initialize` handshake. */
75
+ static async connect(cmd: McpStdioCommand): Promise<McpConnection> {
76
+ const env = { ...process.env, ...(cmd.env ?? {}) };
77
+ const proc = spawn(cmd.command, cmd.args ?? [], {
78
+ stdio: ["pipe", "pipe", "pipe"],
79
+ env,
80
+ });
81
+
82
+ if (!(proc.stdin && proc.stdout)) {
83
+ proc.kill();
84
+ throw new Error(`MCP server '${cmd.command}' stdin/stdout unavailable`);
85
+ }
86
+
87
+ // Forward server stderr to process.stderr for diagnosability.
88
+ if (proc.stderr) {
89
+ proc.stderr.on("data", (chunk: Buffer) => {
90
+ process.stderr.write(`[mcp-server] ${chunk.toString()}`);
91
+ });
92
+ }
93
+
94
+ const conn = new McpConnection(proc);
95
+
96
+ // Wire up readline using 'line' events so the iterator is never consumed
97
+ // and closed prematurely by a `for await ... return` pattern.
98
+ const rl = createInterface({
99
+ input: proc.stdout,
100
+ crlfDelay: Number.POSITIVE_INFINITY,
101
+ });
102
+
103
+ rl.on("line", (rawLine) => {
104
+ const trimmed = rawLine.trim();
105
+ if (!trimmed) {
106
+ return;
107
+ }
108
+ let parsed: JsonRpcFrame;
109
+ try {
110
+ parsed = JSON.parse(trimmed) as JsonRpcFrame;
111
+ } catch {
112
+ return;
113
+ }
114
+ // Dispatch to the matching waiter (skip notifications with no id).
115
+ if (parsed.id === undefined || parsed.id === null) {
116
+ return;
117
+ }
118
+ const idx = conn.waiters.findIndex((w) => w.id === parsed.id);
119
+ if (idx === -1) {
120
+ return;
121
+ }
122
+ const [waiter] = conn.waiters.splice(idx, 1);
123
+ if (!waiter) {
124
+ return;
125
+ }
126
+ clearTimeout(waiter.timer);
127
+ if (parsed.error) {
128
+ waiter.reject(new Error(`MCP error: ${JSON.stringify(parsed.error)}`));
129
+ } else {
130
+ waiter.resolve(parsed.result ?? null);
131
+ }
132
+ });
133
+
134
+ rl.on("close", () => {
135
+ // Reject any in-flight waiters — the server exited.
136
+ for (const waiter of conn.waiters.splice(0)) {
137
+ clearTimeout(waiter.timer);
138
+ waiter.reject(new Error("MCP server closed the connection"));
139
+ }
140
+ });
141
+
142
+ // initialize → notifications/initialized
143
+ await conn.request("initialize", {
144
+ protocolVersion: MCP_PROTOCOL_VERSION,
145
+ capabilities: {},
146
+ clientInfo: { name: "ryu-sdk", version: "0.0.1" },
147
+ });
148
+ conn.notify("notifications/initialized", {});
149
+
150
+ return conn;
151
+ }
152
+
153
+ /** Send a JSON-RPC request and return the `result` field. */
154
+ request(method: string, params: unknown): Promise<unknown> {
155
+ const id = this.nextId++;
156
+ const frame = JSON.stringify({
157
+ jsonrpc: "2.0",
158
+ id,
159
+ method,
160
+ params,
161
+ });
162
+ this.write(frame);
163
+
164
+ return new Promise<unknown>((resolve, reject) => {
165
+ const timer = setTimeout(
166
+ () => reject(new Error(`MCP request '${method}' timed out`)),
167
+ RPC_TIMEOUT_MS
168
+ );
169
+ this.waiters.push({ id, resolve, reject, timer });
170
+ });
171
+ }
172
+
173
+ /** Send a JSON-RPC notification (no response expected). */
174
+ notify(method: string, params: unknown): void {
175
+ const frame = JSON.stringify({ jsonrpc: "2.0", method, params });
176
+ this.write(frame);
177
+ }
178
+
179
+ private write(frame: string): void {
180
+ if (this.closed) {
181
+ return;
182
+ }
183
+ this.proc.stdin?.write(`${frame}\n`);
184
+ }
185
+
186
+ /** Graceful shutdown: close stdin, then kill. */
187
+ async shutdown(): Promise<void> {
188
+ this.closed = true;
189
+ this.proc.stdin?.end();
190
+ await new Promise<void>((resolve) => {
191
+ this.proc.once("exit", () => resolve());
192
+ this.proc.kill();
193
+ // Resolve after a short grace period even if exit never fires.
194
+ setTimeout(resolve, 500);
195
+ });
196
+ }
197
+ }
198
+
199
+ // ── Public API ────────────────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * List the tools an MCP server advertises (`tools/list`).
203
+ *
204
+ * Spawns the server, completes the initialize handshake, calls `tools/list`,
205
+ * and tears down the process — matching the stateless per-request pattern in
206
+ * `apps/core/src/sidecar/mcp/client.rs`.
207
+ */
208
+ export async function listTools(cmd: McpStdioCommand): Promise<McpTool[]> {
209
+ const conn = await McpConnection.connect(cmd);
210
+ let result: unknown;
211
+ try {
212
+ result = await conn.request("tools/list", {});
213
+ } finally {
214
+ await conn.shutdown();
215
+ }
216
+
217
+ const tools = (result as { tools?: unknown[] } | null)?.tools ?? [];
218
+ return (tools as unknown[])
219
+ .map((t): McpTool | null => {
220
+ const tool = t as Record<string, unknown>;
221
+ const name = tool.name;
222
+ if (typeof name !== "string") {
223
+ return null;
224
+ }
225
+ return {
226
+ name,
227
+ description:
228
+ typeof tool.description === "string" ? tool.description : undefined,
229
+ inputSchema: tool.inputSchema,
230
+ } satisfies McpTool;
231
+ })
232
+ .filter((t): t is McpTool => t !== null);
233
+ }
234
+
235
+ /**
236
+ * Call a tool on an MCP server (`tools/call`) and return the raw result.
237
+ *
238
+ * The returned value is the full `tools/call` result object
239
+ * `{ content: [{type, text}], isError? }`. Callers that need the plain text
240
+ * output should extract `.content[0].text`.
241
+ */
242
+ export async function callTool(
243
+ cmd: McpStdioCommand,
244
+ tool: string,
245
+ args: unknown
246
+ ): Promise<unknown> {
247
+ const conn = await McpConnection.connect(cmd);
248
+ try {
249
+ return await conn.request("tools/call", { name: tool, arguments: args });
250
+ } finally {
251
+ await conn.shutdown();
252
+ }
253
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Fixture MCP stdio server used by bridge.test.ts.
3
+ *
4
+ * Registers one test Runnable ("greet") and serves over stdin/stdout.
5
+ * Spawn with: bun run packages/sdk/src/mcp/fixture-server.ts
6
+ */
7
+ import { McpServer } from "./server.ts";
8
+
9
+ const server = new McpServer().register({
10
+ name: "greet",
11
+ description: "Returns a greeting for the given name.",
12
+ inputSchema: {
13
+ type: "object",
14
+ properties: { name: { type: "string", description: "Name to greet" } },
15
+ required: ["name"],
16
+ },
17
+ run: (args: unknown) => {
18
+ const a = args as { name?: string };
19
+ return Promise.resolve({ message: `Hello, ${a.name ?? "world"}!` });
20
+ },
21
+ });
22
+
23
+ await server.serve();