Skip to content

Relay task elicitation through standard MCP protocol#3136

Merged
chrisguidry merged 6 commits intomainfrom
task-elicitation-relay
Feb 10, 2026
Merged

Relay task elicitation through standard MCP protocol#3136
chrisguidry merged 6 commits intomainfrom
task-elicitation-relay

Conversation

@chrisguidry
Copy link
Copy Markdown
Collaborator

@chrisguidry chrisguidry commented Feb 10, 2026

Stacked on #2906.

When a background task calls ctx.elicit(), the notification subscriber detects
the input_required notification and sends a standard elicitation/create
request to the client via session.elicit(). The client's elicitation_handler
fires, and the relay pushes the response to Redis for the blocked worker.

This means clients can respond to background task elicitation using the same
elicitation_handler they'd use for any other elicitation — no need to interact
with Redis or call handle_task_input() directly.

mcp = FastMCP("Demo")

@mcp.tool(task=True)
async def plan_dinner(ctx: Context) -> str:
    result = await ctx.elicit(
        "What kind of dinner?",
        response_type=DinnerPrefs,
    )
    if isinstance(result, AcceptedElicitation):
        return f"Tonight: {result.data.cuisine} dinner!"
    return "Cancelled"

# Client just uses the standard elicitation_handler
async with Client(mcp, elicitation_handler=my_handler) as client:
    task = await client.call_tool("plan_dinner", {}, task=True)
    result = await task.result()

Also fixes self.sessionself._session in _elicit_for_task() (the
.session property raises in external workers where there's no request
context), and corrects the session type hint in elicit_for_task() to
ServerSession | None.

Fixes the _meta related-task key from modelcontextprotocol.io/related-task
to io.modelcontextprotocol/related-task to match the reverse-DNS style in
the current spec.

🤖 Generated with Claude Code

When a background task calls ctx.elicit(), the notification subscriber now
detects the input_required notification and sends a standard elicitation/create
request to the client via session.elicit(). The client's elicitation_handler
fires, and the relay pushes the response to Redis for the blocked worker.

This means clients can respond to background task elicitation using the same
elicitation_handler they'd use for any other elicitation — no need to interact
with Redis or call handle_task_input() directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@chrisguidry
Copy link
Copy Markdown
Collaborator Author

@gfortaine I was doing some testing here and was able to connect the full loop! How does this look to you? Does it match what you were thinking?

@marvin-context-protocol marvin-context-protocol Bot added enhancement Improvement to existing functionality. For issues and smaller PR improvements. client Related to the FastMCP client SDK or client-side functionality. server Related to FastMCP server implementation or server-side functionality. labels Feb 10, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 10, 2026

Walkthrough

Adds background-task elicitation support: new example examples/task_elicitation.py demonstrating a Docket worker eliciting DinnerPrefs; changes context._elicit_for_task to pass self._session; makes elicit_for_task accept ServerSession | None and accept message/schema/fastmcp args; adds relay_elicitation to forward elicitation requests from background workers to MCP clients and push responses via Redis; wires FastMCP into the notifications subscriber loop and tracks fire-and-forget relay tasks; updates docs for context, elicitation, and notifications.

Possibly related PRs

  • jlowin/fastmcp PR 2578: Modifies server task/elicitation code paths (notifications, task exports, and elicitation/task-related signatures), closely overlapping this PR’s changes.
  • jlowin/fastmcp PR 2676: Touches elicitation API surface and typing/overloads for elicitation functions related to the updated elicit_for_task flow.
  • jlowin/fastmcp PR 2378: Implements broader task/Docket support affecting task elicitation, notification routing, and server/client task handlers that this PR also changes.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main change: implementing a mechanism to relay background task elicitation through the standard MCP protocol.
Description check ✅ Passed The description comprehensively explains the implementation, includes code examples, references stacked PRs, and addresses specific bug fixes (self.session → self._session and metadata key corrections).
Docstring Coverage ✅ Passed Docstring coverage is 91.67% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch task-elicitation-relay

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 361eb08f42

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +272 to +276
async with docket.redis() as redis:
await redis.lpush( # type: ignore[invalid-await]
response_key, json.dumps(cancel)
)
await redis.expire(response_key, ELICIT_TTL_SECONDS)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Set status when pushing cancel fallback response

When _relay_elicitation() falls back after session.elicit() errors (for example, a client without an elicitation handler), it LPUSHes a cancel response but never updates ELICIT_STATUS_KEY to responded. handle_task_input() still treats the task as waiting, so a concurrent/manual input write can also LPUSH onto the same response list, making the worker’s BLPOP outcome race-dependent between cancel and accept paths; this is a behavioral regression from the single-writer contract in handle_task_input()/elicit_for_task.

Useful? React with 👍 / 👎.

@chrisguidry chrisguidry changed the base branch from fix/statusMessage-not-forwarded to main February 10, 2026 20:33
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/fastmcp/server/tasks/notifications.py (1)

271-294: Broad except Exception is acceptable here but consider logging the traceback.

The Ruff BLE001 warning flags the broad except Exception at Lines 271 and 288. In this context, catching broadly is intentional: the cancel fallback must fire regardless of the failure mode to prevent the worker from blocking indefinitely on BLPOP. This is a valid resiliency pattern.

However, the warning at Line 272 only logs %s of the exception, losing the traceback. Adding exc_info=True (or using logger.exception) would aid debugging relay failures in production without changing behavior.

Proposed improvement
     except Exception as e:
-        logger.warning("Failed to relay elicitation for task %s: %s", task_id, e)
+        logger.warning(
+            "Failed to relay elicitation for task %s: %s", task_id, e, exc_info=True
+        )

Comment on lines +194 to +213
# If this is an input_required notification with elicitation metadata,
# relay the elicitation to the client via standard elicitation/create
params = notification_dict.get("params", {})
if params.get("status") == "input_required":
meta = notification_dict.get("_meta", {})
related_task = meta.get("modelcontextprotocol.io/related-task", {})
elicitation = related_task.get("elicitation")
if elicitation:
task_id = params.get("taskId")
if not task_id:
logger.warning(
"input_required notification missing taskId, skipping relay"
)
return
task = asyncio.create_task(
_relay_elicitation(session, session_id, task_id, elicitation, docket),
name=f"elicitation-relay-{task_id[:8]}",
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing KeyError guard on elicitation["message"] / elicitation["requestedSchema"] access in the spawned task.

If a malformed notification arrives without message or requestedSchema in the elicitation dict, _relay_elicitation will raise a KeyError at Lines 243-244. That exception is caught by the broad except Exception block (Line 271), which triggers the cancel fallback — so the worker won't deadlock. However, the root cause (a schema key missing from the notification payload) would be masked as a generic "Failed to relay elicitation" warning. Consider validating presence of required keys before spawning the relay task so the log message is diagnostic.

Proposed fix
         elicitation = related_task.get("elicitation")
         if elicitation:
+            if "message" not in elicitation or "requestedSchema" not in elicitation:
+                logger.warning(
+                    "Elicitation metadata missing required keys (message/requestedSchema), "
+                    "skipping relay for task %s",
+                    params.get("taskId"),
+                )
+                return
             task_id = params.get("taskId")

chrisguidry and others added 2 commits February 10, 2026 16:00
Moves relay_elicitation() into elicitation.py so it can reuse
handle_task_input() for the Redis push instead of duplicating that logic.
notifications.py just detects the trigger and calls it.

Also fixes the related-task metadata key from modelcontextprotocol.io/ to
io.modelcontextprotocol/ to match the current spec:
https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@chrisguidry chrisguidry merged commit efcc12b into main Feb 10, 2026
14 of 15 checks passed
@chrisguidry chrisguidry deleted the task-elicitation-relay branch February 10, 2026 21:06
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/python-sdk/fastmcp-server-tasks-elicitation.mdx (1)

1-96: ⚠️ Potential issue | 🟡 Minor

This file should not be manually modified — it is auto-generated.

As per coding guidelines, docs/python-sdk/** files are auto-generated and should never be modified directly. These changes will likely be overwritten by the next documentation generation run. The API surface updates (new relay_elicitation function, updated elicit_for_task signature) should be picked up automatically by the doc generator from the source code docstrings.

As per coding guidelines: docs/python-sdk/**: "Never modify docs/python-sdk/** (auto-generated)".

🧹 Nitpick comments (2)
src/fastmcp/server/tasks/notifications.py (1)

165-221: Unused docket parameter in _send_mcp_notification.

The docket parameter (line 169) is accepted but never used within _send_mcp_notification. Ruff confirms this (ARG001). Since relay_elicitation (the only downstream consumer) doesn't need it either, remove it from the signature and the call site at line 125.

Proposed fix
 async def _send_mcp_notification(
     session: ServerSession,
     notification_dict: dict[str, Any],
     session_id: str,
-    docket: Docket,
     fastmcp: FastMCP,
 ) -> None:

And at the call site (line 124-126):

                     await _send_mcp_notification(
-                        session, notification_dict, session_id, docket, fastmcp
+                        session, notification_dict, session_id, fastmcp
                     )
src/fastmcp/server/tasks/elicitation.py (1)

324-328: Minor TOCTOU between status check and LPUSH — acceptable given TTL expiry.

The redis.get(status_key) check at line 326 and the subsequent redis.lpush at line 332 are not atomic. A concurrent caller could push a duplicate response between these operations. However, since elicit_for_task's BLPOP only consumes the first item and extras expire via TTL, this is safe in practice. Worth noting if this code is ever adapted for stricter exactly-once semantics.

@gfortaine
Copy link
Copy Markdown
Contributor

yes — this is the piece that was missing. having the client use the same elicitation_handler for foreground and background tasks is the right call. it means the distributed complexity stays invisible to tool authors.

the self._session fix and the reverse-dns key correction are solid catches too.

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

Labels

client Related to the FastMCP client SDK or client-side functionality. enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants