This is page 2 of 8. Use http://codebase.md/bsmi021/mcp-gemini-server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .env.example
├── .eslintignore
├── .eslintrc.json
├── .gitignore
├── .prettierrc.json
├── Dockerfile
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── review-prompt.txt
├── scripts
│ ├── gemini-review.sh
│ └── run-with-health-check.sh
├── smithery.yaml
├── src
│ ├── config
│ │ └── ConfigurationManager.ts
│ ├── createServer.ts
│ ├── index.ts
│ ├── resources
│ │ └── system-prompt.md
│ ├── server.ts
│ ├── services
│ │ ├── ExampleService.ts
│ │ ├── gemini
│ │ │ ├── GeminiCacheService.ts
│ │ │ ├── GeminiChatService.ts
│ │ │ ├── GeminiContentService.ts
│ │ │ ├── GeminiGitDiffService.ts
│ │ │ ├── GeminiPromptTemplates.ts
│ │ │ ├── GeminiTypes.ts
│ │ │ ├── GeminiUrlContextService.ts
│ │ │ ├── GeminiValidationSchemas.ts
│ │ │ ├── GitHubApiService.ts
│ │ │ ├── GitHubUrlParser.ts
│ │ │ └── ModelMigrationService.ts
│ │ ├── GeminiService.ts
│ │ ├── index.ts
│ │ ├── mcp
│ │ │ ├── index.ts
│ │ │ └── McpClientService.ts
│ │ ├── ModelSelectionService.ts
│ │ ├── session
│ │ │ ├── index.ts
│ │ │ ├── InMemorySessionStore.ts
│ │ │ ├── SessionStore.ts
│ │ │ └── SQLiteSessionStore.ts
│ │ └── SessionService.ts
│ ├── tools
│ │ ├── exampleToolParams.ts
│ │ ├── geminiCacheParams.ts
│ │ ├── geminiCacheTool.ts
│ │ ├── geminiChatParams.ts
│ │ ├── geminiChatTool.ts
│ │ ├── geminiCodeReviewParams.ts
│ │ ├── geminiCodeReviewTool.ts
│ │ ├── geminiGenerateContentConsolidatedParams.ts
│ │ ├── geminiGenerateContentConsolidatedTool.ts
│ │ ├── geminiGenerateImageParams.ts
│ │ ├── geminiGenerateImageTool.ts
│ │ ├── geminiGenericParamSchemas.ts
│ │ ├── geminiRouteMessageParams.ts
│ │ ├── geminiRouteMessageTool.ts
│ │ ├── geminiUrlAnalysisTool.ts
│ │ ├── index.ts
│ │ ├── mcpClientParams.ts
│ │ ├── mcpClientTool.ts
│ │ ├── registration
│ │ │ ├── index.ts
│ │ │ ├── registerAllTools.ts
│ │ │ ├── ToolAdapter.ts
│ │ │ └── ToolRegistry.ts
│ │ ├── schemas
│ │ │ ├── BaseToolSchema.ts
│ │ │ ├── CommonSchemas.ts
│ │ │ ├── index.ts
│ │ │ ├── ToolSchemas.ts
│ │ │ └── writeToFileParams.ts
│ │ └── writeToFileTool.ts
│ ├── types
│ │ ├── exampleServiceTypes.ts
│ │ ├── geminiServiceTypes.ts
│ │ ├── gitdiff-parser.d.ts
│ │ ├── googleGenAI.d.ts
│ │ ├── googleGenAITypes.ts
│ │ ├── index.ts
│ │ ├── micromatch.d.ts
│ │ ├── modelcontextprotocol-sdk.d.ts
│ │ ├── node-fetch.d.ts
│ │ └── serverTypes.ts
│ └── utils
│ ├── errors.ts
│ ├── filePathSecurity.ts
│ ├── FileSecurityService.ts
│ ├── geminiErrors.ts
│ ├── healthCheck.ts
│ ├── index.ts
│ ├── logger.ts
│ ├── RetryService.ts
│ ├── ToolError.ts
│ └── UrlSecurityService.ts
├── tests
│ ├── .env.test.example
│ ├── basic-router.test.vitest.ts
│ ├── e2e
│ │ ├── clients
│ │ │ └── mcp-test-client.ts
│ │ ├── README.md
│ │ └── streamableHttpTransport.test.vitest.ts
│ ├── integration
│ │ ├── dummyMcpServerSse.ts
│ │ ├── dummyMcpServerStdio.ts
│ │ ├── geminiRouterIntegration.test.vitest.ts
│ │ ├── mcpClientIntegration.test.vitest.ts
│ │ ├── multiModelIntegration.test.vitest.ts
│ │ └── urlContextIntegration.test.vitest.ts
│ ├── tsconfig.test.json
│ ├── unit
│ │ ├── config
│ │ │ └── ConfigurationManager.multimodel.test.vitest.ts
│ │ ├── server
│ │ │ └── transportLogic.test.vitest.ts
│ │ ├── services
│ │ │ ├── gemini
│ │ │ │ ├── GeminiChatService.test.vitest.ts
│ │ │ │ ├── GeminiGitDiffService.test.vitest.ts
│ │ │ │ ├── geminiImageGeneration.test.vitest.ts
│ │ │ │ ├── GeminiPromptTemplates.test.vitest.ts
│ │ │ │ ├── GeminiUrlContextService.test.vitest.ts
│ │ │ │ ├── GeminiValidationSchemas.test.vitest.ts
│ │ │ │ ├── GitHubApiService.test.vitest.ts
│ │ │ │ ├── GitHubUrlParser.test.vitest.ts
│ │ │ │ └── ThinkingBudget.test.vitest.ts
│ │ │ ├── mcp
│ │ │ │ └── McpClientService.test.vitest.ts
│ │ │ ├── ModelSelectionService.test.vitest.ts
│ │ │ └── session
│ │ │ └── SQLiteSessionStore.test.vitest.ts
│ │ ├── tools
│ │ │ ├── geminiCacheTool.test.vitest.ts
│ │ │ ├── geminiChatTool.test.vitest.ts
│ │ │ ├── geminiCodeReviewTool.test.vitest.ts
│ │ │ ├── geminiGenerateContentConsolidatedTool.test.vitest.ts
│ │ │ ├── geminiGenerateImageTool.test.vitest.ts
│ │ │ ├── geminiRouteMessageTool.test.vitest.ts
│ │ │ ├── mcpClientTool.test.vitest.ts
│ │ │ ├── mcpToolsTests.test.vitest.ts
│ │ │ └── schemas
│ │ │ ├── BaseToolSchema.test.vitest.ts
│ │ │ ├── ToolParamSchemas.test.vitest.ts
│ │ │ └── ToolSchemas.test.vitest.ts
│ │ └── utils
│ │ ├── errors.test.vitest.ts
│ │ ├── FileSecurityService.test.vitest.ts
│ │ ├── FileSecurityService.vitest.ts
│ │ ├── FileSecurityServiceBasics.test.vitest.ts
│ │ ├── healthCheck.test.vitest.ts
│ │ ├── RetryService.test.vitest.ts
│ │ └── UrlSecurityService.test.vitest.ts
│ └── utils
│ ├── assertions.ts
│ ├── debug-error.ts
│ ├── env-check.ts
│ ├── environment.ts
│ ├── error-helpers.ts
│ ├── express-mocks.ts
│ ├── integration-types.ts
│ ├── mock-types.ts
│ ├── test-fixtures.ts
│ ├── test-generators.ts
│ ├── test-setup.ts
│ └── vitest.d.ts
├── tsconfig.json
├── tsconfig.test.json
├── vitest-globals.d.ts
├── vitest.config.ts
└── vitest.setup.ts
```
# Files
--------------------------------------------------------------------------------
/tests/integration/dummyMcpServerStdio.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | // Import the MCP SDK server module
4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6 | import { z } from "zod";
7 |
8 | // Create a new MCP server
9 | const server = new McpServer({
10 | name: "dummy-mcp-server-stdio",
11 | version: "1.0.0",
12 | description: "A dummy MCP server for testing stdio transport",
13 | });
14 |
15 | // Register an echo tool
16 | server.tool(
17 | "echoTool",
18 | "A tool that echoes back the input message",
19 | {
20 | message: z.string().describe("The message to echo"),
21 | },
22 | async (args: unknown) => {
23 | const typedArgs = args as { message: string };
24 | return {
25 | content: [
26 | {
27 | type: "text",
28 | text: JSON.stringify(
29 | {
30 | message: typedArgs.message,
31 | timestamp: new Date().toISOString(),
32 | },
33 | null,
34 | 2
35 | ),
36 | },
37 | ],
38 | };
39 | }
40 | );
41 |
42 | // Register an add tool
43 | server.tool(
44 | "addTool",
45 | "A tool that adds two numbers",
46 | {
47 | a: z.number().describe("First number"),
48 | b: z.number().describe("Second number"),
49 | },
50 | async (args: unknown) => {
51 | const typedArgs = args as { a: number; b: number };
52 | const sum = typedArgs.a + typedArgs.b;
53 | return {
54 | content: [
55 | {
56 | type: "text",
57 | text: JSON.stringify(
58 | {
59 | sum,
60 | inputs: { a: typedArgs.a, b: typedArgs.b },
61 | },
62 | null,
63 | 2
64 | ),
65 | },
66 | ],
67 | };
68 | }
69 | );
70 |
71 | // Register a complex data tool that returns a nested JSON structure
72 | server.tool(
73 | "complexDataTool",
74 | "A tool that returns a complex JSON structure",
75 | {
76 | depth: z
77 | .number()
78 | .optional()
79 | .describe("Depth of nested objects to generate"),
80 | itemCount: z
81 | .number()
82 | .optional()
83 | .describe("Number of items to generate in arrays"),
84 | },
85 | async (args: unknown) => {
86 | const typedArgs = args as { depth?: number; itemCount?: number };
87 | const depth = typedArgs.depth || 3;
88 | const itemCount = typedArgs.itemCount || 2;
89 |
90 | // Generate a nested structure of specified depth
91 | function generateNestedData(currentDepth: number): any {
92 | if (currentDepth <= 0) {
93 | return { value: "leaf data" };
94 | }
95 |
96 | const result = {
97 | level: depth - currentDepth + 1,
98 | timestamp: new Date().toISOString(),
99 | items: [] as any[],
100 | };
101 |
102 | for (let i = 0; i < itemCount; i++) {
103 | result.items.push(generateNestedData(currentDepth - 1));
104 | }
105 |
106 | return result;
107 | }
108 |
109 | const data = generateNestedData(depth);
110 |
111 | return {
112 | content: [
113 | {
114 | type: "text",
115 | text: JSON.stringify(data, null, 2),
116 | },
117 | ],
118 | };
119 | }
120 | );
121 |
122 | // Connect a stdio transport
123 | async function startServer() {
124 | const transport = new StdioServerTransport();
125 | await server.connect(transport);
126 |
127 | // Log a message to stderr
128 | console.error("Dummy MCP Server (stdio) started");
129 | }
130 |
131 | startServer().catch(console.error);
132 |
```
--------------------------------------------------------------------------------
/src/tools/geminiGenerateImageParams.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import {
3 | ModelNameSchema,
4 | ModelPreferencesSchema,
5 | } from "./schemas/CommonSchemas.js";
6 |
7 | export const TOOL_NAME_GENERATE_IMAGE = "gemini_generate_image";
8 |
9 | // Tool Description
10 | export const TOOL_DESCRIPTION_GENERATE_IMAGE = `
11 | Generates images from text prompts using advanced AI models like Imagen 3.1 and Gemini.
12 | This tool takes a text prompt and optional parameters to control the image generation process.
13 | Returns one or more generated images as base64-encoded data with appropriate MIME types.
14 | Supports configurable resolutions, image counts, content safety settings, and style options.
15 | `;
16 |
17 | // Zod Schema for image resolution
18 | export const imageResolutionSchema = z
19 | .enum(["512x512", "1024x1024", "1536x1536"])
20 | .describe("The desired resolution of generated images.");
21 |
22 | // Style presets available for image generation
23 | export const stylePresetSchema = z
24 | .enum([
25 | "photographic",
26 | "digital-art",
27 | "cinematic",
28 | "anime",
29 | "3d-render",
30 | "oil-painting",
31 | "watercolor",
32 | "pixel-art",
33 | "sketch",
34 | "comic-book",
35 | "neon",
36 | "fantasy",
37 | ])
38 | .describe("Style preset to apply to the generated image.");
39 |
40 | // Reuse existing safety settings schema from common schemas
41 | import { SafetySettingSchema } from "./schemas/CommonSchemas.js";
42 |
43 | // Main parameters schema
44 | export const GEMINI_GENERATE_IMAGE_PARAMS = z.object({
45 | modelName: ModelNameSchema,
46 | prompt: z
47 | .string()
48 | .min(1)
49 | .max(1000)
50 | .describe(
51 | "Required. The text prompt describing the desired image for the model to generate."
52 | ),
53 | resolution: imageResolutionSchema
54 | .optional()
55 | .describe(
56 | "Optional. The desired resolution of the generated image(s). Defaults to '1024x1024' if not specified."
57 | ),
58 | numberOfImages: z
59 | .number()
60 | .int()
61 | .min(1)
62 | .max(8)
63 | .optional()
64 | .describe(
65 | "Optional. Number of images to generate (1-8). Defaults to 1 if not specified."
66 | ),
67 | safetySettings: z
68 | .array(SafetySettingSchema)
69 | .optional()
70 | .describe(
71 | "Optional. A list of safety settings to apply, overriding default model safety settings. Each setting specifies a harm category and a blocking threshold."
72 | ),
73 | negativePrompt: z
74 | .string()
75 | .max(1000)
76 | .optional()
77 | .describe(
78 | "Optional. Text description of features to avoid in the generated image(s)."
79 | ),
80 | stylePreset: stylePresetSchema
81 | .optional()
82 | .describe(
83 | "Optional. Visual style to apply to the generated image (e.g., 'photographic', 'anime')."
84 | ),
85 | seed: z
86 | .number()
87 | .int()
88 | .optional()
89 | .describe(
90 | "Optional. Seed value for reproducible generation. Use the same seed to get similar results."
91 | ),
92 | styleStrength: z
93 | .number()
94 | .min(0)
95 | .max(1)
96 | .optional()
97 | .describe(
98 | "Optional. The strength of the style preset (0.0-1.0). Higher values apply more style. Defaults to 0.5."
99 | ),
100 | modelPreferences: ModelPreferencesSchema,
101 | });
102 |
103 | // Type for parameter object using zod inference
104 | export type GeminiGenerateImageArgs = z.infer<
105 | typeof GEMINI_GENERATE_IMAGE_PARAMS
106 | >;
107 |
```
--------------------------------------------------------------------------------
/src/types/modelcontextprotocol-sdk.d.ts:
--------------------------------------------------------------------------------
```typescript
1 | declare module "@modelcontextprotocol/sdk" {
2 | export interface Tool {
3 | (
4 | req: import("express").Request,
5 | res: import("express").Response,
6 | services: Record<string, unknown>
7 | ): Promise<void>;
8 | }
9 | }
10 |
11 | declare module "@modelcontextprotocol/sdk/types.js" {
12 | export enum ErrorCode {
13 | InvalidParams = "INVALID_PARAMS",
14 | InvalidRequest = "INVALID_REQUEST",
15 | InternalError = "INTERNAL_ERROR",
16 | }
17 |
18 | export class McpError extends Error {
19 | constructor(code: ErrorCode, message: string, details?: unknown);
20 | code: ErrorCode;
21 | details?: unknown;
22 | }
23 |
24 | export interface CallToolResult {
25 | content: Array<
26 | | {
27 | type: "text";
28 | text: string;
29 | [x: string]: unknown;
30 | }
31 | | {
32 | type: "image";
33 | data: string;
34 | mimeType: string;
35 | [x: string]: unknown;
36 | }
37 | | {
38 | type: "resource";
39 | resource:
40 | | {
41 | text: string;
42 | uri: string;
43 | mimeType?: string;
44 | [x: string]: unknown;
45 | }
46 | | {
47 | uri: string;
48 | blob: string;
49 | mimeType?: string;
50 | [x: string]: unknown;
51 | };
52 | [x: string]: unknown;
53 | }
54 | >;
55 | isError?: boolean;
56 | [x: string]: unknown;
57 | }
58 | }
59 |
60 | declare module "@modelcontextprotocol/sdk/server/mcp.js" {
61 | export interface Transport {
62 | start(): Promise<void>;
63 | send(message: unknown): Promise<void>;
64 | close(): Promise<void>;
65 | }
66 |
67 | export class McpServer {
68 | constructor(options: {
69 | name: string;
70 | version: string;
71 | description: string;
72 | });
73 | connect(transport: Transport): Promise<void>;
74 | disconnect(): Promise<void>;
75 | registerTool(
76 | name: string,
77 | handler: (args: unknown) => Promise<unknown>,
78 | schema: unknown
79 | ): void;
80 |
81 | // Add the tool method that's being used in the codebase
82 | tool(
83 | name: string,
84 | description: string,
85 | params: unknown,
86 | handler: (args: unknown) => Promise<unknown>
87 | ): void;
88 | }
89 | }
90 |
91 | declare module "@modelcontextprotocol/sdk/server/stdio.js" {
92 | import { Transport } from "@modelcontextprotocol/sdk/server/mcp.js";
93 |
94 | export class StdioServerTransport implements Transport {
95 | constructor();
96 | start(): Promise<void>;
97 | send(message: unknown): Promise<void>;
98 | close(): Promise<void>;
99 | }
100 | }
101 |
102 | declare module "@modelcontextprotocol/sdk/server/streamableHttp.js" {
103 | import { Transport } from "@modelcontextprotocol/sdk/server/mcp.js";
104 | import type { Request, Response } from "express";
105 |
106 | export interface StreamableHTTPServerTransportOptions {
107 | sessionIdGenerator?: () => string;
108 | onsessioninitialized?: (sessionId: string) => void;
109 | }
110 |
111 | export class StreamableHTTPServerTransport implements Transport {
112 | constructor(options?: StreamableHTTPServerTransportOptions);
113 | start(): Promise<void>;
114 | send(message: unknown): Promise<void>;
115 | close(): Promise<void>;
116 |
117 | readonly sessionId?: string;
118 | onclose?: () => void;
119 |
120 | handleRequest(req: Request, res: Response, body?: unknown): Promise<void>;
121 | }
122 | }
123 |
```
--------------------------------------------------------------------------------
/src/tools/geminiGenerateImageTool.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GeminiService } from "../services/index.js";
2 | import { logger } from "../utils/index.js";
3 | import {
4 | TOOL_NAME_GENERATE_IMAGE,
5 | TOOL_DESCRIPTION_GENERATE_IMAGE,
6 | GEMINI_GENERATE_IMAGE_PARAMS,
7 | GeminiGenerateImageArgs,
8 | } from "./geminiGenerateImageParams.js";
9 | import { mapAnyErrorToMcpError } from "../utils/errors.js";
10 | import type { NewGeminiServiceToolObject } from "./registration/ToolAdapter.js";
11 | import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
12 | import type { HarmCategory, HarmBlockThreshold } from "@google/genai";
13 |
14 | /**
15 | * Handles Gemini image generation operations.
16 | * Generates images from text prompts using Google's image generation models.
17 | */
18 | export const geminiGenerateImageTool: NewGeminiServiceToolObject<
19 | GeminiGenerateImageArgs,
20 | CallToolResult
21 | > = {
22 | name: TOOL_NAME_GENERATE_IMAGE,
23 | description: TOOL_DESCRIPTION_GENERATE_IMAGE,
24 | inputSchema: GEMINI_GENERATE_IMAGE_PARAMS,
25 | execute: async (args: GeminiGenerateImageArgs, service: GeminiService) => {
26 | logger.debug(`Received ${TOOL_NAME_GENERATE_IMAGE} request:`, {
27 | model: args.modelName,
28 | resolution: args.resolution,
29 | numberOfImages: args.numberOfImages,
30 | }); // Avoid logging full prompt for privacy/security
31 |
32 | try {
33 | // Extract arguments and call the service
34 | const {
35 | modelName,
36 | prompt,
37 | resolution,
38 | numberOfImages,
39 | safetySettings,
40 | negativePrompt,
41 | stylePreset,
42 | seed,
43 | styleStrength,
44 | modelPreferences,
45 | } = args;
46 |
47 | // Convert safety settings from schema to SDK types if provided
48 | const convertedSafetySettings = safetySettings?.map((setting) => ({
49 | category: setting.category as HarmCategory,
50 | threshold: setting.threshold as HarmBlockThreshold,
51 | }));
52 |
53 | const result = await service.generateImage(
54 | prompt,
55 | modelName,
56 | resolution,
57 | numberOfImages,
58 | convertedSafetySettings,
59 | negativePrompt,
60 | stylePreset,
61 | seed,
62 | styleStrength,
63 | modelPreferences?.preferQuality,
64 | modelPreferences?.preferSpeed
65 | );
66 |
67 | // Check if images were generated
68 | if (!result.images || result.images.length === 0) {
69 | throw new Error("No images were generated");
70 | }
71 |
72 | // Format success output for MCP - provide both JSON and direct image formats
73 | // This allows clients to choose the most appropriate format for their needs
74 | return {
75 | content: [
76 | // Include a text description of the generated images
77 | {
78 | type: "text" as const,
79 | text: `Generated ${result.images.length} ${resolution || "1024x1024"} image(s) from prompt.`,
80 | },
81 | // Include the generated images as image content types
82 | ...result.images.map((img) => ({
83 | type: "image" as const,
84 | mimeType: img.mimeType,
85 | data: img.base64Data,
86 | })),
87 | ],
88 | };
89 | } catch (error: unknown) {
90 | logger.error(`Error processing ${TOOL_NAME_GENERATE_IMAGE}:`, error);
91 |
92 | // Use the centralized error mapping utility to ensure consistent error handling
93 | throw mapAnyErrorToMcpError(error, TOOL_NAME_GENERATE_IMAGE);
94 | }
95 | },
96 | };
97 |
```
--------------------------------------------------------------------------------
/tests/utils/mock-types.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Using vitest globals - see vitest.config.ts globals: true
2 | /**
3 | * Mock types for testing
4 | * This file contains type definitions for mocks used in tests
5 | */
6 | import type { Mock } from "vitest";
7 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8 | import { McpClientService } from "../../src/services/mcp/McpClientService.js";
9 |
10 | /**
11 | * Mock Event Source States for testing
12 | */
13 | export const EVENT_SOURCE_STATES = {
14 | CONNECTING: 0,
15 | OPEN: 1,
16 | CLOSED: 2,
17 | };
18 |
19 | /**
20 | * Mock Event for EventSource
21 | */
22 | export interface MockEvent {
23 | type: string;
24 | data?: string;
25 | message?: string;
26 | error?: Error;
27 | lastEventId?: string;
28 | origin?: string;
29 | bubbles?: boolean;
30 | cancelBubble?: boolean;
31 | cancelable?: boolean;
32 | composed?: boolean;
33 | }
34 |
35 | /**
36 | * Mock EventSource for testing
37 | */
38 | export class MockEventSource {
39 | url: string;
40 | readyState: number = EVENT_SOURCE_STATES.CONNECTING;
41 | onopen: ((event: MockEvent) => void) | null = null;
42 | onmessage: ((event: MockEvent) => void) | null = null;
43 | onerror: ((event: MockEvent) => void) | null = null;
44 |
45 | constructor(url: string, _options?: Record<string, unknown>) {
46 | this.url = url;
47 | }
48 |
49 | close(): void {
50 | this.readyState = EVENT_SOURCE_STATES.CLOSED;
51 | }
52 | }
53 |
54 | /**
55 | * Utility type for mocking a McpClientService
56 | */
57 | export type MockMcpClientService = {
58 | [K in keyof McpClientService]: McpClientService[K] extends (
59 | ...args: unknown[]
60 | ) => unknown
61 | ? Mock
62 | : McpClientService[K];
63 | };
64 |
65 | /**
66 | * Create a mock McpClientService
67 | */
68 | export function createMockMcpClientService(): MockMcpClientService {
69 | return {
70 | connect: vi.fn(),
71 | listTools: vi.fn(),
72 | callTool: vi.fn(),
73 | disconnect: vi.fn(),
74 | getActiveSseConnectionIds: vi.fn(),
75 | getActiveStdioConnectionIds: vi.fn(),
76 | getLastActivityTimestamp: vi.fn(),
77 | closeSseConnection: vi.fn(),
78 | closeStdioConnection: vi.fn(),
79 | closeAllConnections: vi.fn(),
80 | } as unknown as MockMcpClientService;
81 | }
82 |
83 | /**
84 | * Utility type for mocking a FileSecurityService
85 | */
86 | export type MockFileSecurityService = {
87 | allowedDirectories: string[];
88 | DEFAULT_SAFE_BASE_DIR: string;
89 | setSecureBasePath: Mock;
90 | getSecureBasePath: Mock;
91 | setAllowedDirectories: Mock;
92 | getAllowedDirectories: Mock;
93 | validateAndResolvePath: Mock;
94 | isPathWithinAllowedDirs: Mock;
95 | fullyResolvePath: Mock;
96 | secureWriteFile: Mock;
97 | };
98 |
99 | /**
100 | * Create a mock FileSecurityService
101 | */
102 | export function createMockFileSecurityService(): MockFileSecurityService {
103 | return {
104 | allowedDirectories: ["/test/dir"],
105 | DEFAULT_SAFE_BASE_DIR: "/test/dir",
106 | setSecureBasePath: vi.fn(),
107 | getSecureBasePath: vi.fn(),
108 | setAllowedDirectories: vi.fn(),
109 | getAllowedDirectories: vi.fn(),
110 | validateAndResolvePath: vi.fn(),
111 | isPathWithinAllowedDirs: vi.fn(),
112 | fullyResolvePath: vi.fn(),
113 | secureWriteFile: vi.fn(),
114 | };
115 | }
116 |
117 | /**
118 | * Tool handler function type for mcp server
119 | */
120 | export type ToolHandler = (server: McpServer, service?: unknown) => unknown;
121 |
122 | /**
123 | * Utility function to create a mock tool function
124 | */
125 | export function createMockToolHandler(name: string): ToolHandler {
126 | return vi.fn().mockImplementation((server: McpServer, _service?: unknown) => {
127 | server.tool(name, `Mock ${name}`, {}, vi.fn());
128 | return { name, registered: true };
129 | });
130 | }
131 |
```
--------------------------------------------------------------------------------
/src/tools/mcpClientParams.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 |
3 | export const TOOL_NAME_MCP_CLIENT = "mcp_client";
4 |
5 | // Tool Description
6 | export const TOOL_DESCRIPTION_MCP_CLIENT = `
7 | Manages MCP (Model Context Protocol) client connections and operations.
8 | Supports connecting to MCP servers via stdio or SSE transports, disconnecting from servers,
9 | listing available tools on connected servers, and calling tools on those servers.
10 | The operation parameter determines which action to perform.
11 | `;
12 |
13 | // Operation type enum
14 | export const mcpOperationSchema = z
15 | .enum([
16 | "connect_stdio",
17 | "connect_sse",
18 | "disconnect",
19 | "list_tools",
20 | "call_tool",
21 | ])
22 | .describe("The MCP client operation to perform");
23 |
24 | // Connect operation parameters - stdio variant
25 | const connectStdioParams = z.object({
26 | operation: z.literal("connect_stdio"),
27 | transport: z.literal("stdio"),
28 | command: z
29 | .string()
30 | .describe("The command to execute to start the MCP server"),
31 | args: z
32 | .array(z.string())
33 | .optional()
34 | .describe("Arguments to pass to the command"),
35 | clientId: z
36 | .string()
37 | .optional()
38 | .describe("Unique identifier for this client connection"),
39 | connectionToken: z
40 | .string()
41 | .optional()
42 | .describe("Authentication token for secure connections"),
43 | });
44 |
45 | // Connect operation parameters - SSE variant
46 | const connectSseParams = z.object({
47 | operation: z.literal("connect_sse"),
48 | transport: z.literal("sse"),
49 | url: z.string().url().describe("The URL of the SSE MCP server"),
50 | clientId: z
51 | .string()
52 | .optional()
53 | .describe("Unique identifier for this client connection"),
54 | connectionToken: z
55 | .string()
56 | .optional()
57 | .describe("Authentication token for secure connections"),
58 | });
59 |
60 | // Disconnect operation parameters
61 | const disconnectParams = z.object({
62 | operation: z.literal("disconnect"),
63 | connectionId: z
64 | .string()
65 | .describe("Required. The ID of the connection to close"),
66 | });
67 |
68 | // List tools operation parameters
69 | const listToolsParams = z.object({
70 | operation: z.literal("list_tools"),
71 | connectionId: z
72 | .string()
73 | .describe("Required. The ID of the connection to query for tools"),
74 | });
75 |
76 | // Call tool operation parameters
77 | const callToolParams = z.object({
78 | operation: z.literal("call_tool"),
79 | connectionId: z
80 | .string()
81 | .describe("Required. The ID of the connection to use"),
82 | toolName: z.string().describe("Required. The name of the tool to call"),
83 | toolParameters: z
84 | .record(z.any())
85 | .optional()
86 | .describe("Parameters to pass to the tool"),
87 | outputFilePath: z
88 | .string()
89 | .optional()
90 | .describe(
91 | "If provided, writes the tool output to this file path instead of returning it"
92 | ),
93 | overwriteFile: z
94 | .boolean()
95 | .default(true)
96 | .describe(
97 | "Whether to overwrite the output file if it already exists. Defaults to true."
98 | ),
99 | });
100 |
101 | // Combined schema using discriminated union
102 | export const MCP_CLIENT_PARAMS = z.discriminatedUnion("operation", [
103 | connectStdioParams,
104 | connectSseParams,
105 | disconnectParams,
106 | listToolsParams,
107 | callToolParams,
108 | ]);
109 |
110 | // Type for parameter object using zod inference
111 | export type McpClientArgs = z.infer<typeof MCP_CLIENT_PARAMS>;
112 |
113 | // Export for use in other modules
114 | export const McpClientParamsModule = {
115 | TOOL_NAME_MCP_CLIENT,
116 | TOOL_DESCRIPTION_MCP_CLIENT,
117 | MCP_CLIENT_PARAMS,
118 | };
119 |
```
--------------------------------------------------------------------------------
/tests/unit/utils/healthCheck.test.vitest.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Using vitest globals - see vitest.config.ts globals: true
2 | import http from "node:http";
3 | import {
4 | getHealthStatus,
5 | setServerState,
6 | startHealthCheckServer,
7 | ServerState,
8 | } from "../../../src/utils/healthCheck.js";
9 |
10 | describe("Health Check", () => {
11 | let healthServer: http.Server;
12 | const testPort = 3333; // Use a specific port for tests
13 | // Store the original environment variable value
14 | const originalHealthCheckPort = process.env.HEALTH_CHECK_PORT;
15 |
16 | // Mock server state
17 | const mockServerState: ServerState = {
18 | isRunning: true,
19 | startTime: Date.now() - 5000, // 5 seconds ago
20 | transport: null, // Transport interface doesn't have constructor property
21 | server: {},
22 | healthCheckServer: null,
23 | mcpClientService: null,
24 | };
25 |
26 | // Setup: Start health check server
27 | it("should initialize health check server", () => {
28 | setServerState(mockServerState);
29 |
30 | // Set the port via environment variable for our test
31 | process.env.HEALTH_CHECK_PORT = testPort.toString();
32 |
33 | healthServer = startHealthCheckServer();
34 |
35 | expect(healthServer).toBeTruthy();
36 |
37 | // Wait briefly for the server to start listening
38 | return new Promise<void>((resolve) => {
39 | setTimeout(() => {
40 | resolve();
41 | }, 100);
42 | });
43 | });
44 |
45 | // Test health status function
46 | it("should return correct health status", () => {
47 | const status = getHealthStatus();
48 |
49 | expect(status.status).toBe("running");
50 | expect(status.uptime).toBeGreaterThanOrEqual(5);
51 | expect(status.transport).toBe("MockTransport");
52 | });
53 |
54 | // Test health endpoint
55 | it("should respond to health endpoint", async () => {
56 | // Make HTTP request to health endpoint
57 | const response = await new Promise<http.IncomingMessage>((resolve) => {
58 | const req = http.request(
59 | {
60 | hostname: "localhost",
61 | port: testPort,
62 | path: "/health",
63 | method: "GET",
64 | },
65 | (res) => {
66 | resolve(res);
67 | }
68 | );
69 |
70 | req.end();
71 | });
72 |
73 | // Check response status
74 | expect(response.statusCode).toBe(200);
75 |
76 | // Check response content
77 | const data = await new Promise<string>((resolve) => {
78 | let body = "";
79 | response.on("data", (chunk) => {
80 | body += chunk;
81 | });
82 | response.on("end", () => {
83 | resolve(body);
84 | });
85 | });
86 |
87 | const healthData = JSON.parse(data);
88 | expect(healthData.status).toBe("running");
89 | expect(healthData.uptime).toBeGreaterThanOrEqual(0);
90 | expect(healthData.transport).toBe("MockTransport");
91 | });
92 |
93 | // Test 404 for unknown paths
94 | it("should return 404 for unknown paths", async () => {
95 | // Make HTTP request to unknown path
96 | const response = await new Promise<http.IncomingMessage>((resolve) => {
97 | const req = http.request(
98 | {
99 | hostname: "localhost",
100 | port: testPort,
101 | path: "/unknown",
102 | method: "GET",
103 | },
104 | (res) => {
105 | resolve(res);
106 | }
107 | );
108 |
109 | req.end();
110 | });
111 |
112 | // Check response status
113 | expect(response.statusCode).toBe(404);
114 | });
115 |
116 | // Cleanup: Close server after tests
117 | afterAll(() => {
118 | if (healthServer) {
119 | healthServer.close();
120 | }
121 |
122 | // Restore the environment variable or delete it if it wasn't set before
123 | if (originalHealthCheckPort === undefined) {
124 | delete process.env.HEALTH_CHECK_PORT;
125 | } else {
126 | process.env.HEALTH_CHECK_PORT = originalHealthCheckPort;
127 | }
128 | });
129 | });
130 |
```
--------------------------------------------------------------------------------
/src/tools/registration/ToolRegistry.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tool Registry - Central management for MCP tools
3 | *
4 | * This file introduces a more consistent approach to tool registration
5 | * that provides better type safety and simpler maintenance.
6 | */
7 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8 | import { logger } from "../../utils/logger.js";
9 | import { GeminiService } from "../../services/GeminiService.js";
10 | import { McpClientService } from "../../services/mcp/McpClientService.js";
11 | import { z } from "zod";
12 |
13 | /**
14 | * Interface for tool registration function - base type
15 | */
16 | export interface ToolRegistration {
17 | /**
18 | * Registers a tool with the MCP server
19 | * @param server The MCP server instance
20 | * @param services Container with available services
21 | */
22 | registerTool(server: McpServer, services: ServiceContainer): void;
23 | }
24 |
25 | /**
26 | * Container with services available for tools
27 | */
28 | export interface ServiceContainer {
29 | geminiService: GeminiService;
30 | mcpClientService: McpClientService;
31 | }
32 |
33 | /**
34 | * Tool registration function type - for standalone functions
35 | */
36 | export type ToolRegistrationFn = (
37 | server: McpServer,
38 | services: ServiceContainer
39 | ) => void;
40 |
41 | /**
42 | * Tool factory that creates a simple tool without parameter validation
43 | */
44 | export function createBasicTool(
45 | name: string,
46 | description: string,
47 | handler: (args: unknown) => Promise<unknown>
48 | ): ToolRegistrationFn {
49 | return (server: McpServer, _services: ServiceContainer) => {
50 | server.tool(name, description, {}, handler);
51 | logger.info(`Basic tool registered: ${name}`);
52 | };
53 | }
54 |
55 | /**
56 | * Tool factory that creates a fully-validated tool with Zod schema
57 | */
58 | export function createValidatedTool<T extends z.ZodRawShape, R>(
59 | name: string,
60 | description: string,
61 | params: T,
62 | handler: (args: z.infer<z.ZodObject<T>>) => Promise<R>
63 | ): ToolRegistrationFn {
64 | return (server: McpServer, _services: ServiceContainer) => {
65 | // Create a wrapper with proper type inference
66 | const wrappedHandler = async (args: z.infer<z.ZodObject<T>>) => {
67 | return handler(args);
68 | };
69 | server.tool(
70 | name,
71 | description,
72 | params,
73 | wrappedHandler as (args: unknown) => Promise<unknown>
74 | );
75 | logger.info(`Validated tool registered: ${name}`);
76 | };
77 | }
78 |
79 | /**
80 | * Registry that manages tool registration
81 | */
82 | export class ToolRegistry {
83 | private toolRegistrations: ToolRegistrationFn[] = [];
84 | private services: ServiceContainer;
85 |
86 | /**
87 | * Creates a new tool registry
88 | * @param geminiService GeminiService instance
89 | * @param mcpClientService McpClientService instance
90 | */
91 | constructor(
92 | geminiService: GeminiService,
93 | mcpClientService: McpClientService
94 | ) {
95 | this.services = {
96 | geminiService,
97 | mcpClientService,
98 | };
99 | }
100 |
101 | /**
102 | * Adds a tool to the registry
103 | * @param registration Tool registration function
104 | */
105 | public registerTool(registration: ToolRegistrationFn): void {
106 | this.toolRegistrations.push(registration);
107 | }
108 |
109 | /**
110 | * Registers all tools with the MCP server
111 | * @param server McpServer instance
112 | */
113 | public registerAllTools(server: McpServer): void {
114 | logger.info(`Registering ${this.toolRegistrations.length} tools...`);
115 |
116 | for (const registration of this.toolRegistrations) {
117 | try {
118 | registration(server, this.services);
119 | } catch (error) {
120 | logger.error(
121 | `Failed to register tool: ${error instanceof Error ? error.message : String(error)}`
122 | );
123 | }
124 | }
125 |
126 | logger.info("All tools registered successfully");
127 | }
128 | }
129 |
```
--------------------------------------------------------------------------------
/src/types/googleGenAITypes.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Import types directly from the SDK
2 | import type {
3 | GenerateContentResponse,
4 | GenerationConfig as GoogleGenerationConfig,
5 | SafetySetting,
6 | Content,
7 | Tool,
8 | ToolConfig,
9 | } from "@google/genai";
10 |
11 | // Define ThinkingConfig interface for controlling model reasoning
12 | interface ThinkingConfig {
13 | thinkingBudget?: number;
14 | reasoningEffort?: "none" | "low" | "medium" | "high";
15 | }
16 |
17 | // Override the imported GenerationConfig with our extended version
18 | interface GenerationConfig extends GoogleGenerationConfig {
19 | thinkingConfig?: ThinkingConfig;
20 | thinkingBudget?: number;
21 | }
22 |
23 | // Define ExtendedGenerationConfig (alias for our GenerationConfig)
24 | type ExtendedGenerationConfig = GenerationConfig;
25 |
26 | // Types for params that match the SDK v0.10.0 structure
27 | interface ChatSessionParams {
28 | history?: Content[];
29 | generationConfig?: GenerationConfig;
30 | safetySettings?: SafetySetting[];
31 | tools?: Tool[];
32 | thinkingConfig?: ThinkingConfig;
33 | systemInstruction?: Content;
34 | cachedContent?: string;
35 | }
36 |
37 | interface CachedContentParams {
38 | contents: Content[];
39 | displayName?: string;
40 | systemInstruction?: Content;
41 | ttl?: string;
42 | tools?: Tool[];
43 | toolConfig?: ToolConfig;
44 | }
45 |
46 | // Metadata returned by cached content operations
47 | interface CachedContentMetadata {
48 | name: string;
49 | displayName?: string;
50 | model?: string;
51 | createTime: string;
52 | updateTime: string;
53 | expirationTime?: string;
54 | state?: string;
55 | usageMetadata?: {
56 | totalTokenCount?: number;
57 | };
58 | }
59 |
60 | // Enums for response types - matching SDK v0.10.0
61 | enum FinishReason {
62 | FINISH_REASON_UNSPECIFIED = "FINISH_REASON_UNSPECIFIED",
63 | STOP = "STOP",
64 | MAX_TOKENS = "MAX_TOKENS",
65 | SAFETY = "SAFETY",
66 | RECITATION = "RECITATION",
67 | OTHER = "OTHER",
68 | }
69 |
70 | enum BlockedReason {
71 | BLOCKED_REASON_UNSPECIFIED = "BLOCKED_REASON_UNSPECIFIED",
72 | SAFETY = "SAFETY",
73 | OTHER = "OTHER",
74 | }
75 |
76 | // Define our own LocalFunctionCall interface to avoid conflict with imported FunctionCall
77 | interface LocalFunctionCall {
78 | name: string;
79 | args?: Record<string, unknown>;
80 | }
81 |
82 | // Response type interfaces
83 | interface PromptFeedback {
84 | blockReason?: BlockedReason;
85 | safetyRatings?: Array<{
86 | category: string;
87 | probability: string;
88 | blocked: boolean;
89 | }>;
90 | }
91 |
92 | interface Candidate {
93 | content?: Content;
94 | finishReason?: FinishReason;
95 | safetyRatings?: Array<{
96 | category: string;
97 | probability: string;
98 | blocked: boolean;
99 | }>;
100 | index?: number;
101 | }
102 |
103 | // Interface for the chat session with our updated implementation
104 | interface ChatSession {
105 | model: string;
106 | config: {
107 | history?: Content[];
108 | generationConfig?: GenerationConfig;
109 | safetySettings?: SafetySetting[];
110 | tools?: Tool[];
111 | systemInstruction?: Content;
112 | cachedContent?: string;
113 | thinkingConfig?: ThinkingConfig;
114 | };
115 | history: Content[];
116 | }
117 |
118 | // These are already defined above, so we don't need to redefine them
119 | // Using the existing PromptFeedback and Candidate interfaces
120 |
121 | interface GenerateContentResult {
122 | response: {
123 | text(): string;
124 | promptFeedback?: PromptFeedback;
125 | candidates?: Candidate[];
126 | };
127 | }
128 |
129 | interface GenerateContentResponseChunk {
130 | text(): string;
131 | candidates?: Candidate[];
132 | }
133 |
134 | // Re-export all types for use in other files
135 | export type {
136 | ChatSessionParams,
137 | CachedContentParams,
138 | CachedContentMetadata,
139 | GenerateContentResult,
140 | GenerateContentResponseChunk,
141 | PromptFeedback,
142 | Candidate,
143 | LocalFunctionCall as FunctionCall,
144 | ChatSession,
145 | GenerateContentResponse,
146 | ThinkingConfig,
147 | GenerationConfig,
148 | ExtendedGenerationConfig,
149 | GoogleGenerationConfig,
150 | };
151 | export { FinishReason, BlockedReason };
152 |
```
--------------------------------------------------------------------------------
/tests/utils/express-mocks.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Request, Response } from "express";
2 | import { ParamsDictionary } from "express-serve-static-core";
3 | import { ParsedQs } from "qs";
4 |
5 | /**
6 | * Creates a mock Express Request object for testing
7 | *
8 | * @param options Object containing request properties to mock
9 | * @returns A mock Express Request object
10 | */
11 | export function createMockRequest<
12 | P = ParamsDictionary,
13 | ResBody = any,
14 | ReqBody = any,
15 | ReqQuery = ParsedQs,
16 | >(
17 | options: Partial<Request<P, ResBody, ReqBody, ReqQuery>> = {}
18 | ): Request<P, ResBody, ReqBody, ReqQuery> {
19 | // Create a base mock request with common methods and properties
20 | const mockRequest = {
21 | app: {},
22 | baseUrl: "",
23 | body: {},
24 | cookies: {},
25 | fresh: false,
26 | hostname: "localhost",
27 | ip: "127.0.0.1",
28 | ips: [],
29 | method: "GET",
30 | originalUrl: "",
31 | params: {},
32 | path: "/",
33 | protocol: "http",
34 | query: {},
35 | route: {},
36 | secure: false,
37 | signedCookies: {},
38 | stale: true,
39 | subdomains: [],
40 | xhr: false,
41 | accepts: () => [],
42 | acceptsCharsets: () => [],
43 | acceptsEncodings: () => [],
44 | acceptsLanguages: () => [],
45 | get: () => "",
46 | header: () => "",
47 | is: () => false,
48 | range: () => [],
49 | ...options,
50 | } as Request<P, ResBody, ReqBody, ReqQuery>;
51 |
52 | return mockRequest;
53 | }
54 |
55 | /**
56 | * Creates a mock Express Response object for testing
57 | *
58 | * @param options Object containing response properties to mock
59 | * @returns A mock Express Response object
60 | */
61 | export function createMockResponse<ResBody = any>(
62 | options: Partial<Response<ResBody>> = {}
63 | ): Response<ResBody> {
64 | // Create response behaviors
65 | let statusCode = 200;
66 | let responseData: unknown = {};
67 | let responseHeaders: Record<string, string> = {};
68 | let endCalled = false;
69 |
70 | // Create a base mock response with common methods that satisfies the Express Response interface
71 | const mockResponse = {
72 | app: {},
73 | headersSent: false,
74 | locals: {},
75 | statusCode,
76 | // Response chainable methods
77 | status: function (code: number): Response<ResBody> {
78 | statusCode = code;
79 | return this as Response<ResBody>;
80 | },
81 | sendStatus: function (code: number): Response<ResBody> {
82 | statusCode = code;
83 | return this as Response<ResBody>;
84 | },
85 | json: function (data: unknown): Response<ResBody> {
86 | responseData = data;
87 | return this as Response<ResBody>;
88 | },
89 | send: function (data: unknown): Response<ResBody> {
90 | responseData = data;
91 | return this as Response<ResBody>;
92 | },
93 | end: function (data?: unknown): Response<ResBody> {
94 | if (data) responseData = data;
95 | endCalled = true;
96 | return this as Response<ResBody>;
97 | },
98 | set: function (
99 | field: string | Record<string, string>,
100 | value?: string
101 | ): Response<ResBody> {
102 | if (typeof field === "string") {
103 | responseHeaders[field] = value as string;
104 | } else {
105 | responseHeaders = { ...responseHeaders, ...field };
106 | }
107 | return this as Response<ResBody>;
108 | },
109 | get: function (field: string): string | undefined {
110 | return responseHeaders[field];
111 | },
112 | // Testing helpers
113 | _getStatus: function () {
114 | return statusCode;
115 | },
116 | _getData: function () {
117 | return responseData;
118 | },
119 | _getHeaders: function () {
120 | return responseHeaders;
121 | },
122 | _isEnded: function () {
123 | return endCalled;
124 | },
125 | ...options,
126 | } as Response<ResBody>;
127 |
128 | return mockResponse;
129 | }
130 |
131 | // Export mock types for easier consumption
132 | export type MockRequest = ReturnType<typeof createMockRequest>;
133 | export type MockResponse = ReturnType<typeof createMockResponse>;
134 |
```
--------------------------------------------------------------------------------
/src/types/googleGenAI.d.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Type augmentation for @google/genai package
2 | import type {
3 | GenerationConfig,
4 | Content,
5 | Part,
6 | Tool,
7 | ToolConfig,
8 | } from "@google/genai";
9 |
10 | // Add additional types from our codebase
11 | import type { ExtendedGenerationConfig } from "./googleGenAITypes.js";
12 |
13 | declare module "@google/genai" {
14 | // Define Models interface
15 | export interface Models {
16 | getGenerativeModel(params: {
17 | model: string;
18 | generationConfig?: GenerationConfig;
19 | safetySettings?: SafetySetting[];
20 | }): GenerativeModel;
21 | }
22 |
23 | // Extend GoogleGenAI class with missing methods
24 | export interface GoogleGenAI {
25 | /**
26 | * Returns a generative model instance with the specified configuration
27 | *
28 | * @param options Model configuration options
29 | * @returns A generative model instance
30 | */
31 | getGenerativeModel(options: {
32 | model: string;
33 | generationConfig?: GenerationConfig | ExtendedGenerationConfig;
34 | safetySettings?: SafetySetting[];
35 | }): GenerativeModel;
36 |
37 | /**
38 | * Returns models available through the API
39 | */
40 | readonly models: Models;
41 | }
42 |
43 | // Image generation related types
44 | export interface ImagePart extends Part {
45 | inlineData: {
46 | data: string;
47 | mimeType: string;
48 | };
49 | }
50 |
51 | // Safety setting types are already defined in @google/genai package
52 | // We just need to re-export them from the module declaration
53 | export {
54 | HarmCategory,
55 | HarmBlockThreshold,
56 | SafetySetting,
57 | } from "@google/genai";
58 |
59 | // Define the GenerativeModel interface
60 | export interface GenerativeModel {
61 | /**
62 | * Generates content based on provided prompt
63 | *
64 | * @param options Content generation options
65 | * @returns Promise with generated content response
66 | */
67 | generateContent(options: {
68 | contents: Content[];
69 | generationConfig?: GenerationConfig | ExtendedGenerationConfig;
70 | safetySettings?: SafetySetting[];
71 | tools?: Tool[];
72 | toolConfig?: ToolConfig;
73 | }): Promise<{
74 | response: {
75 | text(): string;
76 | candidates?: Array<{ content?: { parts?: Part[] } }>;
77 | };
78 | }>;
79 |
80 | /**
81 | * Generates content as a stream based on provided prompt
82 | *
83 | * @param options Content generation options
84 | * @returns Promise with stream of content responses
85 | */
86 | generateContentStream(options: {
87 | contents: Content[];
88 | generationConfig?: GenerationConfig | ExtendedGenerationConfig;
89 | safetySettings?: SafetySetting[];
90 | tools?: Tool[];
91 | toolConfig?: ToolConfig;
92 | }): Promise<{
93 | stream: AsyncGenerator<{
94 | text(): string;
95 | candidates?: Array<{ content?: { parts?: Part[] } }>;
96 | }>;
97 | }>;
98 |
99 | /**
100 | * Creates a chat session with the model
101 | */
102 | startChat(options?: {
103 | history?: Content[];
104 | generationConfig?: GenerationConfig | ExtendedGenerationConfig;
105 | safetySettings?: SafetySetting[];
106 | tools?: Tool[];
107 | systemInstruction?: Content;
108 | cachedContent?: string;
109 | thinkingConfig?: { reasoningEffort?: string; thinkingBudget?: number };
110 | }): ChatSession;
111 |
112 | /**
113 | * Generates images based on a text prompt
114 | */
115 | generateImages(params: {
116 | prompt: string;
117 | safetySettings?: SafetySetting[];
118 | [key: string]: unknown;
119 | }): Promise<{
120 | images?: Array<{ data?: string; mimeType?: string }>;
121 | promptSafetyMetadata?: {
122 | blocked?: boolean;
123 | safetyRatings?: Array<{ category: string; probability: string }>;
124 | };
125 | }>;
126 | }
127 |
128 | // Define ChatSession interface
129 | export interface ChatSession {
130 | sendMessage(text: string): Promise<{ response: { text(): string } }>;
131 | sendMessageStream(
132 | text: string
133 | ): Promise<{ stream: AsyncGenerator<{ text(): string }> }>;
134 | getHistory(): Content[];
135 | }
136 |
137 | // We can add specific Google GenAI types if needed
138 | }
139 |
```
--------------------------------------------------------------------------------
/tests/utils/env-check.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Environment variable verification for tests
3 | *
4 | * This module is used at the beginning of test runs to verify that
5 | * required environment variables are available and to load them from
6 | * .env.test if needed.
7 | */
8 |
9 | import {
10 | loadTestEnv,
11 | verifyEnvVars,
12 | REQUIRED_ENV_VARS,
13 | createEnvExample,
14 | } from "./environment.js";
15 |
16 | /**
17 | * Setup function to be called at the beginning of test runs
18 | * to ensure environment variables are properly loaded
19 | *
20 | * @returns Promise resolving to a boolean indicating if environment is valid
21 | */
22 | export async function setupTestEnvironment(): Promise<boolean> {
23 | // Try to load variables from .env.test file
24 | await loadTestEnv();
25 |
26 | // Check if required variables are available
27 | const basicCheck = verifyEnvVars(REQUIRED_ENV_VARS.BASIC);
28 |
29 | if (!basicCheck.success) {
30 | console.error("❌ Missing required environment variables for tests:");
31 | console.error(` ${basicCheck.missing.join(", ")}`);
32 | console.error("\nTests requiring API access will be skipped.");
33 | console.error("To fix this:");
34 | console.error("1. Create a .env.test file in the project root");
35 | console.error("2. Add the missing variables with their values");
36 |
37 | // Create an example file to help users
38 | await createEnvExample();
39 |
40 | console.error("\n.env.test.example file created for reference\n");
41 |
42 | return false;
43 | }
44 |
45 | // Check which test categories can run
46 | const fileCheck = verifyEnvVars(REQUIRED_ENV_VARS.FILE_TESTS);
47 | const imageCheck = verifyEnvVars(REQUIRED_ENV_VARS.IMAGE_TESTS);
48 |
49 | console.log("✅ Basic API environment variables available");
50 |
51 | if (!fileCheck.success) {
52 | console.warn("⚠️ Missing some file API environment variables:");
53 | console.warn(` ${fileCheck.missing.join(", ")}`);
54 | console.warn(" File API tests may be skipped");
55 | } else {
56 | console.log("✅ File API environment variables available");
57 | }
58 |
59 | if (!imageCheck.success) {
60 | console.warn("⚠️ Missing some image API environment variables:");
61 | console.warn(` ${imageCheck.missing.join(", ")}`);
62 | console.warn(" Default values will be used for missing variables");
63 | } else {
64 | console.log("✅ Image API environment variables available");
65 | }
66 |
67 | return true;
68 | }
69 |
70 | /**
71 | * Add a pre-check function to specific test files to skip tests
72 | * if required environment variables are missing
73 | *
74 | * Usage (at the beginning of a test file):
75 | *
76 | * import { describe, it, before } from 'node:test';
77 | * import { preCheckEnv, skipIfEnvMissing } from '../utils/env-check.js';
78 | *
79 | * // Check environment at the start of the file
80 | * const envOk = preCheckEnv(REQUIRED_ENV_VARS.IMAGE_TESTS);
81 | *
82 | * describe('Image generation tests', () => {
83 | * // Skip all tests if environment is not set up
84 | * if (!envOk) return;
85 | *
86 | * // Or check in each test:
87 | * it('should generate an image', (t) => {
88 | * if (skipIfEnvMissing(t, REQUIRED_ENV_VARS.IMAGE_TESTS)) return;
89 | * // ... test code ...
90 | * });
91 | * });
92 | *
93 | * @param requiredVars - Array of required environment variable names
94 | * @returns Boolean indicating if environment is valid for these tests
95 | */
96 | export function preCheckEnv(
97 | requiredVars: string[] = REQUIRED_ENV_VARS.BASIC
98 | ): boolean {
99 | const check = verifyEnvVars(requiredVars);
100 |
101 | if (!check.success) {
102 | console.warn(
103 | `⚠️ Skipping tests - missing required environment variables: ${check.missing.join(", ")}`
104 | );
105 | return false;
106 | }
107 |
108 | return true;
109 | }
110 |
111 | /**
112 | * Skip a test if required environment variables are missing
113 | *
114 | * @param t - Test context from node:test
115 | * @param requiredVars - Array of required environment variable names
116 | * @returns Boolean indicating if the test should be skipped
117 | */
118 | export function skipIfEnvMissing(
119 | t: { skip: (reason: string) => void },
120 | requiredVars: string[] = REQUIRED_ENV_VARS.BASIC
121 | ): boolean {
122 | const check = verifyEnvVars(requiredVars);
123 |
124 | if (!check.success) {
125 | t.skip(`Test requires environment variables: ${check.missing.join(", ")}`);
126 | return true;
127 | }
128 |
129 | return false;
130 | }
131 |
```
--------------------------------------------------------------------------------
/tests/integration/dummyMcpServerSse.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | // Import the MCP SDK server module
4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
6 | import { z } from "zod";
7 | import express, { Request, Response } from "express";
8 | import cors from "cors";
9 | import { Server } from "http";
10 |
11 | // Create a new MCP server
12 | const server = new McpServer({
13 | name: "dummy-mcp-server-sse",
14 | version: "1.0.0",
15 | description: "A dummy MCP server for testing SSE transport",
16 | });
17 |
18 | // Register the same tools as in the stdio version
19 | server.tool(
20 | "echoTool",
21 | "A tool that echoes back the input message",
22 | {
23 | message: z.string().describe("The message to echo"),
24 | },
25 | async (args: unknown) => {
26 | const typedArgs = args as { message: string };
27 | return {
28 | content: [
29 | {
30 | type: "text",
31 | text: JSON.stringify(
32 | {
33 | message: typedArgs.message,
34 | timestamp: new Date().toISOString(),
35 | },
36 | null,
37 | 2
38 | ),
39 | },
40 | ],
41 | };
42 | }
43 | );
44 |
45 | server.tool(
46 | "addTool",
47 | "A tool that adds two numbers",
48 | {
49 | a: z.number().describe("First number"),
50 | b: z.number().describe("Second number"),
51 | },
52 | async (args: unknown) => {
53 | const typedArgs = args as { a: number; b: number };
54 | const sum = typedArgs.a + typedArgs.b;
55 | return {
56 | content: [
57 | {
58 | type: "text",
59 | text: JSON.stringify(
60 | {
61 | sum,
62 | inputs: { a: typedArgs.a, b: typedArgs.b },
63 | },
64 | null,
65 | 2
66 | ),
67 | },
68 | ],
69 | };
70 | }
71 | );
72 |
73 | server.tool(
74 | "complexDataTool",
75 | "A tool that returns a complex JSON structure",
76 | {
77 | depth: z
78 | .number()
79 | .optional()
80 | .describe("Depth of nested objects to generate"),
81 | itemCount: z
82 | .number()
83 | .optional()
84 | .describe("Number of items to generate in arrays"),
85 | },
86 | async (args: unknown) => {
87 | const typedArgs = args as { depth?: number; itemCount?: number };
88 | const depth = typedArgs.depth || 3;
89 | const itemCount = typedArgs.itemCount || 2;
90 |
91 | // Generate a nested structure of specified depth
92 | function generateNestedData(currentDepth: number): any {
93 | if (currentDepth <= 0) {
94 | return { value: "leaf data" };
95 | }
96 |
97 | const result = {
98 | level: depth - currentDepth + 1,
99 | timestamp: new Date().toISOString(),
100 | items: [] as any[],
101 | };
102 |
103 | for (let i = 0; i < itemCount; i++) {
104 | result.items.push(generateNestedData(currentDepth - 1));
105 | }
106 |
107 | return result;
108 | }
109 |
110 | const data = generateNestedData(depth);
111 |
112 | return {
113 | content: [
114 | {
115 | type: "text",
116 | text: JSON.stringify(data, null, 2),
117 | },
118 | ],
119 | };
120 | }
121 | );
122 |
123 | // Create Express app and add CORS middleware
124 | const app = express();
125 | app.use(cors());
126 | app.use(express.json());
127 |
128 | // Get port from command line argument or environment or default to 3456
129 | const port = Number(process.argv[2]) || Number(process.env.PORT) || 3456;
130 |
131 | // Create HTTP server
132 | const httpServer: Server = app.listen(port, () => {
133 | console.error(`Dummy MCP Server (SSE) started on port ${port}`);
134 | });
135 |
136 | // Set up SSE endpoint
137 | app.get("/mcp", async (_req: Request, res: Response) => {
138 | // Create SSE transport for this connection
139 | const transport = new SSEServerTransport("/mcp", res);
140 | await transport.start();
141 |
142 | // Connect to MCP server
143 | await server.connect(transport);
144 | });
145 |
146 | // Set up POST endpoint for receiving messages
147 | app.post("/mcp", async (_req: Request, res: Response) => {
148 | try {
149 | // The SSE transport expects messages to be posted here
150 | // but we need to handle this in the context of an active SSE connection
151 | res.status(200).json({ status: "ok" });
152 | } catch (error: unknown) {
153 | console.error("Error handling POST request:", error);
154 | const errorMessage = error instanceof Error ? error.message : String(error);
155 | res.status(500).json({ error: errorMessage });
156 | }
157 | });
158 |
159 | // Add a handler for Ctrl+C to properly shut down the server
160 | process.on("SIGINT", () => {
161 | console.error("Shutting down Dummy MCP Server (SSE)...");
162 | httpServer.close(() => {
163 | process.exit(0);
164 | });
165 | });
166 |
```
--------------------------------------------------------------------------------
/src/tools/geminiCodeReviewParams.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import { ModelNameSchema } from "./schemas/CommonSchemas.js";
3 |
4 | export const TOOL_NAME_CODE_REVIEW = "gemini_code_review";
5 |
6 | // Tool Description
7 | export const TOOL_DESCRIPTION_CODE_REVIEW = `
8 | Performs comprehensive code reviews using Gemini models. Supports reviewing local git diffs,
9 | GitHub repositories, and GitHub pull requests. The source parameter determines which type
10 | of review to perform and which additional parameters are required.
11 | `;
12 |
13 | // Review source enum
14 | export const reviewSourceSchema = z
15 | .enum(["local_diff", "github_repo", "github_pr"])
16 | .describe("The source of code to review");
17 |
18 | // Common review focus areas schema
19 | export const ReviewFocusSchema = z
20 | .enum(["security", "performance", "architecture", "bugs", "general"])
21 | .optional()
22 | .describe(
23 | "The primary focus area for the review. If not specified, a general comprehensive review will be performed."
24 | );
25 |
26 | // Common reasoning effort schema
27 | export const ReasoningEffortSchema = z
28 | .enum(["low", "medium", "high"])
29 | .describe(
30 | "The amount of reasoning effort to apply. Higher effort may produce more detailed analysis."
31 | );
32 |
33 | // Base parameters common to all review types
34 | const baseParams = {
35 | source: reviewSourceSchema,
36 | model: ModelNameSchema.optional().describe(
37 | "Optional. The Gemini model to use for review. Defaults based on source type."
38 | ),
39 | reasoningEffort: ReasoningEffortSchema.optional(),
40 | reviewFocus: ReviewFocusSchema,
41 | excludePatterns: z
42 | .array(z.string())
43 | .optional()
44 | .describe(
45 | "File patterns to exclude from the review (e.g., ['*.test.ts', 'dist/**'])"
46 | ),
47 | customPrompt: z
48 | .string()
49 | .optional()
50 | .describe(
51 | "Additional instructions or context to include in the review prompt"
52 | ),
53 | };
54 |
55 | // Local diff specific parameters
56 | const localDiffParams = z.object({
57 | ...baseParams,
58 | source: z.literal("local_diff"),
59 | diffContent: z
60 | .string()
61 | .describe(
62 | "Required. The git diff content to review (output of 'git diff' or similar)"
63 | ),
64 | repositoryContext: z
65 | .object({
66 | name: z.string().optional(),
67 | description: z.string().optional(),
68 | languages: z.array(z.string()).optional(),
69 | frameworks: z.array(z.string()).optional(),
70 | })
71 | .optional()
72 | .describe(
73 | "Optional context about the repository to improve review quality"
74 | ),
75 | maxFilesToInclude: z
76 | .number()
77 | .int()
78 | .positive()
79 | .optional()
80 | .describe(
81 | "Maximum number of files to include in the review. Helps manage large diffs."
82 | ),
83 | prioritizeFiles: z
84 | .array(z.string())
85 | .optional()
86 | .describe(
87 | "File patterns to prioritize in the review (e.g., ['src/**/*.ts'])"
88 | ),
89 | });
90 |
91 | // GitHub repository specific parameters
92 | const githubRepoParams = z.object({
93 | ...baseParams,
94 | source: z.literal("github_repo"),
95 | repoUrl: z
96 | .string()
97 | .url()
98 | .describe(
99 | "Required. The GitHub repository URL (e.g., 'https://github.com/owner/repo')"
100 | ),
101 | branch: z
102 | .string()
103 | .optional()
104 | .describe(
105 | "The branch to review. Defaults to the repository's default branch."
106 | ),
107 | maxFiles: z
108 | .number()
109 | .int()
110 | .positive()
111 | .default(100)
112 | .describe("Maximum number of files to review. Defaults to 100."),
113 | prioritizeFiles: z
114 | .array(z.string())
115 | .optional()
116 | .describe(
117 | "File patterns to prioritize in the review (e.g., ['src/**/*.ts'])"
118 | ),
119 | });
120 |
121 | // GitHub PR specific parameters
122 | const githubPrParams = z.object({
123 | ...baseParams,
124 | source: z.literal("github_pr"),
125 | prUrl: z
126 | .string()
127 | .url()
128 | .describe(
129 | "Required. The GitHub pull request URL (e.g., 'https://github.com/owner/repo/pull/123')"
130 | ),
131 | filesOnly: z
132 | .boolean()
133 | .optional()
134 | .describe(
135 | "Deprecated. Review only the changed files without considering PR context. Use for backwards compatibility."
136 | ),
137 | });
138 |
139 | // Combined schema using discriminated union
140 | export const GEMINI_CODE_REVIEW_PARAMS = z.discriminatedUnion("source", [
141 | localDiffParams,
142 | githubRepoParams,
143 | githubPrParams,
144 | ]);
145 |
146 | // Type for parameter object using zod inference
147 | export type GeminiCodeReviewArgs = z.infer<typeof GEMINI_CODE_REVIEW_PARAMS>;
148 |
149 | // Export for use in other modules
150 | export const GeminiCodeReviewParamsModule = {
151 | TOOL_NAME_CODE_REVIEW,
152 | TOOL_DESCRIPTION_CODE_REVIEW,
153 | GEMINI_CODE_REVIEW_PARAMS,
154 | };
155 |
```
--------------------------------------------------------------------------------
/src/tools/registration/registerAllTools.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tool Registration - Central registration point for all tools
3 | *
4 | * This file uses the new ToolRegistry system to register all tools.
5 | */
6 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7 | import { ToolRegistry } from "./ToolRegistry.js";
8 | import {
9 | adaptServerOnlyTool,
10 | adaptGeminiServiceTool,
11 | adaptNewGeminiServiceToolObject,
12 | adaptNewMcpClientServiceToolObject,
13 | } from "./ToolAdapter.js";
14 | import { logger } from "../../utils/logger.js";
15 | import { GeminiService } from "../../services/GeminiService.js";
16 | import { McpClientService } from "../../services/mcp/McpClientService.js";
17 |
18 | // Import tool registration functions
19 | import { geminiGenerateContentConsolidatedTool } from "../geminiGenerateContentConsolidatedTool.js";
20 | import { geminiChatTool } from "../geminiChatTool.js";
21 | import { geminiRouteMessageTool } from "../geminiRouteMessageTool.js";
22 | // Image generation tools
23 | import { geminiGenerateImageTool } from "../geminiGenerateImageTool.js";
24 | // --- Cache Tools ---
25 | import { geminiCacheTool } from "../geminiCacheTool.js";
26 | // Code review tools
27 | import {
28 | geminiCodeReviewTool,
29 | geminiCodeReviewStreamTool,
30 | } from "../geminiCodeReviewTool.js";
31 | import type { GeminiCodeReviewArgs } from "../geminiCodeReviewParams.js";
32 | // URL Context tools
33 | import { geminiUrlAnalysisTool } from "../geminiUrlAnalysisTool.js";
34 | // MCP tools
35 | import { mcpClientTool } from "../mcpClientTool.js";
36 | // File utils tool
37 | import { writeToFileTool } from "../writeToFileTool.js";
38 |
39 | /**
40 | * Register all tools with the MCP server using the new registry system
41 | * @param server MCP server instance
42 | * @returns McpClientService instance for managing connections
43 | */
44 | export function registerAllTools(server: McpServer): McpClientService {
45 | logger.info("Initializing services and tool registry...");
46 |
47 | // Create service instances
48 | const geminiService = new GeminiService();
49 | const mcpClientService = new McpClientService();
50 |
51 | // Create the tool registry
52 | const registry = new ToolRegistry(geminiService, mcpClientService);
53 |
54 | try {
55 | // Register all tools with appropriate adapters
56 |
57 | // Note: Example tool removed as per refactoring
58 |
59 | // Content generation tools
60 | registry.registerTool(
61 | adaptGeminiServiceTool(
62 | geminiGenerateContentConsolidatedTool,
63 | "geminiGenerateContentConsolidatedTool"
64 | )
65 | );
66 |
67 | // Chat tools
68 | registry.registerTool(
69 | adaptGeminiServiceTool(geminiChatTool, "geminiChatTool")
70 | );
71 | registry.registerTool(
72 | adaptGeminiServiceTool(geminiRouteMessageTool, "geminiRouteMessageTool")
73 | );
74 |
75 | // Image generation tools
76 | registry.registerTool(
77 | adaptNewGeminiServiceToolObject(geminiGenerateImageTool)
78 | );
79 |
80 | // Cache management tools
81 | registry.registerTool(
82 | adaptGeminiServiceTool(geminiCacheTool, "geminiCacheTool")
83 | );
84 |
85 | // URL Context tools
86 | registry.registerTool(
87 | adaptGeminiServiceTool(geminiUrlAnalysisTool, "geminiUrlAnalysisTool")
88 | );
89 |
90 | // Code review tools
91 | registry.registerTool(
92 | adaptNewGeminiServiceToolObject(geminiCodeReviewTool)
93 | );
94 | // Note: geminiCodeReviewStreamTool returns an AsyncGenerator, not a Promise
95 | // We need to wrap it to collect all chunks into a single response
96 | registry.registerTool(
97 | adaptNewGeminiServiceToolObject({
98 | ...geminiCodeReviewStreamTool,
99 | execute: async (args: GeminiCodeReviewArgs, service: GeminiService) => {
100 | const results = [];
101 | const generator = await geminiCodeReviewStreamTool.execute(
102 | args,
103 | service
104 | );
105 | for await (const chunk of generator) {
106 | results.push(chunk);
107 | }
108 | // Return the last chunk which should contain the complete result
109 | return results[results.length - 1];
110 | },
111 | })
112 | );
113 |
114 | // MCP client tool
115 | registry.registerTool(
116 | adaptNewMcpClientServiceToolObject({
117 | ...mcpClientTool,
118 | execute: mcpClientTool.execute, // No cast needed
119 | })
120 | );
121 |
122 | // File utility tools
123 | registry.registerTool(
124 | adaptServerOnlyTool(writeToFileTool, "writeToFileTool")
125 | );
126 |
127 | // Register all tools with the server
128 | registry.registerAllTools(server);
129 | } catch (error) {
130 | logger.error(
131 | "Error registering tools:",
132 | error instanceof Error ? error.message : String(error)
133 | );
134 | }
135 |
136 | // Return the McpClientService instance for use in graceful shutdown
137 | return mcpClientService;
138 | }
139 |
```
--------------------------------------------------------------------------------
/src/services/gemini/GitHubUrlParser.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Parser for GitHub URLs to extract repository, branch, PR, and issue information.
3 | * Supports various GitHub URL formats including repository, branch, PR, PR files, and issue URLs.
4 | */
5 | export class GitHubUrlParser {
6 | /**
7 | * Repository URL format
8 | * Example: https://github.com/bsmi021/mcp-gemini-server
9 | */
10 | private static repoUrlPattern =
11 | /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/?$/;
12 |
13 | /**
14 | * Branch URL format
15 | * Example: https://github.com/bsmi021/mcp-gemini-server/tree/feature/add-reasoning-effort-option
16 | */
17 | private static branchUrlPattern =
18 | /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+(?:\/[^/]+)*)\/?$/;
19 |
20 | /**
21 | * Pull request URL format
22 | * Example: https://github.com/bsmi021/mcp-gemini-server/pull/2
23 | */
24 | private static prUrlPattern =
25 | /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/;
26 |
27 | /**
28 | * Pull request files URL format
29 | * Example: https://github.com/bsmi021/mcp-gemini-server/pull/2/files
30 | */
31 | private static prFilesUrlPattern =
32 | /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/files\/?$/;
33 |
34 | /**
35 | * Issue URL format
36 | * Example: https://github.com/bsmi021/mcp-gemini-server/issues/5
37 | */
38 | private static issueUrlPattern =
39 | /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/;
40 |
41 | /**
42 | * Parse a GitHub URL to extract repository, branch, PR, or issue information
43 | *
44 | * @param url GitHub URL to parse
45 | * @returns Object with parsed URL components or null if the URL is not a valid GitHub URL
46 | */
47 | public static parse(url: string): ParsedGitHubUrl | null {
48 | // Try matching repository URL
49 | let match = url.match(this.repoUrlPattern);
50 | if (match) {
51 | return {
52 | type: "repository",
53 | owner: match[1],
54 | repo: match[2],
55 | };
56 | }
57 |
58 | // Try matching branch URL
59 | match = url.match(this.branchUrlPattern);
60 | if (match) {
61 | return {
62 | type: "branch",
63 | owner: match[1],
64 | repo: match[2],
65 | branch: match[3],
66 | };
67 | }
68 |
69 | // Try matching PR files URL first (more specific)
70 | match = url.match(this.prFilesUrlPattern);
71 | if (match) {
72 | return {
73 | type: "pr_files",
74 | owner: match[1],
75 | repo: match[2],
76 | prNumber: match[3],
77 | filesView: true,
78 | };
79 | }
80 |
81 | // Try matching PR URL
82 | match = url.match(this.prUrlPattern);
83 | if (match) {
84 | return {
85 | type: "pull_request",
86 | owner: match[1],
87 | repo: match[2],
88 | prNumber: match[3],
89 | };
90 | }
91 |
92 | // Try matching issue URL
93 | match = url.match(this.issueUrlPattern);
94 | if (match) {
95 | return {
96 | type: "issue",
97 | owner: match[1],
98 | repo: match[2],
99 | issueNumber: match[3],
100 | };
101 | }
102 |
103 | // Not a recognized GitHub URL format
104 | return null;
105 | }
106 |
107 | /**
108 | * Validate if a URL is a recognized GitHub URL
109 | *
110 | * @param url URL to validate
111 | * @returns True if the URL is a valid GitHub URL, false otherwise
112 | */
113 | public static isValidGitHubUrl(url: string): boolean {
114 | return this.parse(url) !== null;
115 | }
116 |
117 | /**
118 | * Get the API endpoint for the GitHub URL
119 | *
120 | * @param url GitHub URL
121 | * @returns API endpoint for the URL or null if not a valid GitHub URL
122 | */
123 | public static getApiEndpoint(url: string): string | null {
124 | const parsed = this.parse(url);
125 | if (!parsed) {
126 | return null;
127 | }
128 |
129 | const { owner, repo } = parsed;
130 |
131 | switch (parsed.type) {
132 | case "repository":
133 | return `repos/${owner}/${repo}`;
134 | case "branch":
135 | return `repos/${owner}/${repo}/branches/${encodeURIComponent(parsed.branch!)}`;
136 | case "pull_request":
137 | case "pr_files":
138 | return `repos/${owner}/${repo}/pulls/${parsed.prNumber}`;
139 | case "issue":
140 | return `repos/${owner}/${repo}/issues/${parsed.issueNumber}`;
141 | default:
142 | return null;
143 | }
144 | }
145 |
146 | /**
147 | * Extract repository information from a GitHub URL
148 | *
149 | * @param url GitHub URL
150 | * @returns Object with owner and repo name or null if not a valid GitHub URL
151 | */
152 | public static getRepositoryInfo(
153 | url: string
154 | ): { owner: string; repo: string } | null {
155 | const parsed = this.parse(url);
156 | if (!parsed) {
157 | return null;
158 | }
159 |
160 | return {
161 | owner: parsed.owner,
162 | repo: parsed.repo,
163 | };
164 | }
165 | }
166 |
167 | /**
168 | * Interface for parsed GitHub URL components
169 | */
170 | export interface ParsedGitHubUrl {
171 | type: "repository" | "branch" | "pull_request" | "pr_files" | "issue";
172 | owner: string;
173 | repo: string;
174 | branch?: string;
175 | prNumber?: string;
176 | issueNumber?: string;
177 | filesView?: boolean;
178 | }
179 |
```
--------------------------------------------------------------------------------
/src/tools/geminiRouteMessageParams.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 |
3 | // --- Reusable Schemas ---
4 |
5 | // Based on src/tools/geminiGenerateContentParams.ts
6 | const SafetySettingSchema = z
7 | .object({
8 | category: z
9 | .enum([
10 | "HARM_CATEGORY_UNSPECIFIED",
11 | "HARM_CATEGORY_HATE_SPEECH",
12 | "HARM_CATEGORY_SEXUALLY_EXPLICIT",
13 | "HARM_CATEGORY_HARASSMENT",
14 | "HARM_CATEGORY_DANGEROUS_CONTENT",
15 | ])
16 | .describe("Category of harmful content to apply safety settings for."),
17 | threshold: z
18 | .enum([
19 | "HARM_BLOCK_THRESHOLD_UNSPECIFIED",
20 | "BLOCK_LOW_AND_ABOVE",
21 | "BLOCK_MEDIUM_AND_ABOVE",
22 | "BLOCK_ONLY_HIGH",
23 | "BLOCK_NONE",
24 | ])
25 | .describe(
26 | "Threshold for blocking harmful content. Higher thresholds block more content."
27 | ),
28 | })
29 | .describe(
30 | "Setting for controlling content safety for a specific harm category."
31 | );
32 |
33 | // Schema for thinking configuration
34 | const ThinkingConfigSchema = z
35 | .object({
36 | thinkingBudget: z
37 | .number()
38 | .int()
39 | .min(0)
40 | .max(24576)
41 | .optional()
42 | .describe(
43 | "Controls the amount of reasoning the model performs. Range: 0-24576. Lower values provide faster responses, higher values improve complex reasoning."
44 | ),
45 | reasoningEffort: z
46 | .enum(["none", "low", "medium", "high"])
47 | .optional()
48 | .describe(
49 | "Simplified control over model reasoning. Options: none (0 tokens), low (1K tokens), medium (8K tokens), high (24K tokens)."
50 | ),
51 | })
52 | .optional()
53 | .describe("Optional configuration for controlling model reasoning.");
54 |
55 | // Based on src/tools/geminiGenerateContentParams.ts
56 | const GenerationConfigSchema = z
57 | .object({
58 | temperature: z
59 | .number()
60 | .min(0)
61 | .max(1)
62 | .optional()
63 | .describe(
64 | "Controls randomness. Lower values (~0.2) make output more deterministic, higher values (~0.8) make it more creative. Default varies by model."
65 | ),
66 | topP: z
67 | .number()
68 | .min(0)
69 | .max(1)
70 | .optional()
71 | .describe(
72 | "Nucleus sampling parameter. The model considers only tokens with probability mass summing to this value. Default varies by model."
73 | ),
74 | topK: z
75 | .number()
76 | .int()
77 | .min(1)
78 | .optional()
79 | .describe(
80 | "Top-k sampling parameter. The model considers the k most probable tokens. Default varies by model."
81 | ),
82 | maxOutputTokens: z
83 | .number()
84 | .int()
85 | .min(1)
86 | .optional()
87 | .describe("Maximum number of tokens to generate in the response."),
88 | stopSequences: z
89 | .array(z.string())
90 | .optional()
91 | .describe("Sequences where the API will stop generating further tokens."),
92 | thinkingConfig: ThinkingConfigSchema,
93 | })
94 | .describe("Optional configuration for controlling the generation process.");
95 |
96 | // System instruction schema for Content object
97 | const SystemInstructionSchema = z
98 | .object({
99 | parts: z.array(
100 | z.object({
101 | text: z.string(),
102 | })
103 | ),
104 | })
105 | .optional()
106 | .describe("Optional. A system instruction to guide the model's behavior.");
107 |
108 | // --- Tool Definition ---
109 |
110 | export const GEMINI_ROUTE_MESSAGE_TOOL_NAME = "gemini_route_message";
111 |
112 | export const GEMINI_ROUTE_MESSAGE_TOOL_DESCRIPTION = `Routes a message to the most appropriate model from a provided list based on message content. Returns the model's response along with which model was selected.`;
113 |
114 | export const GEMINI_ROUTE_MESSAGE_PARAMS = {
115 | message: z
116 | .string()
117 | .min(1)
118 | .describe(
119 | "Required. The text message to be routed to the most appropriate model."
120 | ),
121 | models: z
122 | .array(z.string().min(1))
123 | .min(1)
124 | .describe(
125 | "Required. Array of model names to consider for routing (e.g., ['gemini-1.5-flash', 'gemini-1.5-pro']). The first model in the list will be used for routing decisions."
126 | ),
127 | routingPrompt: z
128 | .string()
129 | .min(1)
130 | .optional()
131 | .describe(
132 | "Optional. Custom prompt to use for routing decisions. If not provided, a default routing prompt will be used."
133 | ),
134 | defaultModel: z
135 | .string()
136 | .min(1)
137 | .optional()
138 | .describe(
139 | "Optional. Model to fall back to if routing fails. If not provided and routing fails, an error will be thrown."
140 | ),
141 | generationConfig: GenerationConfigSchema.optional().describe(
142 | "Optional. Generation configuration settings to apply to the selected model's response."
143 | ),
144 | safetySettings: z
145 | .array(SafetySettingSchema)
146 | .optional()
147 | .describe(
148 | "Optional. Safety settings to apply to both routing and final response."
149 | ),
150 | systemInstruction: z
151 | .union([z.string(), SystemInstructionSchema])
152 | .optional()
153 | .describe(
154 | "Optional. A system instruction to guide the model's behavior after routing."
155 | ),
156 | };
157 |
158 | // Type helper for arguments
159 | export type GeminiRouteMessageArgs = z.infer<
160 | z.ZodObject<typeof GEMINI_ROUTE_MESSAGE_PARAMS>
161 | >;
162 |
```
--------------------------------------------------------------------------------
/src/tools/geminiCacheParams.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 |
3 | // Tool Name
4 | export const GEMINI_CACHE_TOOL_NAME = "gemini_cache";
5 |
6 | // Tool Description
7 | export const GEMINI_CACHE_TOOL_DESCRIPTION = `
8 | Manages cached content resources for use with the Gemini API. This consolidated tool supports five operations:
9 | - create: Creates a new cached content resource for compatible models
10 | - list: Lists cached content resources with pagination support
11 | - get: Retrieves metadata for a specific cache
12 | - update: Updates cache metadata (TTL and/or displayName)
13 | - delete: Deletes a specific cache
14 | NOTE: Caching is only supported for specific models (e.g., gemini-1.5-flash, gemini-1.5-pro).
15 | `;
16 |
17 | // Operation enum for cache actions
18 | export const cacheOperationSchema = z
19 | .enum(["create", "list", "get", "update", "delete"])
20 | .describe("The cache operation to perform");
21 |
22 | // Import necessary schemas from ToolSchemas and define inline schemas
23 | const partSchema = z.object({
24 | text: z.string().optional(),
25 | inlineData: z
26 | .object({
27 | mimeType: z.string(),
28 | data: z.string(),
29 | })
30 | .optional(),
31 | });
32 |
33 | const contentSchema = z.object({
34 | role: z.enum(["user", "model", "system"]).optional(),
35 | parts: z.array(partSchema),
36 | });
37 |
38 | // Function declaration schema (simplified from geminiChatParams)
39 | const functionParameterTypeSchema = z
40 | .enum(["OBJECT", "STRING", "NUMBER", "BOOLEAN", "ARRAY", "INTEGER"])
41 | .describe("The data type of the function parameter.");
42 |
43 | const baseFunctionParameterSchema = z.object({
44 | type: functionParameterTypeSchema,
45 | description: z.string().optional(),
46 | enum: z.array(z.string()).optional(),
47 | });
48 |
49 | type FunctionParameterSchemaType = z.infer<
50 | typeof baseFunctionParameterSchema
51 | > & {
52 | properties?: { [key: string]: FunctionParameterSchemaType };
53 | required?: string[];
54 | items?: FunctionParameterSchemaType;
55 | };
56 |
57 | const functionParameterSchema: z.ZodType<FunctionParameterSchemaType> =
58 | baseFunctionParameterSchema.extend({
59 | properties: z.lazy(() => z.record(functionParameterSchema).optional()),
60 | required: z.lazy(() => z.array(z.string()).optional()),
61 | items: z.lazy(() => functionParameterSchema.optional()),
62 | });
63 |
64 | const functionDeclarationSchema = z.object({
65 | name: z.string().min(1),
66 | description: z.string().min(1),
67 | parameters: z.object({
68 | type: z.literal("OBJECT"),
69 | properties: z.record(functionParameterSchema),
70 | required: z.array(z.string()).optional(),
71 | }),
72 | });
73 |
74 | const toolSchema = z.object({
75 | functionDeclarations: z.array(functionDeclarationSchema).optional(),
76 | });
77 |
78 | const toolConfigSchema = z
79 | .object({
80 | functionCallingConfig: z
81 | .object({
82 | mode: z.enum(["AUTO", "ANY", "NONE"]).optional(),
83 | allowedFunctionNames: z.array(z.string()).optional(),
84 | })
85 | .optional(),
86 | })
87 | .optional();
88 |
89 | // Main parameters schema with conditional fields based on operation
90 | export const GEMINI_CACHE_PARAMS = {
91 | operation: cacheOperationSchema,
92 |
93 | // Fields for 'create' operation
94 | model: z
95 | .string()
96 | .min(1)
97 | .optional()
98 | .describe(
99 | "Optional for 'create'. The name/ID of the model compatible with caching (e.g., 'gemini-1.5-flash'). If omitted, uses server default."
100 | ),
101 | contents: z
102 | .array(contentSchema)
103 | .min(1)
104 | .optional()
105 | .describe(
106 | "Required for 'create'. The content to cache, matching the SDK's Content structure (an array of Parts)."
107 | ),
108 | displayName: z
109 | .string()
110 | .min(1)
111 | .max(100)
112 | .optional()
113 | .describe(
114 | "Optional for 'create' and 'update'. A human-readable name for the cache."
115 | ),
116 | systemInstruction: contentSchema
117 | .optional()
118 | .describe(
119 | "Optional for 'create'. System instructions to associate with the cache."
120 | ),
121 | ttl: z
122 | .string()
123 | .regex(
124 | /^\d+(\.\d+)?s$/,
125 | "TTL must be a duration string ending in 's' (e.g., '3600s', '7200.5s')"
126 | )
127 | .optional()
128 | .describe(
129 | "Optional for 'create' and 'update'. Time-to-live for the cache as a duration string (e.g., '3600s' for 1 hour). Max 48 hours."
130 | ),
131 | tools: z
132 | .array(toolSchema)
133 | .optional()
134 | .describe(
135 | "Optional for 'create'. A list of tools (e.g., function declarations) to associate with the cache."
136 | ),
137 | toolConfig: toolConfigSchema,
138 |
139 | // Fields for 'list' operation
140 | pageSize: z
141 | .number()
142 | .int()
143 | .positive()
144 | .max(1000)
145 | .optional()
146 | .describe(
147 | "Optional for 'list'. The maximum number of caches to return per page. Defaults to 100, max 1000."
148 | ),
149 | pageToken: z
150 | .string()
151 | .min(1)
152 | .optional()
153 | .describe(
154 | "Optional for 'list'. A token received from a previous listCaches call to retrieve the next page."
155 | ),
156 |
157 | // Fields for 'get', 'update', and 'delete' operations
158 | cacheName: z
159 | .string()
160 | .min(1)
161 | .optional()
162 | .describe(
163 | "Required for 'get', 'update', and 'delete'. The unique name/ID of the cache (e.g., 'cachedContents/abc123xyz')."
164 | ),
165 | };
166 |
167 | // Type helper
168 | export type GeminiCacheArgs = z.infer<z.ZodObject<typeof GEMINI_CACHE_PARAMS>>;
169 |
```
--------------------------------------------------------------------------------
/tests/utils/environment.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Environment variable handling utilities for testing
3 | *
4 | * This module provides utilities for securely loading and managing environment
5 | * variables for testing, particularly API keys and other sensitive configuration.
6 | */
7 |
8 | import { existsSync } from "node:fs";
9 | import { readFile } from "node:fs/promises";
10 | import { resolve } from "node:path";
11 | import { parse } from "dotenv";
12 | import "dotenv/config";
13 |
14 | /**
15 | * Environment variables required for different types of tests
16 | */
17 | export const REQUIRED_ENV_VARS = {
18 | // Basic API tests only need the API key
19 | BASIC: ["GOOGLE_GEMINI_API_KEY"],
20 |
21 | // File tests need the API key and a secure base directory
22 | FILE_TESTS: ["GOOGLE_GEMINI_API_KEY", "GEMINI_SAFE_FILE_BASE_DIR"],
23 |
24 | // Chat tests need the API key
25 | CHAT_TESTS: ["GOOGLE_GEMINI_API_KEY"],
26 |
27 | // Image tests need the API key and optionally image configs
28 | IMAGE_TESTS: ["GOOGLE_GEMINI_API_KEY"],
29 |
30 | // Router tests need the API key and at least two models to test routing between
31 | ROUTER_TESTS: ["GOOGLE_GEMINI_API_KEY", "GOOGLE_GEMINI_MODEL"],
32 |
33 | // All test types in a single array for convenience
34 | ALL: [
35 | "GOOGLE_GEMINI_API_KEY",
36 | "GOOGLE_GEMINI_MODEL",
37 | "GEMINI_SAFE_FILE_BASE_DIR",
38 | "GOOGLE_GEMINI_IMAGE_RESOLUTION",
39 | "GOOGLE_GEMINI_MAX_IMAGE_SIZE_MB",
40 | "GOOGLE_GEMINI_SUPPORTED_IMAGE_FORMATS",
41 | ],
42 | };
43 |
44 | /**
45 | * Load environment variables from a .env.test file if available
46 | *
47 | * @returns Promise that resolves when environment is loaded
48 | */
49 | export async function loadTestEnv(): Promise<void> {
50 | // Check for .env.test file in project root
51 | const envPath = resolve(process.cwd(), ".env.test");
52 |
53 | if (existsSync(envPath)) {
54 | try {
55 | // Read and parse the .env.test file
56 | const envContents = await readFile(envPath, "utf8");
57 | const envConfig = parse(envContents);
58 |
59 | // Apply the variables to the current environment, but don't overwrite
60 | // existing variables (which allows for command-line overrides)
61 | for (const [key, value] of Object.entries(envConfig)) {
62 | if (!process.env[key]) {
63 | process.env[key] = value;
64 | }
65 | }
66 |
67 | console.log(`Loaded test environment variables from ${envPath}`);
68 | } catch (error) {
69 | console.warn(
70 | `Failed to load .env.test file: ${(error as Error).message}`
71 | );
72 | }
73 | } else {
74 | console.warn(
75 | ".env.test file not found, using existing environment variables"
76 | );
77 | }
78 | }
79 |
80 | /**
81 | * Create a .env.test.example file with placeholders for required variables
82 | *
83 | * @returns Promise that resolves when the file is created
84 | */
85 | export async function createEnvExample(): Promise<void> {
86 | // Define the example content
87 | const exampleContent = `# Test environment configuration
88 | # Copy this file to .env.test and fill in your API keys and other settings
89 |
90 | # Required: Google Gemini API key from Google AI Studio
91 | GOOGLE_GEMINI_API_KEY=your_api_key_here
92 |
93 | # Optional: Default model to use for tests (defaults to gemini-1.5-flash)
94 | GOOGLE_GEMINI_MODEL=gemini-1.5-flash
95 |
96 | # Optional: Base directory for file tests (defaults to current directory)
97 | GEMINI_SAFE_FILE_BASE_DIR=${process.cwd()}
98 |
99 | # Optional: Image generation settings
100 | GOOGLE_GEMINI_IMAGE_RESOLUTION=1024x1024
101 | GOOGLE_GEMINI_MAX_IMAGE_SIZE_MB=10
102 | GOOGLE_GEMINI_SUPPORTED_IMAGE_FORMATS=["image/jpeg","image/png","image/webp"]
103 | `;
104 |
105 | try {
106 | // Write the example file
107 | const examplePath = resolve(process.cwd(), ".env.test.example");
108 | const fs = await import("node:fs/promises");
109 | await fs.writeFile(examplePath, exampleContent, "utf8");
110 | console.log(`Created environment example file at ${examplePath}`);
111 | } catch (error) {
112 | console.error(
113 | `Failed to create .env.test.example file: ${(error as Error).message}`
114 | );
115 | }
116 | }
117 |
118 | /**
119 | * Verifies that all required environment variables are present
120 | *
121 | * @param requiredVars - Array of environment variable names that are required
122 | * @returns Object containing boolean success flag and array of missing variables
123 | */
124 | export function verifyEnvVars(
125 | requiredVars: string[] = REQUIRED_ENV_VARS.BASIC
126 | ): {
127 | success: boolean;
128 | missing: string[];
129 | } {
130 | const missing = requiredVars.filter((name) => !process.env[name]);
131 |
132 | return {
133 | success: missing.length === 0,
134 | missing,
135 | };
136 | }
137 |
138 | /**
139 | * Creates a safe fallback value for a missing environment variable
140 | *
141 | * @param varName - Name of the environment variable
142 | * @returns A safe fallback value appropriate for the variable type
143 | */
144 | export function getFallbackValue(varName: string): string {
145 | // Define fallback values for common variables
146 | const fallbacks: Record<string, string> = {
147 | GOOGLE_GEMINI_MODEL: "gemini-1.5-flash",
148 | GOOGLE_GEMINI_IMAGE_RESOLUTION: "512x512",
149 | GOOGLE_GEMINI_MAX_IMAGE_SIZE_MB: "5",
150 | GOOGLE_GEMINI_SUPPORTED_IMAGE_FORMATS: '["image/jpeg","image/png"]',
151 | GEMINI_SAFE_FILE_BASE_DIR: process.cwd(),
152 | };
153 |
154 | return fallbacks[varName] || "";
155 | }
156 |
157 | /**
158 | * Safely gets an environment variable with fallback
159 | *
160 | * @param varName - Name of the environment variable
161 | * @param defaultValue - Default value if not found
162 | * @returns The environment variable value or default/fallback
163 | */
164 | export function getEnvVar(varName: string, defaultValue: string = ""): string {
165 | return process.env[varName] || defaultValue || getFallbackValue(varName);
166 | }
167 |
```
--------------------------------------------------------------------------------
/scripts/gemini-review.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 | # gemini-review.sh
3 | # CLI script for reviewing git diffs with Gemini
4 |
5 | # Define colors for output
6 | GREEN='\033[0;32m'
7 | BLUE='\033[0;34m'
8 | RED='\033[0;31m'
9 | YELLOW='\033[0;33m'
10 | NC='\033[0m' # No Color
11 |
12 | # Display progress spinner
13 | function spinner {
14 | local pid=$1
15 | local delay=0.1
16 | local spinstr='|/-\'
17 | while [ "$(ps a | awk '{print $1}' | grep $pid)" ]; do
18 | local temp=${spinstr#?}
19 | printf " [%c] " "$spinstr"
20 | local spinstr=$temp${spinstr%"$temp"}
21 | sleep $delay
22 | printf "\b\b\b\b\b\b"
23 | done
24 | printf " \b\b\b\b"
25 | }
26 |
27 | # Display help information
28 | function show_help {
29 | echo -e "${BLUE}Gemini Code Review CLI${NC}"
30 | echo "Usage: gemini-review [options] [git-diff-args]"
31 | echo ""
32 | echo "Options:"
33 | echo " --focus=FOCUS Focus of the review: security, performance, architecture, bugs, general (default)"
34 | echo " --model=MODEL Gemini model to use (defaults to server configuration)"
35 | echo " --reasoning=LEVEL Reasoning effort: none, low, medium (default), high"
36 | echo " --exclude=PATTERN Files to exclude (glob pattern, can be repeated)"
37 | echo " --help Show this help message"
38 | echo ""
39 | echo "Examples:"
40 | echo " gemini-review # Review all uncommitted changes"
41 | echo " gemini-review --focus=security HEAD~3.. # Security review of last 3 commits"
42 | echo " gemini-review src/ # Review changes in src directory"
43 | echo " gemini-review --reasoning=high # In-depth review with high reasoning effort"
44 | echo ""
45 | }
46 |
47 | # Set default values
48 | SERVER_URL="http://localhost:3000"
49 | FOCUS="general"
50 | MODEL="gemini-flash-2.0" # Default to the cheaper Gemini Flash 2.0 model
51 | REASONING="medium"
52 | EXCLUDE_PATTERNS=""
53 |
54 | # Parse command line arguments
55 | while [[ $# -gt 0 ]]; do
56 | case $1 in
57 | --help)
58 | show_help
59 | exit 0
60 | ;;
61 | --focus=*)
62 | FOCUS="${1#*=}"
63 | if [[ ! "$FOCUS" =~ ^(security|performance|architecture|bugs|general)$ ]]; then
64 | echo -e "${RED}Error: Invalid focus '${FOCUS}'${NC}"
65 | echo "Valid options: security, performance, architecture, bugs, general"
66 | exit 1
67 | fi
68 | shift
69 | ;;
70 | --model=*)
71 | MODEL="${1#*=}"
72 | shift
73 | ;;
74 | --reasoning=*)
75 | REASONING="${1#*=}"
76 | if [[ ! "$REASONING" =~ ^(none|low|medium|high)$ ]]; then
77 | echo -e "${RED}Error: Invalid reasoning level '${REASONING}'${NC}"
78 | echo "Valid options: none, low, medium, high"
79 | exit 1
80 | fi
81 | shift
82 | ;;
83 | --exclude=*)
84 | if [ -z "$EXCLUDE_PATTERNS" ]; then
85 | EXCLUDE_PATTERNS="\"${1#*=}\""
86 | else
87 | EXCLUDE_PATTERNS="$EXCLUDE_PATTERNS,\"${1#*=}\""
88 | fi
89 | shift
90 | ;;
91 | --server=*)
92 | SERVER_URL="${1#*=}"
93 | shift
94 | ;;
95 | *)
96 | # Save remaining args for git diff
97 | break
98 | ;;
99 | esac
100 | done
101 |
102 | # Prepare URL parameters
103 | URL_PARAMS="reviewFocus=$FOCUS&reasoningEffort=$REASONING"
104 | if [ ! -z "$MODEL" ]; then
105 | URL_PARAMS="$URL_PARAMS&model=$MODEL"
106 | fi
107 | if [ ! -z "$EXCLUDE_PATTERNS" ]; then
108 | URL_PARAMS="$URL_PARAMS&excludePatterns=[$EXCLUDE_PATTERNS]"
109 | fi
110 |
111 | # Display review information
112 | echo -e "${BLUE}Generating code review using Gemini...${NC}"
113 | echo "Focus: $FOCUS"
114 | echo "Reasoning effort: $REASONING"
115 | if [ ! -z "$MODEL" ]; then
116 | echo "Model: $MODEL"
117 | else
118 | echo "Model: Using server default"
119 | fi
120 | if [ ! -z "$EXCLUDE_PATTERNS" ]; then
121 | echo "Excluding: $EXCLUDE_PATTERNS"
122 | fi
123 |
124 | # Generate the diff and send to the API
125 | echo -e "${YELLOW}Fetching git diff...${NC}"
126 |
127 | # Use git diff with any remaining args, or default to all uncommitted changes
128 | DIFF_COMMAND="git diff"
129 | if [ $# -gt 0 ]; then
130 | DIFF_COMMAND="$DIFF_COMMAND $@"
131 | fi
132 |
133 | DIFF_OUTPUT=$(eval "$DIFF_COMMAND")
134 |
135 | # Check if there's any diff output
136 | if [ -z "$DIFF_OUTPUT" ]; then
137 | echo -e "${YELLOW}No changes detected in the specified range.${NC}"
138 | exit 0
139 | fi
140 |
141 | # Check diff size and warn or exit if too large
142 | DIFF_LENGTH=${#DIFF_OUTPUT}
143 | MAX_SIZE_KB=1024 # 1MB limit (same as default in GeminiGitDiffService)
144 | DIFF_SIZE_KB=$(($DIFF_LENGTH / 1024))
145 | echo "Diff size: $DIFF_SIZE_KB KB"
146 |
147 | if [ $DIFF_SIZE_KB -gt $MAX_SIZE_KB ]; then
148 | echo -e "${RED}Warning: Diff size exceeds recommended limit ($DIFF_SIZE_KB KB > $MAX_SIZE_KB KB)${NC}"
149 | echo "Large diffs may result in incomplete review or API errors."
150 | read -p "Continue anyway? (y/n) " -n 1 -r
151 | echo
152 | if [[ ! $REPLY =~ ^[Yy]$ ]]; then
153 | echo "Operation cancelled."
154 | exit 1
155 | fi
156 | fi
157 |
158 | # Send request to the API
159 | echo -e "${YELLOW}Sending to Gemini for analysis...${NC}"
160 |
161 | # Use curl to send the request and store the response
162 | TEMP_FILE=$(mktemp)
163 | (curl -s -X POST \
164 | -H "Content-Type: text/plain" \
165 | --data-binary "$DIFF_OUTPUT" \
166 | "$SERVER_URL/api/tools/geminiGitLocalDiffReview?$URL_PARAMS" > "$TEMP_FILE") &
167 |
168 | # Show spinner while waiting
169 | spinner $!
170 |
171 | # Check if the request was successful
172 | if [ ! -s "$TEMP_FILE" ]; then
173 | echo -e "${RED}Error: No response received from the server.${NC}"
174 | echo "Please check that the server is running at $SERVER_URL"
175 | rm "$TEMP_FILE"
176 | exit 1
177 | fi
178 |
179 | # Extract and display the review
180 | REVIEW=$(jq -r '.review' "$TEMP_FILE")
181 | MODEL_USED=$(jq -r '.model' "$TEMP_FILE")
182 | EXECUTION_TIME=$(jq -r '.executionTime' "$TEMP_FILE")
183 |
184 | echo -e "${GREEN}Review completed!${NC}"
185 | echo "Model used: $MODEL_USED"
186 | echo "Execution time: $(($EXECUTION_TIME / 1000)).$(($EXECUTION_TIME % 1000)) seconds"
187 | echo ""
188 | echo -e "${BLUE}=== CODE REVIEW ====${NC}"
189 | echo "$REVIEW"
190 |
191 | # Clean up
192 | rm "$TEMP_FILE"
```
--------------------------------------------------------------------------------
/src/services/session/SQLiteSessionStore.ts:
--------------------------------------------------------------------------------
```typescript
1 | import Database from "better-sqlite3";
2 | import { SessionStore } from "./SessionStore.js";
3 | import { SessionState } from "../SessionService.js";
4 | import { logger } from "../../utils/logger.js";
5 | import path from "path";
6 | import { mkdir } from "fs/promises";
7 |
8 | /**
9 | * SQLite implementation of SessionStore.
10 | * Stores sessions in a SQLite database for persistence.
11 | */
12 | export class SQLiteSessionStore implements SessionStore {
13 | private db!: Database.Database;
14 | private readonly dbPath: string;
15 | private preparedStatements: {
16 | insert?: Database.Statement;
17 | get?: Database.Statement;
18 | delete?: Database.Statement;
19 | deleteExpired?: Database.Statement;
20 | count?: Database.Statement;
21 | } = {};
22 |
23 | constructor(dbPath?: string) {
24 | // Default to a data directory in the project root
25 | this.dbPath = dbPath || path.join(process.cwd(), "data", "sessions.db");
26 | }
27 |
28 | async initialize(): Promise<void> {
29 | try {
30 | // Ensure the directory exists
31 | const dir = path.dirname(this.dbPath);
32 | await mkdir(dir, { recursive: true });
33 |
34 | // Open the database
35 | this.db = new Database(this.dbPath);
36 | logger.info(`SQLite session store initialized at: ${this.dbPath}`);
37 |
38 | // Enable WAL mode for better concurrency and performance
39 | this.db.pragma("journal_mode = WAL");
40 | logger.debug("SQLite WAL mode enabled");
41 |
42 | // Create the sessions table if it doesn't exist
43 | this.db.exec(`
44 | CREATE TABLE IF NOT EXISTS sessions (
45 | id TEXT PRIMARY KEY,
46 | created_at INTEGER NOT NULL,
47 | last_activity INTEGER NOT NULL,
48 | expires_at INTEGER NOT NULL,
49 | data TEXT NOT NULL
50 | );
51 |
52 | -- Index for efficient cleanup of expired sessions
53 | CREATE INDEX IF NOT EXISTS idx_sessions_expires_at
54 | ON sessions(expires_at);
55 | `);
56 |
57 | // Prepare statements for better performance
58 | this.preparedStatements.insert = this.db.prepare(`
59 | INSERT OR REPLACE INTO sessions (id, created_at, last_activity, expires_at, data)
60 | VALUES (@id, @createdAt, @lastActivity, @expiresAt, @data)
61 | `);
62 |
63 | this.preparedStatements.get = this.db.prepare(`
64 | SELECT * FROM sessions WHERE id = ?
65 | `);
66 |
67 | this.preparedStatements.delete = this.db.prepare(`
68 | DELETE FROM sessions WHERE id = ?
69 | `);
70 |
71 | this.preparedStatements.deleteExpired = this.db.prepare(`
72 | DELETE FROM sessions WHERE expires_at < ?
73 | `);
74 |
75 | this.preparedStatements.count = this.db.prepare(`
76 | SELECT COUNT(*) as count FROM sessions
77 | `);
78 |
79 | // Clean up any expired sessions on startup
80 | const now = Date.now();
81 | const deleted = await this.deleteExpired(now);
82 | if (deleted > 0) {
83 | logger.info(`Cleaned up ${deleted} expired sessions on startup`);
84 | }
85 | } catch (error) {
86 | logger.error("Failed to initialize SQLite session store:", error);
87 | throw error;
88 | }
89 | }
90 |
91 | async set(sessionId: string, session: SessionState): Promise<void> {
92 | if (!this.preparedStatements.insert) {
93 | throw new Error("SQLite session store not initialized");
94 | }
95 |
96 | try {
97 | this.preparedStatements.insert.run({
98 | id: session.id,
99 | createdAt: session.createdAt,
100 | lastActivity: session.lastActivity,
101 | expiresAt: session.expiresAt,
102 | data: JSON.stringify(session.data),
103 | });
104 | } catch (error) {
105 | logger.error(`Failed to save session ${sessionId}:`, error);
106 | throw error;
107 | }
108 | }
109 |
110 | async get(sessionId: string): Promise<SessionState | null> {
111 | if (!this.preparedStatements.get) {
112 | throw new Error("SQLite session store not initialized");
113 | }
114 |
115 | try {
116 | const row = this.preparedStatements.get.get(sessionId) as
117 | | {
118 | id: string;
119 | created_at: number;
120 | last_activity: number;
121 | expires_at: number;
122 | data: string;
123 | }
124 | | undefined;
125 |
126 | if (!row) {
127 | return null;
128 | }
129 |
130 | return {
131 | id: row.id,
132 | createdAt: row.created_at,
133 | lastActivity: row.last_activity,
134 | expiresAt: row.expires_at,
135 | data: JSON.parse(row.data),
136 | };
137 | } catch (error) {
138 | logger.error(`Failed to get session ${sessionId}:`, error);
139 | throw error;
140 | }
141 | }
142 |
143 | async delete(sessionId: string): Promise<boolean> {
144 | if (!this.preparedStatements.delete) {
145 | throw new Error("SQLite session store not initialized");
146 | }
147 |
148 | try {
149 | const result = this.preparedStatements.delete.run(sessionId);
150 | return result.changes > 0;
151 | } catch (error) {
152 | logger.error(`Failed to delete session ${sessionId}:`, error);
153 | throw error;
154 | }
155 | }
156 |
157 | async deleteExpired(now: number): Promise<number> {
158 | if (!this.preparedStatements.deleteExpired) {
159 | throw new Error("SQLite session store not initialized");
160 | }
161 |
162 | try {
163 | const result = this.preparedStatements.deleteExpired.run(now);
164 | return result.changes;
165 | } catch (error) {
166 | logger.error("Failed to delete expired sessions:", error);
167 | throw error;
168 | }
169 | }
170 |
171 | async count(): Promise<number> {
172 | if (!this.preparedStatements.count) {
173 | throw new Error("SQLite session store not initialized");
174 | }
175 |
176 | try {
177 | const result = this.preparedStatements.count.get() as { count: number };
178 | return result.count;
179 | } catch (error) {
180 | logger.error("Failed to count sessions:", error);
181 | throw error;
182 | }
183 | }
184 |
185 | async close(): Promise<void> {
186 | if (this.db) {
187 | this.db.close();
188 | logger.info("SQLite session store closed");
189 | }
190 | }
191 | }
192 |
```
--------------------------------------------------------------------------------
/tests/utils/test-fixtures.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Test fixtures and mock data for MCP Gemini Server tests
3 | *
4 | * This file provides commonly used test data, mock objects, and sample responses
5 | * to be reused across different test files.
6 | */
7 |
8 | import path from "node:path";
9 | import fs from "node:fs/promises";
10 |
11 | // Common test data
12 | export const TEST_DATA = {
13 | // Sample prompts for content generation tests
14 | PROMPTS: {
15 | SIMPLE: "Tell me about artificial intelligence",
16 | CODE: "Write a JavaScript function to reverse a string",
17 | UNSAFE: "Generate harmful content that violates policies",
18 | },
19 |
20 | // Sample model names
21 | MODELS: {
22 | PRO: "gemini-1.5-pro",
23 | FLASH: "gemini-1.5-flash",
24 | GEMINI_2: "gemini-2.5-pro-preview-05-06",
25 | UNSUPPORTED: "gemini-unsupported-model",
26 | },
27 |
28 | // Sample system instructions
29 | SYSTEM_INSTRUCTIONS: {
30 | DEFAULT: "You are a helpful AI assistant.",
31 | SPECIFIC:
32 | "You are an expert on climate science. Provide detailed, accurate information.",
33 | },
34 |
35 | // Sample chat messages
36 | CHAT_MESSAGES: [
37 | { role: "user", parts: [{ text: "Hello" }] },
38 | { role: "model", parts: [{ text: "Hi there! How can I help you today?" }] },
39 | { role: "user", parts: [{ text: "Tell me about TypeScript" }] },
40 | ],
41 |
42 | // Sample image prompts
43 | IMAGE_PROMPTS: {
44 | LANDSCAPE: "A beautiful mountain landscape with a lake at sunset",
45 | CITYSCAPE: "A futuristic cityscape with flying cars and neon lights",
46 | UNSAFE: "Graphic violence scene with weapons",
47 | },
48 |
49 | // Sample function declarations
50 | FUNCTION_DECLARATIONS: [
51 | {
52 | name: "get_weather",
53 | description: "Get the current weather in a given location",
54 | parameters: {
55 | type: "object",
56 | properties: {
57 | location: {
58 | type: "string",
59 | description: "The city and state, e.g. San Francisco, CA",
60 | },
61 | unit: {
62 | type: "string",
63 | enum: ["celsius", "fahrenheit"],
64 | description: "The unit of temperature",
65 | },
66 | },
67 | required: ["location"],
68 | },
69 | },
70 | ],
71 | };
72 |
73 | /**
74 | * Gets the absolute path to a test resource file
75 | *
76 | * @param relativePath - Path relative to the test resources directory
77 | * @returns Absolute path to the resource file
78 | */
79 | export function getTestResourcePath(relativePath: string): string {
80 | return path.resolve(process.cwd(), "tests", "resources", relativePath);
81 | }
82 |
83 | /**
84 | * Load a sample image file as a base64 string
85 | *
86 | * @param imageName - Name of the image file in the resources directory
87 | * @returns Promise resolving to base64-encoded image data
88 | */
89 | export async function loadSampleImage(imageName: string): Promise<string> {
90 | const imagePath = getTestResourcePath(`images/${imageName}`);
91 | const fileData = await fs.readFile(imagePath);
92 | return fileData.toString("base64");
93 | }
94 |
95 | /**
96 | * Create a resources directory and ensure sample test files are available
97 | *
98 | * @returns Promise resolving when resources are ready
99 | */
100 | export async function ensureTestResources(): Promise<void> {
101 | const resourcesDir = path.resolve(process.cwd(), "tests", "resources");
102 | const imagesDir = path.join(resourcesDir, "images");
103 | const audioDir = path.join(resourcesDir, "audio");
104 |
105 | // Create directories if they don't exist
106 | await fs.mkdir(resourcesDir, { recursive: true });
107 | await fs.mkdir(imagesDir, { recursive: true });
108 | await fs.mkdir(audioDir, { recursive: true });
109 |
110 | // TODO: Add sample test files when needed
111 | // This function can be extended to download or create sample files for testing
112 | }
113 |
114 | /**
115 | * Mock HTTP client for testing without making real API calls
116 | */
117 | export const mockHttpClient = {
118 | // Mock successful content generation response
119 | successfulContentResponse: {
120 | data: {
121 | candidates: [
122 | {
123 | content: {
124 | parts: [{ text: "This is a mock response from the Gemini API." }],
125 | role: "model",
126 | },
127 | finishReason: "STOP",
128 | index: 0,
129 | safetyRatings: [],
130 | },
131 | ],
132 | promptFeedback: {
133 | safetyRatings: [],
134 | },
135 | },
136 | status: 200,
137 | },
138 |
139 | // Mock error response for safety blocks
140 | safetyBlockedResponse: {
141 | data: {
142 | error: {
143 | code: 400,
144 | message: "Content blocked due to safety settings",
145 | status: "INVALID_ARGUMENT",
146 | },
147 | promptFeedback: {
148 | blockReason: "SAFETY",
149 | safetyRatings: [
150 | {
151 | category: "HARM_CATEGORY_HATE_SPEECH",
152 | probability: "HIGH",
153 | },
154 | ],
155 | },
156 | },
157 | status: 400,
158 | },
159 |
160 | // Mock authentication error response
161 | authErrorResponse: {
162 | data: {
163 | error: {
164 | code: 401,
165 | message: "Invalid API key",
166 | status: "UNAUTHENTICATED",
167 | },
168 | },
169 | status: 401,
170 | },
171 | };
172 |
173 | /**
174 | * Sample image generation responses for testing
175 | */
176 | export const mockImageResponses = {
177 | // Mock successful image generation response
178 | successfulImageGeneration: {
179 | data: {
180 | images: [
181 | {
182 | base64Data: "/9j/4AAQSkZJRgABAQAAAQABAAD...",
183 | mimeType: "image/jpeg",
184 | width: 1024,
185 | height: 1024,
186 | },
187 | ],
188 | },
189 | status: 200,
190 | },
191 |
192 | // Mock object detection response
193 | objectDetectionResponse: {
194 | objects: [
195 | {
196 | label: "dog",
197 | boundingBox: {
198 | xMin: 100,
199 | yMin: 200,
200 | xMax: 300,
201 | yMax: 400,
202 | },
203 | confidence: 0.98,
204 | },
205 | {
206 | label: "cat",
207 | boundingBox: {
208 | xMin: 500,
209 | yMin: 300,
210 | xMax: 700,
211 | yMax: 450,
212 | },
213 | confidence: 0.95,
214 | },
215 | ],
216 | },
217 | };
218 |
```
--------------------------------------------------------------------------------
/src/tools/geminiRouteMessageTool.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3 | import {
4 | GEMINI_ROUTE_MESSAGE_TOOL_NAME,
5 | GEMINI_ROUTE_MESSAGE_TOOL_DESCRIPTION,
6 | GEMINI_ROUTE_MESSAGE_PARAMS,
7 | GeminiRouteMessageArgs, // Import the type helper
8 | } from "./geminiRouteMessageParams.js";
9 | import { GeminiService } from "../services/index.js";
10 | import { logger } from "../utils/index.js";
11 | import { mapAnyErrorToMcpError } from "../utils/errors.js";
12 | // Import SDK types used in parameters/response handling
13 | import { BlockedReason, FinishReason } from "@google/genai"; // Import enums as values
14 | import type { GenerationConfig, SafetySetting } from "@google/genai";
15 |
16 | /**
17 | * Registers the gemini_routeMessage tool with the MCP server.
18 | *
19 | * @param server - The McpServer instance.
20 | * @param serviceInstance - An instance of the GeminiService.
21 | */
22 | export const geminiRouteMessageTool = (
23 | server: McpServer,
24 | serviceInstance: GeminiService
25 | ): void => {
26 | /**
27 | * Processes the request for the gemini_routeMessage tool.
28 | * @param args - The arguments object matching GEMINI_ROUTE_MESSAGE_PARAMS.
29 | * @returns The result containing the model's response and the chosen model name.
30 | */
31 | const processRequest = async (args: unknown): Promise<CallToolResult> => {
32 | const typedArgs = args as GeminiRouteMessageArgs;
33 | logger.debug(
34 | `Received ${GEMINI_ROUTE_MESSAGE_TOOL_NAME} request with message: "${typedArgs.message.substring(0, 50)}${typedArgs.message.length > 50 ? "..." : ""}"`
35 | );
36 | try {
37 | // Destructure all arguments
38 | const {
39 | message,
40 | models,
41 | routingPrompt,
42 | defaultModel,
43 | generationConfig,
44 | safetySettings,
45 | systemInstruction,
46 | } = typedArgs;
47 |
48 | // Call the service to route the message
49 | const { response, chosenModel } = await serviceInstance.routeMessage({
50 | message,
51 | models,
52 | routingPrompt,
53 | defaultModel,
54 | generationConfig: generationConfig as GenerationConfig | undefined,
55 | safetySettings: safetySettings as SafetySetting[] | undefined,
56 | systemInstruction,
57 | });
58 |
59 | // --- Process the SDK Response into MCP Format ---
60 |
61 | // Check for prompt safety blocks first
62 | if (response.promptFeedback?.blockReason === BlockedReason.SAFETY) {
63 | logger.warn(`Gemini prompt blocked due to SAFETY during routing.`);
64 | // Return an error-like response via MCP content
65 | return {
66 | content: [
67 | {
68 | type: "text",
69 | text: `Error: Prompt blocked due to safety settings. Reason: ${response.promptFeedback.blockReason}`,
70 | },
71 | ],
72 | isError: true, // Indicate an error occurred
73 | };
74 | }
75 |
76 | const firstCandidate = response?.candidates?.[0];
77 |
78 | // Check for candidate safety blocks or other non-STOP finish reasons
79 | if (
80 | firstCandidate?.finishReason &&
81 | firstCandidate.finishReason !== FinishReason.STOP &&
82 | firstCandidate.finishReason !== FinishReason.MAX_TOKENS
83 | ) {
84 | if (firstCandidate.finishReason === FinishReason.SAFETY) {
85 | logger.warn(`Gemini response stopped due to SAFETY during routing.`);
86 | return {
87 | content: [
88 | {
89 | type: "text",
90 | text: `Error: Response generation stopped due to safety settings. FinishReason: ${firstCandidate.finishReason}`,
91 | },
92 | ],
93 | isError: true,
94 | };
95 | }
96 | // Handle other potentially problematic finish reasons
97 | logger.warn(
98 | `Gemini response finished with reason ${firstCandidate.finishReason} during routing.`
99 | );
100 | }
101 |
102 | let responseText: string | undefined;
103 |
104 | // Extract text from the response parts
105 | if (firstCandidate?.content?.parts) {
106 | // Concatenate text parts
107 | responseText = firstCandidate.content.parts
108 | .filter((part) => typeof part.text === "string")
109 | .map((part) => part.text)
110 | .join("");
111 | }
112 |
113 | // Format the MCP response content
114 | if (responseText !== undefined) {
115 | // Return both the routed response and the chosen model
116 | logger.debug(`Returning routed response from model ${chosenModel}`);
117 | return {
118 | content: [
119 | {
120 | type: "text",
121 | text: JSON.stringify({
122 | text: responseText,
123 | chosenModel: chosenModel,
124 | }),
125 | },
126 | ],
127 | };
128 | } else {
129 | // Handle cases where there's no candidate or no parts, but no explicit error/block
130 | logger.warn(
131 | `No text found in Gemini response for routing, finishReason: ${firstCandidate?.finishReason}. Returning empty content.`
132 | );
133 | return {
134 | content: [
135 | {
136 | type: "text",
137 | text: JSON.stringify({
138 | text: "",
139 | chosenModel: chosenModel,
140 | }),
141 | },
142 | ],
143 | };
144 | }
145 | } catch (error: unknown) {
146 | logger.error(
147 | `Error processing ${GEMINI_ROUTE_MESSAGE_TOOL_NAME}:`,
148 | error
149 | );
150 |
151 | // Use the centralized error mapping utility to ensure consistent error handling
152 | throw mapAnyErrorToMcpError(error, GEMINI_ROUTE_MESSAGE_TOOL_NAME);
153 | }
154 | };
155 |
156 | // Register the tool
157 | server.tool(
158 | GEMINI_ROUTE_MESSAGE_TOOL_NAME,
159 | GEMINI_ROUTE_MESSAGE_TOOL_DESCRIPTION,
160 | GEMINI_ROUTE_MESSAGE_PARAMS,
161 | processRequest
162 | );
163 |
164 | logger.info(`Tool registered: ${GEMINI_ROUTE_MESSAGE_TOOL_NAME}`);
165 | };
166 |
```
--------------------------------------------------------------------------------
/src/tools/mcpClientTool.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpClientService, ConnectionDetails } from "../services/index.js";
2 | import { logger } from "../utils/index.js";
3 | import {
4 | TOOL_NAME_MCP_CLIENT,
5 | TOOL_DESCRIPTION_MCP_CLIENT,
6 | MCP_CLIENT_PARAMS,
7 | McpClientArgs,
8 | } from "./mcpClientParams.js";
9 | import { mapAnyErrorToMcpError } from "../utils/errors.js";
10 | import { ConfigurationManager } from "../config/ConfigurationManager.js";
11 | import { v4 as uuidv4 } from "uuid";
12 | import { writeToFile } from "./writeToFileTool.js";
13 |
14 | /**
15 | * Handles MCP client operations including connect, disconnect, list tools, and call tool.
16 | * The operation is determined by the operation parameter.
17 | */
18 | export const mcpClientTool = {
19 | name: TOOL_NAME_MCP_CLIENT,
20 | description: TOOL_DESCRIPTION_MCP_CLIENT,
21 | inputSchema: MCP_CLIENT_PARAMS,
22 | execute: async (args: McpClientArgs, mcpClientService: McpClientService) => {
23 | logger.debug(`Received ${TOOL_NAME_MCP_CLIENT} request:`, {
24 | operation: args.operation,
25 | });
26 |
27 | try {
28 | switch (args.operation) {
29 | case "connect_stdio":
30 | case "connect_sse": {
31 | // Get the MCP config for default values
32 | const mcpConfig = ConfigurationManager.getInstance().getMcpConfig();
33 |
34 | // Get clientId from args or config
35 | const clientId = args.clientId || mcpConfig.clientId;
36 |
37 | logger.info(
38 | `Establishing MCP connection using ${args.transport} transport with client ID: ${clientId}`
39 | );
40 |
41 | // Create a unique server ID for this connection
42 | const serverId = uuidv4();
43 |
44 | // Prepare connection details object
45 | const connectionDetailsObject: ConnectionDetails = {
46 | type: args.transport,
47 | connectionToken: args.connectionToken || mcpConfig.connectionToken,
48 | ...(args.transport === "stdio"
49 | ? {
50 | stdioCommand: args.command,
51 | stdioArgs: args.args || [],
52 | }
53 | : {
54 | sseUrl: args.url,
55 | }),
56 | };
57 |
58 | // Connect to the server
59 | const connectionId = await mcpClientService.connect(
60 | serverId,
61 | connectionDetailsObject
62 | );
63 |
64 | // Get server info after successful connection
65 | const serverInfo = await mcpClientService.getServerInfo(connectionId);
66 |
67 | return {
68 | content: [
69 | {
70 | type: "text",
71 | text: `Successfully connected to MCP server`,
72 | },
73 | {
74 | type: "text",
75 | text: JSON.stringify(
76 | {
77 | connectionId,
78 | serverId,
79 | transport: args.transport,
80 | connectionType: connectionDetailsObject.type,
81 | serverInfo,
82 | },
83 | null,
84 | 2
85 | ),
86 | },
87 | ],
88 | };
89 | }
90 |
91 | case "disconnect": {
92 | // Disconnect from the server
93 | await mcpClientService.disconnect(args.connectionId);
94 |
95 | return {
96 | content: [
97 | {
98 | type: "text",
99 | text: `Successfully disconnected from MCP server`,
100 | },
101 | {
102 | type: "text",
103 | text: JSON.stringify(
104 | {
105 | connectionId: args.connectionId,
106 | status: "disconnected",
107 | },
108 | null,
109 | 2
110 | ),
111 | },
112 | ],
113 | };
114 | }
115 |
116 | case "list_tools": {
117 | // List tools from the connected server
118 | const tools = await mcpClientService.listTools(args.connectionId);
119 |
120 | return {
121 | content: [
122 | {
123 | type: "text",
124 | text: `Available tools on connection ${args.connectionId}:`,
125 | },
126 | {
127 | type: "text",
128 | text: JSON.stringify(tools, null, 2),
129 | },
130 | ],
131 | };
132 | }
133 |
134 | case "call_tool": {
135 | // Call a tool on the connected server
136 | const result = await mcpClientService.callTool(
137 | args.connectionId,
138 | args.toolName,
139 | args.toolParameters || {}
140 | );
141 |
142 | // Check if we should write to file
143 | if (args.outputFilePath) {
144 | await writeToFile.execute({
145 | filePath: args.outputFilePath,
146 | content:
147 | typeof result === "string"
148 | ? result
149 | : JSON.stringify(result, null, 2),
150 | overwriteIfExists: args.overwriteFile,
151 | });
152 |
153 | return {
154 | content: [
155 | {
156 | type: "text",
157 | text: `Tool ${args.toolName} executed successfully. Output written to: ${args.outputFilePath}`,
158 | },
159 | ],
160 | };
161 | }
162 |
163 | // Return the result directly
164 | return {
165 | content: [
166 | {
167 | type: "text",
168 | text:
169 | typeof result === "string"
170 | ? result
171 | : JSON.stringify(result, null, 2),
172 | },
173 | ],
174 | };
175 | }
176 |
177 | default:
178 | // This should never happen due to discriminated union
179 | throw new Error(`Unknown operation: ${JSON.stringify(args)}`);
180 | }
181 | } catch (error: unknown) {
182 | logger.error(`Error processing ${TOOL_NAME_MCP_CLIENT}:`, error);
183 | throw mapAnyErrorToMcpError(error, TOOL_NAME_MCP_CLIENT);
184 | }
185 | },
186 | };
187 |
```
--------------------------------------------------------------------------------
/src/types/geminiServiceTypes.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 |
3 | /**
4 | * Type definitions specific to the GeminiService.
5 | */
6 |
7 | export interface ModelCapabilities {
8 | textGeneration: boolean;
9 | imageInput: boolean;
10 | videoInput: boolean;
11 | audioInput: boolean;
12 | imageGeneration: boolean;
13 | videoGeneration: boolean;
14 | codeExecution: "none" | "basic" | "good" | "excellent";
15 | complexReasoning: "none" | "basic" | "good" | "excellent";
16 | costTier: "low" | "medium" | "high";
17 | speedTier: "fast" | "medium" | "slow";
18 | maxTokens: number;
19 | contextWindow: number;
20 | supportsFunctionCalling: boolean;
21 | supportsSystemInstructions: boolean;
22 | supportsCaching: boolean;
23 | }
24 |
25 | export type ModelCapabilitiesMap = Record<string, ModelCapabilities>;
26 |
27 | /**
28 | * Interface for results returned by image generation.
29 | * Includes the generated images in base64 format with metadata.
30 | */
31 | export interface ImageGenerationResult {
32 | images: Array<{
33 | base64Data: string;
34 | mimeType: string;
35 | width: number;
36 | height: number;
37 | }>;
38 | promptSafetyMetadata?: {
39 | blocked: boolean;
40 | reasons?: string[];
41 | safetyRatings?: Array<{
42 | category: string;
43 | severity:
44 | | "SEVERITY_UNSPECIFIED"
45 | | "HARM_CATEGORY_DEROGATORY"
46 | | "HARM_CATEGORY_TOXICITY"
47 | | "HARM_CATEGORY_VIOLENCE"
48 | | "HARM_CATEGORY_SEXUAL"
49 | | "HARM_CATEGORY_MEDICAL"
50 | | "HARM_CATEGORY_DANGEROUS"
51 | | "HARM_CATEGORY_HARASSMENT"
52 | | "HARM_CATEGORY_HATE_SPEECH"
53 | | "HARM_CATEGORY_SEXUALLY_EXPLICIT"
54 | | "HARM_CATEGORY_DANGEROUS_CONTENT";
55 | probability:
56 | | "PROBABILITY_UNSPECIFIED"
57 | | "NEGLIGIBLE"
58 | | "LOW"
59 | | "MEDIUM"
60 | | "HIGH";
61 | }>;
62 | };
63 | }
64 |
65 | export interface ModelConfiguration {
66 | default: string;
67 | textGeneration: string[];
68 | imageGeneration: string[];
69 | videoGeneration: string[];
70 | codeReview: string[];
71 | complexReasoning: string[];
72 | capabilities: ModelCapabilitiesMap;
73 | routing: {
74 | preferCostEffective: boolean;
75 | preferSpeed: boolean;
76 | preferQuality: boolean;
77 | };
78 | }
79 |
80 | export interface ModelSelectionCriteria {
81 | taskType:
82 | | "text-generation"
83 | | "image-generation"
84 | | "video-generation"
85 | | "code-review"
86 | | "multimodal"
87 | | "reasoning";
88 | complexityLevel?: "simple" | "medium" | "complex";
89 | preferCost?: boolean;
90 | preferSpeed?: boolean;
91 | preferQuality?: boolean;
92 | requiredCapabilities?: (keyof ModelCapabilities)[];
93 | fallbackModel?: string;
94 | urlCount?: number;
95 | estimatedUrlContentSize?: number;
96 | }
97 |
98 | export interface ModelScore {
99 | model: string;
100 | score: number;
101 | capabilities: ModelCapabilities;
102 | }
103 |
104 | export interface ModelSelectionHistory {
105 | timestamp: Date;
106 | criteria: ModelSelectionCriteria;
107 | selectedModel: string;
108 | candidateModels: string[];
109 | scores: ModelScore[];
110 | selectionTime: number;
111 | }
112 |
113 | export interface ModelPerformanceMetrics {
114 | totalCalls: number;
115 | avgLatency: number;
116 | successRate: number;
117 | lastUpdated: Date;
118 | }
119 |
120 | /**
121 | * Configuration interface for the GeminiService.
122 | * Contains API key, model settings, and image processing configurations.
123 | */
124 | export interface GeminiServiceConfig {
125 | apiKey: string;
126 | defaultModel?: string;
127 | defaultImageResolution?: "512x512" | "1024x1024" | "1536x1536";
128 | maxImageSizeMB: number;
129 | supportedImageFormats: string[];
130 | defaultThinkingBudget?: number;
131 | modelConfiguration?: ModelConfiguration;
132 | }
133 |
134 | /**
135 | * Represents the metadata of cached content managed by the Gemini API.
136 | * Based on the structure returned by the @google/genai SDK's Caching API.
137 | */
138 | export interface CachedContentMetadata {
139 | name: string; // e.g., "cachedContents/abc123xyz"
140 | displayName?: string;
141 | model?: string; // Model name this cache is tied to
142 | createTime: string; // ISO 8601 format string
143 | updateTime: string; // ISO 8601 format string
144 | expirationTime?: string; // ISO 8601 format string (renamed from expireTime)
145 | state?: string; // State of the cached content (e.g., "ACTIVE")
146 | usageMetadata?: {
147 | totalTokenCount?: number;
148 | };
149 | }
150 |
151 | const BlobSchema = z
152 | .object({
153 | mimeType: z.string(),
154 | data: z.string(),
155 | })
156 | .strict();
157 |
158 | const FunctionCallSchema = z
159 | .object({
160 | name: z.string(),
161 | args: z.record(z.unknown()),
162 | id: z.string().optional(),
163 | })
164 | .strict();
165 |
166 | const FunctionResponseSchema = z
167 | .object({
168 | name: z.string(),
169 | response: z.record(z.unknown()),
170 | id: z.string().optional(),
171 | })
172 | .strict();
173 |
174 | // Define the main Part schema using discriminated union if possible, or optional fields
175 | // Using optional fields as discriminated union with zod can be tricky with multiple optional fields
176 | export const PartSchema = z
177 | .object({
178 | text: z.string().optional(),
179 | inlineData: BlobSchema.optional(),
180 | functionCall: FunctionCallSchema.optional(),
181 | functionResponse: FunctionResponseSchema.optional(),
182 | // Add other part types like executableCode, codeExecutionResult, videoMetadata if needed later
183 | })
184 | .strict()
185 | .refine(
186 | // Ensure exactly one field is set (or none, though SDK might require one)
187 | // This validation might be complex depending on exact SDK requirements
188 | (part) => {
189 | const setFields = Object.values(part).filter(
190 | (v) => v !== undefined
191 | ).length;
192 | return setFields === 1; // Adjust if zero fields are allowed or more complex validation needed
193 | },
194 | {
195 | message:
196 | "Exactly one field must be set in a Part object (text, inlineData, functionCall, or functionResponse).",
197 | }
198 | );
199 |
200 | // Define the Content schema
201 | export const ContentSchema = z
202 | .object({
203 | parts: z.array(PartSchema).min(1), // Must have at least one part
204 | role: z.enum(["user", "model", "function", "tool"]).optional(), // Role is optional for some contexts
205 | })
206 | .strict();
207 |
```
--------------------------------------------------------------------------------
/tests/unit/services/gemini/GeminiGitDiffService.test.vitest.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Using vitest globals - see vitest.config.ts globals: true
2 | import { GeminiGitDiffService } from "../../../../src/services/gemini/GeminiGitDiffService.js";
3 |
4 | // Mock diff content
5 | const mockDiffContent = `diff --git a/src/utils/logger.ts b/src/utils/logger.ts
6 | index 1234567..abcdef0 100644
7 | --- a/src/utils/logger.ts
8 | +++ b/src/utils/logger.ts
9 | @@ -1,5 +1,6 @@
10 | const logger = {
11 | - log: (message: string) => console.log(message),
12 | + log: (message: string, ...args: any[]) => console.log(message, ...args),
13 | + debug: (message: string, ...args: any[]) => console.debug(message, ...args),
14 | error: (message: string, error?: Error) => console.error(message, error)
15 | };
16 |
17 | `;
18 |
19 | // Mock gitdiff-parser - declare parsed diff inside the mock
20 | vi.mock("gitdiff-parser", () => {
21 | return {
22 | default: {
23 | parse: vi.fn().mockReturnValue([
24 | {
25 | oldPath: "src/utils/logger.ts",
26 | newPath: "src/utils/logger.ts",
27 | hunks: [
28 | {
29 | oldStart: 1,
30 | oldLines: 5,
31 | newStart: 1,
32 | newLines: 6,
33 | changes: [
34 | { type: "normal", content: "const logger = {" },
35 | {
36 | type: "delete",
37 | content: " log: (message: string) => console.log(message),",
38 | },
39 | {
40 | type: "insert",
41 | content:
42 | " log: (message: string, ...args: any[]) => console.log(message, ...args),",
43 | },
44 | {
45 | type: "insert",
46 | content:
47 | " debug: (message: string, ...args: any[]) => console.debug(message, ...args),",
48 | },
49 | {
50 | type: "normal",
51 | content:
52 | " error: (message: string, error?: Error) => console.error(message, error)",
53 | },
54 | { type: "normal", content: "};" },
55 | { type: "normal", content: "" },
56 | ],
57 | },
58 | ],
59 | },
60 | ]),
61 | },
62 | };
63 | });
64 |
65 | interface MockGenerateContentResponse {
66 | response: {
67 | text: () => string;
68 | };
69 | }
70 |
71 | interface MockModel {
72 | generateContent: ReturnType<typeof vi.fn>;
73 | generateContentStream: ReturnType<typeof vi.fn>;
74 | }
75 |
76 | interface MockGenAI {
77 | getGenerativeModel: ReturnType<typeof vi.fn<unknown[], MockModel>>;
78 | }
79 |
80 | describe("GeminiGitDiffService", () => {
81 | let mockGenAI: MockGenAI;
82 | let mockModel: MockModel;
83 | let mockResponse: MockGenerateContentResponse;
84 | let service: GeminiGitDiffService;
85 |
86 | // Setup test fixture
87 | beforeAll(() => {
88 | // Create mock response
89 | mockResponse = {
90 | response: {
91 | text: () => "This is a mock review response",
92 | },
93 | };
94 |
95 | // Create mock model
96 | mockModel = {
97 | generateContent: vi.fn(() => Promise.resolve(mockResponse)),
98 | generateContentStream: vi.fn(() => ({
99 | stream: {
100 | async *[Symbol.asyncIterator]() {
101 | yield { text: () => "Streamed chunk 1" };
102 | yield { text: () => "Streamed chunk 2" };
103 | },
104 | },
105 | })),
106 | };
107 |
108 | // Create mock GoogleGenAI
109 | mockGenAI = {
110 | getGenerativeModel: vi.fn(() => mockModel),
111 | } as any;
112 |
113 | // Create service with flash model as default
114 | service = new GeminiGitDiffService(
115 | mockGenAI as any,
116 | "gemini-flash-2.0", // Use Gemini Flash 2.0 as default model
117 | 1024 * 1024,
118 | ["package-lock.json", "*.min.js"]
119 | );
120 | });
121 |
122 | afterEach(() => {
123 | vi.clearAllMocks();
124 | });
125 |
126 | describe("reviewDiff", () => {
127 | it("should use Gemini Flash 2.0 model when no model is specified", async () => {
128 | // Call the service
129 | await service.reviewDiff({
130 | diffContent: mockDiffContent,
131 | reviewFocus: "general",
132 | });
133 |
134 | // Verify model called with correct parameters
135 | expect(mockGenAI.getGenerativeModel).toHaveBeenCalledTimes(1);
136 | expect(mockGenAI.getGenerativeModel).toHaveBeenCalledWith(
137 | expect.objectContaining({
138 | model: "gemini-flash-2.0",
139 | generationConfig: expect.objectContaining({
140 | thinkingBudget: 4096,
141 | }),
142 | })
143 | );
144 | });
145 |
146 | it("should allow overriding the model", async () => {
147 | // Call the service with a different model
148 | await service.reviewDiff({
149 | diffContent: mockDiffContent,
150 | modelName: "gemini-pro", // Override the default model
151 | reviewFocus: "security",
152 | });
153 |
154 | // Verify model called with correct parameters
155 | expect(mockGenAI.getGenerativeModel).toHaveBeenCalledTimes(1);
156 | expect(mockGenAI.getGenerativeModel).toHaveBeenCalledWith(
157 | expect.objectContaining({
158 | model: "gemini-pro",
159 | })
160 | );
161 | });
162 |
163 | it("should set reasoning effort correctly", async () => {
164 | // Call with low reasoning effort
165 | await service.reviewDiff({
166 | diffContent: mockDiffContent,
167 | reasoningEffort: "low",
168 | });
169 |
170 | // Verify thinking budget set accordingly
171 | expect(mockGenAI.getGenerativeModel).toHaveBeenCalledTimes(1);
172 | expect(mockGenAI.getGenerativeModel).toHaveBeenCalledWith(
173 | expect.objectContaining({
174 | generationConfig: expect.objectContaining({
175 | thinkingBudget: 2048,
176 | }),
177 | })
178 | );
179 | });
180 | });
181 |
182 | describe("reviewDiffStream", () => {
183 | it("should stream content chunks", async () => {
184 | const chunks: string[] = [];
185 |
186 | // Use for-await to consume the stream
187 | for await (const chunk of service.reviewDiffStream({
188 | diffContent: mockDiffContent,
189 | modelName: "gemini-flash-2.0",
190 | })) {
191 | chunks.push(chunk);
192 | }
193 |
194 | // Verify we got both chunks
195 | expect(chunks.length).toBe(2);
196 | expect(chunks[0]).toBe("Streamed chunk 1");
197 | expect(chunks[1]).toBe("Streamed chunk 2");
198 | });
199 | });
200 | });
201 |
```
--------------------------------------------------------------------------------
/src/tools/writeToFileTool.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
3 | // We don't need to import z here, it's imported via the params file
4 | import {
5 | TOOL_NAME,
6 | TOOL_DESCRIPTION,
7 | TOOL_PARAMS,
8 | writeToFileSchema,
9 | } from "./schemas/writeToFileParams.js";
10 | import { z } from "zod";
11 | import { validateAndResolvePath } from "../utils/filePathSecurity.js";
12 | import * as fs from "fs/promises";
13 | import { logger } from "../utils/logger.js";
14 | import { ValidationError } from "../utils/errors.js";
15 |
16 | // Define the type for the write tool parameters
17 | type WriteToFileParams = z.infer<z.ZodObject<typeof TOOL_PARAMS>>;
18 |
19 | /**
20 | * Registers the writeToFile tool with the MCP server.
21 | * @param server - The McpServer instance.
22 | */
23 | export const writeToFileTool = (server: McpServer): void => {
24 | /**
25 | * Process a write to file request.
26 | * @param args - The parameters for the file write operation.
27 | * @returns A response object containing a success message.
28 | * @throws McpError if the operation fails.
29 | */
30 | const processWriteRequest = async (args: unknown) => {
31 | // Validate and parse the arguments
32 | const validatedArgs = writeToFileSchema.parse(args);
33 | logger.debug(
34 | `Received write file request with args: ${JSON.stringify(validatedArgs)}`
35 | );
36 |
37 | try {
38 | // Content is always plain text now
39 | const contentToWrite = validatedArgs.content;
40 |
41 | // Validate and resolve the file path
42 | const safePath = validateAndResolvePath(validatedArgs.filePath, {
43 | mustExist: false,
44 | });
45 |
46 | // Check if file exists and handle overwrite
47 | try {
48 | await fs.access(safePath);
49 | if (!validatedArgs.overwriteFile) {
50 | throw new McpError(
51 | ErrorCode.InvalidParams,
52 | `File already exists: ${validatedArgs.filePath}. Set overwriteFile to true to overwrite.`
53 | );
54 | }
55 | } catch (error: unknown) {
56 | // File doesn't exist, which is fine for writing
57 | if (error instanceof McpError) {
58 | throw error;
59 | }
60 | }
61 |
62 | // Write the file
63 | await fs.writeFile(safePath, contentToWrite, {
64 | encoding: (validatedArgs.encoding || "utf8") as BufferEncoding,
65 | });
66 |
67 | // Return success response
68 | return {
69 | content: [
70 | {
71 | type: "text" as const,
72 | text: JSON.stringify(
73 | {
74 | message: "Content written to file successfully.",
75 | filePath: validatedArgs.filePath,
76 | },
77 | null,
78 | 2
79 | ),
80 | },
81 | ],
82 | };
83 | } catch (error) {
84 | logger.error(`Error writing file: ${error}`);
85 |
86 | // Handle specific errors
87 | if (error instanceof McpError) {
88 | throw error; // Re-throw if it's already an McpError
89 | }
90 |
91 | // Handle ValidationError from file security
92 | if (error instanceof ValidationError) {
93 | throw new McpError(ErrorCode.InvalidParams, error.message);
94 | }
95 |
96 | // Catch-all for unexpected errors
97 | throw new McpError(
98 | ErrorCode.InternalError,
99 | error instanceof Error
100 | ? error.message
101 | : "An unexpected error occurred while writing to file."
102 | );
103 | }
104 | };
105 |
106 | // Register the tool with the server
107 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, processWriteRequest);
108 |
109 | logger.info(`Tool registered: ${TOOL_NAME}`);
110 | };
111 |
112 | // Also export an execute method for direct use in other tools
113 | export const writeToFile = {
114 | name: TOOL_NAME,
115 | description: TOOL_DESCRIPTION,
116 | inputSchema: writeToFileSchema,
117 | execute: async (args: unknown) => {
118 | const typedArgs = args as WriteToFileParams;
119 | logger.debug(
120 | `Executing write file with args: ${JSON.stringify(typedArgs)}`
121 | );
122 |
123 | try {
124 | // Convert boolean to overwrite option
125 | const contentToWrite = typedArgs.content;
126 |
127 | // Validate and resolve the file path
128 | const safePath = validateAndResolvePath(typedArgs.filePath, {
129 | mustExist: false,
130 | });
131 |
132 | // Check if file exists and handle overwrite
133 | try {
134 | await fs.access(safePath);
135 | if (!typedArgs.overwriteFile) {
136 | throw new ValidationError(
137 | `File already exists: ${typedArgs.filePath}. Set overwriteFile to true to overwrite.`
138 | );
139 | }
140 | } catch (error: unknown) {
141 | // File doesn't exist, which is fine for writing
142 | if (error instanceof ValidationError) {
143 | throw error;
144 | }
145 | }
146 |
147 | // Write the file
148 | await fs.writeFile(safePath, contentToWrite, {
149 | encoding: typedArgs.encoding || "utf8",
150 | });
151 |
152 | // Return success response
153 | return {
154 | content: [
155 | {
156 | type: "text" as const,
157 | text: JSON.stringify(
158 | {
159 | message: "Content written to file successfully.",
160 | filePath: typedArgs.filePath,
161 | },
162 | null,
163 | 2
164 | ),
165 | },
166 | ],
167 | };
168 | } catch (error) {
169 | logger.error(`Error writing file: ${error}`);
170 |
171 | // Handle specific errors
172 | if (error instanceof McpError) {
173 | throw error; // Re-throw if it's already an McpError
174 | }
175 |
176 | // Handle ValidationError from FileSecurityService
177 | if (error instanceof ValidationError) {
178 | if (error.message.includes("File already exists")) {
179 | throw new McpError(
180 | ErrorCode.InvalidParams,
181 | `File already exists: ${error.message}`
182 | );
183 | }
184 |
185 | if (
186 | error.message.includes("Access denied") ||
187 | error.message.includes("Security error")
188 | ) {
189 | throw new McpError(
190 | ErrorCode.InvalidParams,
191 | `Security error: ${error.message}`
192 | );
193 | }
194 |
195 | throw new McpError(ErrorCode.InvalidParams, error.message);
196 | }
197 |
198 | // Catch-all for unexpected errors
199 | throw new McpError(
200 | ErrorCode.InternalError,
201 | error instanceof Error
202 | ? error.message
203 | : "An unexpected error occurred while writing to file."
204 | );
205 | }
206 | },
207 | };
208 |
```
--------------------------------------------------------------------------------
/src/resources/system-prompt.md:
--------------------------------------------------------------------------------
```markdown
1 | # System Prompt for Expert Software Developer
2 |
3 | ## 1. Purpose Definition
4 |
5 | You are an expert software developer focused on delivering high-quality, production-ready code that adheres to SOLID principles, follows DRY methodology, and maintains clean code standards. Your primary purpose is to help users design, architect, implement, and refine software that is not only functional but also maintainable, scalable, robust, and ready for production deployment.
6 |
7 | ## 2. Role and Expertise
8 |
9 | You specialize in software engineering best practices with deep expertise in:
10 |
11 | - SOLID principles implementation:
12 | - Single Responsibility Principle: Each class/module has one responsibility (e.g., separating data access, business logic, and presentation)
13 | - Open/Closed Principle: Open for extension, closed for modification (e.g., using strategy patterns or inheritance appropriately)
14 | - Liskov Substitution Principle: Subtypes must be substitutable for their base types (e.g., ensuring overridden methods preserve contracts)
15 | - Interface Segregation Principle: Clients shouldn't depend on interfaces they don't use (e.g., creating focused, specific interfaces)
16 | - Dependency Inversion Principle: Depend on abstractions, not concretions (e.g., using dependency injection and interfaces)
17 |
18 | - DRY (Don't Repeat Yourself) methodology:
19 | - Identifying and eliminating code duplication through refactoring
20 | - Creating reusable components, libraries, and abstractions
21 | - Implementing effective modularization strategies and composition
22 | - Using appropriate design patterns to promote code reuse
23 |
24 | - Clean code practices:
25 | - Meaningful, consistent naming conventions that reveal intent
26 | - Small, focused functions/methods with single purposes (15-30 lines preferred)
27 | - Self-documenting code with appropriate comments for complex logic
28 | - Consistent formatting and structure following language conventions
29 | - Comprehensive test coverage and testable design
30 |
31 | - Production readiness:
32 | - Robust error handling and graceful failure mechanisms
33 | - Comprehensive logging and monitoring integration
34 | - Security best practices and vulnerability prevention
35 | - Performance optimization for scale
36 | - Configuration management and environment handling
37 | - Deployment considerations and CI/CD compatibility
38 |
39 | You demonstrate expertise in software architecture patterns, testing methodologies, security best practices, performance optimization techniques, and collaborative development workflows.
40 |
41 | ## 3. Response Characteristics
42 |
43 | Your responses should be:
44 |
45 | - Precise and technical, using correct terminology
46 | - Well-structured with appropriate code formatting
47 | - Balanced between theory and practical implementation
48 | - Accompanied by explanations of design decisions and trade-offs
49 | - Scalable to the complexity of the problem (simple solutions for simple problems)
50 | - Complete yet concise, focusing on core principles without unnecessary complexity
51 |
52 | When providing code, include:
53 |
54 | - Clear, consistent naming conventions that reveal intent
55 | - Appropriate comments explaining complex logic or design decisions
56 | - Complete error handling and exception management
57 | - Type safety considerations and input validation
58 | - Logging at appropriate levels (error, warning, info, debug)
59 | - Example usage where helpful
60 |
61 | ## 4. Task-Specific Guidelines
62 |
63 | When receiving a coding request:
64 |
65 | - Clarify requirements and edge cases before implementation
66 | - Start with a clear design approach before diving into implementation details
67 | - Structure for testability with clear separation of concerns
68 | - Implement comprehensive error handling, logging, and validation
69 | - Consider deployment and runtime environment factors
70 | - Provide usage examples demonstrating proper implementation
71 | - Include appropriate test strategies (unit, integration, etc.)
72 |
73 | For architecture/design tasks:
74 |
75 | - Begin with understanding the problem domain and requirements
76 | - Consider separation of concerns and appropriate layering
77 | - Design for the appropriate level of abstraction and flexibility
78 | - Account for non-functional requirements (scalability, performance, security)
79 | - Evaluate and recommend appropriate design patterns
80 | - Consider how the architecture will evolve over time
81 | - Address deployment, monitoring, and operational considerations
82 |
83 | For code reviews and refactoring:
84 |
85 | - Identify violations of SOLID principles with specific recommendations
86 | - Highlight potential code duplication with refactoring suggestions
87 | - Suggest improvements for readability and maintenance
88 | - Assess test coverage and quality
89 | - Consider security vulnerabilities and performance implications
90 | - Provide constructive, actionable feedback with examples
91 | - Address technical debt with prioritized refactoring strategies
92 |
93 | For testing guidance:
94 |
95 | - Recommend appropriate testing strategies (unit, integration, E2E)
96 | - Demonstrate test structure and organization
97 | - Guide on test coverage priorities
98 | - Show effective mocking and test isolation approaches
99 | - Emphasize testing both happy paths and edge cases/error conditions
100 |
101 | ## 5. Context and Limitations
102 |
103 | - Focus on widely-accepted industry best practices while acknowledging context-specific trade-offs
104 | - When multiple valid approaches exist, explain the trade-offs considering maintenance, performance, and complexity
105 | - Scale solutions appropriately to project size and requirements (avoid over-engineering)
106 | - Prioritize maintainability and readability over clever or overly complex solutions
107 | - Default to production-grade code with proper error handling, logging, and security unless explicitly requested otherwise
108 | - Acknowledge when a perfect solution isn't possible given constraints and offer pragmatic alternatives
109 | - For language-specific requests beyond your expertise, provide guidance on universal principles and patterns that apply across languages
110 |
111 | For collaborative development:
112 |
113 | - Emphasize clear documentation standards
114 | - Recommend effective version control workflows
115 | - Guide on code review best practices
116 | - Suggest communication and knowledge-sharing approaches
117 |
118 | If a request would result in insecure, unmaintainable, or poor-quality code, provide alternative approaches that maintain quality standards while meeting the core requirements, explaining the rationale for your recommendations.
119 |
```
--------------------------------------------------------------------------------
/tests/unit/utils/FileSecurityServiceBasics.test.vitest.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Using vitest globals - see vitest.config.ts globals: true
2 | import * as path from "node:path";
3 | import * as fs from "node:fs/promises";
4 |
5 | // Import the code to test
6 | import { FileSecurityService } from "../../../src/utils/FileSecurityService.js";
7 | import { logger } from "../../../src/utils/logger.js";
8 |
9 | describe("FileSecurityService Basic Operations", () => {
10 | // Define test constants for all tests
11 | const TEST_CONTENT = "Test file content";
12 |
13 | // Test directories for our tests
14 | const testDir = path.resolve("./test-temp-dir");
15 | const outsideDir = path.resolve("./outside-dir");
16 | const ALLOWED_DIR = path.join(testDir, "allowed");
17 |
18 | // Setup and teardown for tests
19 | beforeEach(async () => {
20 | // Setup test directories
21 | await fs.mkdir(testDir, { recursive: true });
22 | await fs.mkdir(ALLOWED_DIR, { recursive: true });
23 | await fs.mkdir(outsideDir, { recursive: true });
24 |
25 | // Mock logger to prevent console spam
26 | vi.spyOn(logger, "info").mockImplementation(vi.fn());
27 | vi.spyOn(logger, "error").mockImplementation(vi.fn());
28 | vi.spyOn(logger, "warn").mockImplementation(vi.fn());
29 | });
30 |
31 | afterEach(async () => {
32 | // Clean up test directories
33 | await fs.rm(testDir, { recursive: true, force: true });
34 | await fs.rm(outsideDir, { recursive: true, force: true });
35 |
36 | // Restore mocks
37 | vi.restoreAllMocks();
38 | });
39 |
40 | describe("Basic File Security Operations", () => {
41 | it("should write to a file directly within allowed absolute directory", async () => {
42 | // Arrange
43 | const filePath = path.join(ALLOWED_DIR, "file.txt");
44 | const allowedPaths = [ALLOWED_DIR];
45 | const fileSecurityService = new FileSecurityService(allowedPaths);
46 |
47 | // Act
48 | await fileSecurityService.secureWriteFile(filePath, TEST_CONTENT);
49 |
50 | // Assert
51 | const fileContent = await fs.readFile(filePath, "utf8");
52 | expect(fileContent).toBe(TEST_CONTENT);
53 | });
54 |
55 | it("should write to a file in a nested subdirectory of allowed directory", async () => {
56 | // Arrange
57 | const nestedDir = path.join(ALLOWED_DIR, "subdir");
58 | const filePath = path.join(nestedDir, "file.txt");
59 | const allowedPaths = [ALLOWED_DIR];
60 | const fileSecurityService = new FileSecurityService(allowedPaths);
61 |
62 | // Act
63 | await fileSecurityService.secureWriteFile(filePath, TEST_CONTENT);
64 |
65 | // Assert
66 | const fileContent = await fs.readFile(filePath, "utf8");
67 | expect(fileContent).toBe(TEST_CONTENT);
68 | });
69 |
70 | it("should allow writing when filePath is an exact match to allowed absolute file path", async () => {
71 | // Arrange
72 | const exactFilePath = path.join(ALLOWED_DIR, "exact-file.txt");
73 | const allowedPaths = [exactFilePath]; // Allowing the exact file path
74 | const fileSecurityService = new FileSecurityService(allowedPaths);
75 |
76 | // Act
77 | await fileSecurityService.secureWriteFile(exactFilePath, TEST_CONTENT);
78 |
79 | // Assert
80 | const fileContent = await fs.readFile(exactFilePath, "utf8");
81 | expect(fileContent).toBe(TEST_CONTENT);
82 | });
83 |
84 | it("should throw error when filePath resolves outside allowed paths", async () => {
85 | // Arrange
86 | const unsafePath = path.join(outsideDir, "unsafe-file.txt");
87 | const allowedPaths = [ALLOWED_DIR];
88 | const fileSecurityService = new FileSecurityService(allowedPaths);
89 |
90 | // Act & Assert
91 | await expect(
92 | fileSecurityService.secureWriteFile(unsafePath, TEST_CONTENT)
93 | ).rejects.toThrow(
94 | /Access denied: The file path must be within the allowed directories/
95 | );
96 |
97 | // Additional check that logger.warn was called (FileSecurityService uses warn, not error)
98 | expect(logger.warn).toHaveBeenCalled();
99 |
100 | // Verify file was not written
101 | await expect(fs.access(unsafePath)).rejects.toThrow();
102 | });
103 |
104 | it("should throw error when filePath uses directory traversal to escape allowed path", async () => {
105 | // Arrange
106 | const traversalPath = path.join(
107 | ALLOWED_DIR,
108 | "subdir",
109 | "..",
110 | "..",
111 | "outside",
112 | "file.txt"
113 | );
114 | const allowedPaths = [ALLOWED_DIR];
115 | const fileSecurityService = new FileSecurityService(allowedPaths);
116 |
117 | // Act & Assert
118 | await expect(
119 | fileSecurityService.secureWriteFile(traversalPath, TEST_CONTENT)
120 | ).rejects.toThrow(
121 | /Access denied: The file path must be within the allowed directories/
122 | );
123 | });
124 |
125 | it("should use default path when no allowed paths are provided", async () => {
126 | // Arrange
127 | const filePath = path.join(process.cwd(), "test-file.txt");
128 | const fileSecurityService = new FileSecurityService(); // No paths provided uses CWD as default
129 |
130 | try {
131 | // Act
132 | await fileSecurityService.secureWriteFile(filePath, TEST_CONTENT);
133 |
134 | // Assert
135 | const fileContent = await fs.readFile(filePath, "utf8");
136 | expect(fileContent).toBe(TEST_CONTENT);
137 | } finally {
138 | // Cleanup the file created in CWD
139 | try {
140 | await fs.unlink(filePath);
141 | } catch (err) {
142 | // Ignore error if file doesn't exist
143 | }
144 | }
145 | });
146 |
147 | it("should correctly handle path normalization and resolution", async () => {
148 | // Arrange
149 | const complexPath = path.join(
150 | ALLOWED_DIR,
151 | ".",
152 | "subdir",
153 | "..",
154 | "normalized-file.txt"
155 | );
156 | const allowedPaths = [ALLOWED_DIR];
157 | const fileSecurityService = new FileSecurityService(allowedPaths);
158 |
159 | // Act
160 | await fileSecurityService.secureWriteFile(complexPath, TEST_CONTENT);
161 |
162 | // Assert - check the file exists at the normalized location
163 | const expectedPath = path.join(ALLOWED_DIR, "normalized-file.txt");
164 | const fileContent = await fs.readFile(expectedPath, "utf8");
165 | expect(fileContent).toBe(TEST_CONTENT);
166 | });
167 |
168 | it("should handle multiple allowed paths", async () => {
169 | // Arrange
170 | const filePath = path.join(outsideDir, "allowed-outside-file.txt");
171 | const content = "multi-allowed content";
172 | const fileSecurityService = new FileSecurityService([
173 | ALLOWED_DIR,
174 | outsideDir,
175 | ]);
176 |
177 | // Act
178 | await fileSecurityService.secureWriteFile(filePath, content);
179 |
180 | // Assert
181 | const fileContent = await fs.readFile(filePath, "utf8");
182 | expect(fileContent).toBe(content);
183 | });
184 | });
185 | });
186 |
```
--------------------------------------------------------------------------------
/tests/unit/services/gemini/GitHubUrlParser.test.vitest.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Using vitest globals - see vitest.config.ts globals: true
2 | import { GitHubUrlParser } from "../../../../src/services/gemini/GitHubUrlParser.js";
3 |
4 | describe("GitHubUrlParser", () => {
5 | describe("parse()", () => {
6 | it("should parse repository URLs correctly", () => {
7 | const url = "https://github.com/bsmi021/mcp-gemini-server";
8 | const result = GitHubUrlParser.parse(url);
9 |
10 | expect(result?.type).toBe("repository");
11 | expect(result?.owner).toBe("bsmi021");
12 | expect(result?.repo).toBe("mcp-gemini-server");
13 | expect(result?.branch).toBeUndefined();
14 | expect(result?.prNumber).toBeUndefined();
15 | expect(result?.issueNumber).toBeUndefined();
16 | });
17 |
18 | it("should parse branch URLs correctly", () => {
19 | const url =
20 | "https://github.com/bsmi021/mcp-gemini-server/tree/feature/add-reasoning-effort-option";
21 | const result = GitHubUrlParser.parse(url);
22 |
23 | expect(result?.type).toBe("branch");
24 | expect(result?.owner).toBe("bsmi021");
25 | expect(result?.repo).toBe("mcp-gemini-server");
26 | expect(result?.branch).toBe("feature/add-reasoning-effort-option");
27 | expect(result?.prNumber).toBeUndefined();
28 | expect(result?.issueNumber).toBeUndefined();
29 | });
30 |
31 | it("should parse pull request URLs correctly", () => {
32 | const url = "https://github.com/bsmi021/mcp-gemini-server/pull/2";
33 | const result = GitHubUrlParser.parse(url);
34 |
35 | expect(result?.type).toBe("pull_request");
36 | expect(result?.owner).toBe("bsmi021");
37 | expect(result?.repo).toBe("mcp-gemini-server");
38 | expect(result?.branch).toBeUndefined();
39 | expect(result?.prNumber).toBe("2");
40 | expect(result?.issueNumber).toBeUndefined();
41 | });
42 |
43 | it("should parse pull request files URLs correctly", () => {
44 | const url = "https://github.com/bsmi021/mcp-gemini-server/pull/2/files";
45 | const result = GitHubUrlParser.parse(url);
46 |
47 | expect(result?.type).toBe("pr_files");
48 | expect(result?.owner).toBe("bsmi021");
49 | expect(result?.repo).toBe("mcp-gemini-server");
50 | expect(result?.branch).toBeUndefined();
51 | expect(result?.prNumber).toBe("2");
52 | expect(result?.filesView).toBe(true);
53 | expect(result?.issueNumber).toBeUndefined();
54 | });
55 |
56 | it("should parse issue URLs correctly", () => {
57 | const url = "https://github.com/bsmi021/mcp-gemini-server/issues/5";
58 | const result = GitHubUrlParser.parse(url);
59 |
60 | expect(result?.type).toBe("issue");
61 | expect(result?.owner).toBe("bsmi021");
62 | expect(result?.repo).toBe("mcp-gemini-server");
63 | expect(result?.branch).toBeUndefined();
64 | expect(result?.prNumber).toBeUndefined();
65 | expect(result?.issueNumber).toBe("5");
66 | });
67 |
68 | it("should return null for invalid URLs", () => {
69 | const urls = [
70 | "https://example.com",
71 | "https://github.com",
72 | "https://github.com/bsmi021",
73 | "https://github.com/bsmi021/mcp-gemini-server/unknown",
74 | "not a url at all",
75 | ];
76 |
77 | for (const url of urls) {
78 | expect(GitHubUrlParser.parse(url)).toBeNull();
79 | }
80 | });
81 | });
82 |
83 | describe("isValidGitHubUrl()", () => {
84 | it("should return true for valid GitHub URLs", () => {
85 | const urls = [
86 | "https://github.com/bsmi021/mcp-gemini-server",
87 | "https://github.com/bsmi021/mcp-gemini-server/tree/main",
88 | "https://github.com/bsmi021/mcp-gemini-server/pull/2",
89 | "https://github.com/bsmi021/mcp-gemini-server/pull/2/files",
90 | "https://github.com/bsmi021/mcp-gemini-server/issues/5",
91 | ];
92 |
93 | for (const url of urls) {
94 | expect(GitHubUrlParser.isValidGitHubUrl(url)).toBe(true);
95 | }
96 | });
97 |
98 | it("should return false for invalid URLs", () => {
99 | const urls = [
100 | "https://example.com",
101 | "https://github.com",
102 | "https://github.com/bsmi021",
103 | "https://github.com/bsmi021/mcp-gemini-server/unknown",
104 | "not a url at all",
105 | ];
106 |
107 | for (const url of urls) {
108 | expect(GitHubUrlParser.isValidGitHubUrl(url)).toBe(false);
109 | }
110 | });
111 | });
112 |
113 | describe("getApiEndpoint()", () => {
114 | it("should return the correct API endpoint for repository URLs", () => {
115 | const url = "https://github.com/bsmi021/mcp-gemini-server";
116 | expect(GitHubUrlParser.getApiEndpoint(url)).toBe(
117 | "repos/bsmi021/mcp-gemini-server"
118 | );
119 | });
120 |
121 | it("should return the correct API endpoint for branch URLs", () => {
122 | const url = "https://github.com/bsmi021/mcp-gemini-server/tree/main";
123 | expect(GitHubUrlParser.getApiEndpoint(url)).toBe(
124 | "repos/bsmi021/mcp-gemini-server/branches/main"
125 | );
126 | });
127 |
128 | it("should return the correct API endpoint for PR URLs", () => {
129 | const url = "https://github.com/bsmi021/mcp-gemini-server/pull/2";
130 | expect(GitHubUrlParser.getApiEndpoint(url)).toBe(
131 | "repos/bsmi021/mcp-gemini-server/pulls/2"
132 | );
133 | });
134 |
135 | it("should return the correct API endpoint for PR files URLs", () => {
136 | const url = "https://github.com/bsmi021/mcp-gemini-server/pull/2/files";
137 | expect(GitHubUrlParser.getApiEndpoint(url)).toBe(
138 | "repos/bsmi021/mcp-gemini-server/pulls/2"
139 | );
140 | });
141 |
142 | it("should return the correct API endpoint for issue URLs", () => {
143 | const url = "https://github.com/bsmi021/mcp-gemini-server/issues/5";
144 | expect(GitHubUrlParser.getApiEndpoint(url)).toBe(
145 | "repos/bsmi021/mcp-gemini-server/issues/5"
146 | );
147 | });
148 |
149 | it("should return null for invalid URLs", () => {
150 | const url = "https://example.com";
151 | expect(GitHubUrlParser.getApiEndpoint(url)).toBeNull();
152 | });
153 | });
154 |
155 | describe("getRepositoryInfo()", () => {
156 | it("should return repository info for valid GitHub URLs", () => {
157 | const urls = [
158 | "https://github.com/bsmi021/mcp-gemini-server",
159 | "https://github.com/bsmi021/mcp-gemini-server/tree/main",
160 | "https://github.com/bsmi021/mcp-gemini-server/pull/2",
161 | "https://github.com/bsmi021/mcp-gemini-server/issues/5",
162 | ];
163 |
164 | for (const url of urls) {
165 | const info = GitHubUrlParser.getRepositoryInfo(url);
166 | expect(info?.owner).toBe("bsmi021");
167 | expect(info?.repo).toBe("mcp-gemini-server");
168 | }
169 | });
170 |
171 | it("should return null for invalid URLs", () => {
172 | const url = "https://example.com";
173 | expect(GitHubUrlParser.getRepositoryInfo(url)).toBeNull();
174 | });
175 | });
176 | });
177 |
```
--------------------------------------------------------------------------------
/src/services/SessionService.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { v4 as uuidv4 } from "uuid";
2 | import { logger } from "../utils/logger.js";
3 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
4 | import { SessionStore } from "./session/SessionStore.js";
5 | import { InMemorySessionStore } from "./session/InMemorySessionStore.js";
6 | import { SQLiteSessionStore } from "./session/SQLiteSessionStore.js";
7 |
8 | export interface SessionState<T = Record<string, unknown>> {
9 | id: string;
10 | createdAt: number;
11 | lastActivity: number;
12 | expiresAt: number;
13 | data: T; // Generic data for the session (e.g., chat history, tool state)
14 | }
15 |
16 | export class SessionService {
17 | private store: SessionStore;
18 | private cleanupInterval: NodeJS.Timeout | null = null;
19 | private defaultTimeoutSeconds: number;
20 | private initialized: Promise<void>;
21 |
22 | constructor(
23 | defaultTimeoutSeconds: number = 3600,
24 | storeType?: "memory" | "sqlite",
25 | dbPath?: string
26 | ) {
27 | // Default 1 hour
28 | this.defaultTimeoutSeconds = defaultTimeoutSeconds;
29 |
30 | // Initialize the appropriate store based on configuration
31 | const effectiveStoreType =
32 | storeType || process.env.SESSION_STORE_TYPE || "memory";
33 |
34 | switch (effectiveStoreType) {
35 | case "sqlite":
36 | this.store = new SQLiteSessionStore(
37 | dbPath || process.env.SQLITE_DB_PATH
38 | );
39 | break;
40 | case "memory":
41 | default:
42 | this.store = new InMemorySessionStore();
43 | break;
44 | }
45 |
46 | // Initialize the store asynchronously
47 | this.initialized = this.initializeStore();
48 | this.initialized
49 | .then(() => {
50 | this.startCleanupInterval();
51 | logger.info(
52 | `SessionService initialized with ${effectiveStoreType} store and default timeout: ${defaultTimeoutSeconds}s`
53 | );
54 | })
55 | .catch((error) => {
56 | logger.error("Failed to initialize session store:", error);
57 | throw error;
58 | });
59 | }
60 |
61 | private async initializeStore(): Promise<void> {
62 | await this.store.initialize();
63 | }
64 |
65 | /**
66 | * Creates a new session.
67 | * @param initialData Initial data to store in the session.
68 | * @param timeoutSeconds Optional custom timeout for this session.
69 | * @returns The newly created session ID.
70 | */
71 | public async createSession<
72 | T extends Record<string, unknown> = Record<string, unknown>,
73 | >(initialData: T = {} as T, timeoutSeconds?: number): Promise<string> {
74 | // Ensure store is initialized
75 | await this.initialized;
76 |
77 | const sessionId = uuidv4();
78 | const now = Date.now();
79 | const effectiveTimeout = timeoutSeconds ?? this.defaultTimeoutSeconds;
80 | const expiresAt = now + effectiveTimeout * 1000;
81 |
82 | const newSession: SessionState<T> = {
83 | id: sessionId,
84 | createdAt: now,
85 | lastActivity: now,
86 | expiresAt: expiresAt,
87 | data: initialData,
88 | };
89 |
90 | await this.store.set(sessionId, newSession);
91 | logger.debug(
92 | `Session ${sessionId} created, expires in ${effectiveTimeout}s`
93 | );
94 | return sessionId;
95 | }
96 |
97 | /**
98 | * Retrieves a session and updates its last activity timestamp.
99 | * @param sessionId The ID of the session to retrieve.
100 | * @returns The session state.
101 | * @throws McpError if the session is not found or has expired.
102 | */
103 | public async getSession(sessionId: string): Promise<SessionState> {
104 | // Ensure store is initialized
105 | await this.initialized;
106 |
107 | const session = await this.store.get(sessionId);
108 | if (!session) {
109 | throw new McpError(
110 | ErrorCode.InvalidRequest,
111 | `Session not found: ${sessionId}`
112 | );
113 | }
114 | if (Date.now() > session.expiresAt) {
115 | await this.deleteSession(sessionId); // Clean up expired session
116 | throw new McpError(
117 | ErrorCode.InvalidRequest,
118 | `Session expired: ${sessionId}`
119 | );
120 | }
121 |
122 | // Update last activity and extend expiration
123 | session.lastActivity = Date.now();
124 | session.expiresAt =
125 | session.lastActivity + this.defaultTimeoutSeconds * 1000;
126 | await this.store.set(sessionId, session);
127 | logger.debug(`Session ${sessionId} accessed, expiration extended.`);
128 | return session;
129 | }
130 |
131 | /**
132 | * Updates existing session data.
133 | * @param sessionId The ID of the session to update.
134 | * @param partialData Partial data to merge into the session's data.
135 | * @throws McpError if the session is not found or has expired.
136 | */
137 | public async updateSession(
138 | sessionId: string,
139 | partialData: Partial<Record<string, unknown>>
140 | ): Promise<void> {
141 | const session = await this.getSession(sessionId); // This also updates lastActivity
142 | session.data = { ...session.data, ...partialData };
143 | await this.store.set(sessionId, session);
144 | logger.debug(`Session ${sessionId} updated.`);
145 | }
146 |
147 | /**
148 | * Deletes a session.
149 | * @param sessionId The ID of the session to delete.
150 | * @returns True if the session was deleted, false otherwise.
151 | */
152 | public async deleteSession(sessionId: string): Promise<boolean> {
153 | await this.initialized;
154 | const deleted = await this.store.delete(sessionId);
155 | if (deleted) {
156 | logger.debug(`Session ${sessionId} deleted.`);
157 | } else {
158 | logger.warn(`Attempted to delete non-existent session: ${sessionId}`);
159 | }
160 | return deleted;
161 | }
162 |
163 | /**
164 | * Starts the periodic cleanup of expired sessions.
165 | */
166 | private startCleanupInterval(): void {
167 | if (this.cleanupInterval) {
168 | clearInterval(this.cleanupInterval);
169 | }
170 | this.cleanupInterval = setInterval(() => {
171 | this.cleanupExpiredSessions();
172 | }, 60 * 1000); // Check every minute
173 | }
174 |
175 | /**
176 | * Cleans up all expired sessions.
177 | */
178 | private async cleanupExpiredSessions(): Promise<void> {
179 | try {
180 | const now = Date.now();
181 | const cleanedCount = await this.store.deleteExpired(now);
182 | if (cleanedCount > 0) {
183 | logger.info(
184 | `SessionService cleaned up ${cleanedCount} expired sessions.`
185 | );
186 | }
187 | } catch (error) {
188 | logger.error("Error during session cleanup:", error);
189 | }
190 | }
191 |
192 | /**
193 | * Stops the periodic cleanup interval.
194 | */
195 | public stopCleanupInterval(): void {
196 | if (this.cleanupInterval) {
197 | clearInterval(this.cleanupInterval);
198 | this.cleanupInterval = null;
199 | logger.info("SessionService cleanup interval stopped.");
200 | }
201 | }
202 |
203 | /**
204 | * Returns the number of active sessions.
205 | */
206 | public async getActiveSessionCount(): Promise<number> {
207 | await this.initialized;
208 | return this.store.count();
209 | }
210 |
211 | /**
212 | * Closes the session service and cleans up resources.
213 | */
214 | public async close(): Promise<void> {
215 | this.stopCleanupInterval();
216 | await this.store.close();
217 | logger.info("SessionService closed");
218 | }
219 | }
220 |
```