sentinel-mcp 0.3.0 → 0.4.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.
@@ -0,0 +1,16 @@
1
+ export declare function isAllowedUrl(url: string): boolean;
2
+ export declare function findImageInHtml(html: string, uuid: string): string | null;
3
+ interface IssueContext {
4
+ repo: string;
5
+ issueNumber: number;
6
+ }
7
+ type FetchResult = {
8
+ ok: true;
9
+ data: string;
10
+ mimeType: string;
11
+ } | {
12
+ ok: false;
13
+ error: string;
14
+ };
15
+ export declare function fetchImage(url: string, token?: string, context?: IssueContext): Promise<FetchResult>;
16
+ export {};
@@ -0,0 +1,122 @@
1
+ const ALLOWED_PATTERNS = [
2
+ /^https:\/\/github\.com\/user-attachments\/assets\//,
3
+ /^https:\/\/[a-z0-9-]+\.githubusercontent\.com\//,
4
+ ];
5
+ const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
6
+ export function isAllowedUrl(url) {
7
+ try {
8
+ const parsed = new URL(url);
9
+ if (parsed.protocol !== "https:")
10
+ return false;
11
+ return ALLOWED_PATTERNS.some((p) => p.test(url));
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ export function findImageInHtml(html, uuid) {
18
+ const imgPattern = /<img[^>]+src="([^"]+)"/g;
19
+ let match;
20
+ // First pass: match by UUID
21
+ while ((match = imgPattern.exec(html)) !== null) {
22
+ const src = match[1].replace(/&amp;/g, "&");
23
+ if (src.includes(uuid))
24
+ return src;
25
+ }
26
+ // Second pass: fallback to first private-user-images URL
27
+ imgPattern.lastIndex = 0;
28
+ while ((match = imgPattern.exec(html)) !== null) {
29
+ const src = match[1].replace(/&amp;/g, "&");
30
+ if (src.includes("private-user-images.githubusercontent.com"))
31
+ return src;
32
+ }
33
+ return null;
34
+ }
35
+ async function fetchAndEncode(url, headers) {
36
+ let res;
37
+ try {
38
+ res = await fetch(url, { headers });
39
+ }
40
+ catch (err) {
41
+ return {
42
+ ok: false,
43
+ error: `Fetch failed: ${err instanceof Error ? err.message : String(err)}`,
44
+ };
45
+ }
46
+ if (!res.ok) {
47
+ return { ok: false, error: `HTTP ${res.status}` };
48
+ }
49
+ const contentType = res.headers.get("content-type") ?? "";
50
+ if (!contentType.startsWith("image/")) {
51
+ return { ok: false, error: `Response is not an image (${contentType})` };
52
+ }
53
+ const buffer = await res.arrayBuffer();
54
+ if (buffer.byteLength > MAX_IMAGE_SIZE) {
55
+ return {
56
+ ok: false,
57
+ error: `Image too large (${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB)`,
58
+ };
59
+ }
60
+ const data = Buffer.from(buffer).toString("base64");
61
+ const mimeType = contentType.split(";")[0].trim();
62
+ return { ok: true, data, mimeType };
63
+ }
64
+ async function resolveImageFromIssueHtml(targetUrl, repo, issueNumber, token) {
65
+ const headers = {
66
+ Authorization: `token ${token}`,
67
+ Accept: "application/vnd.github.full+json",
68
+ "User-Agent": "sentinel-mcp/1.0",
69
+ "X-GitHub-Api-Version": "2022-11-28",
70
+ };
71
+ const uuidMatch = targetUrl.match(/\/assets\/([a-f0-9-]+)/);
72
+ if (!uuidMatch)
73
+ return null;
74
+ const uuid = uuidMatch[1];
75
+ const [issueRes, commentsRes] = await Promise.all([
76
+ fetch(`https://api.github.com/repos/${repo}/issues/${issueNumber}`, { headers }),
77
+ fetch(`https://api.github.com/repos/${repo}/issues/${issueNumber}/comments?per_page=100`, { headers }),
78
+ ]);
79
+ if (issueRes.ok) {
80
+ const issue = (await issueRes.json());
81
+ if (issue.body_html) {
82
+ const found = findImageInHtml(issue.body_html, uuid);
83
+ if (found)
84
+ return found;
85
+ }
86
+ }
87
+ if (commentsRes.ok) {
88
+ const comments = (await commentsRes.json());
89
+ for (const comment of comments) {
90
+ if (comment.body_html) {
91
+ const found = findImageInHtml(comment.body_html, uuid);
92
+ if (found)
93
+ return found;
94
+ }
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ export async function fetchImage(url, token, context) {
100
+ if (!isAllowedUrl(url)) {
101
+ return { ok: false, error: "URL not allowed" };
102
+ }
103
+ const headers = {
104
+ "User-Agent": "sentinel-mcp/1.0",
105
+ };
106
+ if (token) {
107
+ headers["Authorization"] = `token ${token}`;
108
+ }
109
+ const result = await fetchAndEncode(url, headers);
110
+ // If direct fetch failed and it's a user-attachments URL, try resolving via GitHub Issues API
111
+ if (!result.ok &&
112
+ /github\.com\/user-attachments\/assets\//.test(url) &&
113
+ token &&
114
+ context?.repo &&
115
+ context?.issueNumber) {
116
+ const resolvedUrl = await resolveImageFromIssueHtml(url, context.repo, context.issueNumber, token);
117
+ if (resolvedUrl) {
118
+ return fetchAndEncode(resolvedUrl, { "User-Agent": "sentinel-mcp/1.0" });
119
+ }
120
+ }
121
+ return result;
122
+ }
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
8
8
  import { z } from "zod";
9
9
  import { WsClient, decodeInstallationId } from "./ws-client.js";
10
10
  import { EventQueue } from "./queue.js";
11
+ import { fetchImage } from "./image-server.js";
11
12
  // ---------------------------------------------------------------------------
12
13
  // Config from environment
13
14
  // ---------------------------------------------------------------------------
@@ -261,8 +262,16 @@ server.registerTool("fetch_image", {
261
262
  issue_number: z.number().describe("Issue number where the image was posted"),
262
263
  },
263
264
  }, async ({ url, repo, issue_number }) => {
264
- const result = await mcpToolCall("fetch_image", { url, repo, issue_number });
265
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
265
+ const result = await fetchImage(url, githubToken, { repo, issueNumber: issue_number });
266
+ if (!result.ok) {
267
+ return {
268
+ content: [{ type: "text", text: `${result.error}. Ask the user to describe the image or paste it as text.` }],
269
+ isError: true,
270
+ };
271
+ }
272
+ return {
273
+ content: [{ type: "image", data: result.data, mimeType: result.mimeType }],
274
+ };
266
275
  });
267
276
  server.registerTool("register_worktree", {
268
277
  description: "Register a new worktree for persistent subagent tracking. Call this after creating a worktree with git.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinel-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Sentinel MCP server — connects GitHub issues to Claude Code sessions",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",