Context
MCP is a first-class transport but is currently request-only. create_server registers only nautilus_request (mcp_server.py:232) plus the optional nautilus_declare_handoff (mcp_server.py:259, gated on config.mcp.expose_declare_handoff). MCP agents therefore have no discovery surface: to learn which sources/classifications exist they must blindly issue an intent and read back sources_queried/sources_denied.
REST already solves this with GET /v1/sources (fastapi_app.py:300-319), which returns a metadata-only projection (id, type, description, classification, data_types) over broker.sources and explicitly never exposes the DSN or credentials (AC-12.3). Note that SourceConfig does carry secrets — connection (post-interpolation DSN, config/models.py:109) and auth (config/models.py:121) — so the projection's field whitelist is security-relevant, not cosmetic. The projection is currently an inline dict comprehension in the REST handler, so a second copy in MCP would risk drift (e.g. a future field added to one transport but not the other, or a secret accidentally leaked from one).
Task
Add a read-only nautilus_sources MCP tool mirroring GET /v1/sources, and factor the metadata projection into one shared helper used by both transports.
- Extract the projection logic (the
id/type/description/classification/data_types dict built over broker.sources) from get_sources in fastapi_app.py:308-318 into a shared helper, and have both the REST handler and the new MCP tool call it.
- Register
nautilus_sources in create_server alongside nautilus_request; it returns the same metadata-only projection and never exposes connection (DSN) or auth/credentials.
- Match the existing transport auth posture rather than inventing per-tool auth: stdio MCP is intentionally un-gated (
mcp_server.py:38-40); HTTP is gated at the ASGI-wrapper layer via wrap_http_with_api_key/http_app (mcp_server.py:126, 289). A read-only tool inherits whatever transport-level gate is active — no extra per-tool check is needed (and would be inconsistent with nautilus_request).
- Handle the
broker is None / empty-sources case the same way the REST handler does ({"sources": []}).
Where
nautilus/transport/mcp_server.py — register nautilus_sources in create_server
nautilus/transport/fastapi_app.py — get_sources (lines 300-319); extract the shared projection helper here or in a shared module
nautilus/core/broker.py:808-811 — Broker.sources property (list[SourceConfig])
nautilus/config/models.py:78-127 — SourceConfig (note connection/auth must stay out of the projection)
- Tests:
tests/unit/transport/test_mcp_unit.py, tests/unit/transport/test_mcp_server_smoke.py, tests/integration/test_mcp_e2e.py
Acceptance criteria
Size: S
Context
MCP is a first-class transport but is currently request-only.
create_serverregisters onlynautilus_request(mcp_server.py:232) plus the optionalnautilus_declare_handoff(mcp_server.py:259, gated onconfig.mcp.expose_declare_handoff). MCP agents therefore have no discovery surface: to learn which sources/classifications exist they must blindly issue an intent and read backsources_queried/sources_denied.REST already solves this with
GET /v1/sources(fastapi_app.py:300-319), which returns a metadata-only projection (id, type, description, classification, data_types) overbroker.sourcesand explicitly never exposes the DSN or credentials (AC-12.3). Note thatSourceConfigdoes carry secrets —connection(post-interpolation DSN,config/models.py:109) andauth(config/models.py:121) — so the projection's field whitelist is security-relevant, not cosmetic. The projection is currently an inline dict comprehension in the REST handler, so a second copy in MCP would risk drift (e.g. a future field added to one transport but not the other, or a secret accidentally leaked from one).Task
Add a read-only
nautilus_sourcesMCP tool mirroringGET /v1/sources, and factor the metadata projection into one shared helper used by both transports.id/type/description/classification/data_typesdict built overbroker.sources) fromget_sourcesinfastapi_app.py:308-318into a shared helper, and have both the REST handler and the new MCP tool call it.nautilus_sourcesincreate_serveralongsidenautilus_request; it returns the same metadata-only projection and never exposesconnection(DSN) orauth/credentials.mcp_server.py:38-40); HTTP is gated at the ASGI-wrapper layer viawrap_http_with_api_key/http_app(mcp_server.py:126,289). A read-only tool inherits whatever transport-level gate is active — no extra per-tool check is needed (and would be inconsistent withnautilus_request).broker is None/ empty-sources case the same way the REST handler does ({"sources": []}).Where
nautilus/transport/mcp_server.py— registernautilus_sourcesincreate_servernautilus/transport/fastapi_app.py—get_sources(lines 300-319); extract the shared projection helper here or in a shared modulenautilus/core/broker.py:808-811—Broker.sourcesproperty (list[SourceConfig])nautilus/config/models.py:78-127—SourceConfig(noteconnection/authmust stay out of the projection)tests/unit/transport/test_mcp_unit.py,tests/unit/transport/test_mcp_server_smoke.py,tests/integration/test_mcp_e2e.pyAcceptance criteria
nautilus_sourcesand enumerate sources withid/type/description/classification/data_types.connection(DSN),auth, or any other credential field.nautilus_sourcesis byte-for-byte identical toGET /v1/sources, produced by a single shared helper (no duplicated dict literal).nautilus_sourcesis registered unconditionally alongsidenautilus_request(not behind theexpose_declare_handoffgate).sourceslist rather than erroring, matching REST behavior.uv run pytest -m unitpasses.Size: S