feat(temporal): serve the lane's own Temporal namespace as read-only MCP #1028

Merged
coilysiren merged 1 commit from feat/temporal-mcp into main 2026-08-19 03:02:04 +00:00
Member

Refs deploy#698. The harness half. A deploy PR follows and is the half that actually puts it on Dowel's roster.

What is missing today

The mirror writes: Dowel's deployment carries SIRENS_ECHO_TEMPORAL_HOST=coilyco.gcdqf.tmprl.cloud:7233, its namespace, its task queue, and the API key. DialTemporalMirror returns a dial error rather than swallowing it, so the pod being 1/1 Running is proof it connected.

Nothing reads. Dowel's roster has six servers and none is Temporal, so on a Temporal livestream the agent cannot answer a question about its own durable execution.

Why a binary here rather than a server in deploy

deploy#698 says "1st class if they have one, beaver if not". Neither is available:

  • No first-party MCP. The "Temporal MCP Server" on Temporal's Code Exchange is community-contributed (GethosTheWalrus/temporal-mcp, Apache-2.0, Python), not Temporal's own.
  • beaver cannot wrap it. mcp-beaver wraps HTTP upstreams, and Temporal Cloud workflow operations are gRPC-only on 7233. The Cloud Ops API is control-plane only. I probed https://coilyco.gcdqf.tmprl.cloud directly and every path including / resets the connection.
  • Every community server is stdio. Confirmed from source, not from a README: the Python one runs stdio_server() against mcp.server.Server, and the TypeScript alternative ships no image at all. A stdio server cannot be reached from a sibling container, because a pod shares a network namespace and not stdin, so it would have to be spawned as a child inside a bridge image. serve-upstream takes a URL and never binds stdio, and deploy holds no Dockerfile at all, so that bridge needs a new repo, a publish workflow and registry credentials before the first manifest exists.

Against that, this repo already has everything:

go.temporal.io/sdk v1.47.0                      already a direct dependency
github.com/modelcontextprotocol/go-sdk v1.7.0   already a direct dependency
mcp.NewStreamableHTTPHandler                    already used by mcpserver.go
cmd/                                            already builds 11 binaries into the image

So the server is one more binary in an image that is published anyway, and the deploy change becomes an extraContainers entry running this same image with a different command. No new image, no registry work, no third-party code, no bridge.

The surface

Three tools, all observation:

  • list_workflows - takes a Temporal visibility query, returns flattened rows.
  • describe_workflow - status, times, history length, pending work.
  • get_workflow_history - event ids, types and times.

Nothing that starts, signals, cancels, terminates, schedules or batches is registered. That matters because the community server offers 21 tools including terminate_workflow, batch_terminate and delete_schedule, and taking that route would have made an allowlist load-bearing. Here the mutation has no handler, which is the same deny-by-absence posture the .mcp.kdl guardfiles rely on.

Two further narrowings:

  • The namespace is fixed at dial, not taken per call, so no tool argument can reach a namespace this lane was not granted.
  • Event payloads are never returned. They carry tool arguments, and the mirror is deliberately metadata-only. get_workflow_history returns ids, types and times and nothing else.

Results are flattened rather than passed through as protobuf JSON, which is mostly envelope, and a model reading a tool result pays for every byte.

Tests

TestNoMutatingVerbIsReachable applies the same screen mcp-beaver lint-upstream --read-only heuristic applies to an allowlist, so a mutating tool cannot be added later without a test failing first. TestOnlyReadToolsAreServed pins the list at exactly three and requires each to declare readOnlyHint; checked non-vacuous by flipping the annotation to false, which fails all three. TestDialRefusesAHalfFilledConnection covers the half-configured case the mirror already guards against.

go test ./..., go vet, and pre-commit run --all-files green. go mod tidy promoted go.temporal.io/api from indirect to direct at the same version, since the flattening imports its workflow/v1 and enums/v1 types.

No docs page, and why

docs/ is at 40 of its 40-page cap, and sirens-echo-mcp.md is at exactly 8000 of 8000 characters. There is no room for a page or a paragraph, which is sirens-echo#1020 exactly. The package and command comments name deploy#698 instead. Worth folding into #1020's consolidation rather than shaving another page to fit.

Refs deploy#698. The harness half. A deploy PR follows and is the half that actually puts it on Dowel's roster. ## What is missing today The mirror **writes**: Dowel's deployment carries `SIRENS_ECHO_TEMPORAL_HOST=coilyco.gcdqf.tmprl.cloud:7233`, its namespace, its task queue, and the API key. `DialTemporalMirror` returns a dial error rather than swallowing it, so the pod being `1/1 Running` is proof it connected. Nothing **reads**. Dowel's roster has six servers and none is Temporal, so on a Temporal livestream the agent cannot answer a question about its own durable execution. ## Why a binary here rather than a server in deploy deploy#698 says "1st class if they have one, beaver if not". Neither is available: * **No first-party MCP.** The "Temporal MCP Server" on Temporal's Code Exchange is community-contributed (`GethosTheWalrus/temporal-mcp`, Apache-2.0, Python), not Temporal's own. * **beaver cannot wrap it.** mcp-beaver wraps HTTP upstreams, and Temporal Cloud workflow operations are gRPC-only on 7233. The Cloud Ops API is control-plane only. I probed `https://coilyco.gcdqf.tmprl.cloud` directly and every path including `/` resets the connection. * **Every community server is stdio.** Confirmed from source, not from a README: the Python one runs `stdio_server()` against `mcp.server.Server`, and the TypeScript alternative ships no image at all. A stdio server cannot be reached from a sibling container, because a pod shares a network namespace and not stdin, so it would have to be spawned as a child inside a bridge image. `serve-upstream` takes a URL and never binds stdio, and **deploy holds no Dockerfile at all**, so that bridge needs a new repo, a publish workflow and registry credentials before the first manifest exists. Against that, this repo already has everything: ``` go.temporal.io/sdk v1.47.0 already a direct dependency github.com/modelcontextprotocol/go-sdk v1.7.0 already a direct dependency mcp.NewStreamableHTTPHandler already used by mcpserver.go cmd/ already builds 11 binaries into the image ``` So the server is one more binary in an image that is published anyway, and the deploy change becomes an `extraContainers` entry running **this same image** with a different command. No new image, no registry work, no third-party code, no bridge. ## The surface Three tools, all observation: * `list_workflows` - takes a Temporal visibility query, returns flattened rows. * `describe_workflow` - status, times, history length, pending work. * `get_workflow_history` - event ids, types and times. **Nothing that starts, signals, cancels, terminates, schedules or batches is registered.** That matters because the community server offers 21 tools including `terminate_workflow`, `batch_terminate` and `delete_schedule`, and taking that route would have made an allowlist load-bearing. Here the mutation has no handler, which is the same deny-by-absence posture the `.mcp.kdl` guardfiles rely on. Two further narrowings: * **The namespace is fixed at dial**, not taken per call, so no tool argument can reach a namespace this lane was not granted. * **Event payloads are never returned.** They carry tool arguments, and the mirror is deliberately metadata-only. `get_workflow_history` returns ids, types and times and nothing else. Results are flattened rather than passed through as protobuf JSON, which is mostly envelope, and a model reading a tool result pays for every byte. ## Tests `TestNoMutatingVerbIsReachable` applies the same screen `mcp-beaver lint-upstream --read-only heuristic` applies to an allowlist, so a mutating tool cannot be added later without a test failing first. `TestOnlyReadToolsAreServed` pins the list at exactly three and requires each to declare `readOnlyHint`; **checked non-vacuous by flipping the annotation to false**, which fails all three. `TestDialRefusesAHalfFilledConnection` covers the half-configured case the mirror already guards against. `go test ./...`, `go vet`, and `pre-commit run --all-files` green. `go mod tidy` promoted `go.temporal.io/api` from indirect to direct at the same version, since the flattening imports its `workflow/v1` and `enums/v1` types. ## No docs page, and why `docs/` is at **40 of its 40-page cap**, and `sirens-echo-mcp.md` is at exactly 8000 of 8000 characters. There is no room for a page or a paragraph, which is sirens-echo#1020 exactly. The package and command comments name deploy#698 instead. Worth folding into #1020's consolidation rather than shaving another page to fit.
feat(temporal): serve the lane's own Temporal namespace as read-only MCP
All checks were successful
ci / test (pull_request) Successful in 56s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Successful in 36s
f90a1ccf06
The mirror writes tool calls into Temporal Cloud and nothing reads them
back, so Dowel cannot answer a question about its own durable execution.
Every Temporal MCP on offer is stdio, which no sibling container can
reach and which mcp-beaver cannot front, and deploy holds no Dockerfile
to build a bridge in. This repo already has both SDKs and already serves
MCP over StreamableHTTP, so the cheapest correct server is one more
binary in an image that is already published.

Three tools, all observation: list_workflows, describe_workflow,
get_workflow_history. Nothing that starts, signals, cancels or
terminates is registered, so deny-by-absence is the enforcement rather
than an allowlist someone has to keep right. A test asserts the tool
list is exactly those three, that each declares readOnlyHint, and that
no tool name carries a mutating verb.

The namespace is fixed at dial rather than taken per call, so no tool
argument can reach a namespace this lane was not given. Event payloads
are never returned, matching the mirror's metadata-only posture.

Refs deploy#698.

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo!1028
No description provided.