Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 62 additions & 15 deletions scripts/dev.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Runs the API server, Conduit Vox, Conduit Memoria, and the Operator Console together for local
# development.
# Runs the API server, Conduit Vox, Conduit Memoria, Conduit Instrumenta, and the Operator Console
# together for local development.
#
# Three processes is the honest shape of the stack, but starting them by hand
# Four processes is the honest shape of the stack, but starting them by hand
# means remembering which port the Vite proxy expects and which authentication
# mode the server refuses to start without. This is that trio, started once,
# and stopped together: killing the script kills all three, so there is no orphaned
Expand Down Expand Up @@ -36,6 +36,7 @@ api_port=8080
ops_port=9090
vox_port=8091
memoria_port=8092
instrumenta_port=8085
ui_port=5173
# Empty means anonymous; a path means authenticate against that token file.
tokens=""
Expand All @@ -46,7 +47,7 @@ dry_run=0

usage() {
cat <<USAGE
${SELF} — run the Conduit API, Vox, Memoria, and Operator Console together
${SELF} — run the Conduit API, Vox, Memoria, Instrumenta, and Operator Console together

Usage: scripts/dev.sh [options]

Expand All @@ -61,11 +62,12 @@ Options:
--ops-port PORT Ops API port for /health, /ready, /metrics (default ${ops_port}).
--vox-port PORT Conduit Vox port (default ${vox_port}).
--memoria-port PORT Conduit Memoria port (default ${memoria_port}).
--instrumenta-port PORT Conduit Instrumenta port (default ${instrumenta_port}).
--ui-port PORT Operator Console port (default ${ui_port}).
--dry-run Print what would run, start nothing.
-h, --help Show this help.

All four processes bind loopback only. Ctrl-C stops the quartet.
All five processes bind loopback only. Ctrl-C stops the quintet.
USAGE
}

Expand Down Expand Up @@ -139,6 +141,12 @@ while [[ $# -gt 0 ]]; do
memoria_port="$2"
shift 2
;;
--instrumenta-port)
require_value "$@"
require_port --instrumenta-port "$2"
instrumenta_port="$2"
shift 2
;;
--dry-run)
dry_run=1
shift
Expand All @@ -157,8 +165,8 @@ done

# Distinct ports, or one listener wins and the other dies on bind with an error
# that names an address rather than the flag that collided.
if [[ "${api_port}" == "${ops_port}" || "${api_port}" == "${vox_port}" || "${api_port}" == "${memoria_port}" || "${api_port}" == "${ui_port}" || "${ops_port}" == "${vox_port}" || "${ops_port}" == "${memoria_port}" || "${ops_port}" == "${ui_port}" || "${vox_port}" == "${memoria_port}" || "${vox_port}" == "${ui_port}" || "${memoria_port}" == "${ui_port}" ]]; then
die "--api-port, --ops-port, --vox-port, --memoria-port, and --ui-port must differ (got ${api_port}, ${ops_port}, ${vox_port}, ${memoria_port}, ${ui_port})"
if [[ "${api_port}" == "${ops_port}" || "${api_port}" == "${vox_port}" || "${api_port}" == "${memoria_port}" || "${api_port}" == "${instrumenta_port}" || "${api_port}" == "${ui_port}" || "${ops_port}" == "${vox_port}" || "${ops_port}" == "${memoria_port}" || "${ops_port}" == "${instrumenta_port}" || "${ops_port}" == "${ui_port}" || "${vox_port}" == "${memoria_port}" || "${vox_port}" == "${instrumenta_port}" || "${vox_port}" == "${ui_port}" || "${memoria_port}" == "${instrumenta_port}" || "${memoria_port}" == "${ui_port}" || "${instrumenta_port}" == "${ui_port}" ]]; then
die "--api-port, --ops-port, --vox-port, --memoria-port, --instrumenta-port, and --ui-port must differ (got ${api_port}, ${ops_port}, ${vox_port}, ${memoria_port}, ${instrumenta_port}, ${ui_port})"
fi

# Checked here rather than left to the server: a missing token file after a
Expand Down Expand Up @@ -220,12 +228,18 @@ export MEMORIA_DATA_DIR="${MEMORIA_DATA_DIR:-${memoria_dev_root}/data}"
# origin so Conduit's reachability probe can hit the port dev.sh just started.
export MEMORIA_BASE_URL="${MEMORIA_BASE_URL:-http://127.0.0.1:${memoria_port}}"

instrumenta_dev_root="${ROOT}/output/dev/instrumenta"
readonly instrumenta_dev_root
export INSTRUMENTA_DATA_DIR="${INSTRUMENTA_DATA_DIR:-${instrumenta_dev_root}/data}"
export INSTRUMENTA_BASE_URL="${INSTRUMENTA_BASE_URL:-http://127.0.0.1:${instrumenta_port}}"

cat <<SUMMARY
conduit dev
api http://127.0.0.1:${api_port}
ops http://127.0.0.1:${ops_port}
vox http://127.0.0.1:${vox_port}
memoria http://127.0.0.1:${memoria_port}
instrumenta http://127.0.0.1:${instrumenta_port}
console http://127.0.0.1:${ui_port}
access ${auth_summary}
providers ${provider_summary}
Expand All @@ -246,9 +260,12 @@ if [[ "${dry_run}" -eq 1 ]]; then
SPEAKER_ID_MODEL_DIR=${SPEAKER_ID_MODEL_DIR}
MEMORIA_DATA_DIR=${MEMORIA_DATA_DIR}
MEMORIA_BASE_URL=${MEMORIA_BASE_URL}
INSTRUMENTA_DATA_DIR=${INSTRUMENTA_DATA_DIR}
INSTRUMENTA_BASE_URL=${INSTRUMENTA_BASE_URL}
cargo run ${cargo_args[*]}
.venv/bin/python3 -m uvicorn app:app --host 127.0.0.1 --port ${vox_port}
.venv/bin/python3 -m uvicorn app:app --host 127.0.0.1 --port ${memoria_port}
.venv/bin/python3 -m uvicorn instrumenta.app:create_app --factory --host 127.0.0.1 --port ${instrumenta_port}
npm run dev -- --port ${ui_port} --strictPort --host 127.0.0.1
RESOLVED
exit 0
Expand All @@ -269,7 +286,7 @@ fi
# already holding it, and on a developer machine the answer is usually a tunnel
# or a previous run. Skipped when `lsof` is missing rather than treated as free.
if command -v lsof >/dev/null 2>&1; then
for port_pair in "api:${api_port}" "ops:${ops_port}" "vox:${vox_port}" "memoria:${memoria_port}" "console:${ui_port}"; do
for port_pair in "api:${api_port}" "ops:${ops_port}" "vox:${vox_port}" "memoria:${memoria_port}" "instrumenta:${instrumenta_port}" "console:${ui_port}"; do
label="${port_pair%%:*}"
port="${port_pair##*:}"
if holder=$(lsof -nP -sTCP:LISTEN -iTCP:"${port}" 2>/dev/null | awk 'NR == 2 {print $1 " (pid " $2 ")"}') \
Expand Down Expand Up @@ -311,7 +328,7 @@ if ! "${vox_python}" -c "import conduit_link" >/dev/null 2>&1; then
(cd "${vox_dir}" && "${vox_venv}/bin/pip" install -e "${ROOT}/packages/conduit-link")
fi

mkdir -p "${SPEAKER_ID_DATA_DIR}" "${SPEAKER_ID_MODEL_DIR}" "${MEMORIA_DATA_DIR}"
mkdir -p "${SPEAKER_ID_DATA_DIR}" "${SPEAKER_ID_MODEL_DIR}" "${MEMORIA_DATA_DIR}" "${INSTRUMENTA_DATA_DIR}"

# Compiled before either process starts, so a compile error is a compile error
# and not a console proxying to a port nothing ever opened.
Expand Down Expand Up @@ -344,7 +361,7 @@ descendants() {
stop() {
trap - EXIT INT TERM
local pid victim
for pid in "${ui_pid}" "${memoria_pid}" "${vox_pid}" "${api_pid}"; do
for pid in "${ui_pid}" "${instrumenta_pid}" "${memoria_pid}" "${vox_pid}" "${api_pid}"; do
[[ -n "${pid}" ]] || continue
for victim in $(descendants "${pid}"); do
kill "${victim}" 2>/dev/null || true
Expand Down Expand Up @@ -390,6 +407,34 @@ printf 'starting Conduit Memoria\n'
(cd "${memoria_dir}" && exec "${memoria_python}" -m uvicorn app:app --host 127.0.0.1 --port "${memoria_port}") &
memoria_pid=$!

instrumenta_dir="${ROOT}/services/instrumenta"
readonly instrumenta_dir
instrumenta_venv="${instrumenta_dir}/.venv"
readonly instrumenta_venv
instrumenta_python="${instrumenta_venv}/bin/python"
readonly instrumenta_python

if [[ ! -x "${instrumenta_python}" ]]; then
printf '\ncreating the Instrumenta virtualenv\n'
(cd "${instrumenta_dir}" && python3 -m venv .venv)
fi
if ! "${instrumenta_python}" -c "import fastapi, httpx, uvicorn" >/dev/null 2>&1; then
printf '\ninstalling Instrumenta dependencies\n'
(cd "${instrumenta_dir}" && "${instrumenta_venv}/bin/pip" install -q -r requirements.txt)
fi
if ! "${instrumenta_python}" -c "import modelcontextprotocol" >/dev/null 2>&1; then
printf '\ninstalling modelcontextprotocol into Instrumenta\n'
(cd "${instrumenta_dir}" && "${instrumenta_venv}/bin/pip" install -q modelcontextprotocol)
fi
if ! "${instrumenta_python}" -c "import conduit_link" >/dev/null 2>&1; then
printf '\ninstalling shared conduit-link module into Instrumenta\n'
(cd "${instrumenta_dir}" && "${instrumenta_venv}/bin/pip" install -q -e "${ROOT}/packages/conduit-link")
fi

printf 'starting Conduit Instrumenta\n'
(cd "${instrumenta_dir}" && exec "${instrumenta_python}" -m uvicorn instrumenta.app:create_app --factory --host 127.0.0.1 --port "${instrumenta_port}") &
instrumenta_pid=$!

# `--host 127.0.0.1` because Vite otherwise resolves `localhost` to IPv6 only on
# macOS, and the console would refuse the loopback address this script prints.
printf 'starting the operator console\n\n'
Expand All @@ -400,17 +445,19 @@ ui_pid=$!
# Polled rather than `wait -n`, which needs bash 4.3 and so is absent from the
# bash macOS ships. Either process exiting takes the other down: a console
# proxying to a dead server is a worse debugging experience than a clean stop.
while kill -0 "${api_pid}" 2>/dev/null && kill -0 "${vox_pid}" 2>/dev/null && kill -0 "${memoria_pid}" 2>/dev/null && kill -0 "${ui_pid}" 2>/dev/null; do
while kill -0 "${api_pid}" 2>/dev/null && kill -0 "${vox_pid}" 2>/dev/null && kill -0 "${memoria_pid}" 2>/dev/null && kill -0 "${instrumenta_pid}" 2>/dev/null && kill -0 "${ui_pid}" 2>/dev/null; do
sleep 1
done

if ! kill -0 "${api_pid}" 2>/dev/null; then
printf '\n%s: conduit-api exited; stopping Vox, Memoria, and the operator console\n' "${SELF}" >&2
printf '\n%s: conduit-api exited; stopping Vox, Memoria, Instrumenta, and the operator console\n' "${SELF}" >&2
elif ! kill -0 "${vox_pid}" 2>/dev/null; then
printf '\n%s: Conduit Vox exited; stopping conduit-api, Memoria, and the operator console\n' "${SELF}" >&2
printf '\n%s: Conduit Vox exited; stopping conduit-api, Memoria, Instrumenta, and the operator console\n' "${SELF}" >&2
elif ! kill -0 "${memoria_pid}" 2>/dev/null; then
printf '\n%s: Conduit Memoria exited; stopping conduit-api, Vox, and the operator console\n' "${SELF}" >&2
printf '\n%s: Conduit Memoria exited; stopping conduit-api, Vox, Instrumenta, and the operator console\n' "${SELF}" >&2
elif ! kill -0 "${instrumenta_pid}" 2>/dev/null; then
printf '\n%s: Conduit Instrumenta exited; stopping conduit-api, Vox, Memoria, and the operator console\n' "${SELF}" >&2
else
printf '\n%s: the operator console exited; stopping conduit-api, Vox, and Memoria\n' "${SELF}" >&2
printf '\n%s: the operator console exited; stopping conduit-api, Vox, Memoria, and Instrumenta\n' "${SELF}" >&2
fi
exit 1
95 changes: 82 additions & 13 deletions services/instrumenta/aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,25 @@

At boot Instrumenta reads every enabled HTTP upstream from the backend,
connects to each via the `mcp` SDK's streamable-HTTP client, lists their
tools, and re-registers them on Instrumenta's own `MCPServer` under a
`<server_name>.<tool_name>` prefix so nothing collides with the built-ins.
tools/prompts/resources, and re-registers them on Instrumenta's own
`MCPServer` under a `<server_name>.<item_name>` prefix so nothing collides
with the built-ins.

Live config changes (add/remove servers via the CRUD endpoints) do NOT
mutate the aggregated tool set in v1 — the operator restarts Instrumenta to
mutate the aggregated surface in v1 — the operator restarts Instrumenta to
pick up new upstreams. This keeps the aggregator simple and matches Conduit's
own snapshot-once posture (see wayfinder decision #204). A follow-up PR can
add hot-reload once demand exists.

Filter-on-unreachable is deferred (decision #204): tools from an unreachable
Filter-on-unreachable is deferred (decision #204): items from an unreachable
upstream stay advertised; the call fails loud with the upstream's error.
"""

from __future__ import annotations

import logging
from contextlib import AsyncExitStack
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Callable

from mcp import types
Expand All @@ -42,7 +43,25 @@ class UpstreamStatus:
enabled: bool
reachable: bool
tool_count: int
last_error: str | None
prompt_count: int = 0
resource_count: int = 0
last_error: str | None = None


@dataclass
class UpstreamPrompts:
"""Cached prompt metadata from an upstream."""

server_name: str
prompts: list[types.Prompt] = field(default_factory=list)


@dataclass
class UpstreamResources:
"""Cached resource metadata from an upstream."""

server_name: str
resources: list[types.Resource] = field(default_factory=list)


class Aggregator:
Expand All @@ -69,14 +88,16 @@ def __init__(
self._exit_stack: AsyncExitStack | None = None
self._statuses: dict[str, UpstreamStatus] = {}
self._clients: dict[str, Client] = {}
self._upstream_prompts: dict[str, UpstreamPrompts] = {}
self._upstream_resources: dict[str, UpstreamResources] = {}

@staticmethod
def _default_client_factory(server: UpstreamServer) -> Client:
assert server.url is not None
return Client(server.url, raise_exceptions=True)

async def start(self, mcp_server: MCPServer) -> None:
"""Connect to every enabled HTTP upstream, register its tools."""
"""Connect to every enabled HTTP upstream, register its tools/prompts/resources."""
self._exit_stack = AsyncExitStack()
await self._exit_stack.__aenter__()

Expand All @@ -89,13 +110,12 @@ async def start(self, mcp_server: MCPServer) -> None:
enabled=False,
reachable=False,
tool_count=0,
last_error=None,
)
continue
if server.transport != "http":
# stdio is a later slice; log and skip.
# stdio is handled by the stdio supervisor; log and skip.
LOG.warning(
"upstream %s uses transport=%s; skipping (v1 is HTTP-only)",
"upstream %s uses transport=%s; skipping HTTP aggregation",
server.name,
server.transport,
)
Expand All @@ -109,7 +129,7 @@ async def _attach_http_upstream(
try:
client = self._client_factory(server)
await self._exit_stack.enter_async_context(client)
listed = await client.list_tools()
listed_tools = await client.list_tools()
except Exception as exc: # noqa: BLE001 — surface any client error
LOG.warning("upstream %s unreachable: %s", server.name, exc)
self._statuses[server.id] = UpstreamStatus(
Expand All @@ -124,16 +144,45 @@ async def _attach_http_upstream(
return

self._clients[server.id] = client
for tool in listed.tools:

# Register tools.
for tool in listed_tools.tools:
self._register_forwarding_tool(server, tool, client, mcp_server)

# List prompts and resources (best-effort; some upstreams may not support them).
prompt_count = 0
resource_count = 0
try:
listed_prompts = await client.list_prompts()
if listed_prompts.prompts:
self._upstream_prompts[server.id] = UpstreamPrompts(
server_name=server.name,
prompts=listed_prompts.prompts,
)
prompt_count = len(listed_prompts.prompts)
except Exception as exc: # noqa: BLE001
LOG.debug("upstream %s has no prompts: %s", server.name, exc)

try:
listed_resources = await client.list_resources()
if listed_resources.resources:
self._upstream_resources[server.id] = UpstreamResources(
server_name=server.name,
resources=listed_resources.resources,
)
resource_count = len(listed_resources.resources)
except Exception as exc: # noqa: BLE001
LOG.debug("upstream %s has no resources: %s", server.name, exc)

self._statuses[server.id] = UpstreamStatus(
id=server.id,
name=server.name,
url=server.url,
enabled=True,
reachable=True,
tool_count=len(listed.tools),
tool_count=len(listed_tools.tools),
prompt_count=prompt_count,
resource_count=resource_count,
last_error=None,
)

Expand All @@ -159,6 +208,26 @@ async def forward(**kwargs: Any) -> Any:
description=tool.description or f"Forwarded from {server.name}",
)

def client_for(self, server_id: str) -> Client | None:
"""Return the MCP client for a given upstream, or None."""
return self._clients.get(server_id)

def upstream_prompts(self) -> list[tuple[str, types.Prompt]]:
"""All upstream prompts as (server_name, prompt) pairs."""
result = []
for up in self._upstream_prompts.values():
for p in up.prompts:
result.append((up.server_name, p))
return result

def upstream_resources(self) -> list[tuple[str, types.Resource]]:
"""All upstream resources as (server_name, resource) pairs."""
result = []
for ur in self._upstream_resources.values():
for r in ur.resources:
result.append((ur.server_name, r))
return result

def statuses(self) -> list[UpstreamStatus]:
return list(self._statuses.values())

Expand Down
13 changes: 13 additions & 0 deletions services/instrumenta/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@
)

from .aggregator import Aggregator, UpstreamStatus
from .audit import make_audit_router
from .backend import Backend, SqliteBackend
from .items_router import make_items_router
from .mcp_app import build_mcp_server
from .path_probe import probe_runtimes
from .secret_box import SecretBox, SecretKeyMissingError
from .servers_router import make_servers_router

Expand Down Expand Up @@ -233,7 +236,17 @@ async def list_upstreams() -> list[UpstreamStatus]:
"""
return aggregator.statuses()

@app.get("/runtimes")
async def list_runtimes() -> dict[str, bool]:
"""Boot-time PATH probe for stdio runtimes.

Read-only; reflects the system PATH at startup.
"""
return probe_runtimes()

app.include_router(make_servers_router())
app.include_router(make_items_router())
app.include_router(make_audit_router())

# Mount the streamable-HTTP MCP transport at `/mcp`. The SDK's default
# `streamable_http_path='/mcp'` combined with a mount would become
Expand Down
Loading
Loading