@trycua/cua 0.2.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/README.md +121 -0
- package/bin/cua.js +37 -0
- package/browser/cua_sdk-ffi.d.ts +6 -0
- package/browser/cua_sdk-ffi.js +3 -0
- package/browser/cua_sdk-ffi.ts +14 -0
- package/browser/cua_sdk.d.ts +2237 -0
- package/browser/cua_sdk.js +2607 -0
- package/browser/cua_sdk.ts +3644 -0
- package/browser/cyclops_sdk_schema-ffi.d.ts +6 -0
- package/browser/cyclops_sdk_schema-ffi.js +3 -0
- package/browser/cyclops_sdk_schema-ffi.ts +14 -0
- package/browser/cyclops_sdk_schema.d.ts +996 -0
- package/browser/cyclops_sdk_schema.js +2194 -0
- package/browser/cyclops_sdk_schema.ts +2934 -0
- package/browser/fleet_sdk-ffi.d.ts +28 -0
- package/browser/fleet_sdk-ffi.js +3 -0
- package/browser/fleet_sdk-ffi.ts +35 -0
- package/browser/fleet_sdk.d.ts +3056 -0
- package/browser/fleet_sdk.js +5088 -0
- package/browser/fleet_sdk.ts +6830 -0
- package/browser/index.d.ts +2 -0
- package/browser/index.js +18 -0
- package/browser/index.web.ts +34 -0
- package/browser/tsconfig.json +20 -0
- package/browser/wasm-bindgen/index.d.ts +2346 -0
- package/browser/wasm-bindgen/index.js +6642 -0
- package/browser/wasm-bindgen/index_bg.wasm +0 -0
- package/browser/wasm-bindgen/index_bg.wasm.d.ts +1154 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +207 -0
- package/dist/mcp.d.ts +33 -0
- package/dist/mcp.js +41 -0
- package/dist/native/cua_sdk-ffi.d.ts +1038 -0
- package/dist/native/cua_sdk-ffi.js +5095 -0
- package/dist/native/cua_sdk.d.ts +15906 -0
- package/dist/native/cua_sdk.js +27061 -0
- package/dist/native/index.d.ts +7 -0
- package/dist/native/index.js +12 -0
- package/dist/native/node-runtime.d.ts +72 -0
- package/dist/native/node-runtime.js +35 -0
- package/dist/spaces/cursorArt.d.ts +27 -0
- package/dist/spaces/cursorArt.js +59 -0
- package/dist/spaces/errors.d.ts +81 -0
- package/dist/spaces/errors.js +102 -0
- package/dist/spaces/events.d.ts +154 -0
- package/dist/spaces/events.js +182 -0
- package/dist/spaces/groups.d.ts +130 -0
- package/dist/spaces/groups.js +275 -0
- package/dist/spaces/host.d.ts +71 -0
- package/dist/spaces/host.js +154 -0
- package/dist/spaces/index.d.ts +48 -0
- package/dist/spaces/index.js +44 -0
- package/dist/spaces/pip.d.ts +128 -0
- package/dist/spaces/pip.js +250 -0
- package/dist/spaces/presence.d.ts +187 -0
- package/dist/spaces/presence.js +449 -0
- package/dist/spaces/presenceTypes.check.d.ts +12 -0
- package/dist/spaces/presenceTypes.check.js +1 -0
- package/dist/spaces/presenceTypes.d.ts +47 -0
- package/dist/spaces/presenceTypes.js +7 -0
- package/dist/spaces/routines.d.ts +158 -0
- package/dist/spaces/routines.js +339 -0
- package/dist/spaces/thread.d.ts +147 -0
- package/dist/spaces/thread.js +285 -0
- package/dist/spaces/transport/http.d.ts +51 -0
- package/dist/spaces/transport/http.js +113 -0
- package/dist/spaces/transport/index.d.ts +19 -0
- package/dist/spaces/transport/index.js +19 -0
- package/dist/spaces/transport/session.d.ts +120 -0
- package/dist/spaces/transport/session.js +188 -0
- package/dist/spaces/transport/tauri.d.ts +50 -0
- package/dist/spaces/transport/tauri.js +90 -0
- package/dist/spaces/transport/types.d.ts +123 -0
- package/dist/spaces/transport/types.js +59 -0
- package/dist/teleport/controller.d.ts +34 -0
- package/dist/teleport/controller.js +107 -0
- package/dist/teleport/drop.d.ts +62 -0
- package/dist/teleport/drop.js +138 -0
- package/dist/teleport/dropZone.d.ts +59 -0
- package/dist/teleport/dropZone.js +166 -0
- package/dist/teleport/element.d.ts +28 -0
- package/dist/teleport/element.js +340 -0
- package/dist/teleport/index.d.ts +32 -0
- package/dist/teleport/index.js +32 -0
- package/dist/teleport/install.d.ts +31 -0
- package/dist/teleport/install.js +65 -0
- package/dist/teleport/model.d.ts +245 -0
- package/dist/teleport/model.js +334 -0
- package/dist/teleport/windowDrag.d.ts +86 -0
- package/dist/teleport/windowDrag.js +71 -0
- package/package.json +93 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `McpSession` — the MCP vocabulary, over any {@link Transport}.
|
|
3
|
+
*
|
|
4
|
+
* This is the piece that makes the seam worth having: it is the same code in
|
|
5
|
+
* Node, in a Tauri webview and in a browser, and the only thing that differs
|
|
6
|
+
* between them is which transport it was constructed with.
|
|
7
|
+
*
|
|
8
|
+
* It is deliberately small. It owns the handshake, id allocation, and the
|
|
9
|
+
* unwrapping of the `content` / `isError` envelope — and nothing else. The
|
|
10
|
+
* server on the other side is the Rust Spaces MCP server (`cua daemon mcp`,
|
|
11
|
+
* the daemon's `/mcp`, or the Tauri app's bridge); its tools are the Spaces
|
|
12
|
+
* contract (`libs/cua/spaces-contract/manifest.json`). Node code that can load
|
|
13
|
+
* the native binding uses `@trycua/cua/spaces` (typed) instead.
|
|
14
|
+
*/
|
|
15
|
+
import { type Json, type SendOptions, type Transport } from "./types.js";
|
|
16
|
+
/** The MCP revision offered. The Rust server accepts 2025-06-18, 2025-03-26
|
|
17
|
+
* and 2024-11-05, and answers with the one it picked. */
|
|
18
|
+
export declare const PROTOCOL_VERSION = "2025-06-18";
|
|
19
|
+
export interface ClientInfo {
|
|
20
|
+
name: string;
|
|
21
|
+
version: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ServerInfo {
|
|
24
|
+
name: string;
|
|
25
|
+
version: string;
|
|
26
|
+
}
|
|
27
|
+
/** One part of a tool result. `call_tool` passes through `image`,
|
|
28
|
+
* `audio` and `resource` parts from an in-space tool unchanged, so this is not
|
|
29
|
+
* only ever text. */
|
|
30
|
+
export interface ContentPart {
|
|
31
|
+
type: string;
|
|
32
|
+
text?: string;
|
|
33
|
+
[key: string]: Json | undefined;
|
|
34
|
+
}
|
|
35
|
+
export interface ToolResult {
|
|
36
|
+
content: ContentPart[];
|
|
37
|
+
isError: boolean;
|
|
38
|
+
/** `structuredContent`, when the tool returned one (tool errors carry
|
|
39
|
+
* `{error: {kind, message}}` with a stable `kind`). */
|
|
40
|
+
structured?: Json;
|
|
41
|
+
}
|
|
42
|
+
export interface ToolDescriptor {
|
|
43
|
+
name: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
inputSchema?: Json;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A tool that ran and failed.
|
|
49
|
+
*
|
|
50
|
+
* This is a separate type from {@link TransportError} on purpose. The server
|
|
51
|
+
* reports a failing tool as a *successful* JSON-RPC response carrying
|
|
52
|
+
* `isError: true` — the only JSON-RPC `error` objects it ever sends are
|
|
53
|
+
* `-32601` for an unknown method or tool. Collapsing the two would make
|
|
54
|
+
* "the sandbox refused this command" indistinguishable from "the control
|
|
55
|
+
* plane is not running", and those need different handling by every caller.
|
|
56
|
+
*/
|
|
57
|
+
export declare class ToolError extends Error {
|
|
58
|
+
readonly tag = "ToolError";
|
|
59
|
+
readonly tool: string;
|
|
60
|
+
readonly content: ContentPart[];
|
|
61
|
+
/** The server's stable error kind (`capability_missing`,
|
|
62
|
+
* `teleport_refused`, `not_found`, ...), when it sent one. */
|
|
63
|
+
readonly kind: string | undefined;
|
|
64
|
+
constructor(tool: string, content: ContentPart[], structured?: Json);
|
|
65
|
+
}
|
|
66
|
+
/** Concatenate the text parts, which is what almost every caller wants. */
|
|
67
|
+
export declare function textOf(content: ContentPart[]): string;
|
|
68
|
+
export interface SessionOptions {
|
|
69
|
+
clientInfo?: ClientInfo;
|
|
70
|
+
}
|
|
71
|
+
export declare class McpSession {
|
|
72
|
+
readonly transport: Transport;
|
|
73
|
+
private nextId;
|
|
74
|
+
private handshake;
|
|
75
|
+
private readonly clientInfo;
|
|
76
|
+
constructor(transport: Transport, options?: SessionOptions);
|
|
77
|
+
private allocateId;
|
|
78
|
+
private unwrap;
|
|
79
|
+
/**
|
|
80
|
+
* Perform the MCP handshake, at most once per session.
|
|
81
|
+
*
|
|
82
|
+
* The server does not require this — it builds its dispatch table before the
|
|
83
|
+
* read loop starts, so `tools/call` works cold. We do it anyway, and we
|
|
84
|
+
* memoize it: it is what makes this session usable against a *conforming*
|
|
85
|
+
* MCP server rather than only against this one, and the cost is a single
|
|
86
|
+
* round trip.
|
|
87
|
+
*
|
|
88
|
+
* The result is cached as a promise, not a value, so concurrent first calls
|
|
89
|
+
* share one handshake instead of racing two.
|
|
90
|
+
*/
|
|
91
|
+
initialize(options?: SendOptions): Promise<ServerInfo | undefined>;
|
|
92
|
+
private performHandshake;
|
|
93
|
+
/** Sends a request after the handshake; re-initializes once when the
|
|
94
|
+
* server says the session expired (HTTP 404 from a restarted daemon). */
|
|
95
|
+
private request;
|
|
96
|
+
/** Every tool the server serves, with its input schema. */
|
|
97
|
+
listTools(options?: SendOptions): Promise<ToolDescriptor[]>;
|
|
98
|
+
/**
|
|
99
|
+
* Call a tool and return its envelope, including a failure.
|
|
100
|
+
*
|
|
101
|
+
* Use this when a caller wants to inspect `isError` itself — for instance a
|
|
102
|
+
* probe that treats "not available" as an answer rather than an exception.
|
|
103
|
+
* Most callers want {@link call}.
|
|
104
|
+
*/
|
|
105
|
+
callRaw(name: string, args?: Record<string, Json>, options?: SendOptions): Promise<ToolResult>;
|
|
106
|
+
/** Call a tool, throwing {@link ToolError} if it failed. */
|
|
107
|
+
call(name: string, args?: Record<string, Json>, options?: SendOptions): Promise<ContentPart[]>;
|
|
108
|
+
/** Call a tool and return its text parts joined — the common case, since
|
|
109
|
+
* most of these tools answer with one text part. */
|
|
110
|
+
callText(name: string, args?: Record<string, Json>, options?: SendOptions): Promise<string>;
|
|
111
|
+
/**
|
|
112
|
+
* Call a tool whose text part is JSON, and parse it.
|
|
113
|
+
*
|
|
114
|
+
* Worth its own method because the parse failure needs to say which tool
|
|
115
|
+
* produced the unparsable text — without that, a server-side format change
|
|
116
|
+
* surfaces as a bare `SyntaxError` with no attribution.
|
|
117
|
+
*/
|
|
118
|
+
callJson<T = Json>(name: string, args?: Record<string, Json>, options?: SendOptions): Promise<T>;
|
|
119
|
+
close(): Promise<void>;
|
|
120
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `McpSession` — the MCP vocabulary, over any {@link Transport}.
|
|
3
|
+
*
|
|
4
|
+
* This is the piece that makes the seam worth having: it is the same code in
|
|
5
|
+
* Node, in a Tauri webview and in a browser, and the only thing that differs
|
|
6
|
+
* between them is which transport it was constructed with.
|
|
7
|
+
*
|
|
8
|
+
* It is deliberately small. It owns the handshake, id allocation, and the
|
|
9
|
+
* unwrapping of the `content` / `isError` envelope — and nothing else. The
|
|
10
|
+
* server on the other side is the Rust Spaces MCP server (`cua daemon mcp`,
|
|
11
|
+
* the daemon's `/mcp`, or the Tauri app's bridge); its tools are the Spaces
|
|
12
|
+
* contract (`libs/cua/spaces-contract/manifest.json`). Node code that can load
|
|
13
|
+
* the native binding uses `@trycua/cua/spaces` (typed) instead.
|
|
14
|
+
*/
|
|
15
|
+
import { TransportError, } from "./types.js";
|
|
16
|
+
/** The MCP revision offered. The Rust server accepts 2025-06-18, 2025-03-26
|
|
17
|
+
* and 2024-11-05, and answers with the one it picked. */
|
|
18
|
+
export const PROTOCOL_VERSION = "2025-06-18";
|
|
19
|
+
/**
|
|
20
|
+
* A tool that ran and failed.
|
|
21
|
+
*
|
|
22
|
+
* This is a separate type from {@link TransportError} on purpose. The server
|
|
23
|
+
* reports a failing tool as a *successful* JSON-RPC response carrying
|
|
24
|
+
* `isError: true` — the only JSON-RPC `error` objects it ever sends are
|
|
25
|
+
* `-32601` for an unknown method or tool. Collapsing the two would make
|
|
26
|
+
* "the sandbox refused this command" indistinguishable from "the control
|
|
27
|
+
* plane is not running", and those need different handling by every caller.
|
|
28
|
+
*/
|
|
29
|
+
export class ToolError extends Error {
|
|
30
|
+
tag = "ToolError";
|
|
31
|
+
tool;
|
|
32
|
+
content;
|
|
33
|
+
/** The server's stable error kind (`capability_missing`,
|
|
34
|
+
* `teleport_refused`, `not_found`, ...), when it sent one. */
|
|
35
|
+
kind;
|
|
36
|
+
constructor(tool, content, structured) {
|
|
37
|
+
super(`${tool}: ${textOf(content) || "failed"}`);
|
|
38
|
+
this.name = "ToolError";
|
|
39
|
+
this.tool = tool;
|
|
40
|
+
this.content = content;
|
|
41
|
+
const error = structured?.error;
|
|
42
|
+
this.kind = typeof error?.kind === "string" ? error.kind : undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Concatenate the text parts, which is what almost every caller wants. */
|
|
46
|
+
export function textOf(content) {
|
|
47
|
+
return content
|
|
48
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
49
|
+
.map((part) => part.text)
|
|
50
|
+
.join("\n");
|
|
51
|
+
}
|
|
52
|
+
export class McpSession {
|
|
53
|
+
transport;
|
|
54
|
+
nextId = 1;
|
|
55
|
+
handshake;
|
|
56
|
+
clientInfo;
|
|
57
|
+
constructor(transport, options = {}) {
|
|
58
|
+
this.transport = transport;
|
|
59
|
+
this.clientInfo = options.clientInfo ?? { name: "@trycua/cua", version: "0.2.0" };
|
|
60
|
+
}
|
|
61
|
+
allocateId() {
|
|
62
|
+
return this.nextId++;
|
|
63
|
+
}
|
|
64
|
+
unwrap(response, method) {
|
|
65
|
+
if (response === null) {
|
|
66
|
+
throw new TransportError(`${method} expected a reply and got none`);
|
|
67
|
+
}
|
|
68
|
+
if (response.error) {
|
|
69
|
+
throw new TransportError(`${method}: ${response.error.message}`, response.error.code);
|
|
70
|
+
}
|
|
71
|
+
return response.result ?? null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Perform the MCP handshake, at most once per session.
|
|
75
|
+
*
|
|
76
|
+
* The server does not require this — it builds its dispatch table before the
|
|
77
|
+
* read loop starts, so `tools/call` works cold. We do it anyway, and we
|
|
78
|
+
* memoize it: it is what makes this session usable against a *conforming*
|
|
79
|
+
* MCP server rather than only against this one, and the cost is a single
|
|
80
|
+
* round trip.
|
|
81
|
+
*
|
|
82
|
+
* The result is cached as a promise, not a value, so concurrent first calls
|
|
83
|
+
* share one handshake instead of racing two.
|
|
84
|
+
*/
|
|
85
|
+
async initialize(options) {
|
|
86
|
+
if (!this.handshake) {
|
|
87
|
+
this.handshake = this.performHandshake(options).catch((error) => {
|
|
88
|
+
// A failed handshake must not poison the session forever; a later call
|
|
89
|
+
// should be free to try again against a restarted server.
|
|
90
|
+
this.handshake = undefined;
|
|
91
|
+
throw error;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return this.handshake;
|
|
95
|
+
}
|
|
96
|
+
async performHandshake(options) {
|
|
97
|
+
const result = this.unwrap(await this.transport.send({
|
|
98
|
+
jsonrpc: "2.0",
|
|
99
|
+
id: this.allocateId(),
|
|
100
|
+
method: "initialize",
|
|
101
|
+
params: {
|
|
102
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
103
|
+
capabilities: {},
|
|
104
|
+
clientInfo: { ...this.clientInfo },
|
|
105
|
+
},
|
|
106
|
+
}, options), "initialize");
|
|
107
|
+
await this.transport.send({ jsonrpc: "2.0", method: "notifications/initialized" }, options);
|
|
108
|
+
const info = result?.serverInfo;
|
|
109
|
+
return info;
|
|
110
|
+
}
|
|
111
|
+
/** Sends a request after the handshake; re-initializes once when the
|
|
112
|
+
* server says the session expired (HTTP 404 from a restarted daemon). */
|
|
113
|
+
async request(method, params, options) {
|
|
114
|
+
for (let attempt = 0;; attempt++) {
|
|
115
|
+
await this.initialize(options);
|
|
116
|
+
try {
|
|
117
|
+
const message = params === undefined
|
|
118
|
+
? { jsonrpc: "2.0", id: this.allocateId(), method }
|
|
119
|
+
: { jsonrpc: "2.0", id: this.allocateId(), method, params };
|
|
120
|
+
return this.unwrap(await this.transport.send(message, options), method);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (attempt === 0 && error instanceof TransportError && error.code === 404) {
|
|
124
|
+
this.handshake = undefined;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Every tool the server serves, with its input schema. */
|
|
132
|
+
async listTools(options) {
|
|
133
|
+
const result = await this.request("tools/list", undefined, options);
|
|
134
|
+
const tools = result?.tools;
|
|
135
|
+
return Array.isArray(tools) ? tools : [];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Call a tool and return its envelope, including a failure.
|
|
139
|
+
*
|
|
140
|
+
* Use this when a caller wants to inspect `isError` itself — for instance a
|
|
141
|
+
* probe that treats "not available" as an answer rather than an exception.
|
|
142
|
+
* Most callers want {@link call}.
|
|
143
|
+
*/
|
|
144
|
+
async callRaw(name, args = {}, options) {
|
|
145
|
+
const result = (await this.request("tools/call", { name, arguments: args }, options));
|
|
146
|
+
const content = Array.isArray(result?.content) ? result.content : [];
|
|
147
|
+
// `isError` is read as a field and never inferred from the shape of the
|
|
148
|
+
// content: a missing flag means the server did not say, and reading
|
|
149
|
+
// silence as success is how a failure becomes a wrong answer instead of an
|
|
150
|
+
// error. This server always sends it explicitly, including `false`.
|
|
151
|
+
const out = { content, isError: result?.isError === true };
|
|
152
|
+
if (result?.structuredContent !== undefined)
|
|
153
|
+
out.structured = result.structuredContent;
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
/** Call a tool, throwing {@link ToolError} if it failed. */
|
|
157
|
+
async call(name, args = {}, options) {
|
|
158
|
+
const result = await this.callRaw(name, args, options);
|
|
159
|
+
if (result.isError)
|
|
160
|
+
throw new ToolError(name, result.content, result.structured);
|
|
161
|
+
return result.content;
|
|
162
|
+
}
|
|
163
|
+
/** Call a tool and return its text parts joined — the common case, since
|
|
164
|
+
* most of these tools answer with one text part. */
|
|
165
|
+
async callText(name, args = {}, options) {
|
|
166
|
+
return textOf(await this.call(name, args, options));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Call a tool whose text part is JSON, and parse it.
|
|
170
|
+
*
|
|
171
|
+
* Worth its own method because the parse failure needs to say which tool
|
|
172
|
+
* produced the unparsable text — without that, a server-side format change
|
|
173
|
+
* surfaces as a bare `SyntaxError` with no attribution.
|
|
174
|
+
*/
|
|
175
|
+
async callJson(name, args = {}, options) {
|
|
176
|
+
const text = await this.callText(name, args, options);
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(text);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
throw new TransportError(`${name} did not return JSON: ${text.slice(0, 200)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async close() {
|
|
185
|
+
this.handshake = undefined;
|
|
186
|
+
await this.transport.close();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TauriTransport` — the conformer that makes this SDK usable from a webview.
|
|
3
|
+
*
|
|
4
|
+
* A Tauri frontend cannot load the package's N-API binding, so the root
|
|
5
|
+
* export cannot run there. What it *can* do is `invoke()` a Rust command
|
|
6
|
+
* that hands the JSON-RPC message to the Rust Spaces MCP server (in process
|
|
7
|
+
* via `cua_spaces::mcp::McpServer::handle`, or the daemon), and this class
|
|
8
|
+
* is the lines that carry a message across that call.
|
|
9
|
+
*
|
|
10
|
+
* Everything above it — `McpSession`, the handshake, the tool vocabulary, the
|
|
11
|
+
* `isError` unwrapping — is the same code a Node consumer runs. That is the
|
|
12
|
+
* claim the seam exists to make true.
|
|
13
|
+
*
|
|
14
|
+
* # Why `invoke` is injected rather than imported
|
|
15
|
+
*
|
|
16
|
+
* This package does not depend on `@tauri-apps/api`, and should not: a Node
|
|
17
|
+
* or browser consumer installing the Spaces SDK should not acquire a desktop
|
|
18
|
+
* framework. The host passes its own `invoke`, which also makes the transport
|
|
19
|
+
* trivially testable with a function that returns canned replies — the
|
|
20
|
+
* conformance test does exactly that.
|
|
21
|
+
*/
|
|
22
|
+
import { type RpcOutgoing, type RpcResponse, type SendOptions, type Transport } from "./types.js";
|
|
23
|
+
/** The shape of Tauri's `invoke`, narrowed to what this needs. */
|
|
24
|
+
export type InvokeFn = <T>(command: string, args?: Record<string, unknown>) => Promise<T>;
|
|
25
|
+
export interface TauriOptions {
|
|
26
|
+
/** Usually `(await import("@tauri-apps/api/core")).invoke`. */
|
|
27
|
+
invoke: InvokeFn;
|
|
28
|
+
/** The Rust command name (default `spaces_mcp_request`). */
|
|
29
|
+
requestCommand?: string;
|
|
30
|
+
/** The Rust command that ends the session (default `spaces_mcp_shutdown`). */
|
|
31
|
+
shutdownCommand?: string;
|
|
32
|
+
}
|
|
33
|
+
export declare const DEFAULT_REQUEST_COMMAND = "spaces_mcp_request";
|
|
34
|
+
export declare const DEFAULT_SHUTDOWN_COMMAND = "spaces_mcp_shutdown";
|
|
35
|
+
export declare class TauriTransport implements Transport {
|
|
36
|
+
readonly kind = "tauri";
|
|
37
|
+
private readonly invoke;
|
|
38
|
+
private readonly requestCommand;
|
|
39
|
+
private readonly shutdownCommand;
|
|
40
|
+
constructor(options: TauriOptions);
|
|
41
|
+
send(message: RpcOutgoing, options?: SendOptions): Promise<RpcResponse | null>;
|
|
42
|
+
/**
|
|
43
|
+
* Ask the bridge to close the server's stdin.
|
|
44
|
+
*
|
|
45
|
+
* A failure here is swallowed: the transport is being torn down, the app may
|
|
46
|
+
* already be quitting, and turning "could not reach the bridge while closing"
|
|
47
|
+
* into an exception makes shutdown paths fragile for no gain.
|
|
48
|
+
*/
|
|
49
|
+
close(): Promise<void>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TauriTransport` — the conformer that makes this SDK usable from a webview.
|
|
3
|
+
*
|
|
4
|
+
* A Tauri frontend cannot load the package's N-API binding, so the root
|
|
5
|
+
* export cannot run there. What it *can* do is `invoke()` a Rust command
|
|
6
|
+
* that hands the JSON-RPC message to the Rust Spaces MCP server (in process
|
|
7
|
+
* via `cua_spaces::mcp::McpServer::handle`, or the daemon), and this class
|
|
8
|
+
* is the lines that carry a message across that call.
|
|
9
|
+
*
|
|
10
|
+
* Everything above it — `McpSession`, the handshake, the tool vocabulary, the
|
|
11
|
+
* `isError` unwrapping — is the same code a Node consumer runs. That is the
|
|
12
|
+
* claim the seam exists to make true.
|
|
13
|
+
*
|
|
14
|
+
* # Why `invoke` is injected rather than imported
|
|
15
|
+
*
|
|
16
|
+
* This package does not depend on `@tauri-apps/api`, and should not: a Node
|
|
17
|
+
* or browser consumer installing the Spaces SDK should not acquire a desktop
|
|
18
|
+
* framework. The host passes its own `invoke`, which also makes the transport
|
|
19
|
+
* trivially testable with a function that returns canned replies — the
|
|
20
|
+
* conformance test does exactly that.
|
|
21
|
+
*/
|
|
22
|
+
import { TransportError, } from "./types.js";
|
|
23
|
+
export const DEFAULT_REQUEST_COMMAND = "spaces_mcp_request";
|
|
24
|
+
export const DEFAULT_SHUTDOWN_COMMAND = "spaces_mcp_shutdown";
|
|
25
|
+
export class TauriTransport {
|
|
26
|
+
kind = "tauri";
|
|
27
|
+
invoke;
|
|
28
|
+
requestCommand;
|
|
29
|
+
shutdownCommand;
|
|
30
|
+
constructor(options) {
|
|
31
|
+
this.invoke = options.invoke;
|
|
32
|
+
this.requestCommand = options.requestCommand ?? DEFAULT_REQUEST_COMMAND;
|
|
33
|
+
this.shutdownCommand = options.shutdownCommand ?? DEFAULT_SHUTDOWN_COMMAND;
|
|
34
|
+
}
|
|
35
|
+
async send(message, options = {}) {
|
|
36
|
+
if (options.signal?.aborted) {
|
|
37
|
+
throw new TransportError(`${message.method} was aborted`);
|
|
38
|
+
}
|
|
39
|
+
// The Rust side owns the wait, including the timeout, because it owns the
|
|
40
|
+
// pending-reply map: a deadline enforced only here would leave the bridge
|
|
41
|
+
// holding a waiter for a reply nobody wants any more.
|
|
42
|
+
const call = this.invoke(this.requestCommand, {
|
|
43
|
+
message: message,
|
|
44
|
+
timeoutSecs: options.timeoutSeconds ?? null,
|
|
45
|
+
});
|
|
46
|
+
const value = options.signal ? await race(call, options.signal, message.method) : await call;
|
|
47
|
+
// A notification gets no reply, and the bridge answers `null` for it
|
|
48
|
+
// rather than inventing one.
|
|
49
|
+
if (value === null || value === undefined)
|
|
50
|
+
return null;
|
|
51
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
52
|
+
throw new TransportError(`${message.method}: the bridge returned a non-object reply`);
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Ask the bridge to close the server's stdin.
|
|
58
|
+
*
|
|
59
|
+
* A failure here is swallowed: the transport is being torn down, the app may
|
|
60
|
+
* already be quitting, and turning "could not reach the bridge while closing"
|
|
61
|
+
* into an exception makes shutdown paths fragile for no gain.
|
|
62
|
+
*/
|
|
63
|
+
async close() {
|
|
64
|
+
try {
|
|
65
|
+
await this.invoke(this.shutdownCommand);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* the bridge is gone, which is the state we wanted */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Abort support for a call we cannot actually cancel.
|
|
74
|
+
*
|
|
75
|
+
* The server has no `notifications/cancelled` handling and is strictly
|
|
76
|
+
* sequential, so aborting abandons the reply — the work still runs to
|
|
77
|
+
* completion. This is honest about that rather than implying a cancel: the
|
|
78
|
+
* promise rejects, the bridge's waiter is left to its own timeout.
|
|
79
|
+
*/
|
|
80
|
+
async function race(promise, signal, method) {
|
|
81
|
+
return await Promise.race([
|
|
82
|
+
promise,
|
|
83
|
+
new Promise((_resolve, reject) => {
|
|
84
|
+
const onAbort = () => reject(new TransportError(`${method} was abandoned`));
|
|
85
|
+
if (signal.aborted)
|
|
86
|
+
return onAbort();
|
|
87
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport seam.
|
|
3
|
+
*
|
|
4
|
+
* # Why a seam at all
|
|
5
|
+
*
|
|
6
|
+
* The Spaces control plane is the Rust MCP server (`cua daemon mcp` over
|
|
7
|
+
* stdio, the daemon's `/mcp` over HTTP). Three of the four places this SDK
|
|
8
|
+
* has to run cannot spawn a subprocess:
|
|
9
|
+
*
|
|
10
|
+
* | host | can spawn a subprocess? | can load the N-API binding? |
|
|
11
|
+
* |---|---|---|
|
|
12
|
+
* | Node | yes | yes |
|
|
13
|
+
* | Tauri webview (`apps/cua-spaces`) | no | no |
|
|
14
|
+
* | browser | no | no |
|
|
15
|
+
* | React Native | no | yes, via UBRN |
|
|
16
|
+
*
|
|
17
|
+
* So a TypeScript SDK that owns its own stdio transport can only ever run in
|
|
18
|
+
* Node, and "the TypeScript SDK" would not be the thing our own app uses. The
|
|
19
|
+
* transport is what has to be pluggable for the same API to be true
|
|
20
|
+
* everywhere; everything above it — the handshake, the tool vocabulary, the
|
|
21
|
+
* error shape — is identical whatever carries the bytes.
|
|
22
|
+
*
|
|
23
|
+
* # Where the seam falls, and why it is not at the byte level
|
|
24
|
+
*
|
|
25
|
+
* A `Transport` exchanges **whole JSON-RPC messages**, not bytes. That is the
|
|
26
|
+
* load-bearing choice.
|
|
27
|
+
*
|
|
28
|
+
* Line framing is protocol. It is the bug the architecture document calls §1
|
|
29
|
+
* — a framer that dropped every byte after the first newline in a chunk — and
|
|
30
|
+
* it is invisible until it is catastrophic. If the seam were a byte stream,
|
|
31
|
+
* every host would re-implement that framer, and the whole argument for one
|
|
32
|
+
* Rust core would be conceded at exactly the layer that has already been got
|
|
33
|
+
* wrong once.
|
|
34
|
+
*
|
|
35
|
+
* At the message level the framer lives wherever the server lives: in the
|
|
36
|
+
* Rust `cua_spaces::mcp::stdio` framer, the daemon's HTTP endpoint, or the
|
|
37
|
+
* Tauri app's Rust side. None of them is the renderer's problem.
|
|
38
|
+
*
|
|
39
|
+
* # Nothing in this file imports the native binding
|
|
40
|
+
*
|
|
41
|
+
* This module and everything under `src/transport/` is dependency-free,
|
|
42
|
+
* platform-free TypeScript. It is exported from `@trycua/cua/spaces/transport`
|
|
43
|
+
* rather than the package root precisely so a webview bundle can import it
|
|
44
|
+
* without dragging in `@ubjs/node`, which would fail to resolve and take the
|
|
45
|
+
* whole bundle with it.
|
|
46
|
+
*/
|
|
47
|
+
/** A JSON value, as it crosses the wire. */
|
|
48
|
+
export type Json = null | boolean | number | string | Json[] | {
|
|
49
|
+
[key: string]: Json;
|
|
50
|
+
};
|
|
51
|
+
/** JSON-RPC 2.0 ids: this SDK only ever sends numbers, but a reply echoes
|
|
52
|
+
* whatever it was given, and a conforming peer may use a string. */
|
|
53
|
+
export type RpcId = number | string;
|
|
54
|
+
export interface RpcRequest {
|
|
55
|
+
jsonrpc: "2.0";
|
|
56
|
+
id: RpcId;
|
|
57
|
+
method: string;
|
|
58
|
+
params?: Json;
|
|
59
|
+
}
|
|
60
|
+
/** A message with no `id`. It gets no reply, and a transport must resolve
|
|
61
|
+
* `null` for it rather than inventing one. */
|
|
62
|
+
export interface RpcNotification {
|
|
63
|
+
jsonrpc: "2.0";
|
|
64
|
+
method: string;
|
|
65
|
+
params?: Json;
|
|
66
|
+
}
|
|
67
|
+
export interface RpcError {
|
|
68
|
+
code: number;
|
|
69
|
+
message: string;
|
|
70
|
+
data?: Json;
|
|
71
|
+
}
|
|
72
|
+
export interface RpcResponse {
|
|
73
|
+
jsonrpc: "2.0";
|
|
74
|
+
id: RpcId;
|
|
75
|
+
result?: Json;
|
|
76
|
+
error?: RpcError;
|
|
77
|
+
}
|
|
78
|
+
export type RpcOutgoing = RpcRequest | RpcNotification;
|
|
79
|
+
export interface SendOptions {
|
|
80
|
+
/**
|
|
81
|
+
* Seconds to wait for a reply.
|
|
82
|
+
*
|
|
83
|
+
* The default is deliberately long. `teleport_app` and provisioning run with
|
|
84
|
+
* `timeout=900` server-side, and the agent-CLI registration this server
|
|
85
|
+
* ships with sets `requestTimeoutMs: 900000`. A transport that imposed a
|
|
86
|
+
* short deadline would silently break the app's slowest and most valuable
|
|
87
|
+
* operations, so a conformer must not shorten this on its own initiative.
|
|
88
|
+
*/
|
|
89
|
+
timeoutSeconds?: number;
|
|
90
|
+
/** Aborts the wait. A transport should still expect the server to finish the
|
|
91
|
+
* work: this server has no `notifications/cancelled` handling, so aborting
|
|
92
|
+
* abandons the reply rather than stopping the call. */
|
|
93
|
+
signal?: AbortSignal;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The one interface a host has to implement.
|
|
97
|
+
*
|
|
98
|
+
* Implementations must:
|
|
99
|
+
* - resolve the reply whose `id` matches the request's, and not some other
|
|
100
|
+
* reply that happened to arrive first;
|
|
101
|
+
* - resolve `null` for a notification;
|
|
102
|
+
* - reject every in-flight call if the peer dies, rather than leaving callers
|
|
103
|
+
* hanging on a reply that is never coming;
|
|
104
|
+
* - be safe to call again after `close()`, either by restarting or by
|
|
105
|
+
* rejecting clearly.
|
|
106
|
+
*/
|
|
107
|
+
export interface Transport {
|
|
108
|
+
/** A short name for diagnostics — `"stdio"`, `"tauri"`. */
|
|
109
|
+
readonly kind: string;
|
|
110
|
+
/** Send one message; resolve its reply, or `null` for a notification. */
|
|
111
|
+
send(message: RpcOutgoing, options?: SendOptions): Promise<RpcResponse | null>;
|
|
112
|
+
/** Release the peer. Idempotent. */
|
|
113
|
+
close(): Promise<void>;
|
|
114
|
+
}
|
|
115
|
+
/** Thrown for anything that goes wrong beneath the tool call: a dead peer, a
|
|
116
|
+
* timeout, a JSON-RPC `error` object. A failing *tool* is not this — see
|
|
117
|
+
* `ToolError`, because this server reports tool failure as a successful
|
|
118
|
+
* response carrying `isError: true`. */
|
|
119
|
+
export declare class TransportError extends Error {
|
|
120
|
+
readonly tag = "TransportError";
|
|
121
|
+
readonly code: number | undefined;
|
|
122
|
+
constructor(message: string, code?: number);
|
|
123
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport seam.
|
|
3
|
+
*
|
|
4
|
+
* # Why a seam at all
|
|
5
|
+
*
|
|
6
|
+
* The Spaces control plane is the Rust MCP server (`cua daemon mcp` over
|
|
7
|
+
* stdio, the daemon's `/mcp` over HTTP). Three of the four places this SDK
|
|
8
|
+
* has to run cannot spawn a subprocess:
|
|
9
|
+
*
|
|
10
|
+
* | host | can spawn a subprocess? | can load the N-API binding? |
|
|
11
|
+
* |---|---|---|
|
|
12
|
+
* | Node | yes | yes |
|
|
13
|
+
* | Tauri webview (`apps/cua-spaces`) | no | no |
|
|
14
|
+
* | browser | no | no |
|
|
15
|
+
* | React Native | no | yes, via UBRN |
|
|
16
|
+
*
|
|
17
|
+
* So a TypeScript SDK that owns its own stdio transport can only ever run in
|
|
18
|
+
* Node, and "the TypeScript SDK" would not be the thing our own app uses. The
|
|
19
|
+
* transport is what has to be pluggable for the same API to be true
|
|
20
|
+
* everywhere; everything above it — the handshake, the tool vocabulary, the
|
|
21
|
+
* error shape — is identical whatever carries the bytes.
|
|
22
|
+
*
|
|
23
|
+
* # Where the seam falls, and why it is not at the byte level
|
|
24
|
+
*
|
|
25
|
+
* A `Transport` exchanges **whole JSON-RPC messages**, not bytes. That is the
|
|
26
|
+
* load-bearing choice.
|
|
27
|
+
*
|
|
28
|
+
* Line framing is protocol. It is the bug the architecture document calls §1
|
|
29
|
+
* — a framer that dropped every byte after the first newline in a chunk — and
|
|
30
|
+
* it is invisible until it is catastrophic. If the seam were a byte stream,
|
|
31
|
+
* every host would re-implement that framer, and the whole argument for one
|
|
32
|
+
* Rust core would be conceded at exactly the layer that has already been got
|
|
33
|
+
* wrong once.
|
|
34
|
+
*
|
|
35
|
+
* At the message level the framer lives wherever the server lives: in the
|
|
36
|
+
* Rust `cua_spaces::mcp::stdio` framer, the daemon's HTTP endpoint, or the
|
|
37
|
+
* Tauri app's Rust side. None of them is the renderer's problem.
|
|
38
|
+
*
|
|
39
|
+
* # Nothing in this file imports the native binding
|
|
40
|
+
*
|
|
41
|
+
* This module and everything under `src/transport/` is dependency-free,
|
|
42
|
+
* platform-free TypeScript. It is exported from `@trycua/cua/spaces/transport`
|
|
43
|
+
* rather than the package root precisely so a webview bundle can import it
|
|
44
|
+
* without dragging in `@ubjs/node`, which would fail to resolve and take the
|
|
45
|
+
* whole bundle with it.
|
|
46
|
+
*/
|
|
47
|
+
/** Thrown for anything that goes wrong beneath the tool call: a dead peer, a
|
|
48
|
+
* timeout, a JSON-RPC `error` object. A failing *tool* is not this — see
|
|
49
|
+
* `ToolError`, because this server reports tool failure as a successful
|
|
50
|
+
* response carrying `isError: true`. */
|
|
51
|
+
export class TransportError extends Error {
|
|
52
|
+
tag = "TransportError";
|
|
53
|
+
code;
|
|
54
|
+
constructor(message, code) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "TransportError";
|
|
57
|
+
this.code = code;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {@link TeleportPickerController}: the headless state machine bound to a
|
|
3
|
+
* {@link TeleportHost}. Frameworks subscribe to it (React:
|
|
4
|
+
* `useSyncExternalStore(c.subscribe, () => c.state)`; the web component
|
|
5
|
+
* re-renders on each change).
|
|
6
|
+
*/
|
|
7
|
+
import { type CatalogEntry, type PickerEvent, type PickerState, type TeleportHost } from "./model.js";
|
|
8
|
+
export interface ControllerOptions {
|
|
9
|
+
spaceName: string;
|
|
10
|
+
/** Jump straight to the options for this app (a drop or window drag). */
|
|
11
|
+
preselect?: {
|
|
12
|
+
entry: CatalogEntry;
|
|
13
|
+
files?: string[];
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export declare class TeleportPickerController {
|
|
17
|
+
#private;
|
|
18
|
+
readonly host: TeleportHost;
|
|
19
|
+
constructor(host: TeleportHost, options: ControllerOptions);
|
|
20
|
+
get state(): PickerState;
|
|
21
|
+
/** Subscribe to changes; returns the unsubscribe. */
|
|
22
|
+
subscribe: (listener: () => void) => (() => void);
|
|
23
|
+
dispatch: (event: PickerEvent) => void;
|
|
24
|
+
/** Loads (or reloads) the catalog. */
|
|
25
|
+
load(): Promise<void>;
|
|
26
|
+
/** Opens the options for the selected (or given) app. */
|
|
27
|
+
choose(id?: string): void;
|
|
28
|
+
/** Opens the native chooser and adds what the user picked. */
|
|
29
|
+
chooseFiles(): Promise<void>;
|
|
30
|
+
/** Builds the plan (sizes, installs, consent items). */
|
|
31
|
+
plan(): Promise<void>;
|
|
32
|
+
/** Runs the approved plan with the user's consent. */
|
|
33
|
+
confirm(): Promise<void>;
|
|
34
|
+
}
|