#!/usr/bin/env node
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import axios from "axios";
import { z } from "zod";
import dotenv from "dotenv";
import path from "path";
import { fileURLToPath } from "url";
import { exec, spawn } from "child_process";
import os from "os";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load .env from the current working directory
dotenv.config({ path: path.join(process.cwd(), ".env"), quiet: true });
// --- SUBCOMMAND HANDLING ---
const args = process.argv.slice(2);
const command = args[0];
if (command === 'memory') {
runNodeServer('@modelcontextprotocol/server-memory');
} else if (command === 'sequential-thinking') {
runNodeServer('@modelcontextprotocol/server-sequential-thinking');
} else if (command === 'google') {
// Run Google Search directly in Node.js (no Python/uvx)
runGoogleSearch();
} else if (command === 'docker') {
runUvx('mcp-server-docker', args.slice(1));
} else if (command === 'fetch') {
runUvx('mcp-server-fetch');
} else {
// Default: Gitea Proxy
runGiteaProxy();
}
// --- RUNNERS ---
function runNodeServer(packageName) {
// Run a Node.js MCP server using npx
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
// Use --quiet to prevent npx from printing installation logs to stdout (breaks JSON-RPC)
const child = spawn(cmd, ['-y', '--quiet', packageName], {
stdio: 'inherit',
env: process.env
});
child.on('error', (err) => {
console.error(`Failed to start ${packageName}:`, err);
process.exit(1);
});
}
function runMemory() {
// Deprecated: use runNodeServer
runNodeServer('@modelcontextprotocol/server-memory');
}
function runUvx(toolName, toolArgs = []) {
// Run Python tools via uvx
// Ensure ~/.local/bin is in PATH (common issue in IDEs)
const newEnv = { ...process.env };
const home = os.homedir();
const localBin = path.join(home, '.local', 'bin');
if (process.env.PATH) {
newEnv.PATH = `${localBin}${path.delimiter}${process.env.PATH}`;
} else {
newEnv.PATH = localBin;
}
// Use --quiet to prevent uvx from printing installation logs to stdout
const child = spawn('uvx', ['--quiet', toolName, ...toolArgs], {
stdio: 'inherit',
env: newEnv,
shell: process.platform === 'win32' // Use shell on Windows to find uvx
});
child.on('error', (err) => {
console.error(`Failed to start ${toolName}:`, err);
console.error('Make sure `uv` is installed (curl -LsSf https://astral.sh/uv/install.sh | sh)');
process.exit(1);
});
}
async function runGoogleSearch() {
const API_KEY = process.env.GOOGLE_API_KEY;
const CSE_ID = process.env.GOOGLE_CSE_ID || process.env.GOOGLE_SEARCH_ENGINE_ID;
if (!API_KEY || !CSE_ID) {
console.error("Error: GOOGLE_API_KEY or GOOGLE_CSE_ID not found in .env");
process.exit(1);
}
const server = new McpServer({
name: "google-search",
version: "1.0.0",
});
server.tool(
"google_search",
"Search the web using Google Custom Search",
{
search_term: z.string().describe("The search query")
},
async ({ search_term }) => {
try {
const response = await axios.get("https://customsearch.googleapis.com/customsearch/v1", {
params: {
key: API_KEY,
cx: CSE_ID,
q: search_term
},
timeout: 10000 // 10 seconds timeout
});
const items = response.data.items || [];
if (items.length === 0) {
return { content: [{ type: "text", text: "No results found." }] };
}
const formatted = items.map(item =>
`Title: ${item.title}\nLink: ${item.link}\nSnippet: ${item.snippet}\n`
).join("\n");
return { content: [{ type: "text", text: formatted }] };
} catch (error) {
const msg = error.response?.data?.error?.message || error.message;
return { content: [{ type: "text", text: `Google API Error: ${msg}` }], isError: true };
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
}
async function runGiteaProxy() {
const server = new McpServer({
name: "gitea-proxy-agent",
version: "1.6.0",
});
const CF_ID = process.env.CF_ID;
const CF_SECRET = process.env.CF_SECRET;
const GITEA_TOKEN = process.env.GITEA_TOKEN;
const BASE_URL = process.env.GITEA_API_URL || "https://git.boringstudio.by/api/v1";
if (!GITEA_TOKEN) {
console.error("❌ Error: GITEA_TOKEN not found.");
console.error("");
console.error("To use this MCP server, you need to provide a Gitea Token.");
console.error("You can do this in two ways:");
console.error("");
console.error("1. Create a .env file in the current directory:");
console.error(" GITEA_TOKEN=your_token_here");
console.error(" GITEA_API_URL=https://git.boringstudio.by/api/v1 # Optional");
console.error(" CF_ID=... # Optional (Cloudflare)");
console.error(" CF_SECRET=... # Optional (Cloudflare)");
console.error("");
console.error("2. Or pass it as an environment variable:");
console.error(" GITEA_TOKEN=your_token npx @boringstudio_org/gitea-mcp-proxy");
console.error("");
process.exit(1);
}
const headers = {
"Authorization": `token ${GITEA_TOKEN}`,
"Content-Type": "application/json"
};
if (CF_ID && CF_SECRET) {
headers["CF-Access-Client-Id"] = CF_ID;
headers["CF-Access-Client-Secret"] = CF_SECRET;
console.error("INFO: Running in Cloudflare Proxy mode");
} else {
console.error("INFO: Running in Standard mode");
}
// --- AXIOS INSTANCE ---
const api = axios.create({
baseURL: BASE_URL,
headers: headers
});
// --- HELPER FUNCTIONS ---
async function giteaApi(method, endpoint, data = null, params = null) {
try {
const config = { method, url: endpoint };
if (data) config.data = data;
if (params) config.params = params;
const response = await api(config);
return response.data;
} catch (error) {
const msg = error.response?.data?.message || error.message;
throw new Error(`Gitea API Error: ${msg}`);
}
}
// --- RESOURCES ---
// Resource: List of issues for a repository
// URI: gitea://{owner}/{repo}/issues
server.resource(
"issues-list",
new ResourceTemplate("gitea://{owner}/{repo}/issues", { list: undefined }),
async (uri, { owner, repo }) => {
try {
const issues = await giteaApi("GET", `/repos/${owner}/${repo}/issues`, null, { limit: 50 });
return {
contents: [{
uri: uri.href,
text: JSON.stringify(issues, null, 2),
mimeType: "application/json"
}]
};
} catch (error) {
throw new Error(`Failed to fetch issues resource: ${error.message}`);
}
}
);
// Resource: File content
// URI: gitea://{owner}/{repo}/files/{branch}/{path}
server.resource(
"file-content",
new ResourceTemplate("gitea://{owner}/{repo}/files/{branch}/{path}", { list: undefined }),
async (uri, { owner, repo, branch, path: filePath }) => {
try {
// Gitea API for raw file content
const content = await giteaApi("GET", `/repos/${owner}/${repo}/raw/${branch}/${filePath}`);
return {
contents: [{
uri: uri.href,
text: typeof content === 'string' ? content : JSON.stringify(content),
mimeType: "text/plain"
}]
};
} catch (error) {
throw new Error(`Failed to fetch file resource: ${error.message}`);
}
}
);
// --- PROMPTS ---
// Prompt: Analyze Issue
server.prompt(
"analyze-issue",
{
owner: z.string(),
repo: z.string(),
issue_number: z.string().describe("Issue number (as string)")
},
async ({ owner, repo, issue_number }) => {
try {
const issue = await giteaApi("GET", `/repos/${owner}/${repo}/issues/${issue_number}`);
const comments = await giteaApi("GET", `/repos/${owner}/${repo}/issues/${issue_number}/comments`);
return {
messages: [{
role: "user",
content: {
type: "text",
text: `Please analyze the following Gitea issue and suggest a solution or next steps.\n\n` +
`Title: ${issue.title}\n` +
`State: ${issue.state}\n` +
`Description:\n${issue.body}\n\n` +
`Comments:\n${comments.map(c => `- ${c.user.username}: ${c.body}`).join("\n")}`
}
}]
};
} catch (error) {
return {
messages: [{
role: "user",
content: { type: "text", text: `Error fetching issue for analysis: ${error.message}` }
}]
};
}
}
);
// Prompt: Create Bug Report
server.prompt(
"create-bug-report",
{
owner: z.string(),
repo: z.string()
},
async ({ owner, repo }) => {
return {
messages: [{
role: "user",
content: {
type: "text",
text: `I want to create a bug report for the repository ${owner}/${repo}. ` +
`Please ask me for the following details one by one or all together:\n` +
`1. Title\n2. Description\n3. Steps to Reproduce\n4. Expected Behavior\n5. Actual Behavior\n` +
`Then, use the 'create_issue' tool to submit it.`
}
}]
};
}
);
// --- TOOLS ---
server.tool(
"list_repos",
"Get a list of all user/organization repositories",
{
org: z.string().optional().describe("Organization name (optional, if you need to get organization repositories)")
},
async ({ org }) => {
try {
let endpoint = "/user/repos";
if (org) {
endpoint = `/orgs/${org}/repos`;
}
const repos = await giteaApi("GET", endpoint, null, { limit: 50 });
const formatted = repos.map(r => `${r.full_name} (ID: ${r.id}) - ${r.private ? 'Private' : 'Public'}`).join("\n");
return {
content: [{ type: "text", text: formatted || "No repositories found." }]
};
} catch (error) {
return { content: [{ type: "text", text: `Gitea Error: ${error.message}` }], isError: true };
}
}
);
server.tool(
"list_issues",
"Get a list of all open issues in the repository",
{
owner: z.string().describe("Repository owner (BoringStudio)"),
repo: z.string().describe("Repository name (compose)"),
page: z.number().optional().describe("Page number (default: 1)"),
limit: z.number().optional().describe("Number of issues per page (default: 20)")
},
async ({ owner, repo, page = 1, limit = 20 }) => {
try {
const issues = await giteaApi("GET", `/repos/${owner}/${repo}/issues`, null, { page, limit });
const formatted = issues.map(i => `#${i.number}: ${i.title}`).join("\n");
return {
content: [{ type: "text", text: formatted || "No issues yet." }]
};
} catch (error) {
return { content: [{ type: "text", text: `Gitea Error: ${error.message}` }], isError: true };
}
}
);
server.tool(
"search_issues",
"Search for issues in a repository using keywords",
{
owner: z.string(),
repo: z.string(),
query: z.string().describe("Search keywords"),
state: z.enum(["open", "closed", "all"]).optional().describe("Issue state (default: open)")
},
async ({ owner, repo, query, state = "open" }) => {
try {
const issues = await giteaApi("GET", `/repos/${owner}/${repo}/issues`, null, { q: query, state, limit: 20 });
const formatted = issues.map(i => `#${i.number} (${i.state}): ${i.title}`).join("\n");
return {
content: [{ type: "text", text: formatted || "No matching issues found." }]
};
} catch (error) {
return { content: [{ type: "text", text: `Gitea Error: ${error.message}` }], isError: true };
}
}
);
server.tool(
"get_issue_details",
"Read the full issue description and its status",
{
owner: z.string(),
repo: z.string(),
issue_number: z.number().describe("Issue number")
},
async ({ owner, repo, issue_number }) => {
try {
const i = await giteaApi("GET", `/repos/${owner}/${repo}/issues/${issue_number}`);
const result = `ISSUE #${i.number}: ${i.title}\nStatus: ${i.state}\nDescription:\n${i.body || "No description"}`;
return { content: [{ type: "text", text: result }] };
} catch (error) {
return { content: [{ type: "text", text: `Error fetching data: ${error.message}` }], isError: true };
}
}
);
server.tool(
"add_comment",
"Leave a comment on a Gitea issue",
{
owner: z.string(),
repo: z.string(),
issue_number: z.number(),
body: z.string().describe("Text of your comment")
},
async ({ owner, repo, issue_number, body }) => {
try {
await giteaApi("POST", `/repos/${owner}/${repo}/issues/${issue_number}/comments`, { body });
return { content: [{ type: "text", text: "Success: Comment added." }] };
} catch (error) {
return { content: [{ type: "text", text: `Error sending: ${error.message}` }], isError: true };
}
}
);
server.tool(
"create_issue",
"Create a new issue in the repository",
{
owner: z.string(),
repo: z.string(),
title: z.string().describe("Issue title"),
body: z.string().optional().describe("Issue description")
},
async ({ owner, repo, title, body }) => {
try {
const response = await giteaApi("POST", `/repos/${owner}/${repo}/issues`, { title, body });
return { content: [{ type: "text", text: `Success: Issue #${response.number} created.` }] };
} catch (error) {
return { content: [{ type: "text", text: `Error creating issue: ${error.message}` }], isError: true };
}
}
);
server.tool(
"list_labels",
"Get a list of all labels in the repository",
{
owner: z.string(),
repo: z.string()
},
async ({ owner, repo }) => {
try {
const labels = await giteaApi("GET", `/repos/${owner}/${repo}/labels`);
const formatted = labels.map(l => `${l.name} (ID: ${l.id})`).join("\n");
return {
content: [{ type: "text", text: formatted || "No labels found." }]
};
} catch (error) {
return { content: [{ type: "text", text: `Gitea Error: ${error.message}` }], isError: true };
}
}
);
server.tool(
"list_branches",
"List branches in the repository",
{
owner: z.string(),
repo: z.string()
},
async ({ owner, repo }) => {
try {
const branches = await giteaApi("GET", `/repos/${owner}/${repo}/branches`);
const formatted = branches.map(b => b.name).join("\n");
return {
content: [{ type: "text", text: formatted || "No branches found." }]
};
} catch (error) {
return { content: [{ type: "text", text: `Gitea Error: ${error.message}` }], isError: true };
}
}
);
server.tool(
"create_pull_request",
"Create a Pull Request",
{
owner: z.string(),
repo: z.string(),
head: z.string().describe("Source branch (e.g., feature/my-feature)"),
base: z.string().optional().describe("Target branch (default: main)"),
title: z.string().describe("PR Title"),
body: z.string().optional().describe("PR Description")
},
async ({ owner, repo, head, base = "main", title, body }) => {
try {
const response = await giteaApi("POST", `/repos/${owner}/${repo}/pulls`, {
head,
base,
title,
body
});
return { content: [{ type: "text", text: `Success: PR #${response.number} created: ${response.html_url}` }] };
} catch (error) {
return { content: [{ type: "text", text: `Error creating PR: ${error.message}` }], isError: true };
}
}
);
server.tool(
"update_issue",
"Update an issue (change state, title, body, or labels)",
{
owner: z.string(),
repo: z.string(),
issue_number: z.number(),
state: z.enum(["open", "closed"]).optional().describe("New state (open/closed)"),
title: z.string().optional().describe("New title"),
body: z.string().optional().describe("New description"),
labels: z.array(z.string()).optional().describe("List of label names to set (replaces existing labels)")
},
async ({ owner, repo, issue_number, state, title, body, labels }) => {
try {
const payload = {};
if (state) payload.state = state;
if (title) payload.title = title;
if (body) payload.body = body;
if (labels) {
const repoLabels = await giteaApi("GET", `/repos/${owner}/${repo}/labels`);
const labelIds = labels.map(name => {
const found = repoLabels.find(l => l.name === name);
if (!found) throw new Error(`Label '${name}' not found in repository.`);
return found.id;
});
payload.labels = labelIds;
}
if (Object.keys(payload).length === 0) {
return { content: [{ type: "text", text: "No changes requested." }] };
}
await giteaApi("PATCH", `/repos/${owner}/${repo}/issues/${issue_number}`, payload);
return { content: [{ type: "text", text: `Success: Issue #${issue_number} updated.` }] };
} catch (error) {
return { content: [{ type: "text", text: `Error updating issue: ${error.message}` }], isError: true };
}
}
);
// --- SHELL TOOL ---
const SHELL_TIMEOUT = 30000;
const MAX_OUTPUT_SIZE = 1024 * 1024 * 2;
const ALLOWED_COMMANDS = ["git", "npm", "node", "ls", "dir", "cat", "grep", "echo", "pwd", "whoami", "date"];
const DANGEROUS_PATTERNS = [/\brm\b/i, /\bmv\b/i, /\bsudo\b/i, /\bsu\b/i, /\bchmod\b/i, /\bchown\b/i, /\bwget\b/i, /\bcurl\b/i, /\bdd\b/i, /\bmkfs\b/i, />/, /\|/, /&/, /\ .env/i];
server.tool(
"run_safe_shell",
"Execute a console command in WSL (with security restrictions)",
{
command: z.string().describe("Command to execute")
},
async ({ command }) => {
const trimmedCommand = command.trim();
const firstWord = trimmedCommand.split(/\s+/)[0];
if (!ALLOWED_COMMANDS.includes(firstWord)) {
return {
content: [{ type: "text", text: `⛔ ERROR: Command '${firstWord}' is not allowed.` }],
isError: true
};
}
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(trimmedCommand)) {
return {
content: [{ type: "text", text: `⛔ SECURITY ERROR: Forbidden pattern detected.` }],
isError: true
};
}
}
const safeEnv = { ...process.env };
delete safeEnv.CF_ID;
delete safeEnv.CF_SECRET;
delete safeEnv.GITEA_TOKEN;
return new Promise((resolve) => {
exec(trimmedCommand, {
timeout: SHELL_TIMEOUT,
maxBuffer: MAX_OUTPUT_SIZE,
cwd: process.cwd(),
env: safeEnv
}, (error, stdout, stderr) => {
if (error) {
let errorMsg = error.message;
if (error.killed) errorMsg = "Timeout.";
return resolve({
content: [{ type: "text", text: `Execution error:\n${stderr || errorMsg}` }],
isError: true
});
}
resolve({
content: [{ type: "text", text: stdout.trim() || stderr.trim() || "Done." }]
});
});
});
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
}