@securityreviewai/security-review-mcp 0.2.9

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,172 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { createServer } from "../server.js";
5
+ import { loadDefaultEnvFile, loadEnvFile } from "../utils/env.js";
6
+ function fail(message, code = 1) {
7
+ process.stderr.write(`${message}\n`);
8
+ process.exit(code);
9
+ }
10
+ function parseArgs(argv) {
11
+ const parsed = {};
12
+ for (let index = 0; index < argv.length; index += 1) {
13
+ const arg = argv[index];
14
+ if (!arg) {
15
+ continue;
16
+ }
17
+ if (!arg.startsWith("--")) {
18
+ continue;
19
+ }
20
+ const key = arg.slice(2);
21
+ if (key === "help") {
22
+ parsed.help = true;
23
+ continue;
24
+ }
25
+ if (key === "print-config") {
26
+ parsed.printConfig = true;
27
+ continue;
28
+ }
29
+ if (key === "force-install") {
30
+ parsed.forceInstall = true;
31
+ continue;
32
+ }
33
+ const value = argv[index + 1];
34
+ if (!value || value.startsWith("--")) {
35
+ fail(`Missing value for --${key}`);
36
+ }
37
+ switch (key) {
38
+ case "env-file":
39
+ parsed.envFile = value;
40
+ break;
41
+ case "api-url":
42
+ parsed.apiUrl = value;
43
+ break;
44
+ case "api-token":
45
+ parsed.apiToken = value;
46
+ break;
47
+ case "jira-base-url":
48
+ parsed.jiraBaseUrl = value;
49
+ break;
50
+ case "jira-email":
51
+ parsed.jiraEmail = value;
52
+ break;
53
+ case "jira-api-token":
54
+ parsed.jiraApiToken = value;
55
+ break;
56
+ case "confluence-base-url":
57
+ parsed.confluenceBaseUrl = value;
58
+ break;
59
+ case "confluence-email":
60
+ parsed.confluenceEmail = value;
61
+ break;
62
+ case "confluence-api-token":
63
+ parsed.confluenceApiToken = value;
64
+ break;
65
+ case "python":
66
+ parsed.python = value;
67
+ break;
68
+ default:
69
+ fail(`Unknown option --${key}`);
70
+ }
71
+ index += 1;
72
+ }
73
+ return parsed;
74
+ }
75
+ function printHelp() {
76
+ process.stdout.write([
77
+ "Security Review MCP server",
78
+ "",
79
+ "Usage:",
80
+ " security-review-mcp --api-url <url> --api-token <token> [options]",
81
+ "",
82
+ "Options:",
83
+ " --api-url <url> API base URL (or use env SECURITY_REVIEW_API_URL)",
84
+ " --api-token <token> API token (or use env SECURITY_REVIEW_API_TOKEN)",
85
+ " --env-file <path> Optional .env file path (default: ./.env if present)",
86
+ " --jira-base-url <url> Optional Jira base URL",
87
+ " --jira-email <email> Optional Jira user email",
88
+ " --jira-api-token <token> Optional Jira API token",
89
+ " --confluence-base-url <url> Optional Confluence base URL",
90
+ " --confluence-email <email> Optional Confluence user email",
91
+ " --confluence-api-token <token> Optional Confluence API token",
92
+ " --print-config Print example MCP client config and exit",
93
+ " --help Show this help message",
94
+ "",
95
+ "Deprecated no-op flags (kept for compatibility):",
96
+ " --python <path>",
97
+ " --force-install",
98
+ "",
99
+ ].join("\n"));
100
+ }
101
+ function printConfig() {
102
+ process.stdout.write(`${JSON.stringify({
103
+ mcpServers: {
104
+ "security-review-mcp": {
105
+ command: "npx",
106
+ args: ["-y", "security-review-mcp"],
107
+ env: {
108
+ SECURITY_REVIEW_API_URL: "https://api.example.com",
109
+ SECURITY_REVIEW_API_TOKEN: "YOUR_TOKEN",
110
+ },
111
+ },
112
+ },
113
+ }, null, 2)}\n`);
114
+ }
115
+ function applyArgsToEnv(args, env) {
116
+ if (args.apiUrl) {
117
+ env.SECURITY_REVIEW_API_URL = args.apiUrl;
118
+ }
119
+ if (args.apiToken) {
120
+ env.SECURITY_REVIEW_API_TOKEN = args.apiToken;
121
+ }
122
+ if (args.jiraBaseUrl) {
123
+ env.JIRA_BASE_URL = args.jiraBaseUrl;
124
+ }
125
+ if (args.jiraEmail) {
126
+ env.JIRA_EMAIL = args.jiraEmail;
127
+ }
128
+ if (args.jiraApiToken) {
129
+ env.JIRA_API_TOKEN = args.jiraApiToken;
130
+ }
131
+ if (args.confluenceBaseUrl) {
132
+ env.CONFLUENCE_BASE_URL = args.confluenceBaseUrl;
133
+ }
134
+ if (args.confluenceEmail) {
135
+ env.CONFLUENCE_EMAIL = args.confluenceEmail;
136
+ }
137
+ if (args.confluenceApiToken) {
138
+ env.CONFLUENCE_API_TOKEN = args.confluenceApiToken;
139
+ }
140
+ }
141
+ async function main() {
142
+ const args = parseArgs(process.argv.slice(2));
143
+ if (args.help) {
144
+ printHelp();
145
+ return;
146
+ }
147
+ if (args.printConfig) {
148
+ printConfig();
149
+ return;
150
+ }
151
+ loadDefaultEnvFile(process.cwd(), process.env);
152
+ if (args.envFile) {
153
+ loadEnvFile(path.resolve(process.cwd(), args.envFile), process.env);
154
+ }
155
+ applyArgsToEnv(args, process.env);
156
+ const apiUrl = process.env.SECURITY_REVIEW_API_URL || process.env.SRAI_API_URL;
157
+ const apiToken = process.env.SECURITY_REVIEW_API_TOKEN || process.env.SRAI_API_TOKEN;
158
+ if (!apiUrl) {
159
+ fail("Missing API URL. Pass --api-url or set SECURITY_REVIEW_API_URL.");
160
+ }
161
+ if (!apiToken) {
162
+ fail("Missing API token. Pass --api-token or set SECURITY_REVIEW_API_TOKEN.");
163
+ }
164
+ process.env.SRAI_API_URL = apiUrl;
165
+ process.env.SRAI_API_TOKEN = apiToken;
166
+ const server = createServer();
167
+ const transport = new StdioServerTransport();
168
+ await server.connect(transport);
169
+ }
170
+ main().catch((error) => {
171
+ fail(`Failed to launch MCP server: ${error instanceof Error ? error.message : String(error)}`);
172
+ });
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createServer } from "./server.js";
@@ -0,0 +1,161 @@
1
+ export class ConfluenceClient {
2
+ baseUrl;
3
+ email;
4
+ apiToken;
5
+ constructor(baseUrl, email, apiToken) {
6
+ let configuredBaseUrl = (baseUrl || process.env.CONFLUENCE_BASE_URL || "").replace(/\/+$/u, "");
7
+ if (configuredBaseUrl.endsWith("/wiki")) {
8
+ configuredBaseUrl = configuredBaseUrl.slice(0, -5);
9
+ }
10
+ this.baseUrl = configuredBaseUrl;
11
+ this.email = email || process.env.CONFLUENCE_EMAIL || "";
12
+ this.apiToken = apiToken || process.env.CONFLUENCE_API_TOKEN || "";
13
+ }
14
+ get isConfigured() {
15
+ return Boolean(this.baseUrl && this.email && this.apiToken);
16
+ }
17
+ authHeader() {
18
+ return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
19
+ }
20
+ async fetchPage(pageId) {
21
+ if (!this.isConfigured) {
22
+ return {
23
+ error: "Confluence integration not configured. Set CONFLUENCE_BASE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN.",
24
+ };
25
+ }
26
+ const headers = {
27
+ Accept: "application/json",
28
+ Authorization: this.authHeader(),
29
+ };
30
+ const v2Url = `${this.baseUrl}/wiki/api/v2/pages/${pageId}?body-format=storage`;
31
+ const v2Resp = await fetch(v2Url, { headers });
32
+ if (v2Resp.status === 200) {
33
+ const data = (await v2Resp.json());
34
+ const body = asRecord(data.body);
35
+ const storage = asRecord(body.storage);
36
+ const htmlBody = asString(storage.value) || asString(body.value);
37
+ return {
38
+ id: asString(data.id) || pageId,
39
+ title: asString(data.title),
40
+ space_id: data.spaceId,
41
+ content: htmlToText(htmlBody),
42
+ version: asRecord(data.version).number,
43
+ url: `${this.baseUrl}/wiki${asString(asRecord(data._links).webui)}`,
44
+ };
45
+ }
46
+ const v1Url = `${this.baseUrl}/wiki/rest/api/content/${pageId}?expand=body.storage,body.view,space,version,ancestors`;
47
+ const v1Resp = await fetch(v1Url, { headers });
48
+ if (v1Resp.status !== 200) {
49
+ return {
50
+ error: `Confluence API returned v2=${v2Resp.status} and v1=${v1Resp.status}. v1 response: ${await v1Resp.text()}`,
51
+ };
52
+ }
53
+ const data = (await v1Resp.json());
54
+ const body = asRecord(data.body);
55
+ const htmlBody = asString(asRecord(body.storage).value) || asString(asRecord(body.view).value);
56
+ return {
57
+ id: data.id,
58
+ title: asString(data.title),
59
+ space_key: asString(asRecord(data.space).key),
60
+ space_name: asString(asRecord(data.space).name),
61
+ content: htmlToText(htmlBody),
62
+ version: asRecord(data.version).number,
63
+ url: `${this.baseUrl}/wiki${asString(asRecord(data._links).webui)}`,
64
+ };
65
+ }
66
+ async fetchPageByUrl(pageUrl) {
67
+ const byQuery = /pageId=(\d+)/u.exec(pageUrl);
68
+ if (byQuery) {
69
+ const pageId = byQuery[1];
70
+ if (pageId) {
71
+ return this.fetchPage(pageId);
72
+ }
73
+ }
74
+ const byPath = /\/pages\/(\d+)/u.exec(pageUrl);
75
+ if (byPath) {
76
+ const pageId = byPath[1];
77
+ if (pageId) {
78
+ return this.fetchPage(pageId);
79
+ }
80
+ }
81
+ return { error: `Could not extract page ID from URL: ${pageUrl}` };
82
+ }
83
+ async searchPages(cql, limit = 10, includeContent = true) {
84
+ if (!this.isConfigured) {
85
+ return {
86
+ error: "Confluence integration not configured. Set CONFLUENCE_BASE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN.",
87
+ };
88
+ }
89
+ const safeLimit = Math.max(1, Math.min(limit, 25));
90
+ const query = new URLSearchParams({
91
+ cql,
92
+ limit: String(safeLimit),
93
+ expand: includeContent ? "body.storage,space,version" : "space,version",
94
+ });
95
+ const url = `${this.baseUrl}/wiki/rest/api/content/search?${query.toString()}`;
96
+ const response = await fetch(url, {
97
+ headers: {
98
+ Accept: "application/json",
99
+ Authorization: this.authHeader(),
100
+ },
101
+ });
102
+ if (response.status !== 200) {
103
+ return {
104
+ error: `Confluence search API returned ${response.status}: ${await response.text()}`,
105
+ };
106
+ }
107
+ const data = (await response.json());
108
+ const resultsRaw = Array.isArray(data.results) ? data.results : [];
109
+ const results = resultsRaw.map((rawItem) => {
110
+ const item = asRecord(rawItem);
111
+ const body = asRecord(item.body);
112
+ const htmlBody = asString(asRecord(asRecord(body.storage)).value);
113
+ const webui = asString(asRecord(item._links).webui);
114
+ return {
115
+ id: item.id,
116
+ title: asString(item.title),
117
+ space_key: asString(asRecord(item.space).key),
118
+ space_name: asString(asRecord(item.space).name),
119
+ version: asRecord(item.version).number,
120
+ url: webui ? `${this.baseUrl}/wiki${webui}` : "",
121
+ content: includeContent ? htmlToText(htmlBody) : "",
122
+ };
123
+ });
124
+ return {
125
+ cql,
126
+ count: results.length,
127
+ results,
128
+ next: asRecord(data._links).next,
129
+ };
130
+ }
131
+ }
132
+ function htmlToText(html) {
133
+ let text = html;
134
+ text = text.replace(/<br\s*\/?>/giu, "\n");
135
+ text = text.replace(/<p[^>]*>/giu, "\n");
136
+ text = text.replace(/<\/p>/giu, "\n");
137
+ text = text.replace(/<h[1-6][^>]*>/giu, "\n## ");
138
+ text = text.replace(/<\/h[1-6]>/giu, "\n");
139
+ text = text.replace(/<li[^>]*>/giu, " - ");
140
+ text = text.replace(/<[^>]+>/giu, "");
141
+ text = text.replace(/\n{3,}/gu, "\n\n");
142
+ return decodeHtmlEntities(text).trim();
143
+ }
144
+ function decodeHtmlEntities(value) {
145
+ return value
146
+ .replace(/&nbsp;/giu, " ")
147
+ .replace(/&amp;/giu, "&")
148
+ .replace(/&lt;/giu, "<")
149
+ .replace(/&gt;/giu, ">")
150
+ .replace(/&quot;/giu, "\"")
151
+ .replace(/&#39;/giu, "'");
152
+ }
153
+ function isRecord(value) {
154
+ return typeof value === "object" && value !== null;
155
+ }
156
+ function asRecord(value) {
157
+ return isRecord(value) ? value : {};
158
+ }
159
+ function asString(value) {
160
+ return value === undefined || value === null ? "" : String(value);
161
+ }
@@ -0,0 +1,133 @@
1
+ export class JiraClient {
2
+ baseUrl;
3
+ email;
4
+ apiToken;
5
+ constructor(baseUrl, email, apiToken) {
6
+ this.baseUrl = (baseUrl || process.env.JIRA_BASE_URL || "").replace(/\/+$/u, "");
7
+ this.email = email || process.env.JIRA_EMAIL || "";
8
+ this.apiToken = apiToken || process.env.JIRA_API_TOKEN || "";
9
+ }
10
+ get isConfigured() {
11
+ return Boolean(this.baseUrl && this.email && this.apiToken);
12
+ }
13
+ async fetchIssue(issueId) {
14
+ if (!this.isConfigured) {
15
+ return {
16
+ error: "Jira integration not configured. Set JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN.",
17
+ };
18
+ }
19
+ const url = `${this.baseUrl}/rest/api/3/issue/${issueId}`;
20
+ const response = await fetch(url, {
21
+ headers: {
22
+ Accept: "application/json",
23
+ Authorization: `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`,
24
+ },
25
+ });
26
+ if (response.status !== 200) {
27
+ return {
28
+ error: `Jira API returned ${response.status}: ${await response.text()}`,
29
+ };
30
+ }
31
+ const data = (await response.json());
32
+ const fields = asRecord(data.fields);
33
+ const rawDescription = fields.description;
34
+ const description = isRecord(rawDescription) && Array.isArray(rawDescription.content)
35
+ ? adfToText(rawDescription)
36
+ : String(rawDescription ?? "");
37
+ return {
38
+ key: asString(data.key),
39
+ summary: asString(fields.summary),
40
+ description,
41
+ status: asString(asRecord(fields.status).name),
42
+ issue_type: asString(asRecord(fields.issuetype).name),
43
+ priority: asString(asRecord(fields.priority).name),
44
+ labels: Array.isArray(fields.labels) ? fields.labels : [],
45
+ components: Array.isArray(fields.components)
46
+ ? fields.components.map((component) => asString(asRecord(component).name)).filter(Boolean)
47
+ : [],
48
+ acceptance_criteria: extractAcceptanceCriteria(fields),
49
+ comments: extractComments(fields),
50
+ };
51
+ }
52
+ }
53
+ function adfToText(adf) {
54
+ const parts = [];
55
+ const content = Array.isArray(adf.content) ? adf.content : [];
56
+ for (const rawNode of content) {
57
+ const node = asRecord(rawNode);
58
+ const type = asString(node.type);
59
+ if (type === "paragraph") {
60
+ const inner = Array.isArray(node.content) ? node.content : [];
61
+ for (const rawItem of inner) {
62
+ const item = asRecord(rawItem);
63
+ if (asString(item.type) === "text") {
64
+ parts.push(asString(item.text));
65
+ }
66
+ }
67
+ parts.push("\n");
68
+ continue;
69
+ }
70
+ if (type === "heading") {
71
+ const inner = Array.isArray(node.content) ? node.content : [];
72
+ for (const rawItem of inner) {
73
+ const item = asRecord(rawItem);
74
+ if (asString(item.type) === "text") {
75
+ parts.push(`\n## ${asString(item.text)}\n`);
76
+ }
77
+ }
78
+ continue;
79
+ }
80
+ if (type === "bulletList") {
81
+ const listItems = Array.isArray(node.content) ? node.content : [];
82
+ for (const rawListItem of listItems) {
83
+ const listItem = asRecord(rawListItem);
84
+ const paragraphs = Array.isArray(listItem.content) ? listItem.content : [];
85
+ for (const rawParagraph of paragraphs) {
86
+ const paragraph = asRecord(rawParagraph);
87
+ const tokens = Array.isArray(paragraph.content) ? paragraph.content : [];
88
+ for (const rawToken of tokens) {
89
+ const token = asRecord(rawToken);
90
+ if (asString(token.type) === "text") {
91
+ parts.push(` - ${asString(token.text)}\n`);
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+ return parts.join("").trim();
99
+ }
100
+ function extractAcceptanceCriteria(fields) {
101
+ for (const [key, value] of Object.entries(fields)) {
102
+ if (!key.toLowerCase().includes("acceptance")) {
103
+ continue;
104
+ }
105
+ if (isRecord(value)) {
106
+ return adfToText(value);
107
+ }
108
+ return value === undefined || value === null ? "" : String(value);
109
+ }
110
+ return "";
111
+ }
112
+ function extractComments(fields) {
113
+ const commentData = asRecord(fields.comment);
114
+ const rawComments = Array.isArray(commentData.comments) ? commentData.comments.slice(0, 10) : [];
115
+ return rawComments.map((rawComment) => {
116
+ const comment = asRecord(rawComment);
117
+ const body = comment.body;
118
+ return {
119
+ author: asString(asRecord(comment.author).displayName) || "Unknown",
120
+ body: isRecord(body) ? adfToText(body) : asString(body),
121
+ created: asString(comment.created),
122
+ };
123
+ });
124
+ }
125
+ function isRecord(value) {
126
+ return typeof value === "object" && value !== null;
127
+ }
128
+ function asRecord(value) {
129
+ return isRecord(value) ? value : {};
130
+ }
131
+ function asString(value) {
132
+ return value === undefined || value === null ? "" : String(value);
133
+ }