GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — fix(mcp_server): enforce max_concurrent_requests and request_timeout on tool calls by pcbeingused333 · Pull Request #906 · stackql/stackql

Conversation

@pcbeingused333

@pcbeingused333 pcbeingused333 commented Nov 7, 2026 •
edited by jeffreyaven
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor

Part of #828: covers the first two bullets of "Required outcome" (bounded admission and request deadlines). The issue should stay open, see "Not in this PR" below.

Summary

server.max_concurrent_requests and server.request_timeout were accepted in config but never enforced: the semaphore was built and never acquired, and the timeout was never applied. This PR enforces both.

Original implementation and tests by @pcbeingused333. A maintainer follow-up commit on the same branch keeps the goal and changes where the limits are applied, for the reasons below.

Where the limits apply, and why

The first revision installed the limits as a receiving middleware around tools/call. mcp.NewServer installs the SDK's multi round-trip middleware itself, so a middleware added afterwards wraps it. For clients older than 2026-07-28 the approval prompt is awaited inside the tools/call, which gave three problems once an operator set the knobs:

  • request_timeout counted the time a user took to approve. An approval given after the timeout failed with context deadline exceeded and the approved statement never ran.
  • A slot stayed held while a prompt was open. With max_concurrent_requests: 1, one unanswered prompt stalled every other tool call, server_info included. On a shared HTTP server that is a cheap way for one client to stall the rest.
  • A call refused for lack of a slot never reached the tool gate, so it left no audit record and surfaced as a JSON-RPC protocol error.

The limits now live in a Backend decorator (limitedBackend in pkg/mcp_server/limits.go), applied below the tool gate:

  • A call that the mode refuses, or that is waiting on approval, holds no slot and spends none of its deadline.
  • request_timeout is the budget for one backend call, queue wait included. max_concurrent_requests caps backend calls in flight across all clients; further calls queue.
  • Limit hits are tool errors (isError) that name the setting and go through the gate's audit path. server busy means the call never ran. A mutation or lifecycle call that times out says it may already have taken effect.
  • validate_select_query no longer reports a limit hit as valid: false.
  • server_info, ping and initialize are not limited.
  • A value of zero or less disables that limit, so a zero-valued config cannot deadlock.

limitedBackend is an unexported type built only by newLimitedBackend; nothing is exported from the package.

Behaviour change for existing deployments

Both limits remain off unless set. stackql mcp builds its config from an empty object, so the DefaultConfig() values (100 and 30s) do not reach the binary. A deployment that already sets either key will see it enforced for the first time.

Other changes

  • reverse_proxy backend: passes the request context to ExecContext / QueryContext, so the deadline applies there too (it was dropped before). It also returns a row iteration error instead of a short result.
  • docs/mcp.md: new "Request limits" section. Field comments in pkg/mcp_server/config.go updated.

Review feedback addressed

  1. Timeout not effective on the reverse proxy backend: fixed as above.
  2. TestQueuedToolCallReleasesPermitOnCancel passed with the release removed: replaced by tests that fail under that mutation.
  3. No robot coverage: two scenarios added.

Not in this PR (still open on #828)

  • Finite defaults for both settings.
  • Explicit http.Server timeouts and request body limit.
  • Per-client fairness: the slot pool is shared by all clients of the process.
  • Robot scenarios for the limits over the HTTP transport (the new ones use stdio).

Test plan

  • Unit (pkg/mcp_server/limits_test.go, rewritten on testing/synctest, no sleeps): concurrency bound, timeout ends the call and frees the slot, refusal when no slot frees, caller cancel while queued, write timeout wording, slot released on panic, non-positive limits leave the backend unwrapped. End to end through the in-process client: limit errors are audited tool errors, the timeout applies through the real wiring, and an open approval prompt (2025-06-18 client) holds no slot and spends no deadline.
  • Mutation checks, each caught by the tests: slot never released, limit hit reported as a validation verdict, deadline not applied, limits moved back to the outer middleware.
  • Robot (test/robot/functional/mcp.robot):
    • MCP Stdio Request Timeout Ends Slow Call And Session Survives (new).
    • MCP Stdio Concurrency Limit Admits Sequential Calls (new).
    • MCP Stdio Startup Masks Credentials And Preserves Configuration: its config set request_timeout to 1ms and expected a live query to succeed, which only held while the setting was inert. It now uses 30s. This was the failing CI scenario on the first revision.
  • Local: golangci-lint v2.15.0 reports 0 issues; unit tests pass; the MCP robot suite passes apart from the version-string assertions that need a release-stamped binary.
Original description

Cause. max_concurrent_requests only built a semaphore.Weighted that was never acquired, and request_timeout was never applied to any request.

Fix. New receiving middleware (pkg/mcp_server/limits.go) registered before the tools. For tools/call it applies context.WithTimeout(request_timeout) and acquires a permit from the semaphore (waiting is cancellation-aware; permit released via defer on every exit path). Values <= 0 disable the respective control, so a zero-valued config cannot deadlock on NewWeighted(0). Other methods are untouched, so streaming and initialize/ping are unaffected.

Tests (limits_test.go, in-process client): with max_concurrent_requests=1 two blocking calls never overlap at the backend; request_timeout=20ms unblocks a hung backend call; timed-out calls free their permit; non-positive limit creates no semaphore. Red without the middleware (3 fail), green with it; go test ./pkg/mcp_server/... passes.

This change was prepared with AI assistance (Claude Code) and reviewed before submission.

🤖 Generated with Claude Code

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@pcbeingused333
fix(mcp_server): enforce max_concurrent_requests and request_timeout …
cbc9596
…on tool calls

The configured semaphore was created but never acquired and request_timeout
was never applied. Add receiving middleware for tools/call that acquires a
permit (cancellation-aware, released on every exit path) and bounds the call
with the configured deadline. Non-positive values disable the control.

Refs stackql#828

Signed-off-by: Alex Castillo <[email protected]>
@jeffreyaven
jeffreyaven requested a balanced review from Copilot November 7, 2026 23:01
Copilot started reviewing on behalf of jeffreyaven November 7, 2026 23:02
View session
Copilot AI reviewed Nov 7, 2026
View reviewed changes

Copilot AI left a comment

Copy link
Copy Markdown

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

🟡 Changes recommended

The timeout does not cancel reverse-proxy database calls, transport-level regression coverage is missing, and the permit-release test can pass despite a semaphore leak.

3 open issues
  • Medium severity Propagate request context to reverse-proxy database calls · New
  • Medium severity Assert follow-up calls reach the backend after permit release · New
  • Medium severity Add black-box timeout and cancellation tests for both transports · New
What changed in this PR

This PR adds shared MCP tool-call admission limits and request deadlines, addressing part of #828. HTTP server timeouts and body limits remain deferred.

Changes:

  • Registers middleware that acquires semaphore permits and applies request deadlines to tools/call.
  • Defines non-positive settings as disabling the corresponding control, leaving other methods unchanged.
  • Adds in-process tests for concurrency limits, timeouts, permit recovery, and disabled limits.
File Description
pkg/​mcp_server/​server.go Wires the limits middleware and shared semaphore into server construction.
pkg/​mcp_server/​limits.go Implements cancellation-aware admission, deadlines, and deferred permit release.
pkg/​mcp_server/​limits_test.go Adds blocking-backend tests for the configured controls.

Review effort: Balanced

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread pkg/mcp_server/limits.go Outdated
}
defer sem.Release(1)
}
return next(ctx, method, req)
Comment thread pkg/mcp_server/limits_test.go Outdated
// First call times out and must free its permit; later calls must proceed.
for i := 0; i < 3; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_, _ = cs.CallTool(ctx, &mcp.CallToolParams{Name: "list_providers"})
Comment thread pkg/mcp_server/server.go Outdated
Comment on lines +301 to +303
server.AddReceivingMiddleware(
toolCallLimits(requestSemaphore, time.Duration(config.Server.RequestTimeout)),
)
@jeffreyaven jeffreyaven self-assigned this Nov 12, 2026
jeffreyaven and others added 3 commits November 13, 2026 10:07
@jeffreyaven
Merge remote-tracking branch 'origin/main' into pr-906-review
cbfd188
@jeffreyaven @claude
apply request limits to backend work, not the whole tools/call
9cf695a
The limits were installed as a receiving middleware. mcp.NewServer installs
the SDK's multi round-trip middleware first, so a middleware added afterwards
wraps it. For clients older than 2026-07-28 the approval prompt is awaited
inside the tools/call, which meant:

- request_timeout counted the time a user took to approve, so a slow
  approval failed the call and the approved statement never ran;
- a slot stayed held while a prompt was open, so with a small
  max_concurrent_requests one unanswered prompt stalled every other tool
  call, server_info included;
- a call refused for lack of a slot never reached the tool gate, so it left
  no audit record and surfaced as a protocol error.

Move both limits into a Backend decorator (limitedBackend) applied below the
tool gate. A call that the mode refuses, or that waits on approval, holds no
slot and spends none of its deadline. Limit hits are tool errors that name
the setting and pass through the gate's audit path. A write that times out
warns that it may already have taken effect. validate_select_query no longer
reports a limit hit as valid:false. server_info is not limited.

The reverse proxy backend now passes the context to its database calls, so
the deadline applies there too, and returns row iteration errors instead of
a short result.

Tests: limits_test.go rewritten on testing/synctest (no sleeps), with
end-to-end cases for audited limit errors and for the approval wait sitting
outside the limits. Robot: the startup scenario that set request_timeout to
1ms and expected a live query to succeed now uses 30s (this was the CI
failure); two new stdio scenarios cover the timeout and the concurrency cap.
Docs: request limits section in docs/mcp.md.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
@jeffreyaven
Merge remote-tracking branch 'origin/main' into pr-906-review
22b61aa
@jeffreyaven
jeffreyaven merged commit 56d40eb into stackql:main Nov 13, 2026
32 checks passed

Uh oh!

There was an error while loading. Please reload this page.

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

Reviewers

Copilot code review Copilot
Copilot review effort
Applies to this pull request for everyone.Learn more about Copilot code review.
Copilot left review comments

Uh oh!

There was an error while loading. Please reload this page.

Assignees

@jeffreyaven jeffreyaven

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

3 participants

@pcbeingused333 @jeffreyaven
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.