@skyelight/mcp 0.1.4 → 0.3.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 CHANGED
@@ -4,12 +4,29 @@ An MCP server that lets a coding agent read the feedback people left on your
4
4
  running app — the thread, the page, and the element they were pointing at —
5
5
  and report back on it when the work is done.
6
6
 
7
- Works with anything that speaks MCP over stdio: Claude Code, Cursor, Codex.
7
+ Works with anything that speaks MCP over stdio: Claude Code, Cursor, Grok, Codex.
8
8
 
9
9
  ## Setup
10
10
 
11
11
  **Get a personal access token.** In the Skyelight web app, go to your account
12
- settings → **Personal access tokens** → create one. It is shown once.
12
+ settings → **API Keys** → create one. It is shown once, and you can give
13
+ it an expiry.
14
+
15
+ ## The short way
16
+
17
+ ```bash
18
+ npx @skyelight/mcp init
19
+ ```
20
+
21
+ Finds your coding agent, registers the remote server with it, and leaves the
22
+ sign-in to OAuth — nothing is pasted and no key is stored on disk. It shows
23
+ what it will change and waits; `--yes` skips that once you have read it.
24
+
25
+ It knows Claude Code, Cursor, Grok, Codex and Windsurf. Name one with
26
+ `--client cursor` when more than one is installed, and point at another deployment with
27
+ `SKYELIGHT_URL=https://…`. The deployment tells the installer its own MCP URL
28
+ and OAuth client over `/mcp/install-config`, so this package holds no
29
+ environment-specific constants.
13
30
 
14
31
  The token is yours, not a service account's: anything the agent writes is
15
32
  attributed to you, marked with the tool it came through. That is deliberate —
@@ -119,10 +136,10 @@ There is no service-account mode. Synthetic agent identities existed and were
119
136
  removed: a workspace should not grow a member for every tool somebody plugs in,
120
137
  and a reply nobody is accountable for is worse than one attributed plainly.
121
138
 
122
- A consequence worth knowing: a **workspace API key cannot write.** It stands
123
- for a role, not a person, so there is nobody to attribute a reply to, and the
124
- write tools refuse it with a message saying so. Keys still read, and still
125
- respect a project binding — they are for scripts and CI, not for agents.
139
+ Workspace API keys (`sk_live_...`) used to be a second way in, for scripts and
140
+ CI. They are gone — a credential that outlives whoever minted it is one nobody
141
+ is accountable for, and the write tools already refused them for exactly that
142
+ reason. A personal token is the only credential the API accepts.
126
143
 
127
144
  ## Permissions
128
145
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyelight/mcp",
3
- "version": "0.1.4",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for Skyelight \u2014 read feedback items from your project as an agent",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js ADDED
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * `npx @skyelight/mcp init`
5
+ *
6
+ * Finds your coding agent, registers Skyelight's MCP server with it, and
7
+ * leaves the sign-in to the agent — the server speaks OAuth, so the first
8
+ * time the agent connects it opens a browser and you approve it there. No
9
+ * token is pasted anywhere, and none is stored on disk by this.
10
+ *
11
+ * It shows the change and waits, like `@skyelight/build init` does. Editing
12
+ * a config somebody else wrote is the kind of help that has to ask first.
13
+ * `--yes` exists for people who have already read it once.
14
+ */
15
+
16
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
17
+ import { dirname } from "node:path";
18
+ import { createInterface } from "node:readline/promises";
19
+ import { spawnSync } from "node:child_process";
20
+ import {
21
+ CLIENTS,
22
+ SERVER_NAME,
23
+ detectClients,
24
+ findClient,
25
+ addToJson,
26
+ addToToml,
27
+ } from "./install.js";
28
+
29
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
30
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
31
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
32
+ const amber = (s) => `\x1b[33m${s}\x1b[0m`;
33
+
34
+ /**
35
+ * Which deployment to register. Production unless told otherwise.
36
+ */
37
+ const ORIGIN = (
38
+ process.env.SKYELIGHT_URL ?? "https://mellow-zebra-383.convex.site"
39
+ ).replace(/\/+$/, "");
40
+
41
+ /**
42
+ * Asked for rather than baked in.
43
+ *
44
+ * The OAuth client id is per deployment, so a published package cannot hold
45
+ * one — that would be an npm release per environment, and the first person
46
+ * to run it against the wrong one gets an OAuth error naming a client that
47
+ * is not theirs. `/mcp/install-config` is the deployment saying what it is.
48
+ *
49
+ * A deployment that answers without a client id is one doing dynamic client
50
+ * registration, which is the correct fallback rather than a failure: the
51
+ * flags that carry the id are simply left off.
52
+ */
53
+ async function fetchConfig() {
54
+ const res = await fetch(`${ORIGIN}/mcp/install-config`, {
55
+ headers: { accept: "application/json" },
56
+ });
57
+ if (!res.ok) {
58
+ throw new Error(`${ORIGIN} answered ${res.status}`);
59
+ }
60
+ const body = await res.json();
61
+ if (typeof body?.url !== "string") {
62
+ throw new Error(`${ORIGIN} did not say where its MCP server is`);
63
+ }
64
+ return body;
65
+ }
66
+
67
+ async function confirm(question) {
68
+ if (!process.stdin.isTTY) return false;
69
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
70
+ try {
71
+ return /^y(es)?$/i.test((await rl.question(`${question} [y/N] `)).trim());
72
+ } finally {
73
+ rl.close();
74
+ }
75
+ }
76
+
77
+ function listClients() {
78
+ console.log("Say which one with:\n");
79
+ for (const c of CLIENTS) {
80
+ console.log(` ${bold(`npx @skyelight/mcp init --client ${c.id}`)} ${dim(c.label)}`);
81
+ }
82
+ console.log("");
83
+ }
84
+
85
+ async function init({ yes, clientFlag }) {
86
+ console.log(`\n${bold("Skyelight MCP")}\n`);
87
+
88
+ let config;
89
+ try {
90
+ config = await fetchConfig();
91
+ } catch (err) {
92
+ console.log(amber(`Could not reach Skyelight: ${err.message}\n`));
93
+ console.log(
94
+ dim(" Point at another deployment with SKYELIGHT_URL=https://…\n"),
95
+ );
96
+ process.exitCode = 1;
97
+ return;
98
+ }
99
+ const { url: MCP_URL, clientId: CLIENT_ID, callbackPort } = config;
100
+
101
+ let client;
102
+ if (clientFlag) {
103
+ client = findClient(clientFlag);
104
+ if (!client) {
105
+ console.log(amber(`Unknown agent "${clientFlag}".\n`));
106
+ listClients();
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+ } else {
111
+ const found = detectClients();
112
+ if (found.length === 0) {
113
+ console.log(amber("No coding agent found on this machine.\n"));
114
+ listClients();
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+ if (found.length > 1) {
119
+ console.log(
120
+ `Found ${found.map((c) => bold(c.label)).join(", ")}.\n`,
121
+ );
122
+ listClients();
123
+ process.exitCode = 1;
124
+ return;
125
+ }
126
+ client = found[0];
127
+ console.log(`Found ${bold(client.label)}.\n`);
128
+ }
129
+
130
+ // Claude Code registers through its own CLI — see `install.js`.
131
+ if (client.command) {
132
+ const [bin, args] = client.command(MCP_URL, CLIENT_ID, callbackPort);
133
+ console.log(dim(` ${bin} ${args.join(" ")}\n`));
134
+ if (!yes && !(await confirm("Run it?"))) {
135
+ console.log(dim("\nNothing changed.\n"));
136
+ return;
137
+ }
138
+ const res = spawnSync(bin, args, { stdio: "inherit" });
139
+ if (res.status !== 0) {
140
+ console.log(amber("\nThat command failed. Run it yourself to see why.\n"));
141
+ process.exitCode = 1;
142
+ return;
143
+ }
144
+ done(client);
145
+ return;
146
+ }
147
+
148
+ const file = client.file();
149
+ const before = existsSync(file) ? readFileSync(file, "utf8") : "";
150
+ const after =
151
+ client.format === "json"
152
+ ? addToJson(before, client.entry(MCP_URL, CLIENT_ID))
153
+ : addToToml(before, client.entry(MCP_URL, CLIENT_ID));
154
+
155
+ if (after === null) {
156
+ console.log(green(`Already set up in ${file}\n`));
157
+ return;
158
+ }
159
+
160
+ console.log(`${bold(file)}\n`);
161
+ console.log(
162
+ dim(
163
+ client.format === "json"
164
+ ? ` mcpServers.${SERVER_NAME} → ${MCP_URL}`
165
+ : ` [mcp_servers.${SERVER_NAME}] → ${MCP_URL}`,
166
+ ),
167
+ );
168
+ console.log("");
169
+
170
+ if (!yes && !(await confirm("Write it?"))) {
171
+ console.log(dim("\nNothing written.\n"));
172
+ return;
173
+ }
174
+
175
+ mkdirSync(dirname(file), { recursive: true });
176
+ writeFileSync(file, after, "utf8");
177
+ done(client);
178
+ }
179
+
180
+ function done(client) {
181
+ console.log(green(`\n✓ Skyelight added to ${client.label}\n`));
182
+ console.log("Restart it, then ask it to list your Skyelight items.");
183
+ console.log(
184
+ dim("It will open a browser to sign you in the first time.\n"),
185
+ );
186
+ }
187
+
188
+ const argv = process.argv.slice(2);
189
+ const cmd = argv[0];
190
+
191
+ if (cmd === "init") {
192
+ const yes = argv.includes("--yes") || argv.includes("-y");
193
+ const at = argv.indexOf("--client");
194
+ await init({ yes, clientFlag: at === -1 ? null : argv[at + 1] });
195
+ } else {
196
+ console.log(`\n${bold("@skyelight/mcp")}\n`);
197
+ console.log(" npx @skyelight/mcp init set up your coding agent");
198
+ console.log(" npx @skyelight/mcp init --client cursor\n");
199
+ console.log(dim(" Running with no command starts the stdio server.\n"));
200
+ }
package/src/config.js CHANGED
@@ -92,9 +92,10 @@ export function resolveConfig({
92
92
  "No Skyelight credentials found. Either:",
93
93
  " • export SKYELIGHT_API_URL and SKYELIGHT_API_TOKEN, or",
94
94
  " • put them in .env.local, or",
95
- ' • save ~/.skyelight/credentials as {"apiUrl": "...", "token": "sk_live_..."}',
95
+ ' • save ~/.skyelight/credentials as {"apiUrl": "...", "token": "sky_..."}',
96
96
  "",
97
- "Create a key under Settings → API keys in the Skyelight web app.",
97
+ "Create one under Account settings → API Keys in the",
98
+ "Skyelight web app. Workspace API keys (sk_live_...) no longer work.",
98
99
  ].join("\n"),
99
100
  );
100
101
  }
package/src/index.js CHANGED
@@ -1,8 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Entry point. `npx @skyelight/mcp` — reads credentials, opens stdio.
3
+ * Entry point, wearing two hats.
4
+ *
5
+ * With no arguments this is the stdio MCP server, which is what a client
6
+ * spawns. With `init` it is the installer that registers the *remote* server
7
+ * with a coding agent — see `cli.js`.
8
+ *
9
+ * One binary rather than two, because npm resolves `npx @skyelight/mcp` to
10
+ * the package's only bin whatever it is called; a second bin would make that
11
+ * resolution ambiguous and `npx @skyelight/mcp init` would stop working.
12
+ * Dispatching here keeps the one-command install one command.
4
13
  */
5
14
 
15
+ // The static import below is hoisted, so `config.js` is evaluated either
16
+ // way — but `resolveConfig()` is only *called* past this point, and calling
17
+ // it is what throws when there are no credentials. The installer needs none.
18
+ if (process.argv[2] === "init" || process.argv[2] === "--help") {
19
+ await import("./cli.js");
20
+ process.exit(process.exitCode ?? 0);
21
+ }
22
+
6
23
  import { resolveConfig, ConfigError } from "./config.js";
7
24
  import { createClient } from "./client.js";
8
25
  import { createServer, serveStdio } from "./server.js";
package/src/install.js ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Where each agent keeps its MCP servers, and what to write there.
3
+ *
4
+ * Pure functions, so the CLI is the part that touches the disk and this is
5
+ * the part that can be tested. Every entry answers three questions: how do
6
+ * we know this agent is on the machine, what file holds its servers, and
7
+ * what shape does an entry take in it.
8
+ */
9
+
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { existsSync } from "node:fs";
13
+
14
+ /** The name the server is registered under, in every client. */
15
+ export const SERVER_NAME = "skyelight";
16
+
17
+ /**
18
+ * The loopback port every client is told to listen on.
19
+ *
20
+ * Fixed rather than random because the redirect URI is registered with Clerk
21
+ * in advance, and only a stable one can be. Loopback, so it only ever
22
+ * resolves on the machine running the client.
23
+ */
24
+ export const CALLBACK_PORT = 54545;
25
+
26
+ const home = () => homedir();
27
+
28
+ /**
29
+ * The agents this can configure.
30
+ *
31
+ * Ordered by how likely a given machine is to have one, because the first
32
+ * match wins when nothing is named. That is a guess, and `--client` exists
33
+ * for when it guesses wrong. The same order the settings card lists them in,
34
+ * so the two never disagree about which agent is the obvious one.
35
+ */
36
+ export const CLIENTS = [
37
+ {
38
+ id: "claude",
39
+ label: "Claude Code",
40
+ /**
41
+ * Registered by its own CLI rather than by editing a file.
42
+ *
43
+ * `claude mcp add` knows where the config lives on this machine and what
44
+ * the current schema is; writing the file ourselves would be a guess at
45
+ * both, and a wrong guess silently produces an agent with no tools.
46
+ */
47
+ command: (url, clientId, port = CALLBACK_PORT) => [
48
+ "claude",
49
+ [
50
+ "mcp", "add", "-s", "user", "--transport", "http", SERVER_NAME,
51
+ // Only when the deployment named a client. Without them Claude Code
52
+ // registers itself dynamically, which is the right thing to do and
53
+ // the wrong thing to prevent by passing an empty id.
54
+ ...(clientId
55
+ ? ["--client-id", clientId, "--callback-port", String(port)]
56
+ : []),
57
+ url,
58
+ ],
59
+ ],
60
+ detect: () => hasBinary("claude"),
61
+ },
62
+ {
63
+ id: "cursor",
64
+ label: "Cursor",
65
+ file: () => join(home(), ".cursor", "mcp.json"),
66
+ format: "json",
67
+ entry: (url) => ({ url }),
68
+ detect: () => existsSync(join(home(), ".cursor")),
69
+ },
70
+ {
71
+ /**
72
+ * Grok's own entry, which it needs despite appearing to work without one.
73
+ *
74
+ * It reads `~/.claude.json` as a compatibility source, so on a machine
75
+ * that also has Claude Code it inherits that registration — client id,
76
+ * callback port and all — and looks configured when nothing here ran.
77
+ * On a machine without Claude Code there is nothing to inherit.
78
+ *
79
+ * Two differences from Codex, both from Grok's own config reference:
80
+ * `oauth_client_id` is a flat key rather than a nested table, and there
81
+ * is no callback setting at all — Grok picks the loopback address, which
82
+ * is why both spellings of it are registered with Clerk.
83
+ *
84
+ * `oauth_scopes` is spelled out because Grok sends no `scope` parameter
85
+ * otherwise, and a token minted without `offline_access` carries no
86
+ * refresh: the session works, then quietly stops.
87
+ */
88
+ id: "grok",
89
+ label: "Grok",
90
+ file: () => join(home(), ".grok", "config.toml"),
91
+ format: "toml",
92
+ entry: (url, clientId) =>
93
+ [
94
+ `[mcp_servers.${SERVER_NAME}]`,
95
+ `url = "${url}"`,
96
+ "enabled = true",
97
+ ...(clientId
98
+ ? [
99
+ `oauth_client_id = "${clientId}"`,
100
+ 'oauth_scopes = ["profile", "email", "offline_access"]',
101
+ ]
102
+ : []),
103
+ ].join("\n"),
104
+ detect: () => existsSync(join(home(), ".grok")),
105
+ },
106
+ {
107
+ id: "codex",
108
+ label: "Codex",
109
+ file: () => join(home(), ".codex", "config.toml"),
110
+ format: "toml",
111
+ entry: (url, clientId, port = CALLBACK_PORT) =>
112
+ [
113
+ `[mcp_servers.${SERVER_NAME}]`,
114
+ `url = "${url}"`,
115
+ 'auth = "oauth"',
116
+ // The oauth table only when there is a client to name.
117
+ ...(clientId
118
+ ? [
119
+ "",
120
+ `[mcp_servers.${SERVER_NAME}.oauth]`,
121
+ `client_id = "${clientId}"`,
122
+ `callback_url = "http://127.0.0.1:${port}/callback"`,
123
+ ]
124
+ : []),
125
+ ].join("\n"),
126
+ detect: () => existsSync(join(home(), ".codex")),
127
+ },
128
+ {
129
+ id: "windsurf",
130
+ label: "Windsurf",
131
+ file: () => join(home(), ".codeium", "windsurf", "mcp_config.json"),
132
+ format: "json",
133
+ entry: (url) => ({ serverUrl: url }),
134
+ detect: () => existsSync(join(home(), ".codeium", "windsurf")),
135
+ },
136
+ ];
137
+
138
+ /** On PATH, without running the thing. */
139
+ function hasBinary(name) {
140
+ const paths = (process.env.PATH ?? "").split(":").filter(Boolean);
141
+ return paths.some((p) => existsSync(join(p, name)));
142
+ }
143
+
144
+ /**
145
+ * Which agents are on this machine.
146
+ *
147
+ * All of them, not the first: somebody with Cursor and Claude Code installed
148
+ * should be told both were found rather than have one picked for them.
149
+ */
150
+ export function detectClients(clients = CLIENTS) {
151
+ return clients.filter((c) => {
152
+ try {
153
+ return c.detect();
154
+ } catch {
155
+ return false;
156
+ }
157
+ });
158
+ }
159
+
160
+ export function findClient(id, clients = CLIENTS) {
161
+ return clients.find((c) => c.id === id) ?? null;
162
+ }
163
+
164
+ /**
165
+ * Add the server to a JSON config, preserving whatever else is in it.
166
+ *
167
+ * Returns the new text, or null when the server is already there and points
168
+ * at the same place — nothing to write is a better answer than an identical
169
+ * rewrite that churns the file's mtime.
170
+ */
171
+ export function addToJson(existing, entry) {
172
+ const parsed = existing.trim() ? JSON.parse(existing) : {};
173
+ const servers = parsed.mcpServers ?? {};
174
+ const current = servers[SERVER_NAME];
175
+ if (current && JSON.stringify(current) === JSON.stringify(entry)) return null;
176
+ return `${JSON.stringify(
177
+ { ...parsed, mcpServers: { ...servers, [SERVER_NAME]: entry } },
178
+ null,
179
+ 2,
180
+ )}\n`;
181
+ }
182
+
183
+ /**
184
+ * Append a TOML block, replacing an existing one for the same server.
185
+ *
186
+ * A parser would be the right tool for arbitrary TOML. This only ever writes
187
+ * two tables it wrote itself, so it finds them by their headers and swaps
188
+ * them; anything it does not recognise it leaves alone and appends after.
189
+ */
190
+ export function addToToml(existing, block) {
191
+ const header = `[mcp_servers.${SERVER_NAME}]`;
192
+ if (!existing.includes(header)) {
193
+ const sep = existing.trim() ? "\n\n" : "";
194
+ return `${existing.trimEnd()}${sep}${block}\n`;
195
+ }
196
+ if (existing.includes(block)) return null;
197
+
198
+ const lines = existing.split("\n");
199
+ const start = lines.findIndex((l) => l.trim() === header);
200
+ // Ours runs to the next table that is not one of ours.
201
+ let end = start + 1;
202
+ while (end < lines.length) {
203
+ const t = lines[end].trim();
204
+ if (t.startsWith("[") && !t.startsWith(`[mcp_servers.${SERVER_NAME}`)) break;
205
+ end++;
206
+ }
207
+ return `${[...lines.slice(0, start), block, ...lines.slice(end)]
208
+ .join("\n")
209
+ .trimEnd()}\n`;
210
+ }
package/src/tools.js CHANGED
@@ -8,8 +8,7 @@
8
8
  */
9
9
 
10
10
  const PROJECT_HINT =
11
- 'No project. Pass projectId, add .skyelight.json with {"projectId": "..."}, ' +
12
- "or use a project-bound API key.";
11
+ 'No project. Pass projectId, or add .skyelight.json with {"projectId": "..."}.';
13
12
 
14
13
  /**
15
14
  * @param opts.uiResourceUri When set, `get_item` advertises a `ui://`
@@ -337,13 +336,11 @@ export function renderItem(item) {
337
336
  // The repo, when the project names one. Stated plainly and early: it is
338
337
  // the difference between an agent knowing where to work and inferring it
339
338
  // from a URL, which is how work lands in the wrong codebase.
339
+ // The repository and nothing else. Which branch to work on is the reader's
340
+ // own checkout to answer; which branch the page came from is on the source
341
+ // line below, and that is the half an agent cannot work out for itself.
340
342
  const repo = item.project && item.project.repo;
341
- if (repo) {
342
- const where = [repo.url];
343
- if (repo.branch) where.push(`branch ${repo.branch}`);
344
- if (repo.pathPrefix) where.push(`under ${repo.pathPrefix}`);
345
- lines.push(`Code: ${where.join(", ")}`);
346
- }
343
+ if (repo) lines.push(`Code: ${repo.url}`);
347
344
  if (item.assignee?.name) {
348
345
  // The mode is the ask. An agent that reads "investigate" and opens a
349
346
  // pull request has done the wrong job well.
@@ -396,6 +393,18 @@ export function renderItem(item) {
396
393
  if (item.anchor.selectedText) {
397
394
  lines.push(` with "${item.anchor.selectedText}" selected`);
398
395
  }
396
+ // Which row, when the element is one of many the same component rendered.
397
+ // The author's own React key, so it reads as something from their codebase
398
+ // — "the row with key plan:pro" locates a record, which is a different and
399
+ // better question than where on the page it was drawn.
400
+ //
401
+ // `skyId` is deliberately not here. It is an opaque hash: it anchors the
402
+ // pin and tells a reader nothing they can act on, and `source` below
403
+ // already names the file. It stays in the structured response for anything
404
+ // resolving anchors, out of the prose for anything reading one.
405
+ if (item.anchor.skyKey) {
406
+ lines.push(` the item with key ${item.anchor.skyKey}`);
407
+ }
399
408
  lines.push(` selector: ${item.anchor.selector}`);
400
409
  lines.push(
401
410
  ` seen at ${item.anchor.viewport.width}x${item.anchor.viewport.height}`,