@ruby-mcp/gems-mcp
Version:
MCP server for interacting with RubyGems.org API, Gemfiles, and gemspecs - search gems, get versions, and manage dependencies
1,664 lines (1,652 loc) • 114 kB
JavaScript
#!/usr/bin/env node
// src/index.ts
import { readFileSync } from "fs";
import { dirname, join as join2 } from "path";
import { fileURLToPath } from "url";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Command } from "commander";
// src/api/cache.ts
var ApiCache = class {
constructor(defaultTtl = 5 * 60 * 1e3) {
this.cache = /* @__PURE__ */ new Map();
this.defaultTtl = defaultTtl;
}
get(key) {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
const now = Date.now();
if (now > entry.timestamp + entry.ttl) {
this.cache.delete(key);
return null;
}
return entry.data;
}
set(key, data, ttl) {
const entry = {
data,
timestamp: Date.now(),
ttl: ttl ?? this.defaultTtl
};
this.cache.set(key, entry);
}
has(key) {
return this.get(key) !== null;
}
delete(key) {
return this.cache.delete(key);
}
clear() {
this.cache.clear();
}
cleanup() {
const now = Date.now();
let removedCount = 0;
for (const [key, entry] of this.cache.entries()) {
if (now > entry.timestamp + entry.ttl) {
this.cache.delete(key);
removedCount++;
}
}
return removedCount;
}
getStats() {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys())
};
}
static generateKey(endpoint, params) {
const baseKey = endpoint.toLowerCase();
if (!params || Object.keys(params).length === 0) {
return baseKey;
}
const sortedParams = Object.keys(params).sort().map((key) => `${key}=${encodeURIComponent(String(params[key]))}`).join("&");
return `${baseKey}?${sortedParams}`;
}
};
// src/api/client.ts
var RubyGemsClient = class {
constructor(options = {}) {
this.lastRequestTime = 0;
this.baseUrl = options.baseUrl ?? "https://rubygems.org";
this.timeout = options.timeout ?? 1e4;
this.userAgent = options.userAgent ?? "@ruby-mcp/gems-mcp/0.1.0";
this.cacheEnabled = options.cacheEnabled ?? true;
this.rateLimitDelay = options.rateLimitDelay ?? 100;
this.cache = new ApiCache(options.cacheTtl);
}
async searchGems(query, limit = 10) {
const cacheKey = ApiCache.generateKey("search", { query, limit });
if (this.cacheEnabled) {
const cached = this.cache.get(cacheKey);
if (cached) {
return { data: cached, success: true };
}
}
try {
const url = `${this.baseUrl}/api/v1/search.json?query=${encodeURIComponent(query)}`;
const response = await this.makeRequest(url);
if (!response.success) {
return response;
}
let gems = response.data;
if (limit > 0 && gems.length > limit) {
gems = gems.slice(0, limit);
}
if (this.cacheEnabled) {
this.cache.set(cacheKey, gems);
}
return { data: gems, success: true };
} catch (error) {
return {
data: [],
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred"
};
}
}
async getGemDetails(gemName) {
const cacheKey = ApiCache.generateKey("gem", { name: gemName });
if (this.cacheEnabled) {
const cached = this.cache.get(cacheKey);
if (cached) {
return { data: cached, success: true };
}
}
try {
const url = `${this.baseUrl}/api/v1/gems/${encodeURIComponent(gemName)}.json`;
const response = await this.makeRequest(url);
if (!response.success) {
return response;
}
const gemData = response.data;
if (this.cacheEnabled) {
this.cache.set(cacheKey, gemData);
}
return { data: gemData, success: true };
} catch (error) {
return {
data: {},
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred"
};
}
}
async getGemVersions(gemName) {
const cacheKey = ApiCache.generateKey("versions", { name: gemName });
if (this.cacheEnabled) {
const cached = this.cache.get(cacheKey);
if (cached) {
return { data: cached, success: true };
}
}
try {
const url = `${this.baseUrl}/api/v1/versions/${encodeURIComponent(gemName)}.json`;
const response = await this.makeRequest(url);
if (!response.success) {
return response;
}
const versions = response.data;
if (this.cacheEnabled) {
this.cache.set(cacheKey, versions);
}
return { data: versions, success: true };
} catch (error) {
return {
data: [],
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred"
};
}
}
async getLatestVersion(gemName) {
const cacheKey = ApiCache.generateKey("latest", { name: gemName });
if (this.cacheEnabled) {
const cached = this.cache.get(cacheKey);
if (cached) {
return { data: cached, success: true };
}
}
try {
const versionsResponse = await this.getGemVersions(gemName);
if (!versionsResponse.success) {
return {
data: {},
success: false,
error: versionsResponse.error
};
}
const versions = versionsResponse.data;
if (versions.length === 0) {
return {
data: {},
success: false,
error: `No versions found for gem: ${gemName}`
};
}
const sortedVersions = versions.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
const latestVersion = sortedVersions[0];
if (this.cacheEnabled) {
this.cache.set(cacheKey, latestVersion);
}
return { data: latestVersion, success: true };
} catch (error) {
return {
data: {},
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred"
};
}
}
async getReverseDependencies(gemName) {
const cacheKey = ApiCache.generateKey("reverse_deps", { name: gemName });
if (this.cacheEnabled) {
const cached = this.cache.get(cacheKey);
if (cached) {
return { data: cached, success: true };
}
}
try {
const url = `${this.baseUrl}/api/v1/gems/${encodeURIComponent(gemName)}/reverse_dependencies.json`;
const response = await this.makeRequest(url);
if (!response.success) {
return response;
}
const dependencies = response.data.map((name) => ({
name
}));
if (this.cacheEnabled) {
this.cache.set(cacheKey, dependencies);
}
return { data: dependencies, success: true };
} catch (error) {
return {
data: [],
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred"
};
}
}
async makeRequest(url) {
const now = Date.now();
const timeSinceLastRequest = now - this.lastRequestTime;
if (timeSinceLastRequest < this.rateLimitDelay) {
await new Promise(
(resolve2) => setTimeout(resolve2, this.rateLimitDelay - timeSinceLastRequest)
);
}
this.lastRequestTime = Date.now();
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(url, {
method: "GET",
headers: {
"User-Agent": this.userAgent,
Accept: "application/json"
},
signal: controller.signal
});
clearTimeout(timeoutId);
if (response.status === 404) {
return {
data: null,
success: false,
error: "Resource not found"
};
}
if (response.status === 429) {
return {
data: null,
success: false,
error: "Rate limit exceeded"
};
}
if (response.status >= 400) {
return {
data: null,
success: false,
error: `HTTP ${response.status}`
};
}
const body = await response.json();
return {
data: body,
success: true
};
} catch (error) {
return {
data: null,
success: false,
error: error instanceof Error ? error.message : "Network request failed"
};
}
}
clearCache() {
this.cache.clear();
}
getCacheStats() {
return this.cache.getStats();
}
cleanupCache() {
return this.cache.cleanup();
}
};
// src/changelog/cache.ts
var ChangelogCache = class {
constructor(defaultTtl = 24 * 60 * 60 * 1e3) {
this.cache = /* @__PURE__ */ new Map();
this.defaultTtl = defaultTtl;
}
get(key) {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
const now = Date.now();
if (now > entry.timestamp + entry.ttl) {
this.cache.delete(key);
return null;
}
return entry.data;
}
set(key, data, ttl) {
const entry = {
data,
timestamp: Date.now(),
ttl: ttl ?? this.defaultTtl
};
this.cache.set(key, entry);
}
has(key) {
return this.get(key) !== null;
}
delete(key) {
return this.cache.delete(key);
}
clear() {
this.cache.clear();
}
cleanup() {
const now = Date.now();
let removedCount = 0;
for (const [key, entry] of this.cache.entries()) {
if (now > entry.timestamp + entry.ttl) {
this.cache.delete(key);
removedCount++;
}
}
return removedCount;
}
getStats() {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys())
};
}
static generateKey(gemName, version) {
return version ? `${gemName}@${version}` : gemName;
}
};
// src/changelog/fetcher.ts
var ChangelogFetcher = class {
constructor(options) {
this.client = options.client;
this.cacheEnabled = options.cacheEnabled ?? true;
this.cache = new ChangelogCache(options.cacheTtl);
this.timeout = options.timeout ?? 1e4;
}
async fetchChangelog(gemName, version) {
const cacheKey = ChangelogCache.generateKey(gemName, version);
if (this.cacheEnabled) {
const cached = this.cache.get(cacheKey);
if (cached) {
return {
success: true,
content: cached.content,
source: cached.source
};
}
}
try {
const gemResponse = await this.client.getGemDetails(gemName);
if (!gemResponse.success) {
return {
success: false,
error: `Failed to fetch gem details: ${gemResponse.error}`
};
}
const gem = gemResponse.data;
const sources = this.getChangelogSources(gem, version);
for (const source of sources) {
const result = await this.fetchFromSource(source);
if (result.success && result.content) {
if (this.cacheEnabled) {
this.cache.set(cacheKey, {
content: result.content,
source: result.source || source.url
});
}
return result;
}
}
return {
success: false,
error: "No changelog found from any source"
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error"
};
}
}
getChangelogSources(gem, version) {
const sources = [];
if (gem.changelog_uri) {
sources.push({
type: "changelog_uri",
url: gem.changelog_uri,
version
});
}
if (gem.source_code_uri && this.isGitHubUrl(gem.source_code_uri)) {
const { owner, repo } = this.parseGitHubUrl(gem.source_code_uri);
if (owner && repo) {
if (version) {
sources.push({
type: "github_release",
url: `https://github.com/${owner}/${repo}/releases/tag/v${version}`,
version
});
sources.push({
type: "github_release",
url: `https://github.com/${owner}/${repo}/releases/tag/${version}`,
version
});
}
sources.push({
type: "github_releases",
url: `https://github.com/${owner}/${repo}/releases`
});
const changelogFiles = [
"CHANGELOG.md",
"CHANGELOG",
"HISTORY.md",
"HISTORY",
"CHANGES.md",
"CHANGES",
"NEWS.md",
"NEWS"
];
for (const file of changelogFiles) {
sources.push({
type: "raw_file",
url: `https://raw.githubusercontent.com/${owner}/${repo}/main/${file}`
});
sources.push({
type: "raw_file",
url: `https://raw.githubusercontent.com/${owner}/${repo}/master/${file}`
});
}
}
}
if (gem.documentation_uri) {
sources.push({
type: "documentation",
url: gem.documentation_uri
});
}
return sources;
}
async fetchFromSource(source) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(source.url, {
method: "GET",
headers: {
"User-Agent": "@ruby-mcp/gems-mcp",
Accept: "text/html,text/markdown,text/plain,*/*"
},
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
return { success: false };
}
const contentType = response.headers.get("content-type") || "";
let content = await response.text();
if (contentType.includes("text/html")) {
content = this.htmlToMarkdown(content);
}
if (source.version && source.type !== "raw_file") {
content = this.extractVersionContent(content, source.version);
}
return {
success: true,
content: content.trim(),
source: source.url
};
} catch (_error) {
return { success: false };
}
}
isGitHubUrl(url) {
return url.includes("github.com");
}
parseGitHubUrl(url) {
const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
if (!match) {
return { owner: "", repo: "" };
}
let repo = match[2];
if (repo.endsWith(".git")) {
repo = repo.slice(0, -4);
}
return { owner: match[1], repo };
}
htmlToMarkdown(html) {
let markdown = html;
markdown = markdown.replace(
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
""
);
markdown = markdown.replace(
/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi,
""
);
markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, "# $1\n");
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, "## $1\n");
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, "### $1\n");
markdown = markdown.replace(/<h4[^>]*>(.*?)<\/h4>/gi, "#### $1\n");
markdown = markdown.replace(/<h5[^>]*>(.*?)<\/h5>/gi, "##### $1\n");
markdown = markdown.replace(/<h6[^>]*>(.*?)<\/h6>/gi, "###### $1\n");
markdown = markdown.replace(
/<a[^>]*href=["']([^"']*)["'][^>]*>(.*?)<\/a>/gi,
"[$2]($1)"
);
markdown = markdown.replace(/<li[^>]*>(.*?)<\/li>/gi, "- $1\n");
markdown = markdown.replace(/<ul[^>]*>/gi, "\n");
markdown = markdown.replace(/<\/ul>/gi, "\n");
markdown = markdown.replace(/<ol[^>]*>/gi, "\n");
markdown = markdown.replace(/<\/ol>/gi, "\n");
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, "**$1**");
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, "**$1**");
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, "*$1*");
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, "*$1*");
markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/gi, "`$1`");
markdown = markdown.replace(
/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gi,
"```\n$1\n```"
);
markdown = markdown.replace(/<pre[^>]*>(.*?)<\/pre>/gi, "```\n$1\n```");
markdown = markdown.replace(/<p[^>]*>/gi, "\n");
markdown = markdown.replace(/<\/p>/gi, "\n");
markdown = markdown.replace(/<br\s*\/?>/gi, "\n");
markdown = markdown.replace(/<[^>]+>/g, "");
markdown = markdown.replace(/</g, "<");
markdown = markdown.replace(/>/g, ">");
markdown = markdown.replace(/&/g, "&");
markdown = markdown.replace(/"/g, '"');
markdown = markdown.replace(/'/g, "'");
markdown = markdown.replace(/\n{3,}/g, "\n\n");
markdown = markdown.trim();
return markdown;
}
extractVersionContent(content, version) {
const versionPatterns = [
new RegExp(
`##?\\s*\\[?v?${this.escapeRegex(version)}\\]?[^\\n]*\\n([\\s\\S]*?)(?=##|$)`,
"i"
),
new RegExp(
`^v?${this.escapeRegex(version)}[^\\n]*\\n([\\s\\S]*?)(?=^\\d+\\.\\d+|$)`,
"im"
)
];
for (const pattern of versionPatterns) {
const match = content.match(pattern);
if (match?.[1]) {
return match[1].trim();
}
}
return content;
}
escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
clearCache() {
this.cache.clear();
}
getCacheStats() {
return this.cache.getStats();
}
cleanupCache() {
return this.cache.cleanup();
}
};
// src/project-manager.ts
import { promises as fs } from "fs";
import { join, resolve } from "path";
var ProjectManager = class {
constructor(projects = [], defaultPath) {
this.projects = /* @__PURE__ */ new Map();
this.defaultProject = defaultPath || process.cwd();
if (!projects.some((p) => p.name === "default")) {
this.projects.set("default", this.defaultProject);
}
for (const project of projects) {
this.projects.set(project.name, resolve(project.path));
}
}
/**
* Add a project to the manager
*/
async addProject(name, path) {
const resolvedPath = resolve(path);
try {
const stats = await fs.stat(resolvedPath);
if (!stats.isDirectory()) {
throw new Error(`Project path is not a directory: ${resolvedPath}`);
}
await fs.access(resolvedPath, fs.constants.R_OK);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("ENOENT")) {
throw new Error(`Project directory does not exist: ${resolvedPath}`);
}
if (error.message.includes("EACCES")) {
throw new Error(
`Permission denied accessing project directory: ${resolvedPath}`
);
}
}
throw error;
}
this.projects.set(name, resolvedPath);
}
/**
* Get project path by name, or return default if not found
*/
getProjectPath(name) {
if (!name) {
return this.defaultProject;
}
const path = this.projects.get(name);
if (!path) {
throw new Error(
`Project not found: ${name}. Available projects: ${Array.from(this.projects.keys()).join(", ")}`
);
}
return path;
}
/**
* Resolve a file path within a project
*/
resolveFilePath(filePath, projectName) {
const projectPath = this.getProjectPath(projectName);
if (resolve(filePath) === filePath) {
return filePath;
}
return join(projectPath, filePath);
}
/**
* Get list of all project names
*/
getProjectNames() {
return Array.from(this.projects.keys());
}
/**
* Check if a project exists
*/
hasProject(name) {
return this.projects.has(name);
}
/**
* Get the default project path
*/
getDefaultProjectPath() {
return this.defaultProject;
}
/**
* Validate that all configured projects are accessible
*/
async validateProjects() {
const errors = [];
for (const [name, path] of this.projects.entries()) {
try {
const stats = await fs.stat(path);
if (!stats.isDirectory()) {
errors.push(`Project '${name}' path is not a directory: ${path}`);
continue;
}
await fs.access(path, fs.constants.R_OK);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("ENOENT")) {
errors.push(`Project '${name}' directory does not exist: ${path}`);
} else if (error.message.includes("EACCES")) {
errors.push(
`Permission denied accessing project '${name}' directory: ${path}`
);
} else {
errors.push(
`Error accessing project '${name}' at ${path}: ${error.message}`
);
}
}
}
}
if (errors.length > 0) {
throw new Error(`Project validation failed:
${errors.join("\n")}`);
}
}
};
// src/schemas.ts
import { z } from "zod";
var SearchGemsSchema = z.object({
query: z.string().min(1, "Query cannot be empty").max(100, "Query too long"),
limit: z.number().int().min(1).max(100).optional().default(10)
});
var GemDetailsSchema = z.object({
gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format")
});
var GemVersionsSchema = z.object({
gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"),
include_prerelease: z.boolean().optional().default(false)
});
var LatestVersionSchema = z.object({
gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"),
include_prerelease: z.boolean().optional().default(false)
});
var GemDependenciesSchema = z.object({
gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format")
});
var ChangelogSchema = z.object({
gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"),
version: z.string().min(1, "Version cannot be empty").max(50, "Version too long").regex(
/^[0-9]+(?:\.[0-9]+)*(?:\.(?:pre|rc|alpha|beta)\d*)?$/,
"Invalid version format"
).optional()
});
var GemfileParserSchema = z.object({
file_path: z.string().min(1, "File path cannot be empty").max(500, "File path too long"),
project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional()
});
var GemPinSchema = z.object({
gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"),
version: z.string().min(1, "Version cannot be empty").max(50, "Version too long").regex(
/^[0-9]+(?:\.[0-9]+)*(?:\.(?:pre|rc|alpha|beta)\d*)?$/,
"Invalid version format"
),
pin_type: z.enum(["~>", ">=", ">", "<", "<=", "="]).default("~>"),
quote_style: z.enum(["single", "double"]).optional(),
file_path: z.string().min(1, "File path cannot be empty").max(500, "File path too long").default("Gemfile"),
project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional()
});
var GemUnpinSchema = z.object({
gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"),
quote_style: z.enum(["single", "double"]).optional(),
file_path: z.string().min(1, "File path cannot be empty").max(500, "File path too long").default("Gemfile"),
project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional()
});
var GemAddToGemfileSchema = z.object({
gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"),
version: z.string().min(1, "Version cannot be empty").max(50, "Version too long").regex(
/^[0-9]+(?:\.[0-9]+)*(?:\.(?:pre|rc|alpha|beta)\d*)?$/,
"Invalid version format"
).optional(),
pin_type: z.enum(["~>", ">=", ">", "<", "<=", "="]).default("~>"),
group: z.array(z.string().min(1).max(50)).optional(),
source: z.string().min(1, "Source cannot be empty").max(500, "Source too long").optional(),
require: z.union([z.literal(false), z.string().min(1).max(100)]).optional(),
quote_style: z.enum(["single", "double"]).optional(),
file_path: z.string().min(1, "File path cannot be empty").max(500, "File path too long").default("Gemfile"),
project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional()
});
var GemAddToGemspecSchema = z.object({
gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"),
version: z.string().min(1, "Version cannot be empty").max(50, "Version too long").regex(
/^[0-9]+(?:\.[0-9]+)*(?:\.(?:pre|rc|alpha|beta)\d*)?$/,
"Invalid version format"
).optional(),
pin_type: z.enum(["~>", ">=", ">", "<", "<=", "="]).default("~>"),
dependency_type: z.enum(["runtime", "development"]).default("runtime"),
quote_style: z.enum(["single", "double"]).optional(),
file_path: z.string().min(1, "File path cannot be empty").max(500, "File path too long"),
project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional()
});
var BundleInstallSchema = z.object({
project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional(),
deployment: z.boolean().optional().default(false),
without: z.array(z.string().min(1).max(50)).optional().describe("Groups to exclude during installation"),
gemfile: z.string().min(1, "Gemfile path cannot be empty").max(500, "Gemfile path too long").optional(),
clean: z.boolean().optional().default(false),
frozen: z.boolean().optional().default(false),
quiet: z.boolean().optional().default(false)
});
var BundleCheckSchema = z.object({
project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional(),
gemfile: z.string().min(1, "Gemfile path cannot be empty").max(500, "Gemfile path too long").optional()
});
var BundleShowSchema = z.object({
gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format").optional(),
project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional(),
paths: z.boolean().optional().default(false),
outdated: z.boolean().optional().default(false)
});
var BundleAuditSchema = z.object({
project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional(),
update: z.boolean().optional().default(false),
verbose: z.boolean().optional().default(false),
format: z.enum(["text", "json"]).optional().default("text"),
gemfile_lock: z.string().min(1, "Gemfile.lock path cannot be empty").max(500, "Gemfile.lock path too long").optional()
});
var BundleCleanSchema = z.object({
project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional(),
dry_run: z.boolean().optional().default(false),
force: z.boolean().optional().default(false)
});
var GemDependencySchema = z.object({
name: z.string(),
requirements: z.string()
});
var GemVersionResponseSchema = z.object({
authors: z.string().optional(),
built_at: z.string(),
created_at: z.string(),
description: z.string().optional(),
downloads_count: z.number(),
metadata: z.record(z.string()),
number: z.string(),
summary: z.string().optional(),
platform: z.string(),
ruby_version: z.string().optional(),
rubygems_version: z.string().optional(),
prerelease: z.boolean(),
licenses: z.array(z.string()).optional(),
requirements: z.array(z.string()).optional(),
sha: z.string().optional()
});
var GemDetailsResponseSchema = z.object({
name: z.string(),
downloads: z.number(),
version: z.string(),
version_created_at: z.string(),
version_downloads: z.number(),
platform: z.string(),
authors: z.string().optional(),
info: z.string().optional(),
licenses: z.array(z.string()).optional(),
metadata: z.record(z.string()),
yanked: z.boolean(),
sha: z.string().optional(),
project_uri: z.string(),
gem_uri: z.string(),
homepage_uri: z.string().optional(),
wiki_uri: z.string().optional(),
documentation_uri: z.string().optional(),
mailing_list_uri: z.string().optional(),
source_code_uri: z.string().optional(),
bug_tracker_uri: z.string().optional(),
changelog_uri: z.string().optional(),
funding_uri: z.string().optional(),
dependencies: z.object({
development: z.array(GemDependencySchema),
runtime: z.array(GemDependencySchema)
})
});
var GemSearchResultSchema = z.object({
name: z.string(),
downloads: z.number(),
version: z.string(),
version_created_at: z.string(),
version_downloads: z.number(),
platform: z.string(),
authors: z.string().optional(),
info: z.string().optional(),
licenses: z.array(z.string()).optional(),
metadata: z.record(z.string()),
yanked: z.boolean(),
sha: z.string().optional(),
project_uri: z.string(),
gem_uri: z.string(),
homepage_uri: z.string().optional(),
wiki_uri: z.string().optional(),
documentation_uri: z.string().optional(),
mailing_list_uri: z.string().optional(),
source_code_uri: z.string().optional(),
bug_tracker_uri: z.string().optional(),
changelog_uri: z.string().optional(),
funding_uri: z.string().optional()
});
var ReverseDependencySchema = z.object({
name: z.string()
});
var searchGemsInputSchema = {
type: "object",
properties: {
query: {
type: "string",
description: "Search query for gems (name or keywords)",
minLength: 1,
maxLength: 100
},
limit: {
type: "number",
description: "Maximum number of results to return (1-100)",
minimum: 1,
maximum: 100,
default: 10
}
},
required: ["query"],
additionalProperties: false
};
var gemDetailsInputSchema = {
type: "object",
properties: {
gem_name: {
type: "string",
description: "Name of the gem to get details for",
minLength: 1,
maxLength: 50,
pattern: "^[a-zA-Z0-9_-]+$"
}
},
required: ["gem_name"],
additionalProperties: false
};
var gemVersionsInputSchema = {
type: "object",
properties: {
gem_name: {
type: "string",
description: "Name of the gem to get versions for",
minLength: 1,
maxLength: 50,
pattern: "^[a-zA-Z0-9_-]+$"
},
include_prerelease: {
type: "boolean",
description: "Include prerelease versions in results",
default: false
}
},
required: ["gem_name"],
additionalProperties: false
};
var latestVersionInputSchema = {
type: "object",
properties: {
gem_name: {
type: "string",
description: "Name of the gem to get latest version for",
minLength: 1,
maxLength: 50,
pattern: "^[a-zA-Z0-9_-]+$"
},
include_prerelease: {
type: "boolean",
description: "Include prerelease versions when determining latest",
default: false
}
},
required: ["gem_name"],
additionalProperties: false
};
var gemDependenciesInputSchema = {
type: "object",
properties: {
gem_name: {
type: "string",
description: "Name of the gem to get reverse dependencies for",
minLength: 1,
maxLength: 50,
pattern: "^[a-zA-Z0-9_-]+$"
}
},
required: ["gem_name"],
additionalProperties: false
};
var changelogInputSchema = {
type: "object",
properties: {
gem_name: {
type: "string",
description: "Name of the gem to fetch changelog for",
minLength: 1,
maxLength: 50,
pattern: "^[a-zA-Z0-9_-]+$"
},
version: {
type: "string",
description: "Specific version to fetch changelog for (optional)",
minLength: 1,
maxLength: 50,
pattern: "^[0-9]+(?:\\.[0-9]+)*(?:\\.(?:pre|rc|alpha|beta)\\d*)?$"
}
},
required: ["gem_name"],
additionalProperties: false
};
var gemfileParserInputSchema = {
type: "object",
properties: {
file_path: {
type: "string",
description: "Path to the Gemfile or .gemspec file to parse",
minLength: 1,
maxLength: 500
},
project: {
type: "string",
description: "Optional project name to resolve file path within",
minLength: 1,
maxLength: 100
}
},
required: ["file_path"],
additionalProperties: false
};
var gemPinInputSchema = {
type: "object",
properties: {
gem_name: {
type: "string",
description: "Name of the gem to pin",
minLength: 1,
maxLength: 50,
pattern: "^[a-zA-Z0-9_-]+$"
},
version: {
type: "string",
description: "Version to pin the gem to",
minLength: 1,
maxLength: 50,
pattern: "^[0-9]+(?:\\.[0-9]+)*(?:\\.(?:pre|rc|alpha|beta)\\d*)?$"
},
pin_type: {
type: "string",
description: "Type of version pinning (~>, >=, >, <, <=, =)",
enum: ["~>", ">=", ">", "<", "<=", "="],
default: "~>"
},
quote_style: {
type: "string",
description: "Quote style to use for gem declaration (single or double)",
enum: ["single", "double"]
},
file_path: {
type: "string",
description: "Path to the Gemfile to modify",
minLength: 1,
maxLength: 500,
default: "Gemfile"
},
project: {
type: "string",
description: "Optional project name to resolve file path within",
minLength: 1,
maxLength: 100
}
},
required: ["gem_name", "version"],
additionalProperties: false
};
var gemUnpinInputSchema = {
type: "object",
properties: {
gem_name: {
type: "string",
description: "Name of the gem to unpin (remove version constraints)",
minLength: 1,
maxLength: 50,
pattern: "^[a-zA-Z0-9_-]+$"
},
quote_style: {
type: "string",
description: "Quote style to use for gem declaration (single or double)",
enum: ["single", "double"]
},
file_path: {
type: "string",
description: "Path to the Gemfile to modify",
minLength: 1,
maxLength: 500,
default: "Gemfile"
},
project: {
type: "string",
description: "Optional project name to resolve file path within",
minLength: 1,
maxLength: 100
}
},
required: ["gem_name"],
additionalProperties: false
};
var gemAddToGemfileInputSchema = {
type: "object",
properties: {
gem_name: {
type: "string",
description: "Name of the gem to add",
minLength: 1,
maxLength: 50,
pattern: "^[a-zA-Z0-9_-]+$"
},
version: {
type: "string",
description: "Version to constrain the gem to",
minLength: 1,
maxLength: 50,
pattern: "^[0-9]+(?:\\.[0-9]+)*(?:\\.(?:pre|rc|alpha|beta)\\d*)?$"
},
pin_type: {
type: "string",
description: "Type of version constraint (~>, >=, >, <, <=, =)",
enum: ["~>", ">=", ">", "<", "<=", "="],
default: "~>"
},
group: {
type: "array",
description: "Groups to add the gem to (e.g., development, test)",
items: {
type: "string",
minLength: 1,
maxLength: 50
}
},
source: {
type: "string",
description: "Alternative source for the gem (git URL, path, or custom source)",
minLength: 1,
maxLength: 500
},
require: {
oneOf: [
{
type: "boolean",
description: "Set to false to not require the gem on load"
},
{
type: "string",
description: "Custom require path for the gem",
minLength: 1,
maxLength: 100
}
],
description: "Require option for the gem (false or custom path)"
},
quote_style: {
type: "string",
description: "Quote style to use for gem declaration (single or double)",
enum: ["single", "double"]
},
file_path: {
type: "string",
description: "Path to the Gemfile to modify",
minLength: 1,
maxLength: 500,
default: "Gemfile"
},
project: {
type: "string",
description: "Optional project name to resolve file path within",
minLength: 1,
maxLength: 100
}
},
required: ["gem_name"],
additionalProperties: false
};
var gemAddToGemspecInputSchema = {
type: "object",
properties: {
gem_name: {
type: "string",
description: "Name of the gem to add as dependency",
minLength: 1,
maxLength: 50,
pattern: "^[a-zA-Z0-9_-]+$"
},
version: {
type: "string",
description: "Version constraint for the dependency",
minLength: 1,
maxLength: 50,
pattern: "^[0-9]+(?:\\.[0-9]+)*(?:\\.(?:pre|rc|alpha|beta)\\d*)?$"
},
pin_type: {
type: "string",
description: "Type of version constraint (~>, >=, >, <, <=, =)",
enum: ["~>", ">=", ">", "<", "<=", "="],
default: "~>"
},
dependency_type: {
type: "string",
description: "Type of dependency (runtime or development)",
enum: ["runtime", "development"],
default: "runtime"
},
quote_style: {
type: "string",
description: "Quote style to use for dependency declaration (single or double)",
enum: ["single", "double"]
},
file_path: {
type: "string",
description: "Path to the .gemspec file to modify",
minLength: 1,
maxLength: 500
},
project: {
type: "string",
description: "Optional project name to resolve file path within",
minLength: 1,
maxLength: 100
}
},
required: ["gem_name", "file_path"],
additionalProperties: false
};
var bundleInstallInputSchema = {
type: "object",
properties: {
project: {
type: "string",
description: "Optional project name to run bundle install within",
minLength: 1,
maxLength: 100
},
deployment: {
type: "boolean",
description: "Install gems in deployment mode (production install)",
default: false
},
without: {
type: "array",
description: "Groups to exclude during installation (e.g., development, test)",
items: {
type: "string",
minLength: 1,
maxLength: 50
}
},
gemfile: {
type: "string",
description: "Path to specific Gemfile to use (relative to project)",
minLength: 1,
maxLength: 500
},
clean: {
type: "boolean",
description: "Clean up old gems after installation",
default: false
},
frozen: {
type: "boolean",
description: "Do not allow Gemfile.lock to be updated",
default: false
},
quiet: {
type: "boolean",
description: "Suppress output during installation",
default: false
}
},
required: [],
additionalProperties: false
};
var bundleCheckInputSchema = {
type: "object",
properties: {
project: {
type: "string",
description: "Optional project name to run bundle check within",
minLength: 1,
maxLength: 100
},
gemfile: {
type: "string",
description: "Path to specific Gemfile to use (relative to project)",
minLength: 1,
maxLength: 500
}
},
required: [],
additionalProperties: false
};
var bundleShowInputSchema = {
type: "object",
properties: {
gem_name: {
type: "string",
description: "Name of gem to show (omit to show all gems)",
minLength: 1,
maxLength: 50,
pattern: "^[a-zA-Z0-9_-]+$"
},
project: {
type: "string",
description: "Optional project name to run bundle show within",
minLength: 1,
maxLength: 100
},
paths: {
type: "boolean",
description: "Show gem installation paths",
default: false
},
outdated: {
type: "boolean",
description: "Show outdated gems only",
default: false
}
},
required: [],
additionalProperties: false
};
var bundleAuditInputSchema = {
type: "object",
properties: {
project: {
type: "string",
description: "Optional project name to run bundle audit within",
minLength: 1,
maxLength: 100
},
update: {
type: "boolean",
description: "Update vulnerability database before auditing",
default: false
},
verbose: {
type: "boolean",
description: "Show verbose output",
default: false
},
format: {
type: "string",
description: "Output format for audit results",
enum: ["text", "json"],
default: "text"
},
gemfile_lock: {
type: "string",
description: "Path to specific Gemfile.lock to audit (relative to project)",
minLength: 1,
maxLength: 500
}
},
required: [],
additionalProperties: false
};
var bundleCleanInputSchema = {
type: "object",
properties: {
project: {
type: "string",
description: "Optional project name to run bundle clean within",
minLength: 1,
maxLength: 100
},
dry_run: {
type: "boolean",
description: "Show what would be cleaned without actually cleaning",
default: false
},
force: {
type: "boolean",
description: "Force clean even if bundle is not frozen",
default: false
}
},
required: [],
additionalProperties: false
};
// src/tools/add.ts
import { promises as fs2 } from "fs";
// src/utils/quotes.ts
var DEFAULT_QUOTE_CONFIG = {
gemfile: "single",
gemspec: "double"
};
function getQuoteChar(style) {
return style === "single" ? "'" : '"';
}
function formatGemDeclaration(gemName, options) {
const quote = getQuoteChar(options.quoteStyle);
let declaration = `gem ${quote}${gemName}${quote}`;
if (options.version && options.pinType) {
declaration += `, ${quote}${options.pinType} ${options.version}${quote}`;
}
if (options.source) {
if (options.source.startsWith("http") || options.source.startsWith("git")) {
declaration += `, git: ${quote}${options.source}${quote}`;
} else if (options.source.startsWith("/") || options.source.startsWith("./") || options.source.startsWith("../")) {
declaration += `, path: ${quote}${options.source}${quote}`;
} else {
declaration += `, source: ${quote}${options.source}${quote}`;
}
}
if (options.require !== void 0) {
if (options.require === false) {
declaration += ", require: false";
} else {
declaration += `, require: ${quote}${options.require}${quote}`;
}
}
return declaration;
}
function formatDependencyDeclaration(gemName, options) {
const quote = getQuoteChar(options.quoteStyle);
const methodName = options.dependencyType === "development" ? "add_development_dependency" : "add_dependency";
let declaration = ` spec.${methodName} ${quote}${gemName}${quote}`;
if (options.version && options.pinType) {
declaration += `, ${quote}${options.pinType} ${options.version}${quote}`;
}
return declaration;
}
function parseQuoteStyle(value) {
const normalized = value.toLowerCase().trim();
if (normalized === "single" || normalized === "'") {
return "single";
}
if (normalized === "double" || normalized === '"') {
return "double";
}
throw new Error(
`Invalid quote style: ${value}. Must be 'single' or 'double'`
);
}
function formatVersionRequirement(version, pinType, quoteStyle) {
const quote = getQuoteChar(quoteStyle);
return `${quote}${pinType} ${version}${quote}`;
}
function detectQuoteStyle(gemLine) {
const match = gemLine.match(/gem\s+(['"])/);
if (match) {
return match[1] === "'" ? "single" : "double";
}
return "single";
}
// src/utils/validation.ts
import { ZodError } from "zod";
function validateInput(schema, data) {
try {
const validated = schema.parse(data);
return {
success: true,
data: validated
};
} catch (error) {
if (error instanceof ZodError) {
const issues = error.issues.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join(".") : "root";
return `${path}: ${issue.message}`;
});
return {
success: false,
error: `Validation failed: ${issues[0]}`,
issues
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Unknown validation error"
};
}
}
// src/tools/add.ts
var GemAddTool = class {
constructor(options) {
this.projectManager = options?.projectManager;
this.quoteConfig = options?.quoteConfig;
}
async executeAddToGemfile(args) {
const validation = validateInput(GemAddToGemfileSchema, args);
if (!validation.success) {
return {
content: [
{
type: "text",
text: `Error: ${validation.error}`
}
],
isError: true
};
}
const {
gem_name,
version,
pin_type,
group,
source,
require: requireOption,
quote_style,
file_path,
project
} = validation.data;
let resolvedFilePath;
try {
resolvedFilePath = this.projectManager ? this.projectManager.resolveFilePath(file_path, project) : file_path;
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`
}
],
isError: true
};
}
try {
await fs2.access(resolvedFilePath, fs2.constants.R_OK | fs2.constants.W_OK);
const fileStats = await fs2.stat(resolvedFilePath);
if (!fileStats.isFile()) {
return {
content: [
{
type: "text",
text: `Error: ${resolvedFilePath} is not a file`
}
],
isError: true
};
}
const content = await fs2.readFile(resolvedFilePath, "utf-8");
const lines = content.split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith("#") || !trimmedLine) continue;
const gemMatch = line.match(/gem\s+['"]([^'"]+)['"]/);
if (gemMatch && gemMatch[1] === gem_name) {
return {
content: [
{
type: "text",
text: `Gem '${gem_name}' already exists in ${resolvedFilePath}`
}
],
isError: true
};
}
}
const effectiveQuoteStyle = quote_style || this.quoteConfig?.gemfile || "single";
const gemDeclaration = formatGemDeclaration(gem_name, {
version,
pinType: pin_type,
source,
require: requireOption,
quoteStyle: effectiveQuoteStyle
});
if (group && group.length > 0) {
const groupNames = group.join(", :");
const groupPattern = new RegExp(
`^\\s*group\\s+:${groupNames.replace(", :", ",\\s*:")}\\s+do\\s*$`
);
let groupStartIndex = -1;
let groupEndIndex = -1;
let indentLevel = "";
for (let i = 0; i < lines.length; i++) {
if (groupPattern.test(lines[i])) {
groupStartIndex = i;
indentLevel = lines[i].match(/^(\s*)/)?.[1] || "";
let blockLevel = 1;
for (let j = i + 1; j < lines.length; j++) {
if (/^\s*group\s+.*do\s*$/.test(lines[j])) {
blockLevel++;
} else if (/^\s*end\s*$/.test(lines[j])) {
blockLevel--;
if (blockLevel === 0) {
groupEndIndex = j;
break;
}
}
}
break;
}
}
if (groupStartIndex !== -1 && groupEndIndex !== -1) {
lines.splice(groupEndIndex, 0, `${indentLevel} ${gemDeclaration}`);
} else {
const newGroup = [
"",
`group :${groupNames} do`,
` ${gemDeclaration}`,
"end"
];
lines.push(...newGroup);
}
} else {
let insertIndex = lines.length;
while (insertIndex > 0 && lines[insertIndex - 1].trim() === "") {
insertIndex--;
}
lines.splice(insertIndex, 0, gemDeclaration);
}
await fs2.writeFile(resolvedFilePath, lines.join("\n"), "utf-8");
const groupInfo = group && group.length > 0 ? ` in group [:${group.join(", :")}]` : "";
const versionInfo = version ? ` with version '${pin_type} ${version}'` : "";
return {
content: [
{
type: "text",
text: `Successfully added '${gem_name}'${versionInfo}${groupInfo} to ${resolvedFilePath}`
}
]
};
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("ENOENT")) {
return {
content: [
{
type: "text",
text: `Error: File not found: ${resolvedFilePath}`
}
],
isError: true
};
}
if (error.message.includes("EACCES")) {
return {
content: [
{
type: "text",
text: `Error: Permission denied accessing file: ${resolvedFilePath}`
}
],
isError: true
};
}
}
return {
content: [
{
type: "text",
text: `Unexpected error adding gem: ${error instanceof Error ? error.message : "Unknown error"}`
}
],
isError: true
};
}
}
async executeAddToGemspec(args) {
const validation = validateInput(GemAddToGemspecSchema, args);
if (!validation.success) {
return {
content: [
{
type: "text",
text: `Error: ${validation.error}`