diff --git a/backend/app/services/file_analyzer.py b/backend/app/services/file_analyzer.py index 4ad8164..1596ec5 100644 --- a/backend/app/services/file_analyzer.py +++ b/backend/app/services/file_analyzer.py @@ -1,6 +1,8 @@ import json import xml.etree.ElementTree as ET from typing import List, Dict, Any +import urllib.request +import urllib.error def detect_package_manager(filename: str, content: str) -> str: @@ -68,6 +70,60 @@ def local_name(tag: str) -> str: return deps +def parse_maven_pom(text: str) -> List[Dict[str, Any]]: + """Parse Maven pom.xml and extract dependencies. + + Returns list of {name, version, groupId, artifactId}. + """ + deps: List[Dict[str, Any]] = [] + try: + root = ET.fromstring(text) + except ET.ParseError: + return [] + + def local_name(tag: str) -> str: + return tag.split('}')[-1] if '}' in tag else tag + + # Find all elements (handle XML namespaces) + for elem in root.iter(): + if local_name(elem.tag) != 'dependency': + continue + + group_id = None + artifact_id = None + version = None + scope = None + + for child in list(elem): + child_name = local_name(child.tag) + child_text = (child.text or '').strip() + + if child_name == 'groupId': + group_id = child_text + elif child_name == 'artifactId': + artifact_id = child_text + elif child_name == 'version': + version = child_text + elif child_name == 'scope': + scope = child_text + + # Skip test dependencies + if scope == 'test': + continue + + if group_id and artifact_id: + # Maven convention: groupId:artifactId + name = f"{group_id}:{artifact_id}" + deps.append({ + "name": name, + "version": version, + "groupId": group_id, + "artifactId": artifact_id + }) + + return deps + + def parse_requirements(text: str, filename: str="") -> List[Dict[str, Any]]: # Support two common formats: # 1) pip-style requirements (lines with optional ==version) @@ -122,20 +178,146 @@ def parse_packages_config(text: str) -> List[Dict[str, Any]]: return deps +def get_latest_npm_version(package_name: str) -> str | None: + """Get the latest version of an npm package.""" + try: + url = f"https://registry.npmjs.org/{package_name}/latest" + with urllib.request.urlopen(url, timeout=5) as response: + data = json.loads(response.read()) + return data.get("version") + except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, KeyError, Exception): + return None + + +def get_latest_pypi_version(package_name: str) -> str | None: + """Get the latest version of a PyPI package.""" + try: + url = f"https://pypi.org/pypi/{package_name}/json" + with urllib.request.urlopen(url, timeout=5) as response: + data = json.loads(response.read()) + info = data.get("info", {}) + version = info.get("version") + return version + except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, KeyError, Exception): + return None + + +def get_latest_maven_version(group_id: str, artifact_id: str) -> str | None: + """Get the latest version of a Maven artifact.""" + try: + # Maven Central search API + url = f"https://search.maven.org/solrsearch/select?q=g:{group_id}+AND+a:{artifact_id}&rows=1&wt=json" + with urllib.request.urlopen(url, timeout=5) as response: + data = json.loads(response.read()) + docs = data.get("response", {}).get("docs", []) + if docs: + return docs[0].get("latestVersion") + except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, KeyError, Exception): + return None + + +def get_latest_nuget_version(package_name: str) -> str | None: + """Get the latest version of a NuGet package.""" + try: + url = f"https://api.nuget.org/v3-flatcontainer/{package_name.lower()}/index.json" + with urllib.request.urlopen(url, timeout=5) as response: + data = json.loads(response.read()) + versions = data.get("versions", []) + if versions: + # Return the last (latest) version + return versions[-1] + except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, KeyError, Exception): + return None + + +def get_latest_go_version(module_path: str) -> str | None: + """Get the latest version of a Go module.""" + try: + # Go module proxy + url = f"https://proxy.golang.org/{module_path}/@latest" + with urllib.request.urlopen(url, timeout=5) as response: + data = json.loads(response.read()) + return data.get("Version") + except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, KeyError, Exception): + return None + + +def enrich_dependencies_with_latest_versions(dependencies: List[Dict[str, Any]], ecosystem: str) -> List[Dict[str, Any]]: + """Enrich dependencies that don't have versions with their latest versions. + + Supports: npm, pypi, maven, nuget, go + """ + enriched = [] + for dep in dependencies: + name = dep.get("name") + version = dep.get("version") + + # Skip if version already exists + if version: + enriched.append(dep) + continue + + latest_version = None + version_source = None + + if ecosystem == "npm" and name: + latest_version = get_latest_npm_version(name) + version_source = "latest_from_npm" + elif ecosystem == "pypi" and name: + latest_version = get_latest_pypi_version(name) + version_source = "latest_from_pypi" + elif ecosystem == "maven" and name: + # Maven format: groupId:artifactId + if ":" in name: + group_id, artifact_id = name.split(":", 1) + latest_version = get_latest_maven_version(group_id, artifact_id) + version_source = "latest_from_maven" + # Also check if we have separate groupId/artifactId fields + elif dep.get("groupId") and dep.get("artifactId"): + latest_version = get_latest_maven_version(dep["groupId"], dep["artifactId"]) + version_source = "latest_from_maven" + elif ecosystem == "nuget" and name: + latest_version = get_latest_nuget_version(name) + version_source = "latest_from_nuget" + elif ecosystem == "go" and name: + # Use full_name if available (includes module path), otherwise use name + module_path = dep.get("full_name") or name + latest_version = get_latest_go_version(module_path) + version_source = "latest_from_goproxy" + + if latest_version: + dep = dep.copy() + dep["version"] = latest_version + dep["version_source"] = version_source + + enriched.append(dep) + + return enriched + + def analyze_file(filename: str, content: str) -> Dict[str, Any]: manager = detect_package_manager(filename, content) result = {"packageManager": manager, "dependencies": []} if manager == "npm": - result["dependencies"] = parse_package_json(content) + deps = parse_package_json(content) + # Enrich with latest versions for packages without version + result["dependencies"] = enrich_dependencies_with_latest_versions(deps, "npm") result["ecosystem"] = "npm" elif manager == "pypi": - result["dependencies"] = parse_requirements(content) + deps = parse_requirements(content) + # Enrich with latest versions for packages without version + result["dependencies"] = enrich_dependencies_with_latest_versions(deps, "pypi") result["ecosystem"] = "pypi" elif manager == "maven": + deps = parse_maven_pom(content) + # Enrich with latest versions for packages without version + result["dependencies"] = enrich_dependencies_with_latest_versions(deps, "maven") result["ecosystem"] = "maven" elif manager == "nuget": - result["dependencies"] = parse_requirements(content, filename=filename) + deps = parse_requirements(content, filename=filename) + # Enrich with latest versions for packages without version + result["dependencies"] = enrich_dependencies_with_latest_versions(deps, "nuget") result["ecosystem"] = "nuget" else: result["ecosystem"] = "unknown" diff --git a/backend/app/services/repo_scanner.py b/backend/app/services/repo_scanner.py index e5a6b89..b6a936c 100644 --- a/backend/app/services/repo_scanner.py +++ b/backend/app/services/repo_scanner.py @@ -32,13 +32,88 @@ def is_dependency_file(filename: str) -> bool: return lower in DEP_FILES +# Common directories and files to ignore during scanning +IGNORED_DIRS = { + "node_modules", + "build", + "dist", + ".git", + ".svn", + ".hg", + "vendor", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".venv", + "venv", + "env", + ".env", + "target", + "bin", + "obj", + ".idea", + ".vscode", + ".vs", + "coverage", + ".coverage", + ".nyc_output", + ".next", + "out", + ".cache", + "tmp", + "temp", + ".tmp", + ".temp", +} + + +def _should_ignore_path(path: str) -> bool: + """ + Check if a path should be ignored based on common ignore patterns. + Path can be absolute or relative. + """ + # Normalize path separators + normalized = path.replace("\\", "/") + parts = normalized.split("/") + + # Check if any part matches ignored directories + for part in parts: + if part in IGNORED_DIRS: + return True + # Ignore hidden directories (starting with .) except for specific files + if part.startswith(".") and part not in [".", ".."]: + # Allow .csproj files (they are dependency files, not directories) + if part.endswith(".csproj"): + continue + # Ignore other hidden directories + return True + + return False + + def find_dependency_files(root: str) -> List[str]: + """ + Find dependency files in a repository, excluding common build/ignore directories. + """ matches = [] - for dirpath, _, files in os.walk(root): + root_abs = os.path.abspath(root) + + for dirpath, dirnames, files in os.walk(root): + # Prune ignored directories from os.walk + dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRS and not d.startswith(".")] + + # Check if current directory should be ignored + rel_dir = os.path.relpath(dirpath, root) + if _should_ignore_path(rel_dir): + continue + for name in files: if is_dependency_file(name): rel = os.path.relpath(os.path.join(dirpath, name), root) - matches.append(rel) + # Double-check the full path isn't in an ignored directory + if not _should_ignore_path(rel): + matches.append(rel) + return matches diff --git a/servers/mcp-licenguard/src/index.js b/servers/mcp-licenguard/src/index.js index c796d9e..4fb53fb 100644 --- a/servers/mcp-licenguard/src/index.js +++ b/servers/mcp-licenguard/src/index.js @@ -26,9 +26,10 @@ logInfo('[mcp] __dirname:', __dirname); logInfo('[mcp] __filename:', __filename); -dotenv.config({ path: path.join(__dirname, '.env') }); +const envPath = path.join(__dirname, '..', '.env'); +dotenv.config({ path: envPath }); -logInfo('Dotenv loaded from', path.join(__dirname, '.env')); +logInfo('Dotenv loaded from', envPath); logInfo('LLM config', getActiveLlmInfo()); const llmInfo = getActiveLlmInfo(); @@ -453,7 +454,7 @@ function createServer() { const analyzeFileHandler = async ({ filename, content }) => { if (!filename || !content) throw new Error('filename and content are required'); - const report = analyzeFile({ filename, content }); + const report = await analyzeFile({ filename, content }); logInfo('[mcp] analyze-file', JSON.stringify({ filename, manager: report.packageManager, deps: report.dependencies })); return { content: [{ type: 'text', text: JSON.stringify(report, null, 2) }], diff --git a/servers/mcp-licenguard/src/services/fileAnalyzer.js b/servers/mcp-licenguard/src/services/fileAnalyzer.js index 8ada463..d57857b 100644 --- a/servers/mcp-licenguard/src/services/fileAnalyzer.js +++ b/servers/mcp-licenguard/src/services/fileAnalyzer.js @@ -1,3 +1,76 @@ +/** + * Normalize version string to a single fixed version. + * Handles complex version formats like: + * - "1.0.3 || ^2.0.0" -> extracts first valid version or highest + * - "npm:typescript@^3.1.6" -> "3.1.6" + * - "^1.2.3" -> "1.2.3" + * - ">=1.0.0" -> "1.0.0" + * - "1.4 - 1.8" -> "1.8" + * - "1.x, 1.*" -> "1.0.0" (placeholder) + * - "dexy#1.0.1" -> "1.0.1" + * - ">=" -> null (invalid) + */ +export function normalizeVersion(versionString) { + if (!versionString || typeof versionString !== 'string') { + return null; + } + + let version = versionString.trim(); + + // Remove npm: prefix (e.g., "npm:typescript@^3.1.6") + version = version.replace(/^npm:\s*/i, ''); + + // Remove git/URL prefixes (e.g., "dexy#1.0.1", "git+https://...") + version = version.replace(/^(git\+|https?:\/\/|ssh:\/\/|dexy#|github:|gitlab:).*?[@#]/, ''); + version = version.replace(/^.*?[@#]/, ''); + + // Handle OR conditions (e.g., "1.0.3 || ^2.0.0") + if (version.includes('||')) { + const parts = version.split('||').map(p => p.trim()); + // Take the first part that looks like a version + for (const part of parts) { + const cleaned = part.replace(/^[~^><=\s]+/, '').trim(); + if (cleaned && /^[\d.]+/.test(cleaned)) { + version = cleaned; + break; + } + } + } + + // Handle ranges (e.g., "1.4 - 1.8", "1.4-1.8") + if (version.includes(' - ') || version.includes('-')) { + const rangeMatch = version.match(/([\d.]+)\s*-\s*([\d.]+)/); + if (rangeMatch) { + version = rangeMatch[2]; // Take the higher version + } + } + + // Handle wildcards (e.g., "1.x", "1.*", "1.X") + version = version.replace(/\.(x|\*|X)$/, '.0'); + version = version.replace(/^(x|\*|X)\./, '0.'); + + // Remove version operators (^, ~, >=, <=, >, <, =) + version = version.replace(/^[~^><=\s]+/, ''); + + // Remove 'v' prefix (e.g., "v1.2.3") + version = version.replace(/^v/i, ''); + + // Extract version number (e.g., from "1.2.3-alpha" get "1.2.3") + const versionMatch = version.match(/^([\d.]+)/); + if (versionMatch) { + version = versionMatch[1]; + } else { + return null; // No valid version found + } + + // Ensure it's a valid semver-like format (at least one dot) + if (!version.includes('.')) { + version = version + '.0'; + } + + return version.trim() || null; +} + export function detectPackageManager(filename, content) { const lowered = (filename || "").toLowerCase(); const snippet = (content || "").slice(0, 400).toLowerCase(); @@ -73,6 +146,45 @@ export function parseGoMod(text) { return deps; } +export function parseMaven(text) { + // Parse Maven pom.xml dependencies + // Handles both : and within blocks + const deps = []; + if (!text) return deps; + + // Match ... blocks + const depBlockRegex = /([\s\S]*?)<\/dependency>/gi; + let m; + + while ((m = depBlockRegex.exec(text))) { + const depBlock = m[1]; + + // Extract groupId, artifactId, and version from the dependency block + const groupIdMatch = /([^<]+)<\/groupId>/i.exec(depBlock); + const artifactIdMatch = /([^<]+)<\/artifactId>/i.exec(depBlock); + const versionMatch = /([^<]+)<\/version>/i.exec(depBlock); + const scopeMatch = /([^<]+)<\/scope>/i.exec(depBlock); + + if (groupIdMatch && artifactIdMatch) { + const groupId = groupIdMatch[1].trim(); + const artifactId = artifactIdMatch[1].trim(); + const version = versionMatch ? versionMatch[1].trim() : null; + const scope = scopeMatch ? scopeMatch[1].trim() : 'compile'; + + // Skip test dependencies unless we want to include them + if (scope === 'test') { + continue; // Skip test dependencies + } + + // Create full Maven artifact name: groupId:artifactId + const name = `${groupId}:${artifactId}`; + deps.push({ name, version, groupId, artifactId }); + } + } + + return deps; +} + export function parseNuget(text) { // Parse two common NuGet formats: // 1) packages.config entries: @@ -117,20 +229,218 @@ export function parseNuget(text) { return Array.from(seen.values()); } -export function analyzeFile({ filename, content }) { +async function getLatestNpmVersion(packageName) { + /**Get the latest version of an npm package.*/ + try { + const url = `https://registry.npmjs.org/${packageName}/latest`; + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!response.ok) return null; + const data = await response.json(); + return data?.version || null; + } catch (error) { + return null; + } +} + +async function getLatestPypiVersion(packageName) { + /**Get the latest version of a PyPI package.*/ + try { + const url = `https://pypi.org/pypi/${packageName}/json`; + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!response.ok) return null; + const data = await response.json(); + return data?.info?.version || null; + } catch (error) { + return null; + } +} + +async function getLatestMavenVersion(groupId, artifactId) { + /**Get the latest version of a Maven artifact.*/ + try { + const url = `https://search.maven.org/solrsearch/select?q=g:${encodeURIComponent(groupId)}+AND+a:${encodeURIComponent(artifactId)}&rows=1&wt=json`; + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!response.ok) return null; + const data = await response.json(); + const docs = data?.response?.docs; + if (docs && docs.length > 0) { + return docs[0]?.latestVersion || null; + } + return null; + } catch (error) { + return null; + } +} + +async function getLatestNugetVersion(packageName) { + /**Get the latest version of a NuGet package.*/ + try { + const url = `https://api.nuget.org/v3-flatcontainer/${packageName.toLowerCase()}/index.json`; + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!response.ok) return null; + const data = await response.json(); + const versions = data?.versions; + if (versions && versions.length > 0) { + // Return the last (latest) version + return versions[versions.length - 1]; + } + return null; + } catch (error) { + return null; + } +} + +async function getLatestGoVersion(modulePath) { + /**Get the latest version of a Go module.*/ + try { + const url = `https://proxy.golang.org/${modulePath}/@latest`; + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!response.ok) return null; + const data = await response.json(); + return data?.Version || null; + } catch (error) { + return null; + } +} + +async function enrichDependenciesWithLatestVersions(dependencies, ecosystem) { + /**Enrich dependencies with normalized versions and latest versions. + + Strategy: + 1. Normalize existing versions to fixed format + 2. If version is missing or invalid, try AI first + 3. If AI doesn't provide version, fall back to package manager APIs + + Supports: npm, pypi, maven, nuget, go + */ + // Dynamic import to avoid circular dependency + const { discoverLibraryInfo } = await import('./libraryDiscovery.js'); + + const enriched = []; + for (const dep of dependencies) { + const name = dep.name; + let version = dep.version; + + // First, normalize the version if it exists + if (version) { + const normalized = normalizeVersion(version); + if (normalized) { + enriched.push({ + ...dep, + version: normalized, + original_version: version, + version_source: 'normalized' + }); + continue; + } + // If normalization failed, treat as missing version + version = null; + } + + // If version is missing or invalid, try AI first + let latestVersion = null; + let versionSource = null; + + if (name) { + try { + const aiResult = await discoverLibraryInfo({ + name: name, + version: null, + ecosystem: ecosystem + }); + + // Extract version from AI result + if (aiResult?.matches && Array.isArray(aiResult.matches) && aiResult.matches.length > 0) { + const match = aiResult.matches[0]; + if (match.version) { + const aiNormalized = normalizeVersion(match.version); + if (aiNormalized) { + latestVersion = aiNormalized; + versionSource = 'latest_from_ai'; + } + } + } + } catch (error) { + // AI failed, continue to package manager lookup + console.warn(`[mcp] AI lookup failed for ${name}, falling back to package manager`, error?.message); + } + } + + // If AI didn't provide version, try package manager APIs + if (!latestVersion) { + if (ecosystem === "npm" && name) { + latestVersion = await getLatestNpmVersion(name); + versionSource = "latest_from_npm"; + } else if (ecosystem === "pypi" && name) { + latestVersion = await getLatestPypiVersion(name); + versionSource = "latest_from_pypi"; + } else if (ecosystem === "maven" && name) { + // Maven format: groupId:artifactId + if (name.includes(":")) { + const [groupId, artifactId] = name.split(":", 2); + latestVersion = await getLatestMavenVersion(groupId, artifactId); + versionSource = "latest_from_maven"; + } else if (dep.groupId && dep.artifactId) { + latestVersion = await getLatestMavenVersion(dep.groupId, dep.artifactId); + versionSource = "latest_from_maven"; + } + } else if (ecosystem === "nuget" && name) { + latestVersion = await getLatestNugetVersion(name); + versionSource = "latest_from_nuget"; + } else if (ecosystem === "go" && name) { + // Use full_name if available (includes module path), otherwise use name + const modulePath = dep.full_name || name; + latestVersion = await getLatestGoVersion(modulePath); + versionSource = "latest_from_goproxy"; + } + } + + if (latestVersion) { + enriched.push({ + ...dep, + version: latestVersion, + original_version: dep.version || null, + version_source: versionSource + }); + } else { + // Keep original dependency even if we couldn't find version + enriched.push({ + ...dep, + original_version: dep.version || null + }); + } + } + + return enriched; +} + +export async function analyzeFile({ filename, content }) { const manager = detectPackageManager(filename || "unknown", content || ""); const result = { packageManager: manager, ecosystem: manager, dependencies: [] }; + + let deps = []; if (manager === "npm") { - result.dependencies = parsePackageJson(content || ""); + deps = parsePackageJson(content || ""); + // Enrich with latest versions for packages without version + deps = await enrichDependenciesWithLatestVersions(deps, "npm"); } else if (manager === "pypi") { - result.dependencies = parseRequirements(content || ""); + deps = parseRequirements(content || ""); + // Enrich with latest versions for packages without version + deps = await enrichDependenciesWithLatestVersions(deps, "pypi"); } else if (manager === "go") { - result.dependencies = parseGoMod(content || ""); + deps = parseGoMod(content || ""); + // Enrich with latest versions for packages without version + deps = await enrichDependenciesWithLatestVersions(deps, "go"); } else if (manager === "maven") { - result.dependencies = parseMaven(content || ""); + deps = parseMaven(content || ""); + // Enrich with latest versions for packages without version + deps = await enrichDependenciesWithLatestVersions(deps, "maven"); } else if (manager === "nuget") { - result.dependencies = parseNuget(content || ""); + deps = parseNuget(content || ""); + // Enrich with latest versions for packages without version + deps = await enrichDependenciesWithLatestVersions(deps, "nuget"); } - + + result.dependencies = deps; return result; } diff --git a/servers/mcp-licenguard/src/services/llmClient.js b/servers/mcp-licenguard/src/services/llmClient.js index 9d4950b..a3a78d0 100644 --- a/servers/mcp-licenguard/src/services/llmClient.js +++ b/servers/mcp-licenguard/src/services/llmClient.js @@ -1,83 +1,136 @@ -const OPENAI_API_KEY = process.env.OPENAI_API_KEY; -const OPENAI_API_URL = process.env.OPENAI_API_URL ?? 'https://api.openai.com/v1/chat/completions'; -const OPENAI_MODEL = process.env.OPENAI_MODEL ?? 'gpt-4o-mini'; +// Lazy loading of environment variables to ensure dotenv.config() runs first +function getEnvVars() { + const OPENAI_API_KEY = process.env.OPENAI_API_KEY; + const OPENAI_API_URL = process.env.OPENAI_API_URL ?? 'https://api.openai.com/v1/chat/completions'; + const OPENAI_MODEL = process.env.OPENAI_MODEL ?? 'gpt-4o-mini'; -const LOCAL_LLM_API_KEY = process.env.LOCAL_LLM_API_KEY; -const LOCAL_LLM_API_URL = process.env.LOCAL_LLM_API_URL; -const LOCAL_LLM_MODEL = process.env.LOCAL_LLM_MODEL; -const LOCAL_LLM_AUTH_HEADER = process.env.LOCAL_LLM_AUTH_HEADER; -const LOCAL_LLM_EXTRA_HEADERS_RAW = process.env.LOCAL_LLM_EXTRA_HEADERS; + const LOCAL_LLM_API_KEY = process.env.LOCAL_LLM_API_KEY; + const LOCAL_LLM_API_URL = process.env.LOCAL_LLM_API_URL; + const LOCAL_LLM_MODEL = process.env.LOCAL_LLM_MODEL; + const LOCAL_LLM_AUTH_HEADER = process.env.LOCAL_LLM_AUTH_HEADER; + const LOCAL_LLM_EXTRA_HEADERS_RAW = process.env.LOCAL_LLM_EXTRA_HEADERS; -let LOCAL_LLM_EXTRA_HEADERS = { 'X-Request-Source': 'mcp' }; + // Debug: Log raw environment variables (first call only) + if (!getEnvVars._logged) { + console.log('[mcp] Environment variables check:', { + LOCAL_LLM_API_KEY: LOCAL_LLM_API_KEY ? `present (${LOCAL_LLM_API_KEY.length} chars)` : 'missing', + LOCAL_LLM_API_URL: LOCAL_LLM_API_URL || 'missing', + LOCAL_LLM_AUTH_HEADER: LOCAL_LLM_AUTH_HEADER || 'missing', + OPENAI_API_KEY: OPENAI_API_KEY ? 'present' : 'missing' + }); + getEnvVars._logged = true; + } -const sanitizeHeaderValue = (value, fallback = '') => { - if (value === undefined || value === null) return fallback; - const cleaned = String(value).replace(/[^\x20-\x7E]/g, '').trim(); - return cleaned || fallback; -}; + let LOCAL_LLM_EXTRA_HEADERS = { 'X-Request-Source': 'mcp' }; -if (LOCAL_LLM_EXTRA_HEADERS_RAW) { - try { - const normalized = - LOCAL_LLM_EXTRA_HEADERS_RAW.trim().startsWith("'") && - LOCAL_LLM_EXTRA_HEADERS_RAW.trim().endsWith("'") - ? LOCAL_LLM_EXTRA_HEADERS_RAW.trim().slice(1, -1) - : LOCAL_LLM_EXTRA_HEADERS_RAW; - const parsed = JSON.parse(normalized); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - LOCAL_LLM_EXTRA_HEADERS = parsed; + if (LOCAL_LLM_EXTRA_HEADERS_RAW) { + try { + const normalized = + LOCAL_LLM_EXTRA_HEADERS_RAW.trim().startsWith("'") && + LOCAL_LLM_EXTRA_HEADERS_RAW.trim().endsWith("'") + ? LOCAL_LLM_EXTRA_HEADERS_RAW.trim().slice(1, -1) + : LOCAL_LLM_EXTRA_HEADERS_RAW; + const parsed = JSON.parse(normalized); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + LOCAL_LLM_EXTRA_HEADERS = parsed; + } + } catch (err) { + console.warn('[mcp] LOCAL_LLM_EXTRA_HEADERS parse failed; ignoring', err?.message); } - } catch (err) { - console.warn('[mcp] LOCAL_LLM_EXTRA_HEADERS parse failed; ignoring', err?.message); } + + const USING_LOCAL_LLM = Boolean(LOCAL_LLM_API_URL && LOCAL_LLM_API_KEY); + const CHAT_API_KEY = USING_LOCAL_LLM ? LOCAL_LLM_API_KEY : OPENAI_API_KEY; + const CHAT_API_URL = USING_LOCAL_LLM ? LOCAL_LLM_API_URL : OPENAI_API_URL; + // For LOCAL_LLM, use model if provided, otherwise don't send model field (let API use default) + const CHAT_MODEL = USING_LOCAL_LLM + ? (LOCAL_LLM_MODEL?.trim() || null) + : OPENAI_MODEL; + + return { + OPENAI_API_KEY, + OPENAI_API_URL, + OPENAI_MODEL, + LOCAL_LLM_API_KEY, + LOCAL_LLM_API_URL, + LOCAL_LLM_MODEL, + LOCAL_LLM_AUTH_HEADER, + LOCAL_LLM_EXTRA_HEADERS, + USING_LOCAL_LLM, + CHAT_API_KEY, + CHAT_API_URL, + CHAT_MODEL + }; } -const USING_LOCAL_LLM = Boolean(LOCAL_LLM_API_URL && LOCAL_LLM_API_KEY); -const CHAT_API_KEY = USING_LOCAL_LLM ? LOCAL_LLM_API_KEY : OPENAI_API_KEY; -const CHAT_API_URL = USING_LOCAL_LLM ? LOCAL_LLM_API_URL : OPENAI_API_URL; -const CHAT_MODEL = USING_LOCAL_LLM ? LOCAL_LLM_MODEL : OPENAI_MODEL; +const sanitizeHeaderValue = (value, fallback = '') => { + if (value === undefined || value === null) return fallback; + const cleaned = String(value).replace(/[^\x20-\x7E]/g, '').trim(); + return cleaned || fallback; +}; export function getActiveLlmInfo() { + const env = getEnvVars(); return { - provider: USING_LOCAL_LLM ? 'local' : 'openai', - apiUrl: CHAT_API_URL, - model: CHAT_MODEL ?? null, - keyPresent: Boolean(CHAT_API_KEY), - localEnabled: USING_LOCAL_LLM, - authHeader: USING_LOCAL_LLM ? LOCAL_LLM_AUTH_HEADER?.trim() || 'X-API-Key' : 'Authorization', - authPrefix: - USING_LOCAL_LLM !== undefined - ? USING_LOCAL_LLM - : 'Bearer', - extraHeaders: USING_LOCAL_LLM ? Object.keys(LOCAL_LLM_EXTRA_HEADERS) : [], + provider: env.USING_LOCAL_LLM ? 'local' : 'openai', + apiUrl: env.CHAT_API_URL, + model: env.CHAT_MODEL ?? null, + keyPresent: Boolean(env.CHAT_API_KEY), + localEnabled: env.USING_LOCAL_LLM, + authHeader: env.USING_LOCAL_LLM ? (env.LOCAL_LLM_AUTH_HEADER?.trim() || 'X-API-Key') : 'Authorization', + authPrefix: env.USING_LOCAL_LLM ? '' : 'Bearer', + extraHeaders: env.USING_LOCAL_LLM ? Object.keys(env.LOCAL_LLM_EXTRA_HEADERS) : [], }; } export async function callChat({ messages, temperature = 0, responseFormat = { type: 'json_object' } }) { try { - if (!CHAT_API_KEY || !CHAT_API_URL) { - throw new Error('LLM credentials missing (CHAT_API_KEY or CHAT_API_URL)'); + const env = getEnvVars(); + + // Debug: Log environment state + console.log('[mcp] callChat check:', { + usingLocal: env.USING_LOCAL_LLM, + hasApiKey: Boolean(env.CHAT_API_KEY), + hasApiUrl: Boolean(env.CHAT_API_URL), + apiKeyLength: env.CHAT_API_KEY?.length || 0, + apiUrl: env.CHAT_API_URL, + localApiKey: env.LOCAL_LLM_API_KEY ? 'present' : 'missing', + localApiUrl: env.LOCAL_LLM_API_URL ? 'present' : 'missing' + }); + + if (!env.CHAT_API_KEY || !env.CHAT_API_URL) { + throw new Error(`LLM credentials missing (CHAT_API_KEY: ${Boolean(env.CHAT_API_KEY)}, CHAT_API_URL: ${Boolean(env.CHAT_API_URL)})`); } const headers = { 'Content-Type': 'application/json' }; - if (USING_LOCAL_LLM) { - const headerName = sanitizeHeaderValue(LOCAL_LLM_AUTH_HEADER, 'X-API-Key'); - const prefix = sanitizeHeaderValue(LOCAL_LLM_API_KEY, ''); - const cleanApiKey = String(CHAT_API_KEY || '').replace(/[\r\n]/g, '').trim(); - Object.assign(headers, LOCAL_LLM_EXTRA_HEADERS); - headers[headerName] = prefix.trim() ? `${prefix.trim()}` : cleanApiKey; + if (env.USING_LOCAL_LLM) { + const headerName = sanitizeHeaderValue(env.LOCAL_LLM_AUTH_HEADER, 'X-API-Key'); + const cleanApiKey = String(env.CHAT_API_KEY || '').replace(/[\r\n]/g, '').trim(); + Object.assign(headers, env.LOCAL_LLM_EXTRA_HEADERS); + headers[headerName] = cleanApiKey; + + console.log('[mcp] Using LOCAL LLM:', { + apiUrl: env.CHAT_API_URL, + headerName, + apiKeyLength: cleanApiKey.length, + model: env.CHAT_MODEL + }); } else { - headers.Authorization = `Bearer ${CHAT_API_KEY}`; + headers.Authorization = `Bearer ${env.CHAT_API_KEY}`; } const body = { - model: CHAT_MODEL, temperature, response_format: responseFormat, messages }; + + // Only include model if it's specified (some APIs don't require it) + if (env.CHAT_MODEL) { + body.model = env.CHAT_MODEL; + } - const res = await fetch(CHAT_API_URL, { method: 'POST', headers, body: JSON.stringify(body) }); + const res = await fetch(env.CHAT_API_URL, { method: 'POST', headers, body: JSON.stringify(body) }); if (!res.ok) { const message = await res.text(); throw new Error(message || `LLM request failed (${res.status})`); @@ -90,11 +143,12 @@ export async function callChat({ messages, temperature = 0, responseFormat = { t if (!text) throw new Error('LLM returned no content'); return text; } catch (err) { + const env = getEnvVars(); console.error('[mcp] callChat failed', err); console.error('[mcp] callChat params', { - apiUrl: CHAT_API_URL, - model: CHAT_MODEL, - usingLocal: USING_LOCAL_LLM, + apiUrl: env.CHAT_API_URL, + model: env.CHAT_MODEL, + usingLocal: env.USING_LOCAL_LLM, messages: messages }); @@ -103,8 +157,8 @@ export async function callChat({ messages, temperature = 0, responseFormat = { t } export const llmEnv = { - USING_LOCAL_LLM, - CHAT_API_KEY, - CHAT_API_URL, - CHAT_MODEL, + get USING_LOCAL_LLM() { return getEnvVars().USING_LOCAL_LLM; }, + get CHAT_API_KEY() { return getEnvVars().CHAT_API_KEY; }, + get CHAT_API_URL() { return getEnvVars().CHAT_API_URL; }, + get CHAT_MODEL() { return getEnvVars().CHAT_MODEL; }, };