chore: import upstream snapshot with attribution
Spell checking / Check Spelling (push) Has been cancelled
Spell checking / Update PR (push) Has been cancelled
Publish Dev Docs Website / build (push) Failing after 1s
Publish Dev Docs Website / deploy (push) Has been skipped
Spell checking / Report (Push) (push) Has been cancelled
Spell checking / Report (PR) (push) Has been cancelled
Spell checking / Check Spelling (push) Has been cancelled
Spell checking / Update PR (push) Has been cancelled
Publish Dev Docs Website / build (push) Failing after 1s
Publish Dev Docs Website / deploy (push) Has been skipped
Spell checking / Report (Push) (push) Has been cancelled
Spell checking / Report (PR) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,35 @@
|
||||
// Centralized control over when the GitHub bearer token may be attached to an
|
||||
// outgoing request.
|
||||
//
|
||||
// Image and ZIP URLs are extracted from untrusted issue/PR Markdown and can
|
||||
// point to any host. The GitHub bearer token (GITHUB_TOKEN) must therefore only
|
||||
// ever be sent to explicitly allowlisted GitHub API hosts, never to URLs that
|
||||
// originate from issue content. Sending it elsewhere would leak the token to an
|
||||
// attacker-controlled server.
|
||||
|
||||
export const AUTH_ALLOWED_HOSTS = new Set([
|
||||
"api.github.com"
|
||||
]);
|
||||
|
||||
// Returns true only when it is safe to attach the GitHub bearer token to the
|
||||
// given URL: the request must be HTTPS and target an allowlisted GitHub API host.
|
||||
export function shouldSendGitHubToken(rawUrl) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return parsed.protocol === "https:" && AUTH_ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());
|
||||
}
|
||||
|
||||
// Builds request headers, attaching Authorization only when the target URL is an
|
||||
// allowlisted GitHub API host. Arbitrary image/ZIP URLs from issue content never
|
||||
// receive the token.
|
||||
export function headersForUrl(rawUrl, token, extra = {}) {
|
||||
return {
|
||||
...extra,
|
||||
...(token && shouldSendGitHubToken(rawUrl) ? { "Authorization": `Bearer ${token}` } : {}),
|
||||
"User-Agent": "issue-images-mcp"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { existsSync } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname } from "path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
process.chdir(__dirname);
|
||||
|
||||
if (!existsSync("node_modules")) {
|
||||
console.log("[MCP] Installing dependencies...");
|
||||
execSync("npm install", { stdio: "inherit" });
|
||||
}
|
||||
|
||||
import("./server.js");
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "github-artifacts",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"test": "node test-token-allowlist.js && node test-github_issue_images.js && node test-github_issue_attachments.js",
|
||||
"test:unit": "node test-token-allowlist.js",
|
||||
"start": "node server.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"jszip": "^3.10.1",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { execFile } from "child_process";
|
||||
import { headersForUrl } from "./auth.js";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "issue-images",
|
||||
version: "0.1.0"
|
||||
});
|
||||
|
||||
const GH_API_PER_PAGE = 100;
|
||||
const MAX_TEXT_FILE_BYTES = 100000;
|
||||
// Limit to common text files to avoid binary blobs, huge payloads, and non-UTF8 noise.
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
".txt", ".log", ".json", ".xml", ".yaml", ".yml", ".md", ".csv",
|
||||
".ini", ".config", ".conf", ".bat", ".ps1", ".sh", ".reg", ".etl"
|
||||
]);
|
||||
|
||||
function extractImageUrls(markdownOrHtml) {
|
||||
const urls = new Set();
|
||||
|
||||
// Markdown images: 
|
||||
for (const m of markdownOrHtml.matchAll(/!\[[^\]]*?\]\((https?:\/\/[^\s)]+)\)/g)) {
|
||||
urls.add(m[1]);
|
||||
}
|
||||
|
||||
// HTML <img src="...">
|
||||
for (const m of markdownOrHtml.matchAll(/<img[^>]+src="(https?:\/\/[^">]+)"/g)) {
|
||||
urls.add(m[1]);
|
||||
}
|
||||
|
||||
return [...urls];
|
||||
}
|
||||
|
||||
function extractZipUrls(markdownOrHtml) {
|
||||
const urls = new Set();
|
||||
|
||||
// Markdown links to .zip files: [text](url.zip)
|
||||
for (const m of markdownOrHtml.matchAll(/\[[^\]]*?\]\((https?:\/\/[^\s)]+\.zip)\)/gi)) {
|
||||
urls.add(m[1]);
|
||||
}
|
||||
|
||||
// Plain URLs ending in .zip
|
||||
for (const m of markdownOrHtml.matchAll(/(https?:\/\/[^\s<>"]+\.zip)/gi)) {
|
||||
urls.add(m[1]);
|
||||
}
|
||||
|
||||
return [...urls];
|
||||
}
|
||||
|
||||
async function fetchJson(url, token) {
|
||||
const res = await fetch(url, {
|
||||
headers: headersForUrl(url, token, {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28"
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub API failed: ${res.status} ${res.statusText}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function downloadBytes(url, token) {
|
||||
// URL comes from untrusted issue content: headersForUrl withholds the token
|
||||
// unless the host is an allowlisted GitHub API host.
|
||||
const res = await fetch(url, {
|
||||
headers: headersForUrl(url, token)
|
||||
});
|
||||
if (!res.ok) throw new Error(`Image download failed: ${res.status} ${res.statusText}`);
|
||||
const buf = new Uint8Array(await res.arrayBuffer());
|
||||
const ct = res.headers.get("content-type") || "image/png";
|
||||
return { buf, mimeType: ct };
|
||||
}
|
||||
|
||||
async function downloadZipBytes(url, token) {
|
||||
const zipUrl = url.includes("?") ? url : `${url}?download=1`;
|
||||
|
||||
// URL comes from untrusted issue content: headersForUrl withholds the token
|
||||
// unless the host is an allowlisted GitHub API host.
|
||||
const res = await fetch(zipUrl, {
|
||||
headers: headersForUrl(zipUrl, token, { "Accept": "application/octet-stream" }),
|
||||
redirect: "follow"
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`ZIP download failed: ${res.status} ${res.statusText}`);
|
||||
|
||||
const contentType = (res.headers.get("content-type") || "").toLowerCase();
|
||||
const buf = new Uint8Array(await res.arrayBuffer());
|
||||
|
||||
const isZip = buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b;
|
||||
|
||||
if (!isZip || contentType.includes("text/html") || buf.length < 100) {
|
||||
throw new Error("ZIP download returned HTML or invalid data. Check permissions or rate limits.");
|
||||
}
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
function execFileAsync(file, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(file, args, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(stderr || error.message));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function extractZipToFolder(zipPath, extractPath) {
|
||||
if (process.platform === "win32") {
|
||||
await execFileAsync("powershell", [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`$ProgressPreference='SilentlyContinue'; Expand-Archive -Path \"${zipPath}\" -DestinationPath \"${extractPath}\" -Force -ErrorAction Stop | Out-Null`
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
await execFileAsync("unzip", ["-o", zipPath, "-d", extractPath]);
|
||||
}
|
||||
|
||||
async function listFilesRecursively(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await listFilesRecursively(fullPath));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function pathExists(p) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllComments(owner, repo, issueNumber, token) {
|
||||
let comments = [];
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const pageComments = await fetchJson(
|
||||
`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${GH_API_PER_PAGE}&page=${page}`,
|
||||
token
|
||||
);
|
||||
comments = comments.concat(pageComments);
|
||||
if (pageComments.length < GH_API_PER_PAGE) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return comments;
|
||||
}
|
||||
|
||||
async function fetchIssueAndComments(owner, repo, issueNumber, token) {
|
||||
const issue = await fetchJson(`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`, token);
|
||||
const comments = issue.comments > 0 ? await fetchAllComments(owner, repo, issueNumber, token) : [];
|
||||
return { issue, comments };
|
||||
}
|
||||
|
||||
function buildBlobs(issue, comments) {
|
||||
return [issue.body || "", ...comments.map(c => c.body || "")].join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
server.registerTool(
|
||||
"github_issue_images",
|
||||
{
|
||||
title: "GitHub Issue Images",
|
||||
description: `Download and return images from a GitHub issue or pull request.
|
||||
|
||||
USE THIS TOOL WHEN:
|
||||
- User asks about a GitHub issue/PR that contains screenshots, images, or visual content
|
||||
- User wants to understand a bug report with attached images
|
||||
- User asks to analyze, describe, or review images in an issue/PR
|
||||
- User references a GitHub issue/PR URL and the context suggests images are relevant
|
||||
- User asks about UI bugs, visual glitches, design issues, or anything visual in nature
|
||||
|
||||
WHAT IT DOES:
|
||||
- Fetches all images from the issue/PR body and all comments
|
||||
- Returns actual image data (not just URLs) so the LLM can see and analyze the images
|
||||
- Supports PNG, JPEG, GIF, and other common image formats
|
||||
|
||||
EXAMPLES OF WHEN TO USE:
|
||||
- "What does the bug in issue #123 look like?"
|
||||
- "Can you see the screenshot in this PR?"
|
||||
- "Analyze the images in microsoft/PowerToys#25595"
|
||||
- "What UI problem is shown in this issue?"`,
|
||||
inputSchema: {
|
||||
owner: z.string(),
|
||||
repo: z.string(),
|
||||
issueNumber: z.number(),
|
||||
maxImages: z.number().min(1).max(20).optional()
|
||||
},
|
||||
outputSchema: {
|
||||
images: z.number(),
|
||||
comments: z.number()
|
||||
}
|
||||
},
|
||||
async ({ owner, repo, issueNumber, maxImages = 20 }) => {
|
||||
try {
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const { issue, comments } = await fetchIssueAndComments(owner, repo, issueNumber, token);
|
||||
const blobs = buildBlobs(issue, comments);
|
||||
const urls = extractImageUrls(blobs).slice(0, maxImages);
|
||||
|
||||
const content = [
|
||||
{ type: "text", text: `Found ${urls.length} image(s) in issue #${issueNumber} (from ${comments.length} comments). Returning as image parts.` }
|
||||
];
|
||||
|
||||
for (const url of urls) {
|
||||
const { buf, mimeType } = await downloadBytes(url, token);
|
||||
const b64 = Buffer.from(buf).toString("base64");
|
||||
content.push({ type: "image", data: b64, mimeType });
|
||||
content.push({ type: "text", text: `Image source: ${url}` });
|
||||
}
|
||||
|
||||
const output = { images: urls.length, comments: comments.length };
|
||||
return { content, structuredContent: output };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"github_issue_attachments",
|
||||
{
|
||||
title: "GitHub Issue Attachments",
|
||||
description: `Download and extract ZIP file attachments from a GitHub issue or pull request.
|
||||
|
||||
USE THIS TOOL WHEN:
|
||||
- User asks about diagnostic logs, crash reports, or debug information in an issue
|
||||
- Issue contains ZIP attachments like PowerToysReport_*.zip, logs.zip, debug.zip
|
||||
- User wants to analyze log files, configuration files, or system info from an issue
|
||||
- User asks about error logs, stack traces, or diagnostic data attached to an issue
|
||||
- Issue mentions attached files that need to be examined
|
||||
|
||||
WHAT IT DOES:
|
||||
- Finds all ZIP file attachments in the issue body and comments
|
||||
- Downloads and extracts each ZIP to a local folder
|
||||
- Returns file listing and contents of text files (logs, json, xml, txt, etc.)
|
||||
- Each ZIP is extracted to: {extractFolder}/{zipFileName}/
|
||||
|
||||
EXAMPLES OF WHEN TO USE:
|
||||
- "What's in the diagnostic report attached to issue #39476?"
|
||||
- "Can you check the logs in the PowerToysReport zip?"
|
||||
- "Analyze the crash dump attached to this issue"
|
||||
- "What error is shown in the attached log files?"`,
|
||||
inputSchema: {
|
||||
owner: z.string(),
|
||||
repo: z.string(),
|
||||
issueNumber: z.number(),
|
||||
extractFolder: z.string(),
|
||||
maxFiles: z.number().min(1).optional()
|
||||
},
|
||||
outputSchema: {
|
||||
zips: z.number(),
|
||||
extracted: z.number(),
|
||||
extractedTo: z.array(z.string())
|
||||
}
|
||||
},
|
||||
async ({ owner, repo, issueNumber, extractFolder, maxFiles = 50 }) => {
|
||||
try {
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const { issue, comments } = await fetchIssueAndComments(owner, repo, issueNumber, token);
|
||||
const blobs = buildBlobs(issue, comments);
|
||||
const zipUrls = extractZipUrls(blobs);
|
||||
|
||||
if (zipUrls.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: `No ZIP attachments found in issue #${issueNumber}.` }],
|
||||
structuredContent: { zips: 0, extracted: 0, extractedTo: [] }
|
||||
};
|
||||
}
|
||||
|
||||
await fs.mkdir(extractFolder, { recursive: true });
|
||||
|
||||
const content = [
|
||||
{ type: "text", text: `Found ${zipUrls.length} ZIP attachment(s) in issue #${issueNumber}. Extracting to: ${extractFolder}` }
|
||||
];
|
||||
|
||||
let totalFilesReturned = 0;
|
||||
const extractedPaths = [];
|
||||
let extractedCount = 0;
|
||||
|
||||
for (const zipUrl of zipUrls) {
|
||||
try {
|
||||
const urlPath = new URL(zipUrl).pathname;
|
||||
const zipFileName = path.basename(urlPath, ".zip");
|
||||
const extractPath = path.join(extractFolder, zipFileName);
|
||||
const zipPath = path.join(extractFolder, `${zipFileName}.zip`);
|
||||
|
||||
let extractedFiles = [];
|
||||
const extractPathExists = await pathExists(extractPath);
|
||||
|
||||
if (extractPathExists) {
|
||||
extractedFiles = await listFilesRecursively(extractPath);
|
||||
}
|
||||
|
||||
if (!extractPathExists || extractedFiles.length === 0) {
|
||||
const zipExists = await pathExists(zipPath);
|
||||
if (!zipExists) {
|
||||
const buf = await downloadZipBytes(zipUrl, token);
|
||||
await fs.writeFile(zipPath, buf);
|
||||
}
|
||||
|
||||
await fs.mkdir(extractPath, { recursive: true });
|
||||
await extractZipToFolder(zipPath, extractPath);
|
||||
extractedFiles = await listFilesRecursively(extractPath);
|
||||
}
|
||||
|
||||
extractedPaths.push(extractPath);
|
||||
extractedCount++;
|
||||
|
||||
const fileList = [];
|
||||
const textContents = [];
|
||||
|
||||
for (const fullPath of extractedFiles) {
|
||||
const relPath = path.relative(extractPath, fullPath).replace(/\\/g, "/");
|
||||
const ext = path.extname(relPath).toLowerCase();
|
||||
const stat = await fs.stat(fullPath);
|
||||
const sizeKB = Math.round(stat.size / 1024);
|
||||
fileList.push(` ${relPath} (${sizeKB} KB)`);
|
||||
|
||||
if (TEXT_EXTENSIONS.has(ext) && totalFilesReturned < maxFiles && stat.size < MAX_TEXT_FILE_BYTES) {
|
||||
try {
|
||||
const textContent = await fs.readFile(fullPath, "utf-8");
|
||||
textContents.push({ path: relPath, content: textContent });
|
||||
totalFilesReturned++;
|
||||
} catch {
|
||||
// Not valid UTF-8, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content.push({ type: "text", text: `\n📦 ${zipFileName}.zip extracted to: ${extractPath}\nFiles:\n${fileList.join("\n")}` });
|
||||
|
||||
for (const { path: fPath, content: fContent } of textContents) {
|
||||
content.push({ type: "text", text: `\n--- ${fPath} ---\n${fContent}` });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
content.push({ type: "text", text: `❌ Failed to extract ${zipUrl}: ${message}` });
|
||||
}
|
||||
}
|
||||
|
||||
const output = { zips: zipUrls.length, extracted: extractedCount, extractedTo: extractedPaths };
|
||||
return { content, structuredContent: output };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
@@ -0,0 +1,141 @@
|
||||
// Test script for github_issue_attachments tool
|
||||
// Run with: node test-github_issue_attachments.js
|
||||
// Make sure GITHUB_TOKEN is set in environment
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const extractFolder = path.join(__dirname, "test-extracts");
|
||||
|
||||
const server = spawn("node", ["server.js"], {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: { ...process.env }
|
||||
});
|
||||
|
||||
// Send initialize request
|
||||
const initRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test-client", version: "1.0.0" }
|
||||
}
|
||||
};
|
||||
|
||||
server.stdin.write(JSON.stringify(initRequest) + "\n");
|
||||
|
||||
// Send list tools request
|
||||
setTimeout(() => {
|
||||
const listToolsRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/list",
|
||||
params: {}
|
||||
};
|
||||
server.stdin.write(JSON.stringify(listToolsRequest) + "\n");
|
||||
}, 500);
|
||||
|
||||
// Send call tool request - test with PowerToys issue that has ZIP attachments
|
||||
setTimeout(() => {
|
||||
const callToolRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "github_issue_attachments",
|
||||
arguments: {
|
||||
owner: "microsoft",
|
||||
repo: "PowerToys",
|
||||
issueNumber: 39476, // Has PowerToysReport_*.zip attachment
|
||||
extractFolder: extractFolder,
|
||||
maxFiles: 20
|
||||
}
|
||||
}
|
||||
};
|
||||
server.stdin.write(JSON.stringify(callToolRequest) + "\n");
|
||||
}, 1000);
|
||||
|
||||
// Track summary
|
||||
let summary = { zips: 0, files: 0, extractPath: "" };
|
||||
let buffer = "";
|
||||
|
||||
// Read responses
|
||||
server.stdout.on("data", (data) => {
|
||||
buffer += data.toString();
|
||||
|
||||
// Try to parse complete JSON objects from buffer
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const response = JSON.parse(line);
|
||||
console.log("\n=== Response ===");
|
||||
console.log("ID:", response.id);
|
||||
if (response.result?.tools) {
|
||||
console.log("Tools:", response.result.tools.map(t => t.name));
|
||||
} else if (response.result?.content) {
|
||||
for (const item of response.result.content) {
|
||||
if (item.type === "text") {
|
||||
// Truncate long file contents for display
|
||||
const text = item.text;
|
||||
if (text.startsWith("---") && text.length > 500) {
|
||||
console.log(text.substring(0, 500) + "\n... [truncated]");
|
||||
} else {
|
||||
console.log(text);
|
||||
}
|
||||
|
||||
// Track stats
|
||||
if (text.includes("ZIP attachment")) {
|
||||
const match = text.match(/Found (\d+) ZIP/);
|
||||
if (match) summary.zips = parseInt(match[1]);
|
||||
}
|
||||
if (text.includes("extracted to:")) {
|
||||
summary.extractPath = text.match(/extracted to: (.+)/)?.[1] || "";
|
||||
}
|
||||
if (text.includes("Files:")) {
|
||||
summary.files = (text.match(/ /g) || []).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("Result:", JSON.stringify(response.result, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
// Likely incomplete JSON, will be in next chunk
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Exit after 60 seconds (ZIP download may take time)
|
||||
setTimeout(() => {
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log("=== Test Summary ===");
|
||||
console.log("=".repeat(50));
|
||||
console.log(`ZIP files found: ${summary.zips}`);
|
||||
console.log(`Files extracted: ${summary.files}`);
|
||||
if (summary.extractPath) {
|
||||
console.log(`Extract location: ${summary.extractPath}`);
|
||||
}
|
||||
console.log("=".repeat(50));
|
||||
console.log("Cleaning up extracted files...");
|
||||
fs.rm(extractFolder, { recursive: true, force: true })
|
||||
.then(() => {
|
||||
console.log("Cleanup done.");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(`Cleanup failed: ${err.message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
console.log("Test complete!");
|
||||
server.kill();
|
||||
process.exit(0);
|
||||
});
|
||||
}, 60000);
|
||||
@@ -0,0 +1,118 @@
|
||||
// Simple test script - run with: node test-github_issue_images.js
|
||||
// Make sure GITHUB_TOKEN is set in environment
|
||||
|
||||
import { spawn } from "child_process";
|
||||
|
||||
const server = spawn("node", ["server.js"], {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: { ...process.env }
|
||||
});
|
||||
|
||||
// Send initialize request
|
||||
const initRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test-client", version: "1.0.0" }
|
||||
}
|
||||
};
|
||||
|
||||
server.stdin.write(JSON.stringify(initRequest) + "\n");
|
||||
|
||||
// Send list tools request
|
||||
setTimeout(() => {
|
||||
const listToolsRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/list",
|
||||
params: {}
|
||||
};
|
||||
server.stdin.write(JSON.stringify(listToolsRequest) + "\n");
|
||||
}, 500);
|
||||
|
||||
// Send call tool request (test with a real issue)
|
||||
setTimeout(() => {
|
||||
const callToolRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "github_issue_images",
|
||||
arguments: {
|
||||
owner: "microsoft",
|
||||
repo: "PowerToys",
|
||||
issueNumber: 25595, // 315 comments, many images - tests pagination!
|
||||
maxImages: 5
|
||||
}
|
||||
}
|
||||
};
|
||||
server.stdin.write(JSON.stringify(callToolRequest) + "\n");
|
||||
}, 1000);
|
||||
|
||||
// Track summary
|
||||
let summary = { images: 0, totalKB: 0, text: "" };
|
||||
let gotToolResponse = false;
|
||||
let buffer = "";
|
||||
|
||||
// Read responses
|
||||
server.stdout.on("data", (data) => {
|
||||
buffer += data.toString();
|
||||
|
||||
// Try to parse complete JSON objects from buffer
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const response = JSON.parse(line);
|
||||
console.log("\n=== Response ===");
|
||||
console.log("ID:", response.id);
|
||||
if (response.result?.tools) {
|
||||
console.log("Tools:", response.result.tools.map(t => t.name));
|
||||
} else if (response.result?.content) {
|
||||
gotToolResponse = true;
|
||||
let imageCount = 0;
|
||||
for (const item of response.result.content) {
|
||||
if (item.type === "text") {
|
||||
console.log("Text:", item.text);
|
||||
summary.text = item.text;
|
||||
} else if (item.type === "image") {
|
||||
imageCount++;
|
||||
const sizeKB = Math.round(item.data.length * 0.75 / 1024); // base64 to actual size
|
||||
console.log(` [Image ${imageCount}] ${item.mimeType} - ${sizeKB} KB downloaded`);
|
||||
summary.images++;
|
||||
summary.totalKB += sizeKB;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("Result:", JSON.stringify(response.result, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
// Likely incomplete JSON, will be in next chunk
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Exit after 60 seconds (more time for downloads)
|
||||
setTimeout(() => {
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log("=== Test Summary ===");
|
||||
console.log("=".repeat(50));
|
||||
if (summary.text) {
|
||||
console.log(summary.text);
|
||||
}
|
||||
if (summary.images > 0) {
|
||||
console.log(`Total images downloaded: ${summary.images}`);
|
||||
console.log(`Total size: ${summary.totalKB} KB`);
|
||||
} else if (!gotToolResponse) {
|
||||
console.log("No tool response received yet. The request may still be running or was rate-limited.");
|
||||
}
|
||||
console.log("=".repeat(50));
|
||||
console.log("Test complete!");
|
||||
server.kill();
|
||||
process.exit(0);
|
||||
}, 60000);
|
||||
@@ -0,0 +1,77 @@
|
||||
// Regression test for the GITHUB_TOKEN allowlist (security fix).
|
||||
// Run with: node test-token-allowlist.js
|
||||
//
|
||||
// Proves that the bearer token is attached ONLY to allowlisted GitHub API hosts
|
||||
// and is NEVER sent to arbitrary URLs extracted from untrusted issue content.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { shouldSendGitHubToken, headersForUrl } from "./auth.js";
|
||||
|
||||
const TOKEN = "PT_MCP_TEST_FAKE_TOKEN";
|
||||
|
||||
let failures = 0;
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ok - ${name}`);
|
||||
} catch (err) {
|
||||
failures++;
|
||||
console.error(` FAIL - ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Hosts that MUST receive the token.
|
||||
const allowed = [
|
||||
"https://api.github.com/repos/microsoft/PowerToys/issues/1",
|
||||
"https://api.github.com/repos/microsoft/PowerToys/issues/1/comments?per_page=100&page=1"
|
||||
];
|
||||
|
||||
// Hosts that MUST NOT receive the token (attacker-controlled or non-API hosts,
|
||||
// including GitHub's own user-content hosts which never need the API token).
|
||||
const denied = [
|
||||
"http://127.0.0.1:8000/capture.png",
|
||||
"http://127.0.0.1:8000/PowerToysReport.zip?download=1",
|
||||
"https://evil.example.com/capture.png",
|
||||
"https://evil.example.com/PowerToysReport.zip",
|
||||
"http://api.github.com/repos/microsoft/PowerToys/issues/1", // not HTTPS
|
||||
"https://api.github.com.attacker.com/x", // look-alike host
|
||||
"https://objects.githubusercontent.com/some/asset.zip",
|
||||
"https://user-images.githubusercontent.com/1/screenshot.png",
|
||||
"not a url"
|
||||
];
|
||||
|
||||
for (const url of allowed) {
|
||||
check(`token sent to allowlisted host: ${url}`, () => {
|
||||
assert.equal(shouldSendGitHubToken(url), true);
|
||||
const h = headersForUrl(url, TOKEN);
|
||||
assert.equal(h["Authorization"], `Bearer ${TOKEN}`);
|
||||
assert.equal(h["User-Agent"], "issue-images-mcp");
|
||||
});
|
||||
}
|
||||
|
||||
for (const url of denied) {
|
||||
check(`token withheld from untrusted host: ${url}`, () => {
|
||||
assert.equal(shouldSendGitHubToken(url), false);
|
||||
const h = headersForUrl(url, TOKEN);
|
||||
assert.equal("Authorization" in h, false, "Authorization header must not be present");
|
||||
assert.equal(h["User-Agent"], "issue-images-mcp");
|
||||
});
|
||||
}
|
||||
|
||||
check("no token configured => no Authorization even for allowlisted host", () => {
|
||||
const h = headersForUrl("https://api.github.com/x", undefined);
|
||||
assert.equal("Authorization" in h, false);
|
||||
});
|
||||
|
||||
check("extra headers are preserved", () => {
|
||||
const h = headersForUrl("https://api.github.com/x", TOKEN, { "Accept": "application/vnd.github+json" });
|
||||
assert.equal(h["Accept"], "application/vnd.github+json");
|
||||
assert.equal(h["Authorization"], `Bearer ${TOKEN}`);
|
||||
});
|
||||
|
||||
console.log("");
|
||||
if (failures > 0) {
|
||||
console.error(`${failures} test(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("All token-allowlist regression tests passed.");
|
||||
Reference in New Issue
Block a user