diff --git a/README.md b/README.md index 715ef16..c099e6e 100644 --- a/README.md +++ b/README.md @@ -69,14 +69,14 @@ Register the MCP server with your MCP client: ### For NPX Usage (Recommended) -Add this configuration to your Claude Desktop config file: +Add this configuration to your Claude Desktop config file. The `--target-model gemini` flag is recommended to ensure compatibility with Gemini's stricter tool schema. ```json { "mcpServers": { "gemini-cli": { "command": "npx", - "args": ["-y", "gemini-mcp-tool"] + "args": ["-y", "gemini-mcp-tool", "--", "--target-model", "gemini"] } } } @@ -90,7 +90,8 @@ If you installed globally, use this configuration instead: { "mcpServers": { "gemini-cli": { - "command": "gemini-mcp" + "command": "gemini-mcp", + "args": ["--target-model", "gemini"] } } } @@ -105,6 +106,32 @@ If you installed globally, use this configuration instead: After updating the configuration, restart your terminal session. +## Model Compatibility + +Different Large Language Models can have different requirements for how tool schemas are defined. This server can adapt its tool schemas to ensure compatibility with the target model you are using. + +This is controlled by a startup flag or an environment variable. + +### Command-Line Flag + +The recommended way to enable compatibility mode is by passing the `--target-model` flag when starting the server. + +```bash +# Example for Gemini +npx gemini-mcp-tool --target-model gemini +``` + +### Environment Variable + +Alternatively, you can set the `MCP_TARGET_MODEL` environment variable. + +```bash +export MCP_TARGET_MODEL=gemini +npx gemini-mcp-tool +``` + +Currently, the only special target is `gemini`. If the flag is omitted, it will use a `default` mode with more expressive schemas. + ## Example Workflow - **Natural language**: "use gemini to explain index.html", "understand the massive project using gemini", "ask gemini to search for latest news" diff --git a/docs/concepts/schema-compatibility.md b/docs/concepts/schema-compatibility.md new file mode 100644 index 0000000..ddc9d03 --- /dev/null +++ b/docs/concepts/schema-compatibility.md @@ -0,0 +1,97 @@ +# Schema Compatibility + +## The Problem: Divergent Tool Schemas + +Different Large Language Models can have different levels of strictness when it comes to validating the schemas of the tools they are provided. For example, Google Gemini's API enforces a stricter subset of the JSON Schema specification than other models might. + +A common issue is the use of complex schema types like `anyOf` (which is what `zod` produces for a `z.union`). While valid JSON Schema, Gemini's API may reject it, causing `400 Bad Request` errors and preventing the tool from being called. + +A simple solution would be to make all schemas conform to the strictest possible requirements, but this would reduce the expressiveness and validation power of our internal schemas for models that *do* support them. + +## The Solution: A Strategy-Based Approach + +To solve this, we've implemented a **strategy-based pattern** that allows the server to dynamically select the appropriate schema for a tool parameter based on a startup configuration. This gives us the best of both worlds: strict, compatible schemas for models that need them, and rich, expressive schemas for those that don't. + +The system is composed of two parts: + +### 1. Startup Configuration + +The server's "mode" is determined once at startup by the `--target-model` command-line flag or the `MCP_TARGET_MODEL` environment variable. This is managed in `@/src/utils/config.ts`. + +```bash +# Start the server in Gemini compatibility mode +npx gemini-mcp-tool --target-model gemini +``` + +If no target is specified, it defaults to `'default'`. + +### 2. Schema Strategies + +The core of the solution is in `@/src/utils/schema-strategies.ts`. This file contains functions (strategies) that are responsible for choosing which Zod schema to use based on the `config.target`. + +This approach keeps the conditional logic centralized and allows our tool definitions to remain clean and declarative. + +## Developer Guide: Adding a Compatible Parameter + +If you are adding a new tool parameter that requires a different schema for a specific target (like Gemini), follow this pattern. We will use the `chunkIndex` parameter in the `ask-gemini` tool as our example. + +### Step 1: Define Both Schema Variations + +In your tool definition file (e.g., `@/src/tools/ask-gemini.tool.ts`), define both the standard and the model-specific schemas as named constants. + +The `gemini` version should be the simplest possible schema that the API will accept. It's also a good practice to include a `z.preprocess` step to gracefully handle any data that might still come in the "standard" format (e.g., a number) before validation. + +```typescript +// @/src/tools/ask-gemini.tool.ts + +// The standard, expressive schema for most models +const standardChunkIndexSchema = z.union([z.number(), z.string()]).optional().describe("Which chunk to return (1-based)"); + +// The simplified schema for Gemini's stricter API +const geminiChunkIndexSchema = z.preprocess( + (val) => (val === undefined || val === null ? val : String(val)), + z.string().optional() +).describe("Which chunk to return (1-based)"); +``` + +### Step 2: Create or Update a Strategy + +In `@/src/utils/schema-strategies.ts`, ensure there is a strategy function that can select the correct schema. If you're adding a parameter with a new compatibility need, you may need to add a new strategy function. + +Our existing strategy for `chunkIndex` looks like this: + +```typescript +// @/src/utils/schema-strategies.ts + +export const selectChunkIndexSchema = (schemas: { + standard: ZodTypeAny; + gemini: ZodTypeAny; +}): ZodTypeAny => { + if (config.target === 'gemini') { + return schemas.gemini; + } + return schemas.standard; +}; +``` + +### Step 3: Use the Strategy in Your Tool Definition + +Finally, in your tool's main Zod schema, import and use the strategy function. Pass your previously defined schemas to it. This makes your definition declarative and clean. + +```typescript +// @/src/tools/ask-gemini.tool.ts +import { selectChunkIndexSchema } from '../utils/schema-strategies.js'; + +// ... (schemas defined above) ... + +const askGeminiArgsSchema = z.object({ + // ... other parameters + chunkIndex: selectChunkIndexSchema({ + standard: standardChunkIndexSchema, + gemini: geminiChunkIndexSchema, + }), + // ... other parameters +}); +``` + +By following this pattern, you can easily support multiple model targets without cluttering your tool definitions with conditional logic. \ No newline at end of file diff --git a/docs/installation.md b/docs/installation.md index ef17c46..014b5ac 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -17,7 +17,7 @@ No installation needed - runs directly: "mcpServers": { "gemini-cli": { "command": "npx", - "args": ["-y", "gemini-mcp-tool"] + "args": ["-y", "gemini-mcp-tool", "--", "--target-model", "gemini"] } } } @@ -30,16 +30,55 @@ claude mcp add gemini-cli -- npx -y gemini-mcp-tool ``` Then configure: + ```json { "mcpServers": { "gemini-cli": { - "command": "gemini-mcp" + "command": "gemini-mcp", + "args": ["--target-model", "gemini"] } } } ``` +## Server Configuration + +You can configure the server's behavior using command-line arguments or environment variables. + +### Command-Line Arguments + +Arguments are passed after the main command. When using `npx`, you must add `--` before the arguments. + +- `--target-model `: Sets the compatibility mode for the tool schemas. This is crucial for ensuring tools work correctly with specific models. + - **Example**: + ```bash + npx gemini-mcp-tool --target-model gemini + ``` + - **Default**: `default` + +**Example `args` in `mcp-config.json`:** +```json +{ + "args": ["-y", "gemini-mcp-tool", "--", "--target-model", "gemini"] +} +``` + +### Environment Variables + +You can also use environment variables to configure the server. + +- `MCP_TARGET_MODEL=`: Same function as the `--target-model` flag. + - **Example**: + ```bash + export MCP_TARGET_MODEL=gemini + npx gemini-mcp-tool + ``` + +::: tip +Command-line arguments take precedence over environment variables. +::: + ## Method 3: Local Project ```bash diff --git a/src/index.ts b/src/index.ts index 46c6118..1b0025d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,9 @@ #!/usr/bin/env node +// Initialize the application configuration at startup. +// This must be one of the first imports to ensure the configuration is available to all other modules. +import "./utils/config.js"; + import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { diff --git a/src/tools/ask-gemini.tool.ts b/src/tools/ask-gemini.tool.ts index bc76b3a..507ce8c 100644 --- a/src/tools/ask-gemini.tool.ts +++ b/src/tools/ask-gemini.tool.ts @@ -1,17 +1,24 @@ import { z } from 'zod'; import { UnifiedTool } from './registry.js'; import { executeGeminiCLI, processChangeModeOutput } from '../utils/geminiExecutor.js'; -import { - ERROR_MESSAGES, +import { + ERROR_MESSAGES, STATUS_MESSAGES } from '../constants.js'; +import { selectChunkIndexSchema } from '../utils/schema-strategies.js'; const askGeminiArgsSchema = z.object({ prompt: z.string().min(1).describe("Analysis request. Use @ syntax to include files (e.g., '@largefile.js explain what this does') or ask general questions"), model: z.string().optional().describe("Optional model to use (e.g., 'gemini-2.5-flash'). If not specified, uses the default model (gemini-2.5-pro)."), sandbox: z.boolean().default(false).describe("Use sandbox mode (-s flag) to safely test code changes, execute scripts, or run potentially risky operations in an isolated environment"), changeMode: z.boolean().default(false).describe("Enable structured change mode - formats prompts to prevent tool errors and returns structured edit suggestions that Claude can apply directly"), - chunkIndex: z.union([z.number(), z.string()]).optional().describe("Which chunk to return (1-based)"), + chunkIndex: selectChunkIndexSchema({ + standard: z.union([z.number(), z.string()]).optional().describe("Which chunk to return (1-based)"), + gemini: z.preprocess( + (val) => (val === undefined || val === null ? val : String(val)), + z.string().optional() + ).describe("Which chunk to return (1-based)"), + }), chunkCacheKey: z.string().optional().describe("Optional cache key for continuation"), }); @@ -24,33 +31,47 @@ export const askGeminiTool: UnifiedTool = { }, category: 'gemini', execute: async (args, onProgress) => { - const { prompt, model, sandbox, changeMode, chunkIndex, chunkCacheKey } = args; if (!prompt?.trim()) { throw new Error(ERROR_MESSAGES.NO_PROMPT_PROVIDED); } - - if (changeMode && chunkIndex && chunkCacheKey) { + const { prompt, model, sandbox, changeMode, chunkIndex, chunkCacheKey } = args as z.infer; + if (!prompt.trim()) { + throw new Error(ERROR_MESSAGES.NO_PROMPT_PROVIDED); + } + + // Helper function to safely parse chunkIndex to a number. + // This is necessary because the model may provide a string or number, + // and internal logic requires a number. + const parseChunkIndex = (index: unknown): number | undefined => { + if (index === undefined || index === null) return undefined; + const num = parseInt(String(index), 10); + return isNaN(num) ? undefined : num; + }; + + const chunkIndexNum = parseChunkIndex(chunkIndex); + + if (changeMode && chunkIndexNum !== undefined && chunkCacheKey) { return processChangeModeOutput( '', // empty for cache... - chunkIndex as number, - chunkCacheKey as string, - prompt as string + chunkIndexNum, + chunkCacheKey, + prompt ); } - + const result = await executeGeminiCLI( - prompt as string, - model as string | undefined, + prompt, + model, !!sandbox, !!changeMode, onProgress ); - + if (changeMode) { return processChangeModeOutput( result, - args.chunkIndex as number | undefined, + chunkIndexNum, undefined, - prompt as string + prompt ); } return `${STATUS_MESSAGES.GEMINI_RESPONSE}\n${result}`; // changeMode false } -}; \ No newline at end of file +}; diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..fa7af71 --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,69 @@ +/** + * @module config + * Manages the server's runtime configuration, primarily for determining the + * target LLM environment. This allows the application to adapt its behavior, + * such as tool schema generation, based on the specific model it's serving. + * + * The configuration is determined once at startup by checking command-line + * arguments and environment variables, then exported as a read-only object. + */ +import { Logger } from './logger.js'; + +/** + * An immutable, frozen object containing the server's runtime configuration. + */ +export interface AppConfig { + /** The target model environment, used for feature switching like schema generation. */ + readonly target: string; +} + +/** + * Initializes the application configuration by reading from the environment. + * This function should only be executed once when the application starts. + * + * The configuration is resolved with the following precedence: + * 1. Command-line argument: `--target-model ` + * 2. Environment variable: `MCP_TARGET_MODEL=` + * 3. Default value (`'default'`) + * + * @returns A frozen `AppConfig` object. + */ +function initializeConfig(): AppConfig { + let target = 'default'; + let detectedVia = ''; + + // 1. Check for the command-line argument + const argIndex = process.argv.indexOf('--target-model'); + if (argIndex > -1 && process.argv.length > argIndex + 1) { + target = process.argv[argIndex + 1].toLowerCase(); + detectedVia = 'command-line argument'; + } + // 2. Check for the environment variable if not already set by arg + else if (process.env.MCP_TARGET_MODEL) { + target = process.env.MCP_TARGET_MODEL.toLowerCase(); + detectedVia = 'environment variable'; + } + + if (target !== 'default') { + Logger.debug(`Target model set to "${target}" via ${detectedVia}.`); + } + + // Return a frozen, read-only configuration object + return Object.freeze({ target }); +} + +/** + * The single, application-wide configuration instance. + * Initialized once at startup. + */ +export const config: AppConfig = initializeConfig(); + +/** + * A utility function to quickly check if the Gemini compatibility mode is active. + * This is the preferred way to check for the target in application logic. + * + * @returns `true` if the target model is Gemini, otherwise `false`. + */ +export function isGeminiTarget(): boolean { + return config.target === 'gemini'; +} \ No newline at end of file diff --git a/src/utils/schema-strategies.ts b/src/utils/schema-strategies.ts new file mode 100644 index 0000000..7286ed1 --- /dev/null +++ b/src/utils/schema-strategies.ts @@ -0,0 +1,29 @@ +/** + * @module schema-strategies + * Provides a strategy pattern implementation for selecting the appropriate Zod schema + * based on the application's runtime configuration. This allows tool definitions + * to remain declarative while supporting multiple model-specific schema requirements. + */ + +import { ZodTypeAny } from 'zod'; +import { isGeminiTarget } from './config.js'; + +/** + * A schema strategy that selects the appropriate schema for the `chunkIndex` parameter + * based on the current model target configuration. + * + * For the 'gemini' target, it returns the `gemini` schema. For all other targets, + * it returns the `standard` schema. + * + * @param schemas An object containing the `standard` and `gemini` Zod schemas. + * @returns The Zod schema chosen based on the active configuration. + */ +export const selectChunkIndexSchema = (schemas: { + standard: ZodTypeAny; + gemini: ZodTypeAny; +}): ZodTypeAny => { + if (isGeminiTarget()) { + return schemas.gemini; + } + return schemas.standard; +}; \ No newline at end of file