@loupekit/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/index.ts +116 -0
- package/package.json +21 -0
package/index.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
// Tool handlers are exported so they can be unit-tested in-process (the stdio
|
|
45
|
+
// transport below only runs when this file is the entrypoint).
|
|
46
|
+
|
|
47
|
+
export async function listComments({ status, url }: { status?: string; url?: string }) {
|
|
48
|
+
const q = new URLSearchParams({ projectKey: PROJECT_KEY });
|
|
49
|
+
if (url) q.set("url", url);
|
|
50
|
+
let comments = (await api(`/v1/comments?${q}`)) as Comment[];
|
|
51
|
+
if (status) comments = comments.filter((c) => c.status === status);
|
|
52
|
+
if (!comments.length) return wrap("No comments match.");
|
|
53
|
+
const lines = comments.map(
|
|
54
|
+
(c) =>
|
|
55
|
+
`- [${c.status}] #${c.id} — ${c.body}\n ↳ ${c.anchor.testid ? `[data-testid="${c.anchor.testid}"]` : c.anchor.cssPath} on ${c.url} (by ${c.author.name})`,
|
|
56
|
+
);
|
|
57
|
+
return wrap(`${comments.length} comment(s):\n\n${lines.join("\n")}\n\nUse get_comment(id) for the full element context.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function getComment({ id }: { id: string }) {
|
|
61
|
+
const c = (await api(`/v1/comments/${encodeURIComponent(id)}`)) as Comment;
|
|
62
|
+
return wrap(
|
|
63
|
+
[
|
|
64
|
+
`# Feedback #${c.id} from ${c.author.name} (${c.status})`,
|
|
65
|
+
``,
|
|
66
|
+
`**Request:** ${c.body}`,
|
|
67
|
+
`**Page:** ${c.url}`,
|
|
68
|
+
`**Target element:** ${c.anchor.testid ? `[data-testid="${c.anchor.testid}"]` : `\`${c.anchor.cssPath}\``}`,
|
|
69
|
+
c.screenshot ? `**Screenshot:** ${c.screenshot}` : ``,
|
|
70
|
+
``,
|
|
71
|
+
`## Target element HTML`,
|
|
72
|
+
"```html",
|
|
73
|
+
c.context.html,
|
|
74
|
+
"```",
|
|
75
|
+
``,
|
|
76
|
+
`## Computed styles`,
|
|
77
|
+
"```json",
|
|
78
|
+
JSON.stringify(c.context.styles, null, 2),
|
|
79
|
+
"```",
|
|
80
|
+
].filter(Boolean).join("\n"),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function updateStatus({ id, status }: { id: string; status: string }) {
|
|
85
|
+
await api(`/v1/comments/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ status }) });
|
|
86
|
+
return wrap(`#${id} → ${status}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const server = new McpServer({ name: "loupe", version: "0.1.0" });
|
|
90
|
+
server.tool(
|
|
91
|
+
"list_comments",
|
|
92
|
+
"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.",
|
|
93
|
+
{
|
|
94
|
+
status: z.enum(["open", "in_progress", "done"]).optional().describe("Filter by status. Omit for all."),
|
|
95
|
+
url: z.string().optional().describe("Filter to a single page path, e.g. /checkout."),
|
|
96
|
+
},
|
|
97
|
+
listComments,
|
|
98
|
+
);
|
|
99
|
+
server.tool(
|
|
100
|
+
"get_comment",
|
|
101
|
+
"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.",
|
|
102
|
+
{ id: z.string().describe("The comment id from list_comments.") },
|
|
103
|
+
getComment,
|
|
104
|
+
);
|
|
105
|
+
server.tool(
|
|
106
|
+
"update_status",
|
|
107
|
+
"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.",
|
|
108
|
+
{ id: z.string(), status: z.enum(["open", "in_progress", "done"]) },
|
|
109
|
+
updateStatus,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Connect the stdio transport only when run directly (not when imported by tests).
|
|
113
|
+
if (import.meta.url === pathToFileURL(argv[1] ?? "").href) {
|
|
114
|
+
await server.connect(new StdioServerTransport());
|
|
115
|
+
console.error(`[loupe-mcp] connected · project=${PROJECT_KEY} · api=${API}`);
|
|
116
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@loupekit/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server that exposes Loupe comments to Claude Code as an actionable backlog.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": { "type": "git", "url": "https://github.com/mohamed-ashraf-elsaed/loupe.git", "directory": "packages/mcp" },
|
|
7
|
+
"homepage": "https://mohamed-ashraf-elsaed.github.io/loupe/",
|
|
8
|
+
"publishConfig": { "access": "public" },
|
|
9
|
+
"type": "module",
|
|
10
|
+
"files": ["index.ts"],
|
|
11
|
+
"bin": { "loupe-mcp": "./index.ts" },
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node index.ts",
|
|
14
|
+
"test:client": "node test-client.ts"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@loupekit/shared": "^0.1.0",
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
19
|
+
"zod": "^3.24.1"
|
|
20
|
+
}
|
|
21
|
+
}
|