@loupekit/mcp 0.5.0-next.18 → 0.5.1-next.19

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
@@ -104,7 +104,9 @@ The dashboard is the human side of the same backlog Claude reads:
104
104
 
105
105
  Runs over **stdio** using the official
106
106
  [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk).
107
- Node 24+ runs the TypeScript entry directly (native type-stripping) — no build step.
107
+ The published package ships compiled JS (`dist/index.js`), so `npm i -g @loupekit/mcp`
108
+ exposes a working `loupe-mcp` binary. From a source checkout it also runs directly with
109
+ `node index.ts` (Node 24+ native type-stripping).
108
110
 
109
111
  ## Related packages
110
112
 
package/dist/index.js ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+
3
+ // index.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { pathToFileURL } from "url";
7
+ import { argv } from "process";
8
+ import { z } from "zod";
9
+ var API = (process.env.LOUPE_API || "http://localhost:8787").replace(/\/$/, "");
10
+ var PROJECT_KEY = process.env.LOUPE_PROJECT_KEY || "pk_demo_acme";
11
+ var ADMIN = process.env.LOUPE_ADMIN_KEY || "";
12
+ async function api(path, init) {
13
+ const res = await fetch(`${API}${path}`, {
14
+ ...init,
15
+ headers: { "Content-Type": "application/json", "X-Loupe-Admin": ADMIN, ...init?.headers || {} }
16
+ });
17
+ if (!res.ok) throw new Error(`${init?.method || "GET"} ${path} \u2192 ${res.status}`);
18
+ return res.status === 204 ? null : res.json();
19
+ }
20
+ var wrap = (text) => ({ content: [{ type: "text", text }] });
21
+ var targetOf = (c) => c.kind === "free" ? "page-level note" : c.anchor.testid ? `[data-testid="${c.anchor.testid}"]` : c.anchor.cssPath;
22
+ async function listComments({ status, url }) {
23
+ const q = new URLSearchParams({ projectKey: PROJECT_KEY });
24
+ if (url) q.set("url", url);
25
+ let comments = await api(`/v1/comments?${q}`);
26
+ if (status) comments = comments.filter((c) => c.status === status);
27
+ if (!comments.length) return wrap("No comments match.");
28
+ const lines = comments.map(
29
+ (c) => `- [${c.status}] #${c.id} \u2014 ${c.body}
30
+ \u21B3 ${targetOf(c)} on ${c.url} (by ${c.author.name})`
31
+ );
32
+ return wrap(`${comments.length} comment(s):
33
+
34
+ ${lines.join("\n")}
35
+
36
+ Use get_comment(id) for the full element context.`);
37
+ }
38
+ async function getComment({ id }) {
39
+ const c = await api(`/v1/comments/${encodeURIComponent(id)}`);
40
+ if (c.kind === "free") {
41
+ return wrap(
42
+ [
43
+ `# Feedback #${c.id} from ${c.author.name} (${c.status})`,
44
+ ``,
45
+ `**Note:** ${c.body}`,
46
+ `**Page:** ${c.url}`,
47
+ `**Type:** Free note \u2014 a page-level comment, not tied to a specific element (no screenshot).`
48
+ ].join("\n")
49
+ );
50
+ }
51
+ return wrap(
52
+ [
53
+ `# Feedback #${c.id} from ${c.author.name} (${c.status})`,
54
+ ``,
55
+ `**Request:** ${c.body}`,
56
+ `**Page:** ${c.url}`,
57
+ `**Target element:** ${c.anchor.testid ? `[data-testid="${c.anchor.testid}"]` : `\`${c.anchor.cssPath}\``}`,
58
+ c.screenshot ? `**Screenshot:** ${c.screenshot}` : ``,
59
+ ``,
60
+ `## Target element HTML`,
61
+ "```html",
62
+ c.context.html,
63
+ "```",
64
+ ``,
65
+ `## Computed styles`,
66
+ "```json",
67
+ JSON.stringify(c.context.styles, null, 2),
68
+ "```"
69
+ ].filter(Boolean).join("\n")
70
+ );
71
+ }
72
+ async function updateStatus({ id, status }) {
73
+ await api(`/v1/comments/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ status }) });
74
+ return wrap(`#${id} \u2192 ${status}`);
75
+ }
76
+ var server = new McpServer({ name: "loupe", version: "0.5.1" });
77
+ server.tool(
78
+ "list_comments",
79
+ "List Loupe product-feedback comments for the project as a task backlog. Use this to see what a PM has flagged, then work through the items.",
80
+ {
81
+ status: z.enum(["open", "in_progress", "done"]).optional().describe("Filter by status. Omit for all."),
82
+ url: z.string().optional().describe("Filter to a single page path, e.g. /checkout.")
83
+ },
84
+ listComments
85
+ );
86
+ server.tool(
87
+ "get_comment",
88
+ "Get the full context for one comment: the request, the page, the target element's HTML, and its computed styles \u2014 everything needed to make the change.",
89
+ { id: z.string().describe("The comment id from list_comments.") },
90
+ getComment
91
+ );
92
+ server.tool(
93
+ "update_status",
94
+ "Update a comment's status. Set to in_progress when you start it and done when the change is shipped \u2014 this closes the loop back to the PM.",
95
+ { id: z.string(), status: z.enum(["open", "in_progress", "done"]) },
96
+ updateStatus
97
+ );
98
+ if (import.meta.url === pathToFileURL(argv[1] ?? "").href) {
99
+ await server.connect(new StdioServerTransport());
100
+ console.error(`[loupe-mcp] connected \xB7 project=${PROJECT_KEY} \xB7 api=${API}`);
101
+ }
102
+ export {
103
+ getComment,
104
+ listComments,
105
+ updateStatus
106
+ };
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "email": "m.ashraf.saed@gmail.com",
6
6
  "url": "https://www.linkedin.com/in/mohamedashrafelsaed/"
7
7
  },
8
- "version": "0.5.0-next.18",
8
+ "version": "0.5.1-next.19",
9
9
  "description": "MCP server that exposes Loupe comments to Claude Code as an actionable, fully-contextualized backlog.",
10
10
  "keywords": [
11
11
  "loupe",
@@ -28,19 +28,24 @@
28
28
  "access": "public"
29
29
  },
30
30
  "type": "module",
31
+ "main": "./dist/index.js",
31
32
  "files": [
32
- "index.ts"
33
+ "dist"
33
34
  ],
34
35
  "bin": {
35
- "loupe-mcp": "./index.ts"
36
+ "loupe-mcp": "./dist/index.js"
36
37
  },
37
38
  "scripts": {
39
+ "build": "tsup",
38
40
  "start": "node index.ts",
39
- "test:client": "node test-client.ts"
41
+ "test:client": "node test-client.ts",
42
+ "prepublishOnly": "tsup"
40
43
  },
41
44
  "dependencies": {
42
- "@loupekit/shared": "0.5.0-next.18",
43
45
  "@modelcontextprotocol/sdk": "^1.12.0",
44
46
  "zod": "^3.24.1"
47
+ },
48
+ "devDependencies": {
49
+ "@loupekit/shared": "0.5.1-next.19"
45
50
  }
46
51
  }
package/index.ts DELETED
@@ -1,134 +0,0 @@
1
- #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { pathToFileURL } from "node:url";
5
- import { argv } from "node:process";
6
- import { z } from "zod";
7
-
8
- /**
9
- * Loupe MCP server — exposes a project's PM feedback to Claude Code as an
10
- * actionable backlog. This is the payoff of storing comments in a database:
11
- * a developer opens the repo, and Claude can read exactly what to change, with
12
- * the screenshot, the target element's HTML, and its computed styles.
13
- *
14
- * Configure in Claude Code:
15
- * {
16
- * "mcpServers": {
17
- * "loupe": {
18
- * "command": "node",
19
- * "args": ["/path/to/loupe/mcp/index.ts"],
20
- * "env": { "LOUPE_API": "http://localhost:8787", "LOUPE_PROJECT_KEY": "pk_demo_acme" }
21
- * }
22
- * }
23
- * }
24
- */
25
-
26
- import type { Comment } from "@loupekit/shared";
27
-
28
- const API = (process.env.LOUPE_API || "http://localhost:8787").replace(/\/$/, "");
29
- const PROJECT_KEY = process.env.LOUPE_PROJECT_KEY || "pk_demo_acme";
30
- // The MCP server authenticates to the API as an admin (project secret).
31
- const ADMIN = process.env.LOUPE_ADMIN_KEY || "";
32
-
33
- async function api(path: string, init?: RequestInit): Promise<any> {
34
- const res = await fetch(`${API}${path}`, {
35
- ...init,
36
- headers: { "Content-Type": "application/json", "X-Loupe-Admin": ADMIN, ...(init?.headers || {}) },
37
- });
38
- if (!res.ok) throw new Error(`${init?.method || "GET"} ${path} → ${res.status}`);
39
- return res.status === 204 ? null : res.json();
40
- }
41
-
42
- const wrap = (text: string) => ({ content: [{ type: "text" as const, text }] });
43
-
44
- /** How to name a comment's target in a one-liner. Free notes have no element. */
45
- const targetOf = (c: Comment) =>
46
- c.kind === "free"
47
- ? "page-level note"
48
- : c.anchor.testid ? `[data-testid="${c.anchor.testid}"]` : c.anchor.cssPath;
49
-
50
- // Tool handlers are exported so they can be unit-tested in-process (the stdio
51
- // transport below only runs when this file is the entrypoint).
52
-
53
- export async function listComments({ status, url }: { status?: string; url?: string }) {
54
- const q = new URLSearchParams({ projectKey: PROJECT_KEY });
55
- if (url) q.set("url", url);
56
- let comments = (await api(`/v1/comments?${q}`)) as Comment[];
57
- if (status) comments = comments.filter((c) => c.status === status);
58
- if (!comments.length) return wrap("No comments match.");
59
- const lines = comments.map(
60
- (c) =>
61
- `- [${c.status}] #${c.id} — ${c.body}\n ↳ ${targetOf(c)} on ${c.url} (by ${c.author.name})`,
62
- );
63
- return wrap(`${comments.length} comment(s):\n\n${lines.join("\n")}\n\nUse get_comment(id) for the full element context.`);
64
- }
65
-
66
- export async function getComment({ id }: { id: string }) {
67
- const c = (await api(`/v1/comments/${encodeURIComponent(id)}`)) as Comment;
68
- // Free notes aren't tied to an element — skip the element HTML/styles sections.
69
- if (c.kind === "free") {
70
- return wrap(
71
- [
72
- `# Feedback #${c.id} from ${c.author.name} (${c.status})`,
73
- ``,
74
- `**Note:** ${c.body}`,
75
- `**Page:** ${c.url}`,
76
- `**Type:** Free note — a page-level comment, not tied to a specific element (no screenshot).`,
77
- ].join("\n"),
78
- );
79
- }
80
- return wrap(
81
- [
82
- `# Feedback #${c.id} from ${c.author.name} (${c.status})`,
83
- ``,
84
- `**Request:** ${c.body}`,
85
- `**Page:** ${c.url}`,
86
- `**Target element:** ${c.anchor.testid ? `[data-testid="${c.anchor.testid}"]` : `\`${c.anchor.cssPath}\``}`,
87
- c.screenshot ? `**Screenshot:** ${c.screenshot}` : ``,
88
- ``,
89
- `## Target element HTML`,
90
- "```html",
91
- c.context.html,
92
- "```",
93
- ``,
94
- `## Computed styles`,
95
- "```json",
96
- JSON.stringify(c.context.styles, null, 2),
97
- "```",
98
- ].filter(Boolean).join("\n"),
99
- );
100
- }
101
-
102
- export async function updateStatus({ id, status }: { id: string; status: string }) {
103
- await api(`/v1/comments/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ status }) });
104
- return wrap(`#${id} → ${status}`);
105
- }
106
-
107
- const server = new McpServer({ name: "loupe", version: "0.5.0" });
108
- server.tool(
109
- "list_comments",
110
- "List Loupe product-feedback comments for the project as a task backlog. Use this to see what a PM has flagged, then work through the items.",
111
- {
112
- status: z.enum(["open", "in_progress", "done"]).optional().describe("Filter by status. Omit for all."),
113
- url: z.string().optional().describe("Filter to a single page path, e.g. /checkout."),
114
- },
115
- listComments,
116
- );
117
- server.tool(
118
- "get_comment",
119
- "Get the full context for one comment: the request, the page, the target element's HTML, and its computed styles — everything needed to make the change.",
120
- { id: z.string().describe("The comment id from list_comments.") },
121
- getComment,
122
- );
123
- server.tool(
124
- "update_status",
125
- "Update a comment's status. Set to in_progress when you start it and done when the change is shipped — this closes the loop back to the PM.",
126
- { id: z.string(), status: z.enum(["open", "in_progress", "done"]) },
127
- updateStatus,
128
- );
129
-
130
- // Connect the stdio transport only when run directly (not when imported by tests).
131
- if (import.meta.url === pathToFileURL(argv[1] ?? "").href) {
132
- await server.connect(new StdioServerTransport());
133
- console.error(`[loupe-mcp] connected · project=${PROJECT_KEY} · api=${API}`);
134
- }