HTTP gateway
Status: Supported
Milestone: 3 — MCP query
Spec: Module architecture §8, Query §9, Configuration §10
Package: internal/gateway (new), internal/modules, internal/query, modules/http-ingest
Goal
Unify Trove's HTTP surfaces behind a single core listener with declarative
route registration. Capture (POST /ingest, PUT /blobs) and query (MCP
streamable HTTP) register routes on a shared gateway; core owns durable services
(journal, blobs) via symmetric RPC.
Primary outcomes:
- One client-facing URL for iOS Shortcuts, MCP clients, and webhooks.
- Manifest-declared routes instead of hardcoded muxes scattered across core and modules.
- MCP and HTTP ingest follow the same extension pattern.
- Gateway-level auth middleware (see auth) applies once.
Background (before gateway)
Prior to this milestone, HTTP ingest and MCP ran on separate ports with per-module
listen settings and [mcp].listen in core config. Blob uploads used
TROVE_BLOBS_PATH env in the http-ingest subprocess. That split is removed —
all routes now dispatch through [http].listen.
| Surface (legacy) | Process | Config | Routes |
|---|---|---|---|
| HTTP ingest + blobs | http-ingest subprocess |
per-module listen |
Hardcoded in server.go |
| MCP query | trove core built-in |
trove.toml [mcp].listen |
Hardcoded in internal/query/mcp.go |
Target architecture
Principles:
- Core owns the only
http.Serveron[http].listen. - Modules do not call
ListenAndServefor Trove-facing routes. - Modules declare routes in
manifest.toml; core dispatches matching requests viaHandleHTTPRPC. - Core exposes service RPCs to modules (symmetric with today's
Emit): journal append, blob put/get, query API. - Long-running sources without HTTP (MQTT) keep today's
Run+Emitmodel.
Interfaces
Core config
Replace separate [mcp].listen and per-module listen with a single gateway
section. Migration period may accept both with deprecation warnings.
[http]
listen = ":8080"
# Optional global limits; per-route overrides in manifest.
max_body_bytes = 10485760
[mcp] may remain as logical grouping for MCP-specific settings (tool names,
timeouts) but not a separate listen address once gateway lands.
Manifest route declaration
New [[http.routes]] table in module manifest.toml:
name = "http-ingest"
version = "1.0"
kind = "source"
provides = ["trove://type/http/ingest/received/1", "trove://type/note/*", "trove://type/shortcut/*"]
[[http.routes]]
method = "POST"
path = "/ingest/{source}"
[[http.routes]]
method = "PUT"
path = "/blobs"
max_body_bytes = 10485760 # optional override
Example MCP module (built-in or external):
name = "mcp-query"
version = "1.0"
kind = "source" # or new kind "http" — see open questions
[[http.routes]]
method = "POST"
path = "/mcp"
Route matching uses Go 1.22+ ServeMux patterns ({name} path segments).
Conflicting method+path across modules is a startup error.
Module RPC — inbound HTTP
Extend api/proto/trove/v1/module.proto:
message HTTPRequest {
string method = 1;
string path = 2; // matched path pattern, e.g. /ingest/{source}
map<string, string> path_values = 3;
map<string, string> headers = 4;
bytes body = 5;
}
message HTTPResponse {
int32 status = 1;
map<string, string> headers = 2;
bytes body = 3;
}
service HTTPModule {
rpc HandleHTTP(HTTPRequest) returns (HTTPResponse);
}
SourceModule.Run gains a service broker id (like today's ingest_broker_id)
so handlers can call core services during request processing.
v0 gateway scope: unary request/response per HTTP call. Streaming (large blob upload, MCP streamable HTTP chunking) may require a follow-up streaming RPC — see open questions.
Core service RPC — outbound from modules
Today modules only receive Emit. Gateway modules also need:
service CoreServices {
rpc Emit(Event) returns (EmitResponse);
rpc BlobPut(BlobPutRequest) returns (BlobPutResponse);
rpc SearchEvents(...) returns (...);
rpc GetEvent(...) returns (...);
// etc. — mirror internal/query.Service
}
Go-side, internal/query.Service and internal/blob.Store remain in-process;
the broker is a thin gRPC adapter in the core host plugin set.
Gateway router (core)
// Conceptual — package internal/gateway
type Route struct {
Method string
Pattern string
Module string // manifest name
}
type Gateway struct {
Listen string
Routes []Route
// dispatch to module HandleHTTP via go-plugin client
}
Startup sequence:
- Discover modules; collect
[[http.routes]]from manifests. - Validate no duplicate method+pattern; reserve core paths if any.
- Start module subprocesses; obtain
HTTPModuleclients for modules with routes. - Build mux; on match, forward request to module
HandleHTTP. - MQTT-only modules: unchanged
Runloop without HTTP registration.
Implementation notes
Phased delivery
Build in slices; each slice should be deployable and testable.
| Phase | Scope | User-visible change |
|---|---|---|
| G1 | internal/gateway mux + [http].listen; proxy to existing module listeners |
Single URL via reverse proxy; modules unchanged |
| G2 | HandleHTTP RPC + migrate http-ingest off ListenAndServe |
Drop TROVE_BLOBS_PATH; BlobPut via core services |
| G3 | Register MCP on gateway; deprecate [mcp].listen |
MCP and ingest on same port |
| G4 | Gateway auth via validator modules | [http.auth].validator = "module.http-gateway.bearer" |
| G5 | Streaming RPC (if needed) | Large uploads + MCP streaming without buffering entire body |
Recommendation: implement G2 as the real milestone; treat G1 as optional shortcut only if live test needs one URL before RPC dispatch is ready.
http-ingest migration
- Remove
listenfrommodules/http-ingest/manifest.toml. - Remove
runHTTPServer/ localhttp.Server; implementHandleHTTPper route. POST /ingest/{source}: parse JSON, callEmitvia service broker (unchanged logic).PUT /blobs: read body, callBlobPuton core — no local blob store.Run(ctx)blocks on context until cancelled (like MQTT today) or exits after registering routes at startup — see open questions on idle source modules.
MCP migration
- Split
internal/queryinto:- Service — journal query logic (stays in core library).
- MCP handler — tool definitions + MCP streamable HTTP adapter.
- MCP handler registers
POST /mcpon gateway. - Handler calls
query.Servicein-process if built-in, orQueryRPC if externalized. - v1: ship MCP as a built-in route module registered from core (no separate
binary). External
modules/mcp-queryis optional later.
MQTT and non-HTTP sources
No change. mqtt-source keeps Run + Emit; no [[http.routes]].
Auth integration
Gateway dispatches to auth validator modules before HandleHTTP:
[http]
listen = ":8080"
[http.auth]
validator = "module.http-gateway.bearer"
[modules.settings.http-gateway]
token_env = "TROVE_HTTP_TOKEN"
Per-route auth = "inherit" | "none" | "module.<name>.<id>" on [[http.routes]].
Rejected requests never reach module HandleHTTP.
Documentation updates (same PR as implementation)
- getting-started/ios-shortcuts.md — one host URL
- getting-started/mcp-client.md — same host,
/mcppath - getting-started/configuration.md —
[http]section - http-ingest — route registration, remove
listen - mcp-query — gateway registration
- roadmap — new row when landed
Acceptance criteria
- [x] Core listens on
[http].listenonly; no separate[mcp].listenin default config - [x]
http-ingestroutes declared in manifest; module does not bind its own port - [x]
POST /ingest/{source}behaviour unchanged (ingest tests pass) - [x]
PUT /blobsstores via coreBlobPut; noTROVE_BLOBS_PATHenv - [x] MCP streamable HTTP served at declared path (e.g.
POST /mcp) viamcp-querymodule - [x] MCP tools unchanged (
search_events,get_event, etc.) - [x] Duplicate route registration fails at startup with clear error
- [x] Unknown routes return
404; wrong method returns405 - [x] Module crash does not take down gateway listener
- [x] iOS Shortcuts docs use single base URL for ingest and blob upload
Dependencies
- Blocks: unified client URL, gateway-level auth, clean blob authority
- Related: auth, blobs, http-ingest, mcp-query, module-runtime
Non-goals (this milestone)
- Reverse proxy to arbitrary third-party upstreams
- User-defined route middleware chains in manifest
- Automatic OpenAPI generation from routes
- Collapsing MQTT into HTTP gateway
- Moving journal or blob storage out of core
Open questions
| Question | Options | Notes |
|---|---|---|
| Module kind for HTTP-only modules | Keep kind = "source"; add kind = "http" |
MCP is not a traditional source |
Run lifecycle for HTTP-only modules |
Block on <-ctx.Done() after register; exit immediately |
Affects healthcheck semantics |
Streaming HandleHTTP |
Unary v1 + buffer limits; gRPC streaming v2 | MCP streamable HTTP may need v2 |
| Built-in vs external MCP module | Built-in first | Lower friction for default install |
| Route ownership conflicts | Startup fail vs first-wins | Prefer startup fail |
Deprecation of [mcp].listen |
Hard remove vs warn + fallback | One release cycle overlap |
Per-route vs global max_body_bytes |
Global default + manifest override | Matches today's manifest field |
| WebSocket upgrade routes | Defer | HA tap is separate module |
Track decisions in open-items.md when resolved.
See also
- HTTP ingest — ingest + blob routes on the gateway
- MCP query server — MCP route on the gateway
- Network auth — gateway auth validators
- Module runtime — go-plugin supervision model