Skip to content

sse timeout set dont work & always close after 5mins #876

Closed as not planned

Description

@Kelier

Describe the bug
always res.on('close') tigger when 5mins after.

To Reproduce

app.get('/sse', async (req, res) => {
  // Create SSE transport for legacy clients
  const transport = new SSEServerTransport('/messages', res);
  transports.sse[transport.sessionId] = transport;
  
  res.on("close", () => {
    delete transports.sse[transport.sessionId];
  });
  
  await server.connect(transport);
});

Expected behavior
to set a bigger timeout

callTool(xx,undefined, {timeout: 20mins})

but dont work

Activity

  1. antonpk1 commented on Aug 18, 2025

    @antonpk1

    Hey there! The problem is that os kills the network connection after 5min of inactivity. The simplest way to prevent it is send some data regularly, "notifications/progress" MCP message works perfectly for it.

    Please find below working example with 20min task (here I send a notification progress message every 30sec):

    minimal-sse-fix.ts:

    import express from 'express';
    import { Client } from '@modelcontextprotocol/sdk/client/index.js';
    import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
    import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
    import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
    import { z } from 'zod';
    
    // Test: 20-minute task with 30-minute timeout and progress notifications
    const TASK_SECONDS = 1200; // 20 minutes
    const CLIENT_TIMEOUT = 1800000; // 30 minutes
    
    // Server
    function createServer() {
      const app = express();
      app.use(express.json());
      const transports: Record<string, SSEServerTransport> = {};
      
      const server = new McpServer({ name: 'test', version: '1.0.0' }, { capabilities: {} });
      
      server.tool('task', 'Long task with progress', { seconds: z.number() },
        async ({ seconds }, { sendNotification }) => {
          const start = Date.now();
          
          for (let elapsed = 0; elapsed < seconds; elapsed += 30) {
            await new Promise(r => setTimeout(r, Math.min(30, seconds - elapsed) * 1000));
            
            const actualElapsed = Math.round((Date.now() - start) / 1000);
            const remaining = Math.max(0, seconds - actualElapsed);
            
            await sendNotification({
              method: "notifications/progress",
              params: { 
                progressToken: "task-progress",
                progress: (actualElapsed / seconds) * 100,
                message: `${actualElapsed}s/${seconds}s (${remaining}s remaining)`
              }
            });
            
            if (actualElapsed >= seconds) break;
          }
          
          return { content: [{ type: 'text', text: `Completed ${seconds}s task` }] };
        }
      );
    
      app.get('/sse', async (req, res) => {
        const transport = new SSEServerTransport('/messages', res);
        transports[transport.sessionId] = transport;
        transport.onclose = () => delete transports[transport.sessionId];
        await server.connect(transport);
      });
    
      app.post('/messages', async (req, res) => {
        const sessionId = req.query.sessionId as string;
        const transport = transports[sessionId];
        if (!transport) return res.status(404).send('Not found');
        await transport.handlePostMessage(req, res, req.body);
      });
    
      return app.listen(3004);
    }
    
    // Client
    async function runTest(): Promise<boolean> {
      const transport = new SSEClientTransport(new URL('http://localhost:3004/sse'));
      const client = new Client({ name: 'test', version: '1.0.0' }, { capabilities: {} });
    
      const start = Date.now();
      try {
        await client.connect(transport);
        
        console.log(`[${ts()}] Starting ${TASK_SECONDS}s task...`);
        
        await client.callTool({ 
          name: 'task', 
          arguments: { seconds: TASK_SECONDS }
        }, undefined, { timeout: CLIENT_TIMEOUT });
        
        const duration = (Date.now() - start) / 1000;
        console.log(`[${ts()}] ✅ SUCCESS: Completed after ${duration.toFixed(1)}s`);
        return true;
        
      } catch (error: any) {
        const duration = (Date.now() - start) / 1000;
        console.log(`[${ts()}] ❌ FAILED: ${error.message} (after ${duration.toFixed(1)}s)`);
        return false;
      } finally {
        await client.close();
        await transport.close();
      }
    }
    
    // Main
    function ts() { return new Date().toISOString().slice(11, 19); }
    
    async function main() {
      console.log(`[${ts()}] Testing: 20-minute task with progress notifications (no timeout config)`);
      
      const server = createServer();
      await new Promise(r => setTimeout(r, 1000));
      
      const result = await runTest();
      server.close();
      
      console.log(`\n[${ts()}] ` + (result ? 
        '🎯 SOLUTION: Progress notifications keep SSE alive past 20 minutes!' : 
        '❌ Still fails - more investigation needed'
      ));
    }
    
    if (import.meta.url === `file://${process.argv[1]}`) {
      main().catch(console.error);
    }

    package.json:

    {
      "name": "issue_876",
      "version": "1.0.0",
      "type": "module",
      "main": "index.js",
      "scripts": {
        "minimal-fix": "npx tsx minimal-sse-fix.ts"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "description": "",
      "dependencies": {
        "@modelcontextprotocol/sdk": "^1.17.3",
        "express": "^5.1.0",
        "zod": "^3.25.76"
      },
      "devDependencies": {
        "@types/express": "^5.0.3",
        "@types/node": "^24.3.0",
        "ts-node": "^10.9.2",
        "tsx": "^4.20.4",
        "typescript": "^5.9.2"
      }
    }

    output:

    issue_876 % npm run minimal-fix
    
    > issue_876@1.0.0 minimal-fix
    > npx tsx minimal-sse-fix.ts
    
    [10:46:48] Testing: 20-minute task with progress notifications (no timeout config)
    [10:46:49] Starting 1200s task...
    
    [11:06:49] ✅ SUCCESS: Completed after 1200.1s
    
    [11:06:49] 🎯 SOLUTION: Progress notifications keep SSE alive past 20 minutes!
    
    

    Hope that helps! MCP team, should we add this tip to README.md?

  2. ochafik commented on Sep 17, 2025

    @ochafik
    Contributor

    MCP team, should we add this tip to README.md?

    @antonpk1 good idea, may want to point to references of os-specific default timeouts if applicable

  3. pja-ant commented on Oct 13, 2025

    @pja-ant

    The spec actually says that it should be sending pings anyway, which would keep the connection alive.

    https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/ping

    Implementations SHOULD periodically issue pings to detect connection health

    I don't think implementations do that now, so it's a bug. I don't think clients should have to do this manually.

  4. added
    ready for workEnough information for someone to start working on
    P1Significant bug affecting many users, highly requested feature
    improves spec complianceWhen a change improves ability of SDK users to comply with spec definition
    on Oct 13, 2025
  5. sstklen commented on Feb 24, 2026

    @sstklen

    Hey! I ran into a similar pattern in our bug knowledge base and thought this might help.

    The fix mentioned above looks right — we've seen the same pattern work in other projects. ✅

    Added this to our shared KB so other folks hitting the same issue can find it faster.


    🦞 Confucius Debug — community knowledge base for AI agent bugs. Free to search via MCP.

  6. ghost removed
    P1Significant bug affecting many users, highly requested feature
    on Apr 17, 2026
  7. ghost added
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    on Apr 17, 2026
  8. ghost added
    fix proposedBot has a verified fix diff in the comment
    on Apr 17, 2026
  9. felixweinberger commented on Oct 5, 2026

    @felixweinberger
    Contributor

    The reproduction uses the old SSE server transport (SSEServerTransport), which writes nothing while a tool runs, so a client or proxy that gives up on a silent stream after five minutes (Node's fetch does) drops it; the request timeout option does not change that. That transport is deprecated: Streamable HTTP sends a keep-alive comment every 15 seconds since 1.30.0 (#2538) and 2.0.0 (#2541), and on SSE the progress notifications shown above keep the stream open. Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingfix proposedBot has a verified fix diff in the commentimproves spec complianceWhen a change improves ability of SDK users to comply with spec definitionready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions