@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Plastr Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # @skyelight/mcp
2
+
3
+ An MCP server that lets a coding agent read the feedback people left on your
4
+ running app — the thread, the page, and the element they were pointing at.
5
+
6
+ Works with anything that speaks MCP over stdio: Claude Code, Cursor, Codex
7
+ CLI. For hosted agents that cannot spawn a local process, use the remote
8
+ endpoint instead (SKY-274).
9
+
10
+ ## Setup
11
+
12
+ > **Not published yet.** Until `@skyelight/mcp` is on npm, the `npx` form
13
+ > below will fail to resolve. Run it from a checkout instead — same server,
14
+ > same behaviour:
15
+ >
16
+ > ```bash
17
+ > claude mcp add skyelight \
18
+ > --env SKYELIGHT_API_URL=https://<deployment>.convex.site \
19
+ > --env SKYELIGHT_API_TOKEN=sk_live_... \
20
+ > -- node /path/to/skyelight-app/integrations/skyelight-mcp/src/index.js
21
+ > ```
22
+ >
23
+ > The connect snippet generated in Settings → API keys uses the `npx` form,
24
+ > which is what will be correct once this ships. Swap the command for the
25
+ > node path in the meantime.
26
+
27
+ Create a key under **Settings → API keys** in the Skyelight web app. Bind it
28
+ to a project if the agent only ever works on one — a bound key needs no
29
+ further configuration and cannot read anything else.
30
+
31
+ Then add the server to your client:
32
+
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "skyelight": {
37
+ "command": "npx",
38
+ "args": ["-y", "@skyelight/mcp"],
39
+ "env": {
40
+ "SKYELIGHT_API_URL": "https://<deployment>.convex.site",
41
+ "SKYELIGHT_API_TOKEN": "sk_live_..."
42
+ }
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ Claude Code users can skip the file:
49
+
50
+ ```bash
51
+ claude mcp add skyelight \
52
+ --env SKYELIGHT_API_URL=https://<deployment>.convex.site \
53
+ --env SKYELIGHT_API_TOKEN=sk_live_... \
54
+ -- npx -y @skyelight/mcp
55
+ ```
56
+
57
+ ### Credentials
58
+
59
+ Checked in order, so an existing Skyelight setup keeps working:
60
+
61
+ 1. `SKYELIGHT_API_TOKEN` / `SKYELIGHT_API_URL` in the environment
62
+ 2. `.env.local`, then `.env`, in the working directory
63
+ 3. `~/.skyelight/credentials` — `{"apiUrl": "...", "token": "sk_live_..."}`
64
+
65
+ ### Binding a repo to a project
66
+
67
+ With a workspace-wide key used across several repos, drop a `.skyelight.json`
68
+ at the repo root:
69
+
70
+ ```json
71
+ { "projectId": "j57abc...", "projectName": "Checkout rebuild" }
72
+ ```
73
+
74
+ Tools then default to that project, and nobody has to pass an id. A
75
+ project-bound API key makes this unnecessary — the server already knows.
76
+
77
+ ## Tools
78
+
79
+ | Tool | What it is for |
80
+ | -------------- | -------------------------------------------------------------- |
81
+ | `list_items` | What is outstanding. Filter by page, type, status, assignee. |
82
+ | `search_items` | Find items whose thread mentions some text, replies included. |
83
+ | `get_item` | Everything needed to work one item: full thread, page, anchor. |
84
+
85
+ `list_items` leads with a summary — totals, breakdown by type and by page —
86
+ so an agent can tell you the shape of the work before pulling any of it.
87
+ Rows are stubs; `get_item` is where the thread and the anchor live.
88
+
89
+ ## What the agent sees
90
+
91
+ ```
92
+ Alpha: 12 items total — 9 open, 3 resolved, 7 unassigned.
93
+ By type: bug (7), idea (4), feedback (1).
94
+ Busiest pages: /checkout (8), /settings (4).
95
+
96
+ 2 matches:
97
+ - The pay button does nothing on the second click
98
+ bug · open · /checkout · unassigned · 2 replies — id i1
99
+ ```
100
+
101
+ Deliberately prose rather than JSON. A tool that returns a bare array invites
102
+ a model to read the array out; this one invites it to summarise.
103
+
104
+ ## Permissions
105
+
106
+ The key's role decides what the agent can do, and the API enforces it —
107
+ nothing here is client-side. A reviewer-role key can read but not write. A
108
+ project-bound key 404s on anything outside its project rather than reporting
109
+ that it exists but is forbidden.
110
+
111
+ ## Supersedes
112
+
113
+ `integrations/skyelight-skill/`, the Python Claude Code skill — **removed** in
114
+ SKY-291 along with the `/api/v1/brief` and `/api/v1/feedback/ship` endpoints
115
+ it called.
116
+
117
+ That skill was brief-shaped: pull a synthesized decision brief, mark decisions
118
+ shipped. This is item-shaped, which is what an agent working a specific piece
119
+ of feedback actually needs, and it works across clients rather than Claude
120
+ Code alone.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@skyelight/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Skyelight \u2014 read feedback items from your project as an agent",
5
+ "type": "module",
6
+ "bin": {
7
+ "skyelight-mcp": "./src/index.js"
8
+ },
9
+ "main": "./src/index.js",
10
+ "files": [
11
+ "LICENSE",
12
+ "README.md",
13
+ "src"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test 'test/**/*.test.js'",
20
+ "prepublishOnly": "npm test"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "skyelight",
25
+ "feedback"
26
+ ],
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/plastrlab/skyelight.git",
31
+ "directory": "integrations/skyelight-mcp"
32
+ },
33
+ "homepage": "https://github.com/plastrlab/skyelight/tree/main/integrations/skyelight-mcp#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/plastrlab/skyelight/issues"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }
package/src/client.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * HTTP calls to the Skyelight public API. No business logic — the server
3
+ * decides what a key may see; this just carries the bearer token and turns
4
+ * error bodies into readable messages.
5
+ */
6
+
7
+ export class ApiError extends Error {
8
+ constructor(message, status) {
9
+ super(message);
10
+ this.status = status;
11
+ }
12
+ }
13
+
14
+ export function createClient({ apiUrl, token, fetchImpl = fetch }) {
15
+ async function post(path, body) {
16
+ let res;
17
+ try {
18
+ res = await fetchImpl(apiUrl + path, {
19
+ method: "POST",
20
+ headers: {
21
+ Authorization: `Bearer ${token}`,
22
+ "Content-Type": "application/json",
23
+ },
24
+ body: JSON.stringify(body),
25
+ });
26
+ } catch (err) {
27
+ throw new ApiError(`Could not reach ${apiUrl}: ${err.message}`, 0);
28
+ }
29
+ const text = await res.text();
30
+ let parsed;
31
+ try {
32
+ parsed = text ? JSON.parse(text) : {};
33
+ } catch {
34
+ throw new ApiError(
35
+ `${res.status} from ${path}, and the body was not JSON`,
36
+ res.status,
37
+ );
38
+ }
39
+ if (!res.ok) {
40
+ throw new ApiError(
41
+ parsed.error ?? `Request failed (${res.status})`,
42
+ res.status,
43
+ );
44
+ }
45
+ return parsed;
46
+ }
47
+
48
+ async function request(path, params = {}) {
49
+ const url = new URL(apiUrl + path);
50
+ for (const [k, v] of Object.entries(params)) {
51
+ if (v !== undefined && v !== null && v !== "") {
52
+ url.searchParams.set(k, String(v));
53
+ }
54
+ }
55
+
56
+ let res;
57
+ try {
58
+ res = await fetchImpl(url.toString(), {
59
+ headers: { Authorization: `Bearer ${token}` },
60
+ });
61
+ } catch (err) {
62
+ throw new ApiError(`Could not reach ${apiUrl}: ${err.message}`, 0);
63
+ }
64
+
65
+ const text = await res.text();
66
+ let body;
67
+ try {
68
+ body = text ? JSON.parse(text) : {};
69
+ } catch {
70
+ throw new ApiError(
71
+ `${res.status} from ${path}, and the body was not JSON`,
72
+ res.status,
73
+ );
74
+ }
75
+
76
+ if (!res.ok) {
77
+ // The API's own message is more useful than anything invented here —
78
+ // it says which role the key has, or that a project is out of scope.
79
+ throw new ApiError(
80
+ body.error ?? `Request failed (${res.status})`,
81
+ res.status,
82
+ );
83
+ }
84
+ return body;
85
+ }
86
+
87
+ return {
88
+ listWorkspaces: () => request("/api/v1/workspaces"),
89
+ listProjects: (params) => request("/api/v1/projects", params),
90
+ listItems: (params) => request("/api/v1/items", params),
91
+ getItem: (id) => request(`/api/v1/items/${encodeURIComponent(id)}`),
92
+ postUpdate: (body) => post("/api/v1/items/update", body),
93
+ createItem: (body) => post("/api/v1/items/create", body),
94
+ setStatus: (body) => post("/api/v1/items/status", body),
95
+ };
96
+ }
package/src/config.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Where the token, the API and the project binding come from.
3
+ *
4
+ * Resolution order deliberately matches the Python skill this supersedes, so
5
+ * anyone who already has credentials on disk gets a working MCP server
6
+ * without moving anything:
7
+ *
8
+ * 1. SKYELIGHT_API_TOKEN / SKYELIGHT_API_URL in the environment
9
+ * 2. .env.local, then .env, in the working directory
10
+ * 3. ~/.skyelight/credentials ({"apiUrl": "...", "token": "..."})
11
+ *
12
+ * The project binding is separate and comes from `.skyelight.json` in the
13
+ * working directory, so a repo can declare which Skyelight project it is
14
+ * without every developer configuring it.
15
+ *
16
+ * A project-bound API KEY (SKY-269) needs none of this — the server already
17
+ * knows its project. `.skyelight.json` is for workspace-wide keys used
18
+ * against several repos.
19
+ */
20
+
21
+ import { readFileSync, existsSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ export class ConfigError extends Error {}
26
+
27
+ /** Minimal .env parser: KEY=VALUE, optional quotes, # comments. */
28
+ export function parseDotenv(text) {
29
+ const out = {};
30
+ for (const rawLine of text.split("\n")) {
31
+ const line = rawLine.trim();
32
+ if (!line || line.startsWith("#")) continue;
33
+ const eq = line.indexOf("=");
34
+ if (eq === -1) continue;
35
+ const key = line.slice(0, eq).trim();
36
+ let value = line.slice(eq + 1).trim();
37
+ if (
38
+ (value.startsWith('"') && value.endsWith('"')) ||
39
+ (value.startsWith("'") && value.endsWith("'"))
40
+ ) {
41
+ value = value.slice(1, -1);
42
+ }
43
+ if (key) out[key] = value;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function readJsonIfPresent(path) {
49
+ if (!existsSync(path)) return null;
50
+ try {
51
+ return JSON.parse(readFileSync(path, "utf8"));
52
+ } catch {
53
+ // A malformed config should not take the server down — it should be
54
+ // reported and skipped. stderr is safe: stdout is the protocol channel.
55
+ process.stderr.write(`[skyelight-mcp] ignoring malformed ${path}\n`);
56
+ return null;
57
+ }
58
+ }
59
+
60
+ export function resolveConfig({
61
+ cwd = process.cwd(),
62
+ env = process.env,
63
+ // Injectable so tests do not read the developer's real credentials file
64
+ // and pass or fail depending on whose machine they run on.
65
+ home = homedir(),
66
+ } = {}) {
67
+ let token = env.SKYELIGHT_API_TOKEN || null;
68
+ let apiUrl = env.SKYELIGHT_API_URL || null;
69
+
70
+ if (!token || !apiUrl) {
71
+ for (const name of [".env.local", ".env"]) {
72
+ const path = join(cwd, name);
73
+ if (!existsSync(path)) continue;
74
+ const parsed = parseDotenv(readFileSync(path, "utf8"));
75
+ token = token || parsed.SKYELIGHT_API_TOKEN || null;
76
+ apiUrl = apiUrl || parsed.SKYELIGHT_API_URL || null;
77
+ if (token && apiUrl) break;
78
+ }
79
+ }
80
+
81
+ if (!token || !apiUrl) {
82
+ const creds = readJsonIfPresent(join(home, ".skyelight", "credentials"));
83
+ if (creds) {
84
+ token = token || creds.token || null;
85
+ apiUrl = apiUrl || creds.apiUrl || null;
86
+ }
87
+ }
88
+
89
+ if (!token || !apiUrl) {
90
+ throw new ConfigError(
91
+ [
92
+ "No Skyelight credentials found. Either:",
93
+ " • export SKYELIGHT_API_URL and SKYELIGHT_API_TOKEN, or",
94
+ " • put them in .env.local, or",
95
+ ' • save ~/.skyelight/credentials as {"apiUrl": "...", "token": "sk_live_..."}',
96
+ "",
97
+ "Create a key under Settings → API keys in the Skyelight web app.",
98
+ ].join("\n"),
99
+ );
100
+ }
101
+
102
+ const project = readJsonIfPresent(join(cwd, ".skyelight.json")) ?? {};
103
+
104
+ return {
105
+ token,
106
+ apiUrl: apiUrl.replace(/\/+$/, ""),
107
+ // Named projectId to match the API. A bound key overrides this anyway —
108
+ // the server ignores a projectId outside the key's binding.
109
+ projectId: project.projectId ?? null,
110
+ projectName: project.projectName ?? null,
111
+ };
112
+ }
package/src/index.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Entry point. `npx @skyelight/mcp` — reads credentials, opens stdio.
4
+ */
5
+
6
+ import { resolveConfig, ConfigError } from "./config.js";
7
+ import { createClient } from "./client.js";
8
+ import { createServer, serveStdio } from "./server.js";
9
+
10
+ let config;
11
+ try {
12
+ config = resolveConfig();
13
+ } catch (err) {
14
+ if (err instanceof ConfigError) {
15
+ // stderr, and a non-zero exit: an MCP client surfaces this as a failed
16
+ // server rather than hanging on a handshake that will never complete.
17
+ process.stderr.write(`${err.message}\n`);
18
+ process.exit(1);
19
+ }
20
+ throw err;
21
+ }
22
+
23
+ const client = createClient({ apiUrl: config.apiUrl, token: config.token });
24
+ serveStdio(createServer({ client, config }));
@@ -0,0 +1,23 @@
1
+ /** Types for itemCard.js — see tools.d.ts for why declarations are shipped. */
2
+
3
+ export declare const ITEM_CARD_URI: string;
4
+ export declare const MCP_APP_MIME_TYPE: string;
5
+ export declare const UI_EXTENSION_KEY: string;
6
+ export declare const APPS_PROTOCOL_VERSION: string;
7
+
8
+ /** Whether a client declared it can render an MCP Apps HTML resource. */
9
+ export declare function clientSupportsUi(source: unknown): boolean;
10
+
11
+ export declare function itemCardResource(): {
12
+ uri: string;
13
+ name: string;
14
+ description: string;
15
+ mimeType: string;
16
+ };
17
+
18
+ export declare function itemCardContents(): {
19
+ uri: string;
20
+ mimeType: string;
21
+ text: string;
22
+ _meta: { ui: Record<string, unknown> };
23
+ };