Add notifications/progress and notifications/cancelled #11
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Add Progress notifications and Cancellation — both ride on the request
_metafield and serve the same long-running-tool use case.Goal
shell,shell_bg,fetch,web_search, and any future tool can take seconds to minutes. Today the client gets nothing until the response lands, and has no way to ask the server to stop. The spec gives both for free.Methods to add
notifications/progress{ progressToken, progress, total?, message? }. Token is whatever the client sent in_meta.progressTokenon the original request.notifications/cancelled{ requestId, reason? }. Server respects: stop the work, don't send a response. Server may also send: e.g. internal timeout.API for lmcp
ctxis a new third arg to tool handlers — currently they take onlyargs. Backwards compatible because old handlers just don't read it.Wiring
params._meta.progressToken(a number or string) when handlingtools/call. Stash it on the per-requestctx.ctx.progress(progress, message?)sendsnotifications/progressover the open response channel.notifications/cancelled, set a flag the correspondingctx.cancelled()reads.Capabilities
No new capability flag — both notifications are spec-mandatory utilities.
Scope (v1)
ctx.progressandctx.cancelled.run()(server.lua:122) whenctx.cancelled()flips. Surface "cancelled" in the tool response rather than the full output.Depends on
notifications/progressto actually reach the client mid-call. Until then, progress() is a no-op (or stderr-logs)._meta.progressToken, and a subsequentnotifications/cancelledPOST can flip a flag the in-flight handler polls.Priority
Medium. Cancellation is the higher-value half —
shell-tools that runaway today have no escape valve. Progress is nicer-to-have.Finding from implementation triage: cancellation has no deliverable value in pure-sync single-threaded Lua dispatch.
lmcp processes one request at a time: a tool handler runs to completion before the next stdin line / HTTP request is read. A
notifications/cancelledline can therefore only arrive after the target request has already finished —ctx.cancelled()would never flip from false to true mid-call. The structural prerequisite is true async dispatch (a polling thread that reads new requests while a handler is running), which is part of issue #16 (Streamable HTTP) or would require migrating handlers to coroutines + yield points (invasive).Recommendation: keep this open as a v2 follow-up to #16. The progress half (server → client
notifications/progress) is also gated on the same bidirectional transport. Once #16 lands with the event loop, both halves of this issue become tractable in one pass.No code change in this session.
Make this v1.1.0
Acked: v1.1.0. Will land with #20 (the structural prerequisite) — implementation stub already in place (server-initiated request helper, cancellation-token plumbing skeleton). Awaiting the v1.1.0 milestone assignment from a repo-write account.
Implemented (commit
55ead80, on master). Builds on #16 (Streamable HTTP) and #20 (concurrent handler dispatch).ctx augmentation:
ctx.progress(p, total?, message?)— emitsnotifications/progresson the session's notify_q. No-op when the original request omitted_meta.progressToken(per spec). Type-checks numeric args; passes progressToken through unchanged (spec allows number OR string).ctx.cancelled()— returnstrueonce anotifications/cancelledfor this request's id has arrived.Notification side-effect:
notifications/cancelledscans the module-level_ctx_by_cofor an in-flight ctx with matchingrequest_idand flipsself._cancelled_ids[rid_str]only if found. Unknown rids drop silently (no map growth — per Phase 5 review #2).tools/call, the handler is skipped entirely.Cross-module ctx lookup:
_ctx_by_cotable in lmcp.lua keyed by coroutine.lmcp.current_ctx()returns the ctx of the running coroutine.server.lua:run()lazy-requires lmcp and uses it to opt into auto-cancellation without depending on lmcp internals (no_current_serversingleton — Phase 5 review #1).server.lua:run():sleep_mscycle, checksctx.cancelled(); exits poll loop withcancelled=trueif set."(cancelled)"sentinel; handler propagates normally.Measurements:
Phase 4 deviation, documented:
Plan was "silent TCP close" per spec wording "MUST/SHOULD NOT respond." Empirically:
os.executeinserver.lua:run()fork+execs a shell subprocess that inherits the parent's TCP socket FD. Even aftersock:close(), the connection stays open at kernel level until the spawned shell exits (i.e. the long-running command completes anyway, defeating the purpose).Verified luasocket's
close()does work in isolation (curl exits with RST in 511ms on a bare-socket test) — the issue is exclusively fork-inherit.Trade-off taken: emit JSON-RPC
-32800 Request cancelled(conventional code for cancellation). Spec wording is "SHOULD NOT respond" (not MUST). Client sees structured response in ~420ms with proper error correlation — better UX than waiting for the underlying shell to complete.Proper fix deferred: set
FD_CLOEXECon accepted sockets. luasocket doesn't expose it; needs a C shim orluaposix. Tracked as potential v1.2+ follow-up.Memory:
project_fd_inheritance_in_run.mdcaptures the trap so the same surprise doesn't bite future "silent close" features.