diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..bfcc5eb --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,8 @@ +name: "CodeQL Analysis Configuration" + +paths: + - src + +paths-ignore: + - tests + - docs diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0250b50..53596b9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,6 +34,7 @@ jobs: with: languages: ${{ matrix.language }} build-mode: 'none' + config-file: ./.github/codeql/codeql-config.yml - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10 diff --git a/src/devshelf-export.ts b/src/devshelf-export.ts index f634f6e..c377f9b 100644 --- a/src/devshelf-export.ts +++ b/src/devshelf-export.ts @@ -58,6 +58,11 @@ export class DevShelfExporter { const customTags = (options.tags || []).filter((t) => !baseTags.includes(t)); const statusTags = [...baseTags, ...customTags].slice(0, 6); + let cleanRepo = repo; + while (cleanRepo.endsWith("/")) { + cleanRepo = cleanRepo.slice(0, -1); + } + return { name, repo, @@ -68,7 +73,7 @@ export class DevShelfExporter { language: options.language || "TypeScript / Node.js", license: options.license || "Apache-2.0", featured: false, - goodFirstIssues: `${repo.replace(/\/+$/, "")}/issues`, + goodFirstIssues: `${cleanRepo}/issues`, statusTags, }; } diff --git a/src/openapi.ts b/src/openapi.ts index 6ba6c03..e9b9cba 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -451,11 +451,16 @@ export class OpenApiToMcpConverter { if (operationId) { return operationId.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase(); } - const cleanPath = path - .replace(/\{([^}]+)\}/g, "by_$1") + let cleanPath = path + .replace(/\{([a-zA-Z0-9_]+)\}/g, "by_$1") .replace(/[^a-zA-Z0-9]/g, "_") - .replace(/_+/g, "_") - .replace(/^_|_$/g, ""); + .replace(/_+/g, "_"); + while (cleanPath.startsWith("_")) { + cleanPath = cleanPath.slice(1); + } + while (cleanPath.endsWith("_")) { + cleanPath = cleanPath.slice(0, -1); + } return `${method.toLowerCase()}_${cleanPath}`; } diff --git a/src/proto-graphql.ts b/src/proto-graphql.ts index 2add554..ab88eed 100644 --- a/src/proto-graphql.ts +++ b/src/proto-graphql.ts @@ -60,105 +60,109 @@ export function detectSchemaType(content: string): "openapi" | "graphql" | "prot */ export function parseGraphQLSchema(sdlContent: string): ParsedGraphQLField[] { const operations: ParsedGraphQLField[] = []; + const lines = sdlContent.split(/\r?\n/); - // Match Query or Mutation blocks - const typeBlockRegex = /type\s+(Query|Mutation)\s*\{([^}]*)\}/gi; - let typeMatch = typeBlockRegex.exec(sdlContent); - - while (typeMatch !== null) { - const opType = typeMatch[1].toLowerCase() as "query" | "mutation"; - const body = typeMatch[2]; - - // Match individual field definitions e.g.: - // # Get a user by ID - // getUser(id: ID!, limit: Int): User - const lines = body.split("\n"); - let inDocstring = false; - let pendingDocLines: string[] = []; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line) continue; - - if (line.startsWith('"""')) { - if (inDocstring) { - inDocstring = false; - continue; - } - if (line.endsWith('"""') && line.length > 3) { - pendingDocLines = [line.slice(3, -3).trim()]; - continue; - } - inDocstring = true; - const rest = line.slice(3).trim(); - if (rest) pendingDocLines.push(rest); - continue; + let currentOpType: "query" | "mutation" | null = null; + let inDocstring = false; + let pendingDocLines: string[] = []; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + + if (currentOpType === null) { + const typeStartMatch = line.match(/^type\s+(Query|Mutation)\b/i); + if (typeStartMatch) { + currentOpType = typeStartMatch[1].toLowerCase() as "query" | "mutation"; + inDocstring = false; + pendingDocLines = []; } + continue; + } + if (line === "}" || line.startsWith("}")) { + currentOpType = null; + inDocstring = false; + pendingDocLines = []; + continue; + } + + if (line.startsWith('"""')) { if (inDocstring) { - if (line.endsWith('"""')) { - inDocstring = false; - const content = line.slice(0, -3).trim(); - if (content) pendingDocLines.push(content); - } else { - pendingDocLines.push(line); - } + inDocstring = false; continue; } - - if (line.startsWith("#")) { - pendingDocLines.push(line.replace(/^#+\s*/, "").trim()); + if (line.endsWith('"""') && line.length > 3) { + pendingDocLines = [line.slice(3, -3).trim()]; continue; } + inDocstring = true; + const rest = line.slice(3).trim(); + if (rest) pendingDocLines.push(rest); + continue; + } + + if (inDocstring) { + if (line.endsWith('"""')) { + inDocstring = false; + const content = line.slice(0, -3).trim(); + if (content) pendingDocLines.push(content); + } else { + pendingDocLines.push(line); + } + continue; + } - const pendingDoc = pendingDocLines.join(" ").trim(); - - const fieldMatch = line.match(/^([a-zA-Z0-9_]+)(?:\(([^)]*)\))?\s*:\s*([a-zA-Z0-9_!\[\]]+)/); - if (fieldMatch) { - const fieldName = fieldMatch[1]; - const rawArgs = fieldMatch[2] || ""; - const returnType = fieldMatch[3]; - - const args: ToolParameter[] = []; - if (rawArgs.trim()) { - const argList = rawArgs.split(","); - for (const rawArg of argList) { - const parts = rawArg.trim().split(":"); - if (parts.length === 2) { - const argName = parts[0].trim(); - const argTypeRaw = parts[1].trim(); - const isRequired = argTypeRaw.includes("!"); - const cleanType = argTypeRaw.replace(/[!\[\]]/g, "").toLowerCase(); - - let mappedType: ToolParameter["type"] = "string"; - if (cleanType === "int" || cleanType === "float") mappedType = "number"; - else if (cleanType === "boolean") mappedType = "boolean"; - else if (argTypeRaw.includes("[") && argTypeRaw.includes("]")) mappedType = "array"; - else if (cleanType !== "string" && cleanType !== "id") mappedType = "object"; - - args.push({ - name: argName, - type: mappedType, - description: `GraphQL parameter (${argTypeRaw})`, - required: isRequired, - }); - } + if (line.startsWith("#")) { + pendingDocLines.push(line.replace(/^#+\s*/, "").trim()); + continue; + } + + const pendingDoc = pendingDocLines.join(" ").trim(); + + const fieldMatch = line.match(/^([a-zA-Z0-9_]+)(?:\(([^)]*)\))?\s*:\s*([a-zA-Z0-9_!\[\]]+)/); + if (fieldMatch) { + const fieldName = fieldMatch[1]; + const rawArgs = fieldMatch[2] || ""; + const returnType = fieldMatch[3]; + + const args: ToolParameter[] = []; + if (rawArgs.trim()) { + const argList = rawArgs.split(","); + for (const rawArg of argList) { + const parts = rawArg.trim().split(":"); + if (parts.length === 2) { + const argName = parts[0].trim(); + const argTypeRaw = parts[1].trim(); + const isRequired = argTypeRaw.includes("!"); + const cleanType = argTypeRaw.replace(/[!\[\]]/g, "").toLowerCase(); + + let mappedType: ToolParameter["type"] = "string"; + if (cleanType === "int" || cleanType === "float") mappedType = "number"; + else if (cleanType === "boolean") mappedType = "boolean"; + else if (argTypeRaw.includes("[") && argTypeRaw.includes("]")) mappedType = "array"; + else if (cleanType !== "string" && cleanType !== "id") mappedType = "object"; + + args.push({ + name: argName, + type: mappedType, + description: `GraphQL parameter (${argTypeRaw})`, + required: isRequired, + }); } } + } - operations.push({ - name: fieldName, - operationType: opType, - description: pendingDoc || `GraphQL ${opType}: ${fieldName}`, - args, - returnType, - }); + operations.push({ + name: fieldName, + operationType: currentOpType, + description: pendingDoc || `GraphQL ${currentOpType}: ${fieldName}`, + args, + returnType, + }); - pendingDocLines = []; - } + pendingDocLines = []; } - - typeMatch = typeBlockRegex.exec(sdlContent); } return operations; @@ -221,24 +225,28 @@ export function convertGraphQLToTools( */ export function parseProtobufDefinition(protoContent: string): ParsedProtoRpc[] { const rpcs: ParsedProtoRpc[] = []; - - // 1. Extract message fields mapping const messages = new Map(); - const messageRegex = /message\s+([a-zA-Z0-9_]+)\s*\{([^}]*)\}/gi; - let msgMatch = messageRegex.exec(protoContent); - - while (msgMatch !== null) { - const msgName = msgMatch[1]; - const msgBody = msgMatch[2]; - const fields: ToolParameter[] = []; - - const fieldLines = msgBody.split("\n"); - for (const rawLine of fieldLines) { - const line = rawLine.replace(/\/\/.*$/, "").trim(); - if (!line) continue; + const lines = protoContent.split(/\r?\n/); + + let currentMsgName: string | null = null; + let currentMsgFields: ToolParameter[] = []; + let currentServiceName: string | null = null; + + for (const rawLine of lines) { + const commentIdx = rawLine.indexOf("//"); + const stripped = commentIdx >= 0 ? rawLine.slice(0, commentIdx) : rawLine; + const line = stripped.trim(); + if (!line) continue; + + if (currentMsgName !== null) { + if (line === "}" || line.startsWith("}")) { + messages.set(currentMsgName, currentMsgFields); + currentMsgName = null; + currentMsgFields = []; + continue; + } - // e.g. "string user_id = 1;" or "repeated int32 limits = 2;" - const fieldPattern = /(?:(repeated)\s+)?([a-zA-Z0-9_]+)\s+([a-zA-Z0-9_]+)\s*=\s*\d+;/i; + const fieldPattern = /^(?:(repeated)\s+)?([a-zA-Z0-9_]+)\s+([a-zA-Z0-9_]+)\s*=\s*\d+;/i; const match = line.match(fieldPattern); if (match) { const isRepeated = Boolean(match[1]); @@ -267,50 +275,54 @@ export function parseProtobufDefinition(protoContent: string): ParsedProtoRpc[] mappedType = "array"; } - fields.push({ + currentMsgFields.push({ name: fieldName, type: mappedType, description: `Protobuf field ${protoType} tag`, required: false, }); } + continue; } - messages.set(msgName, fields); - msgMatch = messageRegex.exec(protoContent); - } + if (currentServiceName !== null) { + if (line === "}" || line.startsWith("}")) { + currentServiceName = null; + continue; + } - // 2. Extract services and RPCs - const serviceRegex = /service\s+([a-zA-Z0-9_]+)\s*\{([^}]*)\}/gi; - let serviceMatch = serviceRegex.exec(protoContent); - - while (serviceMatch !== null) { - const serviceName = serviceMatch[1]; - const serviceBody = serviceMatch[2]; - - const rpcRegex = - /rpc\s+([a-zA-Z0-9_]+)\s*\(\s*([a-zA-Z0-9_]+)\s*\)\s*returns\s*\(\s*([a-zA-Z0-9_]+)\s*\);/gi; - let rpcMatch = rpcRegex.exec(serviceBody); - - while (rpcMatch !== null) { - const rpcName = rpcMatch[1]; - const reqMsg = rpcMatch[2]; - const respMsg = rpcMatch[3]; - const fields = messages.get(reqMsg) || []; - - rpcs.push({ - serviceName, - rpcName, - description: `gRPC unary call ${serviceName}.${rpcName}`, - requestMessage: reqMsg, - responseMessage: respMsg, - fields, - }); + const rpcMatch = line.match( + /^rpc\s+([a-zA-Z0-9_]+)\s*\(\s*([a-zA-Z0-9_]+)\s*\)\s*returns\s*\(\s*([a-zA-Z0-9_]+)\s*\);/i, + ); + if (rpcMatch) { + const rpcName = rpcMatch[1]; + const reqMsg = rpcMatch[2]; + const respMsg = rpcMatch[3]; + const fields = messages.get(reqMsg) || []; + + rpcs.push({ + serviceName: currentServiceName, + rpcName, + description: `gRPC unary call ${currentServiceName}.${rpcName}`, + requestMessage: reqMsg, + responseMessage: respMsg, + fields, + }); + } + continue; + } - rpcMatch = rpcRegex.exec(serviceBody); + const msgStartMatch = line.match(/^message\s+([a-zA-Z0-9_]+)\b/i); + if (msgStartMatch) { + currentMsgName = msgStartMatch[1]; + currentMsgFields = []; + continue; } - serviceMatch = serviceRegex.exec(protoContent); + const svcStartMatch = line.match(/^service\s+([a-zA-Z0-9_]+)\b/i); + if (svcStartMatch) { + currentServiceName = svcStartMatch[1]; + } } return rpcs; diff --git a/src/templates/python.ts b/src/templates/python.ts index adbf74b..3ee8af9 100644 --- a/src/templates/python.ts +++ b/src/templates/python.ts @@ -2,7 +2,13 @@ import { escapePythonDocstring } from "../generator.js"; import type { GeneratedFile, McpScaffoldOptions } from "../types.js"; export function toSafePythonPackageName(name: string): string { - let pkg = name.toLowerCase().replace(/[^a-z0-9_]/g, "_").replace(/^_+|_+$/g, ""); + let pkg = name.toLowerCase().replace(/[^a-z0-9_]/g, "_"); + while (pkg.startsWith("_")) { + pkg = pkg.slice(1); + } + while (pkg.endsWith("_")) { + pkg = pkg.slice(0, -1); + } if (!pkg) { pkg = "mcp_server"; } @@ -17,10 +23,12 @@ export function generatePythonTemplate(options: McpScaffoldOptions): GeneratedFi const description = options.description || "High-performance FastMCP Server in Python"; const author = options.author || "RitualDev Lab"; + const safeDescription = description.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const pyprojectToml = `[project] name = "${name}" version = "0.1.0" -description = "${description.replace(/"/g, '\\"')}" +description = "${safeDescription}" readme = "README.md" requires-python = ">=3.10" dependencies = [