Skip to content

feat(mcp): stream logs over SSE (scene + client subscribe)#9436

Draft
eordano wants to merge 73 commits into
devfrom
feat/mcp-log-streaming
Draft

feat(mcp): stream logs over SSE (scene + client subscribe)#9436
eordano wants to merge 73 commits into
devfrom
feat/mcp-log-streaming

Conversation

@eordano

@eordano eordano commented Jul 20, 2026

Copy link
Copy Markdown
Member

The log tools are pull-only (get_scene_logs with sinceSeq). Add the server-to-client half of the Streamable HTTP transport so a client can subscribe and receive logs as they happen, over two independent streams:

  • GET ?stream=scene -> the SDK7 scene's JS console (same source as get_scene_logs)
  • GET ?stream=client -> the whole Unity player/editor log (engine, build, editor; Application.logMessageReceivedThreaded)

Each event is a JSON-RPC notifications/message with an RFC 5424 level; an optional ?level= filters per-subscription (default debug = all). The two streams are independent, each with its own level. Advertised in initialize under capabilities.experimental["dcl/logStreams"]. GET without Accept: text/event-stream still 405s; a bad stream/level 400s.

  • McpNotificationChannel: thread-safe SSE broadcaster keyed by stream + min level, drops sinks that fail to write (client gone).
  • McpLogNotifier: bridges the scene log bus and the Unity log callback to the channel.
  • McpHttpServer: GET opens the SSE stream; a per-connection token ends the handler when the sink detects a vanished client.
  • Tests: McpNotificationChannelShould (routing, level filter, drop-on-fail, unsubscribe, dispose) + McpLogLevelShould (parse/wire round-trip).
  • docs/mcp-automation.md: "Streaming logs (SSE subscribe)" section.

pravusjif and others added 30 commits July 5, 2026 03:46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eat/explorer-mcp-server

Resolved conflict in DynamicWorldContainer.cs: dropped the unused
'using CommunicationData.URLHelpers;' brought by the remote side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs
popuz and others added 27 commits July 17, 2026 14:26
…n't drift

  The path rename /mcp → /unity-explorer-mcp reached the HttpListener prefix
  but not the two "listening on …" announce strings (McpHttpServer and
  McpServerPlugin) nor the docs, so the client logged and copy-pasted a dead
  address. Introduce McpHttpServer.EndpointUrl, derived from one ENDPOINT_PATH
  constant, and route the listener prefix and both announces through it — the
  logged address is now computed from the same string it actually binds. Update
  every external copy that can't reference the constant (docs/mcp-automation.md,
  docs/app-arguments.md, the mcp-scene-iteration skill incl. the functional
  screenshot.sh) and anchor them with a sync comment on the constant.
# Conflicts:
#	Explorer/Assets/DCL/PerformanceAndDiagnostics/Diagnostics/ReportsHandling/ReportsHandlingSettingsProduction.asset
…stion

  The Content-Length guard in HandlePostAsync was bypassed by
  Transfer-Encoding: chunked requests (ContentLength64 == -1), letting
  ReadToEndAsync read an unbounded body into memory. Read into a fixed
  MAX_BODY_BYTES buffer and reject oversized bodies with 413.
…s teardown

  - McpHttpServer: cap POST body read into a fixed buffer so the 1MB limit
    can't be bypassed via Transfer-Encoding: chunked (ContentLength64 == -1)
  - McpServerPlugin: store DebugMenuConsoleLogEntryBus as a field and
    unsubscribe logBuffer.Append in Dispose()
  - McpHttpServer: cap POST body read via ReadBlockAsync so chunked requests
    can't bypass the Content-Length limit and exhaust memory
  - McpHttpServer: capture the listener into a local in the accept loop to
    avoid an NRE race with a concurrent Dispose()
  - McpHttpServer: document the deliberate stateless single-session design
    (unconditional, unvalidated Mcp-Session-Id)
  - McpToolsRegistry: hand out a DeepClone of the tools/list payload so
    concurrent dispatches don't race on the shared JObject
  - McpJsonRpcDispatcher: return -32600 (Invalid Request) for well-formed
    JSON that isn't an object, keeping -32700 for unparseable input
  - McpJsonRpcDispatcher: log a warning when the client's initialize
    protocolVersion differs from the server's
  - McpServerPlugin: store the log entry bus in a field and unsubscribe in
    Dispose()
…hed payload

  The implicit JObject conversion on McpToolsRegistry hid a DeepClone behind
  `Result(id, tools)` in the dispatcher — a surprising conversion with a
  per-request allocation. Serialize the tools/list payload once in Build() and
  hand it out via an explicit ToolsListPayload() that wraps the immutable JSON
  string in a fresh JRaw, so concurrent dispatches share it without cloning or
  re-parenting a JToken tree.

  - Drop `implicit operator JObject` and the per-request DeepClone
  - Store the sealed payload as a serialized string, built once in Build()
  - Update the dispatcher call site to tools.ToolsListPayload()
  - Re-parse the raw payload in registry tests via a Payload() helper
  Replace repeated routable.Value.X access with a single tuple
  deconstruction and a property-pattern guard in DispatchAsync.
  Centralize Formatting.Indented serialization in McpToolResult so tools
  can't drift the structured payload from its text mirror or forget the
  formatting. Convert the eight callsites and drop their now-unused
  Newtonsoft.Json import.
  The builder produces both input and output JSON Schemas (OutputSchema,
  VectorSchema), so the "Input" name misrepresented it. Pure rename of the
  type and its file/test.
  The three structured tools declare OutputSchema and build the payload by
  hand, so a field added to one and forgotten in the other would silently
  misdescribe the result. Add McpSchemaAssert.KeysMatch, a recursive
  schema-vs-payload key check, and wire it into the Get*/List* tool tests
  (populating a camera entity and a scene facade so nested objects are
  covered too).
… Build

  Dedup nullable type-token logic in McpJsonSchema via a private
  TypeToken(type, nullable) helper, dropping the (JToken)type cast in
  Property and sharing it with Object. Add symmetric OutputSchema
  validation in McpToolsRegistry.Build so a malformed output schema
  fails fast at registration instead of reaching the client.
  Change the extension to take HttpStatusCode instead of int, casting
  once inside the method. Drops the (int) cast and statusCode: label
  from all seven call sites and makes the status intent type-safe.
  Cap each tools/call at TOOL_CALL_TIMEOUT with a CancellationTokenSource
  linked to the server-lifetime token. A hung tool now returns a timeout
  error result instead of holding its HttpListenerContext open until
  shutdown. Distinguish shutdown cancellation (rethrow) from timeout
  (error result) via a when (ct.IsCancellationRequested) filter.
  Tools declared "switch to the main thread yourself" and 15 of 16 opened
  with an identical `await UniTask.SwitchToMainThread(ct)`. Hop once in
  McpJsonRpcDispatcher.CallToolAsync instead and flip the IMcpTool contract
  to "invoked on the main thread". Readers with no other await become
  non-async (UniTask.FromResult); tools that offload to the thread pool
  keep their return-trip hops.
The log tools are pull-only (get_scene_logs with sinceSeq). Add the
server-to-client half of the Streamable HTTP transport so a client can
subscribe and receive logs as they happen, over two independent streams:

- GET ?stream=scene  -> the SDK7 scene's JS console (same source as get_scene_logs)
- GET ?stream=client -> the whole Unity player/editor log (engine, build, editor;
  Application.logMessageReceivedThreaded)

Each event is a JSON-RPC notifications/message with an RFC 5424 level; an optional
?level= filters per-subscription (default debug = all). The two streams are
independent, each with its own level. Advertised in initialize under
capabilities.experimental["dcl/logStreams"]. GET without Accept: text/event-stream
still 405s; a bad stream/level 400s.

- McpNotificationChannel: thread-safe SSE broadcaster keyed by stream + min level,
  drops sinks that fail to write (client gone).
- McpLogNotifier: bridges the scene log bus and the Unity log callback to the channel.
- McpHttpServer: GET opens the SSE stream; a per-connection token ends the handler
  when the sink detects a vanished client.
- Tests: McpNotificationChannelShould (routing, level filter, drop-on-fail,
  unsubscribe, dispose) + McpLogLevelShould (parse/wire round-trip).
- docs/mcp-automation.md: "Streaming logs (SSE subscribe)" section.
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

badge

Build skipped — no changes detected under Explorer/.

Base automatically changed from feat/explorer-mcp-server to dev July 24, 2026 10:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants