@skyelight/mcp 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/LICENSE +21 -0
- package/README.md +120 -0
- package/package.json +40 -0
- package/src/client.js +96 -0
- package/src/config.js +112 -0
- package/src/index.js +24 -0
- package/src/itemCard.d.ts +23 -0
- package/src/itemCard.js +335 -0
- package/src/server.js +218 -0
- package/src/tools.d.ts +45 -0
- package/src/tools.js +500 -0
package/src/itemCard.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ui://` item card (SKY-278), per MCP Apps spec revision 2026-01-26.
|
|
3
|
+
*
|
|
4
|
+
* Lives in the package, like tools.js, and is imported by convex/mcp — one
|
|
5
|
+
* definition of what the host renders, for both transports.
|
|
6
|
+
*
|
|
7
|
+
* A STATIC template, not a per-item render. The host fetches it once, then
|
|
8
|
+
* pushes each tool result into the running iframe as a
|
|
9
|
+
* `ui/notifications/tool-result` notification — so one cacheable resource
|
|
10
|
+
* serves every item, and the card updates in place when the user asks about
|
|
11
|
+
* another one.
|
|
12
|
+
*
|
|
13
|
+
* Self-contained by construction: no external scripts, styles or fonts, so
|
|
14
|
+
* every CSP domain list is empty. That is the strongest sandbox the host can
|
|
15
|
+
* give us, and it costs nothing here — the card is a few hundred lines of
|
|
16
|
+
* markup, not an application.
|
|
17
|
+
*
|
|
18
|
+
* TWO DELIBERATE OMISSIONS, both worth knowing before reading the markup:
|
|
19
|
+
*
|
|
20
|
+
* No screenshot. The ticket asks for "screenshot with the anchor
|
|
21
|
+
* highlighted", and the data for that does not exist yet. Screenshots live on
|
|
22
|
+
* the SURFACE, which is keyed by host — so a pin on /checkout and a pin on
|
|
23
|
+
* /settings share one image, and a marker placed from the pin's coordinates
|
|
24
|
+
* would sit on the wrong page most of the time. Per-pin capture is SKY-282.
|
|
25
|
+
* A card showing the wrong page confidently is worse than one showing none.
|
|
26
|
+
*
|
|
27
|
+
* Not Preact. The ticket asks to share rendering with overlay-next rather
|
|
28
|
+
* than duplicate it, and those components are being extracted into shared/ by
|
|
29
|
+
* SKY-298 and the chip deleted in Phase 12. Building on them now means
|
|
30
|
+
* building on something mid-demolition, and bundling Preact into a resource
|
|
31
|
+
* that must be a single self-contained document buys nothing for markup this
|
|
32
|
+
* small. When SKY-298 lands, this template is the natural consumer.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export const ITEM_CARD_URI = "ui://skyelight/item-card";
|
|
36
|
+
|
|
37
|
+
/** Required by the spec for HTML apps. */
|
|
38
|
+
export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
|
|
39
|
+
|
|
40
|
+
/** The extension identifier a client declares support with. */
|
|
41
|
+
export const UI_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The app dialect version this template speaks in its `ui/initialize`.
|
|
45
|
+
* Distinct from the core protocol version — MCP Apps revs separately.
|
|
46
|
+
*/
|
|
47
|
+
export const APPS_PROTOCOL_VERSION = "2026-01-26";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether a client can render the card.
|
|
51
|
+
*
|
|
52
|
+
* Read from two places because the two transports differ. stdio has a session
|
|
53
|
+
* and declares capabilities once at `initialize`; the remote endpoint is
|
|
54
|
+
* stateless and carries them per request in `_meta` (SKY-279, and the reason
|
|
55
|
+
* spec 2026-07-28 moved them there at all).
|
|
56
|
+
*
|
|
57
|
+
* Absent means no. A client that says nothing gets the text experience, which
|
|
58
|
+
* is complete on its own — the card is strictly additive.
|
|
59
|
+
*/
|
|
60
|
+
export function clientSupportsUi(source) {
|
|
61
|
+
const caps = source?.capabilities ?? source;
|
|
62
|
+
const ext = caps?.extensions?.[UI_EXTENSION_KEY];
|
|
63
|
+
if (!ext) return false;
|
|
64
|
+
const mimeTypes = ext.mimeTypes;
|
|
65
|
+
// A client that declares the extension but no mime types is taken at its
|
|
66
|
+
// word; one that lists them must include ours.
|
|
67
|
+
if (!Array.isArray(mimeTypes) || mimeTypes.length === 0) return true;
|
|
68
|
+
return mimeTypes.some(
|
|
69
|
+
(m) => typeof m === "string" && m.startsWith("text/html"),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The resource entry for `resources/list`. */
|
|
74
|
+
export function itemCardResource() {
|
|
75
|
+
return {
|
|
76
|
+
uri: ITEM_CARD_URI,
|
|
77
|
+
name: "Skyelight item card",
|
|
78
|
+
description:
|
|
79
|
+
"Renders a feedback item as an interactive card: the thread, the page it is on, and what the person pointed at.",
|
|
80
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The `resources/read` payload, including sandbox metadata. */
|
|
85
|
+
export function itemCardContents() {
|
|
86
|
+
return {
|
|
87
|
+
uri: ITEM_CARD_URI,
|
|
88
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
89
|
+
text: ITEM_CARD_HTML,
|
|
90
|
+
_meta: {
|
|
91
|
+
ui: {
|
|
92
|
+
// Everything is inline. Nothing to allow, so nothing is allowed.
|
|
93
|
+
csp: {
|
|
94
|
+
connectDomains: [],
|
|
95
|
+
resourceDomains: [],
|
|
96
|
+
frameDomains: [],
|
|
97
|
+
baseUriDomains: [],
|
|
98
|
+
},
|
|
99
|
+
permissions: {},
|
|
100
|
+
prefersBorder: true,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ITEM_CARD_HTML = String.raw`<!DOCTYPE html>
|
|
107
|
+
<html lang="en">
|
|
108
|
+
<head>
|
|
109
|
+
<meta charset="utf-8" />
|
|
110
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
111
|
+
<title>Skyelight item</title>
|
|
112
|
+
<style>
|
|
113
|
+
:root {
|
|
114
|
+
color-scheme: light dark;
|
|
115
|
+
--bg: #ffffff; --fg: #17181a; --muted: #6b7280;
|
|
116
|
+
--line: #e5e7eb; --chip: #f3f4f6; --accent: #2563eb;
|
|
117
|
+
}
|
|
118
|
+
[data-theme="dark"] {
|
|
119
|
+
--bg: #17181a; --fg: #f3f4f6; --muted: #9ca3af;
|
|
120
|
+
--line: #2c2e33; --chip: #232529; --accent: #60a5fa;
|
|
121
|
+
}
|
|
122
|
+
* { box-sizing: border-box; }
|
|
123
|
+
body {
|
|
124
|
+
margin: 0; padding: 16px; background: var(--bg); color: var(--fg);
|
|
125
|
+
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
126
|
+
}
|
|
127
|
+
.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
128
|
+
.chips { margin-bottom: 10px; }
|
|
129
|
+
.chip {
|
|
130
|
+
font-size: 11px; letter-spacing: .02em; text-transform: uppercase;
|
|
131
|
+
font-weight: 600; color: var(--muted);
|
|
132
|
+
border: 1px solid var(--line); border-radius: 999px; padding: 3px 8px;
|
|
133
|
+
}
|
|
134
|
+
.chip-open { color: #b45309; border-color: #b4530933; }
|
|
135
|
+
.chip-resolved { color: #15803d; border-color: #15803d33; }
|
|
136
|
+
h1 { font-size: 15px; font-weight: 600; margin: 0 0 2px; }
|
|
137
|
+
a { color: var(--accent); }
|
|
138
|
+
.page { font-size: 12px; color: var(--muted); word-break: break-all; margin-bottom: 14px; }
|
|
139
|
+
.msg { padding: 10px 0; border-top: 1px solid var(--line); }
|
|
140
|
+
.msg:first-of-type { border-top: 0; }
|
|
141
|
+
.who { font-weight: 600; font-size: 13px; }
|
|
142
|
+
.agent {
|
|
143
|
+
font-size: 10px; text-transform: uppercase; letter-spacing: .03em;
|
|
144
|
+
font-weight: 700; color: var(--muted);
|
|
145
|
+
border: 1px solid var(--line); border-radius: 999px; padding: 1px 6px;
|
|
146
|
+
}
|
|
147
|
+
.body { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 3px; }
|
|
148
|
+
.anchor {
|
|
149
|
+
margin-top: 14px; padding: 10px; border: 1px solid var(--line);
|
|
150
|
+
border-radius: 8px; background: var(--chip); font-size: 12px;
|
|
151
|
+
}
|
|
152
|
+
.anchor b { font-weight: 600; }
|
|
153
|
+
.anchor code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
|
|
154
|
+
.actions { margin-top: 14px; display: flex; gap: 8px; flex-wrap: wrap; }
|
|
155
|
+
button {
|
|
156
|
+
font: inherit; font-size: 13px; padding: 6px 12px; border-radius: 7px;
|
|
157
|
+
border: 1px solid var(--line); background: var(--chip); color: var(--fg);
|
|
158
|
+
cursor: pointer;
|
|
159
|
+
}
|
|
160
|
+
button:hover { border-color: var(--accent); }
|
|
161
|
+
button[disabled] { opacity: .5; cursor: default; }
|
|
162
|
+
.note { margin-top: 10px; font-size: 12px; color: var(--muted); }
|
|
163
|
+
.empty { color: var(--muted); }
|
|
164
|
+
</style>
|
|
165
|
+
</head>
|
|
166
|
+
<body>
|
|
167
|
+
<div id="root"><p class="empty">Loading item…</p></div>
|
|
168
|
+
<script>
|
|
169
|
+
(function () {
|
|
170
|
+
"use strict";
|
|
171
|
+
|
|
172
|
+
// --- postMessage JSON-RPC to the host -----------------------------------
|
|
173
|
+
var nextId = 1;
|
|
174
|
+
var pending = {};
|
|
175
|
+
var item = null;
|
|
176
|
+
|
|
177
|
+
function send(msg) {
|
|
178
|
+
// The host is the only possible parent; "*" is safe inside a sandboxed
|
|
179
|
+
// iframe whose parent we cannot address by origin.
|
|
180
|
+
window.parent.postMessage(msg, "*");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function request(method, params) {
|
|
184
|
+
var id = nextId++;
|
|
185
|
+
return new Promise(function (resolve, reject) {
|
|
186
|
+
pending[id] = { resolve: resolve, reject: reject };
|
|
187
|
+
send({ jsonrpc: "2.0", id: id, method: method, params: params || {} });
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
window.addEventListener("message", function (event) {
|
|
192
|
+
var msg = event.data;
|
|
193
|
+
if (!msg || msg.jsonrpc !== "2.0") return;
|
|
194
|
+
|
|
195
|
+
if (msg.id !== undefined && msg.id !== null && pending[msg.id]) {
|
|
196
|
+
var p = pending[msg.id];
|
|
197
|
+
delete pending[msg.id];
|
|
198
|
+
if (msg.error) p.reject(new Error(msg.error.message || "Request failed"));
|
|
199
|
+
else p.resolve(msg.result);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The host pushes each tool result in rather than the card fetching it.
|
|
204
|
+
// One cached template therefore serves every item, and asking about a
|
|
205
|
+
// different one re-renders in place.
|
|
206
|
+
if (msg.method === "ui/notifications/tool-result") {
|
|
207
|
+
var structured = msg.params && msg.params.structuredContent;
|
|
208
|
+
if (structured && structured.thread) {
|
|
209
|
+
item = structured;
|
|
210
|
+
render();
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
request("ui/initialize", {
|
|
217
|
+
protocolVersion: "2026-01-26",
|
|
218
|
+
clientInfo: { name: "skyelight-item-card", version: "0.1.0" },
|
|
219
|
+
appCapabilities: {
|
|
220
|
+
availableDisplayModes: ["inline"],
|
|
221
|
+
tools: { listChanged: false }
|
|
222
|
+
}
|
|
223
|
+
}).then(function (result) {
|
|
224
|
+
var theme = result && result.hostContext && result.hostContext.theme;
|
|
225
|
+
if (theme) document.documentElement.setAttribute("data-theme", theme);
|
|
226
|
+
}).catch(function () {
|
|
227
|
+
// A host that never answers still leaves a readable card once a result
|
|
228
|
+
// arrives; the handshake only supplies theming.
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// --- rendering ----------------------------------------------------------
|
|
232
|
+
function el(tag, cls, text) {
|
|
233
|
+
var n = document.createElement(tag);
|
|
234
|
+
if (cls) n.className = cls;
|
|
235
|
+
if (text !== undefined && text !== null) n.textContent = String(text);
|
|
236
|
+
return n;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isAgent(id) {
|
|
240
|
+
return typeof id === "string" && id.indexOf("agent:") === 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function message(m) {
|
|
244
|
+
var wrap = el("div", "msg");
|
|
245
|
+
var head = el("div", "row");
|
|
246
|
+
head.appendChild(el("span", "who", m.author));
|
|
247
|
+
if (isAgent(m.authorId)) head.appendChild(el("span", "agent", "agent"));
|
|
248
|
+
wrap.appendChild(head);
|
|
249
|
+
wrap.appendChild(el("div", "body", m.content));
|
|
250
|
+
return wrap;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function render() {
|
|
254
|
+
var root = document.getElementById("root");
|
|
255
|
+
root.textContent = "";
|
|
256
|
+
|
|
257
|
+
var chips = el("div", "row chips");
|
|
258
|
+
if (item.type) chips.appendChild(el("span", "chip", item.type));
|
|
259
|
+
chips.appendChild(
|
|
260
|
+
el("span", "chip " + (item.status === "resolved" ? "chip-resolved" : "chip-open"), item.status)
|
|
261
|
+
);
|
|
262
|
+
if (item.project && item.project.name) {
|
|
263
|
+
chips.appendChild(el("span", "chip", item.project.name));
|
|
264
|
+
}
|
|
265
|
+
root.appendChild(chips);
|
|
266
|
+
|
|
267
|
+
root.appendChild(el("h1", null, item.pageTitle || item.page || "Item"));
|
|
268
|
+
var page = el("div", "page");
|
|
269
|
+
if (item.url) {
|
|
270
|
+
var a = el("a", null, item.url);
|
|
271
|
+
a.href = item.url;
|
|
272
|
+
a.target = "_blank";
|
|
273
|
+
a.rel = "noreferrer";
|
|
274
|
+
page.appendChild(a);
|
|
275
|
+
} else {
|
|
276
|
+
page.textContent = item.page || "";
|
|
277
|
+
}
|
|
278
|
+
root.appendChild(page);
|
|
279
|
+
|
|
280
|
+
root.appendChild(message(item.thread.root));
|
|
281
|
+
(item.thread.replies || []).forEach(function (r) {
|
|
282
|
+
root.appendChild(message(r));
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// What the person was pointing at. Visible text first: it is what they
|
|
286
|
+
// would have named, and the part that survives the markup changing.
|
|
287
|
+
if (item.anchor) {
|
|
288
|
+
var box = el("div", "anchor");
|
|
289
|
+
box.appendChild(el("b", null, "Pointing at"));
|
|
290
|
+
if (item.anchor.elementText) {
|
|
291
|
+
box.appendChild(el("div", null, '“' + item.anchor.elementText + '”'));
|
|
292
|
+
}
|
|
293
|
+
if (item.anchor.selectedText) {
|
|
294
|
+
box.appendChild(el("div", null, "selected: " + item.anchor.selectedText));
|
|
295
|
+
}
|
|
296
|
+
var sel = el("div");
|
|
297
|
+
sel.appendChild(el("code", null, item.anchor.selector || "—"));
|
|
298
|
+
box.appendChild(sel);
|
|
299
|
+
root.appendChild(box);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
var actions = el("div", "actions");
|
|
303
|
+
var open = el("button", null, "Open in Skyelight");
|
|
304
|
+
open.addEventListener("click", function () {
|
|
305
|
+
request("ui/open-link", { url: item.url }).catch(function () {});
|
|
306
|
+
});
|
|
307
|
+
actions.appendChild(open);
|
|
308
|
+
|
|
309
|
+
// Calls the same tool the model would. The host proxies it to the server
|
|
310
|
+
// and applies whatever consent it requires — the card never talks to the
|
|
311
|
+
// API itself, and could not: it has no credential.
|
|
312
|
+
var ack = el("button", null, "Reply: looking into it");
|
|
313
|
+
ack.addEventListener("click", function () {
|
|
314
|
+
ack.disabled = true;
|
|
315
|
+
ack.textContent = "Posting…";
|
|
316
|
+
request("tools/call", {
|
|
317
|
+
name: "post_update",
|
|
318
|
+
arguments: { itemId: item.id, body: "Looking into this now." }
|
|
319
|
+
}).then(function () {
|
|
320
|
+
ack.textContent = "Posted";
|
|
321
|
+
}).catch(function (err) {
|
|
322
|
+
ack.disabled = false;
|
|
323
|
+
ack.textContent = "Reply: looking into it";
|
|
324
|
+
var note = document.querySelector(".note") || el("div", "note");
|
|
325
|
+
note.textContent = err.message;
|
|
326
|
+
root.appendChild(note);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
actions.appendChild(ack);
|
|
330
|
+
root.appendChild(actions);
|
|
331
|
+
}
|
|
332
|
+
})();
|
|
333
|
+
</script>
|
|
334
|
+
</body>
|
|
335
|
+
</html>`;
|
package/src/server.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP over stdio, JSON-RPC 2.0, newline-delimited.
|
|
3
|
+
*
|
|
4
|
+
* Written against the protocol directly rather than the SDK. The surface a
|
|
5
|
+
* read-only server needs is four methods, and a package people run with
|
|
6
|
+
* `npx` is worth keeping dependency-free — nothing to install, nothing to
|
|
7
|
+
* audit, and the whole thing is testable by feeding it strings.
|
|
8
|
+
*
|
|
9
|
+
* stdout is the protocol channel. Anything diagnostic goes to stderr; a
|
|
10
|
+
* stray console.log here corrupts the stream and the failure looks like the
|
|
11
|
+
* client being broken.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { toolDefinitions, callTool } from "./tools.js";
|
|
15
|
+
import { ApiError } from "./client.js";
|
|
16
|
+
import {
|
|
17
|
+
ITEM_CARD_URI,
|
|
18
|
+
clientSupportsUi,
|
|
19
|
+
itemCardResource,
|
|
20
|
+
itemCardContents,
|
|
21
|
+
} from "./itemCard.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Protocol revisions this server speaks, newest first.
|
|
25
|
+
*
|
|
26
|
+
* Only `tools` is implemented, and that surface has been stable across all of
|
|
27
|
+
* these, so supporting the range costs nothing and is what keeps a client on
|
|
28
|
+
* an older revision working.
|
|
29
|
+
*
|
|
30
|
+
* `initialize` MUST echo the client's requested version when we support it.
|
|
31
|
+
* Answering with our own newest instead is a hard failure on the client side,
|
|
32
|
+
* not a downgrade — Claude Code 2.1.x asks for 2025-11-25 and refuses a
|
|
33
|
+
* server that replies 2026-07-28 with "protocol version is not supported".
|
|
34
|
+
*/
|
|
35
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = [
|
|
36
|
+
"2026-07-28",
|
|
37
|
+
"2025-11-25",
|
|
38
|
+
"2025-06-18",
|
|
39
|
+
"2025-03-26",
|
|
40
|
+
"2024-11-05",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Newest we speak. Used where there is no client to negotiate with. */
|
|
44
|
+
export const PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pick the version to answer `initialize` with: the client's, when we speak
|
|
48
|
+
* it. Otherwise our newest, and the client decides whether to continue —
|
|
49
|
+
* which is the spec's escape hatch, not an error to raise here.
|
|
50
|
+
*/
|
|
51
|
+
export function negotiateProtocolVersion(requested) {
|
|
52
|
+
return SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
|
|
53
|
+
? requested
|
|
54
|
+
: PROTOCOL_VERSION;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const PARSE_ERROR = -32700;
|
|
58
|
+
const METHOD_NOT_FOUND = -32601;
|
|
59
|
+
const INTERNAL_ERROR = -32603;
|
|
60
|
+
|
|
61
|
+
export function createServer({ client, config, name = "skyelight" }) {
|
|
62
|
+
/**
|
|
63
|
+
* Whether this client can render the item card. Unlike the remote endpoint,
|
|
64
|
+
* stdio HAS a session — the capability is declared once at `initialize` and
|
|
65
|
+
* remembered for the connection (SKY-279).
|
|
66
|
+
*/
|
|
67
|
+
let supportsUi = false;
|
|
68
|
+
|
|
69
|
+
async function handle(message) {
|
|
70
|
+
const { id, method, params } = message;
|
|
71
|
+
// A notification has no id and takes no reply — including
|
|
72
|
+
// notifications/initialized, which is the client saying it is ready.
|
|
73
|
+
const isNotification = id === undefined || id === null;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
let result;
|
|
77
|
+
switch (method) {
|
|
78
|
+
case "initialize":
|
|
79
|
+
supportsUi = clientSupportsUi(params?.capabilities);
|
|
80
|
+
result = {
|
|
81
|
+
protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
|
|
82
|
+
capabilities: { tools: {}, resources: {} },
|
|
83
|
+
serverInfo: { name, version: "0.1.0" },
|
|
84
|
+
instructions:
|
|
85
|
+
"Skyelight holds feedback people left directly on pages of a running app. " +
|
|
86
|
+
"list_items to see what is outstanding, get_item for the full thread and the " +
|
|
87
|
+
"element it points at.",
|
|
88
|
+
};
|
|
89
|
+
break;
|
|
90
|
+
|
|
91
|
+
case "tools/list":
|
|
92
|
+
result = {
|
|
93
|
+
tools: toolDefinitions(
|
|
94
|
+
supportsUi ? { uiResourceUri: ITEM_CARD_URI } : undefined,
|
|
95
|
+
),
|
|
96
|
+
};
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
case "resources/list":
|
|
100
|
+
result = { resources: [itemCardResource()] };
|
|
101
|
+
break;
|
|
102
|
+
|
|
103
|
+
case "resources/read":
|
|
104
|
+
if (params?.uri !== ITEM_CARD_URI) {
|
|
105
|
+
if (isNotification) return null;
|
|
106
|
+
return {
|
|
107
|
+
jsonrpc: "2.0",
|
|
108
|
+
id,
|
|
109
|
+
error: { code: -32602, message: `Unknown resource: ${params?.uri}` },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
result = { contents: [itemCardContents()] };
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
case "tools/call": {
|
|
116
|
+
const { name: toolName, arguments: args = {} } = params ?? {};
|
|
117
|
+
try {
|
|
118
|
+
const { text, data } = await callTool(toolName, args, {
|
|
119
|
+
client,
|
|
120
|
+
config,
|
|
121
|
+
});
|
|
122
|
+
result = {
|
|
123
|
+
content: [{ type: "text", text }],
|
|
124
|
+
structuredContent: data,
|
|
125
|
+
};
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// A failed tool call is a RESULT with isError, not a protocol
|
|
128
|
+
// error — the model should see what went wrong and adjust, not
|
|
129
|
+
// have the call vanish into a transport failure.
|
|
130
|
+
result = {
|
|
131
|
+
content: [{ type: "text", text: toolErrorText(err) }],
|
|
132
|
+
isError: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case "ping":
|
|
139
|
+
result = {};
|
|
140
|
+
break;
|
|
141
|
+
|
|
142
|
+
default:
|
|
143
|
+
if (isNotification) return null;
|
|
144
|
+
return {
|
|
145
|
+
jsonrpc: "2.0",
|
|
146
|
+
id,
|
|
147
|
+
error: { code: METHOD_NOT_FOUND, message: `Unknown method: ${method}` },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (isNotification) return null;
|
|
152
|
+
return { jsonrpc: "2.0", id, result };
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (isNotification) return null;
|
|
155
|
+
return {
|
|
156
|
+
jsonrpc: "2.0",
|
|
157
|
+
id,
|
|
158
|
+
error: { code: INTERNAL_ERROR, message: err.message },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { handle };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Turn an API failure into something a model can act on. */
|
|
167
|
+
function toolErrorText(err) {
|
|
168
|
+
if (err instanceof ApiError) {
|
|
169
|
+
if (err.status === 401) {
|
|
170
|
+
return "Skyelight rejected the API key. Check SKYELIGHT_API_TOKEN — it may have been revoked.";
|
|
171
|
+
}
|
|
172
|
+
if (err.status === 403) {
|
|
173
|
+
// The server's message names the role and what was required.
|
|
174
|
+
return `Not permitted: ${err.message}`;
|
|
175
|
+
}
|
|
176
|
+
if (err.status === 404) {
|
|
177
|
+
return `Not found: ${err.message}`;
|
|
178
|
+
}
|
|
179
|
+
return err.message;
|
|
180
|
+
}
|
|
181
|
+
return err.message;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Wire a server to a byte stream. Split on newlines and ignore blank lines;
|
|
186
|
+
* a partial line is held until the rest arrives.
|
|
187
|
+
*/
|
|
188
|
+
export function serveStdio(server, { input = process.stdin, output = process.stdout } = {}) {
|
|
189
|
+
let buffer = "";
|
|
190
|
+
|
|
191
|
+
input.setEncoding?.("utf8");
|
|
192
|
+
input.on("data", async (chunk) => {
|
|
193
|
+
buffer += chunk;
|
|
194
|
+
let newline;
|
|
195
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
196
|
+
const line = buffer.slice(0, newline).trim();
|
|
197
|
+
buffer = buffer.slice(newline + 1);
|
|
198
|
+
if (!line) continue;
|
|
199
|
+
|
|
200
|
+
let message;
|
|
201
|
+
try {
|
|
202
|
+
message = JSON.parse(line);
|
|
203
|
+
} catch {
|
|
204
|
+
output.write(
|
|
205
|
+
JSON.stringify({
|
|
206
|
+
jsonrpc: "2.0",
|
|
207
|
+
id: null,
|
|
208
|
+
error: { code: PARSE_ERROR, message: "Invalid JSON" },
|
|
209
|
+
}) + "\n",
|
|
210
|
+
);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const response = await server.handle(message);
|
|
215
|
+
if (response) output.write(JSON.stringify(response) + "\n");
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
package/src/tools.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for tools.js.
|
|
3
|
+
*
|
|
4
|
+
* The module is plain JavaScript so the package stays dependency-free and
|
|
5
|
+
* runnable with no build step. It is also imported by convex/mcp/protocol.ts,
|
|
6
|
+
* which is TypeScript — and by the extension's tsconfig, which does not
|
|
7
|
+
* enable allowJs. Shipping declarations is what lets one definition of the
|
|
8
|
+
* tool set serve all three without a build.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ToolDefinition {
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: "object";
|
|
16
|
+
properties: Record<string, unknown>;
|
|
17
|
+
required?: string[];
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export declare function toolDefinitions(opts?: {
|
|
22
|
+
uiResourceUri?: string;
|
|
23
|
+
}): ToolDefinition[];
|
|
24
|
+
|
|
25
|
+
export declare function renderList(
|
|
26
|
+
result: unknown,
|
|
27
|
+
opts?: { searched?: string },
|
|
28
|
+
): string;
|
|
29
|
+
|
|
30
|
+
export declare function renderItem(item: unknown): string;
|
|
31
|
+
export declare function renderWorkspaces(result: unknown): string;
|
|
32
|
+
export declare function renderProjects(result: unknown): string;
|
|
33
|
+
|
|
34
|
+
export declare function renderPosted(
|
|
35
|
+
result: unknown,
|
|
36
|
+
opts: { kind: "update" | "item" },
|
|
37
|
+
): string;
|
|
38
|
+
|
|
39
|
+
export declare function callTool(
|
|
40
|
+
name: string,
|
|
41
|
+
args: Record<string, unknown>,
|
|
42
|
+
deps: { client: unknown; config: { projectId?: string | null } },
|
|
43
|
+
): Promise<{ text: string; data: unknown }>;
|
|
44
|
+
|
|
45
|
+
export declare const PROJECT_HINT: string;
|