Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [1.1.8] - 2026-06-01

### Fixed
- **MCP disconnects after a successful call (#64).** Fast tools (e.g. `ping`) no longer emit any progress notifications: progress is now "lazy", starting only once a call runs past the keepalive interval, so quick calls never bracket their response with a progress notification — the trigger for the Claude Code disconnect tracked upstream in [anthropics/claude-code#53617](https://github.com/anthropics/claude-code/issues/53617). Long analyses still get keepalive progress and survive the 60s request timeout.
- **Concurrent tool calls no longer cancel each other's keepalive.** Per-call progress state replaces the shared module globals, so finishing one call can't stop the progress timer of another in flight.
- **Server stays up through stray async/stream errors.** Added `stdout`/`stderr` and `unhandledRejection`/`uncaughtException` guards so an `EPIPE` (client went away mid-write) or a stray rejection logs to stderr instead of killing the long-lived server. Startup failures still exit.

### Added
- **`GEMINI_MCP_DISABLE_PROGRESS`** — opt-out env var that suppresses all progress notifications, for Claude Code versions still affected by [#53617](https://github.com/anthropics/claude-code/issues/53617). Progress stays **on by default**. (absorbed from #80)

### Security
- Remove unused `inquirer` production dependency — closes CVE-2026-44705 (path traversal in `tmp` via `external-editor`, HIGH/CWE-22)
- Remove unused `ai` production dependency — closes CVE-2026-8769 (uncontrolled resource consumption in `@ai-sdk/provider-utils`, LOW/CWE-400)
Expand Down
22 changes: 22 additions & 0 deletions docs/resources/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,28 @@ claude mcp list
/gemini-cli:analyze @specific-function.js explain this function
```

## Known Issues

### Claude Code disconnect after a successful call (progress notifications)

On some Claude Code versions, a stdio MCP server can be disconnected after a successful `tools/call` response when the server emits progress notifications. This is tracked upstream in [anthropics/claude-code#53617](https://github.com/anthropics/claude-code/issues/53617).

Progress notifications remain **on by default** (they keep long analyses from timing out), and fast calls no longer emit progress at all. If you still hit this disconnect on an affected Claude Code version, you can opt out of progress notifications entirely by setting `GEMINI_MCP_DISABLE_PROGRESS=1` in the MCP server `env` block:

```json
{
"mcpServers": {
"gemini-cli": {
"command": "npx",
"args": ["-y", "gemini-mcp-tool"],
"env": {
"GEMINI_MCP_DISABLE_PROGRESS": "1"
}
}
}
}
```

## File Analysis Issues

### "File not found"
Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const PROTOCOL = {
PROGRESS: "notifications/progress",
},
// Timeout prevention
KEEPALIVE_INTERVAL: 25000, // 25 seconds
KEEPALIVE_INTERVAL: 15000, // 15 seconds — two beats before any ~30s stdio silence timeout
} as const;


Expand Down
193 changes: 99 additions & 94 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,120 +40,116 @@ const server = new Server(
},
);

let isProcessing = false; let currentOperationName = ""; let latestOutput = "";
// Progress notifications are ON by default. Some Claude Code versions disconnect a
// stdio MCP server after a successful tools/call response when the server emits
// progress notifications (anthropics/claude-code#53617). As an opt-out workaround,
// set GEMINI_MCP_DISABLE_PROGRESS=1 (or =true) to suppress progress notifications.
const PROGRESS_DISABLED =
process.env.GEMINI_MCP_DISABLE_PROGRESS === "1" ||
process.env.GEMINI_MCP_DISABLE_PROGRESS === "true";

async function sendNotification(method: string, params: any) {
try {
await server.notification({ method, params });
} catch (error) {
Logger.error("notification failed: ", error);
}
interface ProgressContext {
isProcessing: boolean;
operationName: string;
latestOutput: string;
messageIndex: number;
progress: number;
emittedProgress: boolean;
}

/**
* @param progressToken The progress token provided by the client
* @param progress The current progress value
* @param total Optional total value
* @param message Optional status message
*/
async function sendProgressNotification(
progressToken: string | number | undefined,
progress: number,
total?: number,
message?: string
) {
if (!progressToken) return; // Only send if client requested progress

if (!progressToken) return;
try {
const params: any = {
progressToken,
progress
};

if (total !== undefined) params.total = total; // future cache progress
const params: any = { progressToken, progress };
if (total !== undefined) params.total = total;
if (message) params.message = message;

await server.notification({
method: PROTOCOL.NOTIFICATIONS.PROGRESS,
params
});
await server.notification({ method: PROTOCOL.NOTIFICATIONS.PROGRESS, params });
} catch (error) {
Logger.error("Failed to send progress notification:", error);
}
}

function startProgressUpdates(
operationName: string,
progressToken?: string | number
) {
isProcessing = true;
currentOperationName = operationName;
latestOutput = ""; // Reset latest output

function startProgressUpdates(operationName: string, progressToken?: string | number) {
const ctx: ProgressContext = {
isProcessing: true,
operationName,
latestOutput: "",
messageIndex: 0,
progress: 0,
emittedProgress: false,
};

const progressMessages = [
`🧠 ${operationName} - Gemini is analyzing your request...`,
`📊 ${operationName} - Processing files and generating insights...`,
`✨ ${operationName} - Creating structured response for your review...`,
`⏱️ ${operationName} - Large analysis in progress (this is normal for big requests)...`,
`🔍 ${operationName} - Still working... Gemini takes time for quality results...`,
];

let messageIndex = 0;
let progress = 0;

// Send immediate acknowledgment if progress requested
if (progressToken) {
sendProgressNotification(
progressToken,
0,
undefined, // No total - indeterminate progress
`🔍 Starting ${operationName}`
);
}

// Keep client alive with periodic updates

// Lazy progress: we deliberately do NOT send an immediate progress:0 ack. A fast
// tool (e.g. ping) finishes before the first interval tick and so emits no progress
// notifications at all — which is what avoids the #53617 client disconnect on quick
// calls. Progress only starts once a call actually runs past KEEPALIVE_INTERVAL,
// i.e. exactly the long operations that need the keepalive.
const progressInterval = setInterval(async () => {
if (isProcessing && progressToken) {
// Simply increment progress value
progress += 1;

// Include latest output if available
const baseMessage = progressMessages[messageIndex % progressMessages.length];
const outputPreview = latestOutput.slice(-150).trim(); // Last 150 chars
const message = outputPreview
? `${baseMessage}\n📝 Output: ...${outputPreview}`
: baseMessage;

await sendProgressNotification(
progressToken,
progress,
undefined, // No total - indeterminate progress
message
);
messageIndex++;
} else if (!isProcessing) {
if (!ctx.isProcessing) {
clearInterval(progressInterval);
return;
}
}, PROTOCOL.KEEPALIVE_INTERVAL); // Every 25 seconds

return { interval: progressInterval, progressToken };

// The only server-side lever on a client's in-flight request timeout is a
// notifications/progress carrying the client's progressToken: the MCP SDK
// resets the per-request timer in _onprogress for that token (when the client
// set resetTimeoutOnProgress). Logging notifications do not touch the timer,
// so without a token there is nothing useful to send here. This is precisely
// why a slow (e.g. 15-minute) changeMode survives only when the client opted
// into progress — see PROTOCOL.KEEPALIVE_INTERVAL and the docs on long ops.
if (!progressToken) return;

const baseMessage = progressMessages[ctx.messageIndex % progressMessages.length];
const outputPreview = ctx.latestOutput.slice(-150).trim();
const message = outputPreview ? `${baseMessage}\n📝 Output: ...${outputPreview}` : baseMessage;
ctx.messageIndex++;
ctx.progress += 1;

try {
await server.notification({
method: PROTOCOL.NOTIFICATIONS.PROGRESS,
params: { progressToken, progress: ctx.progress, message },
});
ctx.emittedProgress = true;
} catch (error) {
// Transport gone (e.g. EPIPE after the client went away): stop ticking so
// we neither leak this timer nor keep throwing on a dead pipe.
Logger.error("Keepalive progress notification failed; stopping updates:", error);
ctx.isProcessing = false;
clearInterval(progressInterval);
}
}, PROTOCOL.KEEPALIVE_INTERVAL);

return { interval: progressInterval, progressToken, ctx };
}
Comment on lines +77 to 137

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If progressToken is undefined (which happens when progress is disabled or not requested by the client), we still set up a setInterval timer that ticks every 15 seconds. Although it is cleared when the tool execution finishes, during execution it schedules unnecessary ticks on the event loop that do nothing because of the if (!progressToken) return; guard. We can optimize this by returning early and not scheduling the interval when progressToken is not provided.

function startProgressUpdates(operationName: string, progressToken?: string | number) {
  const ctx: ProgressContext = {
    isProcessing: true,
    operationName,
    latestOutput: "",
    messageIndex: 0,
    progress: 0,
    emittedProgress: false,
  };

  if (!progressToken) {
    return { interval: undefined, progressToken, ctx };
  }

  const progressMessages = [
    "🧠 " + operationName + " - Gemini is analyzing your request...",
    "📊 " + operationName + " - Processing files and generating insights...",
    "✨ " + operationName + " - Creating structured response for your review...",
    "⏱️ " + operationName + " - Large analysis in progress (this is normal for big requests)...",
    "🔍 " + operationName + " - Still working... Gemini takes time for quality results..."
  ];

  const progressInterval = setInterval(async () => {
    if (!ctx.isProcessing) {
      clearInterval(progressInterval);
      return;
    }

    const baseMessage = progressMessages[ctx.messageIndex % progressMessages.length];
    const outputPreview = ctx.latestOutput.slice(-150).trim();
    const message = outputPreview ? baseMessage + "\n📝 Output: ..." + outputPreview : baseMessage;
    ctx.messageIndex++;
    ctx.progress += 1;

    try {
      await server.notification({
        method: PROTOCOL.NOTIFICATIONS.PROGRESS,
        params: { progressToken, progress: ctx.progress, message },
      });
      ctx.emittedProgress = true;
    } catch (error) {
      Logger.error("Keepalive progress notification failed; stopping updates:", error);
      ctx.isProcessing = false;
      clearInterval(progressInterval);
    }
  }, PROTOCOL.KEEPALIVE_INTERVAL);

  return { interval: progressInterval, progressToken, ctx };
}


function stopProgressUpdates(
progressData: { interval: NodeJS.Timeout; progressToken?: string | number },
async function stopProgressUpdates(
progressData: { interval: NodeJS.Timeout; progressToken?: string | number; ctx: ProgressContext },
success: boolean = true
) {
const operationName = currentOperationName; // Store before clearing
isProcessing = false;
currentOperationName = "";
const operationName = progressData.ctx.operationName;
progressData.ctx.isProcessing = false;
clearInterval(progressData.interval);
Comment on lines +139 to 145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the signature of stopProgressUpdates to allow interval to be undefined (since we avoided scheduling the interval when progressToken is not provided), and conditionally clear it.

Suggested change
async function stopProgressUpdates(
progressData: { interval: NodeJS.Timeout; progressToken?: string | number; ctx: ProgressContext },
success: boolean = true
) {
const operationName = currentOperationName; // Store before clearing
isProcessing = false;
currentOperationName = "";
const operationName = progressData.ctx.operationName;
progressData.ctx.isProcessing = false;
clearInterval(progressData.interval);
async function stopProgressUpdates(
progressData: { interval?: NodeJS.Timeout; progressToken?: string | number; ctx: ProgressContext },
success: boolean = true
) {
const operationName = progressData.ctx.operationName;
progressData.ctx.isProcessing = false;
if (progressData.interval) {
clearInterval(progressData.interval);
}

// Send final progress notification if client requested progress
if (progressData.progressToken) {
sendProgressNotification(
progressData.progressToken,
100,
100,

// Only send a final progress notification if this call actually emitted progress
// (i.e. ran long enough to tick). Fast calls emit nothing, so no lone progress
// notification ever brackets their successful response — this is the #53617 guard.
if (progressData.progressToken && progressData.ctx.emittedProgress) {
await sendProgressNotification(
progressData.progressToken, 100, 100,
success ? `✅ ${operationName} completed successfully` : `❌ ${operationName} failed`
);
}
Expand All @@ -169,25 +165,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const toolName: string = request.params.name;

if (toolExists(toolName)) {
// Check if client requested progress updates
const progressToken = (request.params as any)._meta?.progressToken;

// Check if client requested progress updates (unless opted out via env var)
const progressToken = PROGRESS_DISABLED
? undefined
: (request.params as any)._meta?.progressToken;

// Start progress updates if client requested them
const progressData = startProgressUpdates(toolName, progressToken);

try {
// Get prompt and other parameters from arguments with proper typing
const args: ToolArguments = (request.params.arguments as ToolArguments) || {};

Logger.toolInvocation(toolName, request.params.arguments);

// Execute the tool using the unified registry with progress callback
const result = await executeTool(toolName, args, (newOutput) => {
latestOutput = newOutput;
progressData.ctx.latestOutput = newOutput;
});

// Stop progress updates
stopProgressUpdates(progressData, true);
await stopProgressUpdates(progressData, true);

return {
content: [
Expand All @@ -199,8 +194,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
isError: false,
};
} catch (error) {
// Stop progress updates on error
stopProgressUpdates(progressData, false);
await stopProgressUpdates(progressData, false);

Logger.error(`Error in tool '${toolName}':`, error);

Expand Down Expand Up @@ -249,9 +243,20 @@ server.setRequestHandler(GetPromptRequestSchema, async (request: GetPromptReques
};
});

// A long-lived stdio bridge must not die from a stray async/stream error.
// Without these guards a single unhandled rejection — or an EPIPE writing to
// stdout after the client went away mid-call — terminates the process, which
// Claude Code surfaces as the MCP server "disconnecting" after a handful of
// calls (issue #64). Log to stderr and stay up; startup failures still exit.
process.stdout.on("error", (err) => Logger.error("stdout stream error (ignored):", err));
process.stderr.on("error", () => { /* nowhere safe left to log */ });
process.on("unhandledRejection", (reason) => Logger.error("Unhandled rejection (server kept alive):", reason));
process.on("uncaughtException", (error) => Logger.error("Uncaught exception (server kept alive):", error));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Keeping the process alive after an uncaughtException is highly discouraged in Node.js because the application state becomes unpredictable and corrupted. Since you have already added a stream error handler on process.stdout to catch EPIPE errors (which prevents them from becoming uncaught exceptions), any other uncaught exception is likely a critical error where the process should be terminated gracefully to avoid memory leaks or zombie processes.

process.on("uncaughtException", (error) => {
  Logger.error("Uncaught exception (shutting down):", error);
  process.exit(1);
});


// Start the server
async function main() {
Logger.debug("init gemini-mcp-tool");
const transport = new StdioServerTransport(); await server.connect(transport);
Logger.debug("gemini-mcp-tool listening on stdio");
} main().catch((error) => {Logger.error("Fatal error:", error); process.exit(1); });
}
main().catch((error) => { Logger.error("Fatal error during startup:", error); process.exit(1); });
Loading