feat: add source auth diagnostics

This commit is contained in:
lda
2026-06-13 16:07:24 +07:00 Verified
parent 381d0b1924
commit 3f91c4bca5
17 changed files with 1460 additions and 5 deletions
+2
View File
@@ -108,6 +108,8 @@ auth admin are implemented. The next work is polish, not new broad surfaces.
Google OAuth client credentials). OAuth refresh-token support and provider Google OAuth client credentials). OAuth refresh-token support and provider
profiles are now implemented. Production secret manager integration and profiles are now implemented. Production secret manager integration and
encrypted-at-rest file format remain deferred. encrypted-at-rest file format remain deferred.
- Completed source auth diagnostics: `wf source diagnose <source_id>` now reports
transport/auth/catalog state without exposing secret payloads.
- Active specs: - Active specs:
- [`workflow config targets and sources`](superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md) - [`workflow config targets and sources`](superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md)
- [`store-backed source registry`](superpowers/specs/2026-06-03-store-backed-source-registry-design.md) - [`store-backed source registry`](superpowers/specs/2026-06-03-store-backed-source-registry-design.md)
@@ -0,0 +1,875 @@
# Source Auth Diagnostics Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add source-level auth and health diagnostics so users can tell whether a source is configured, authenticated, catalogued, and safe to call before running `wf cap call`.
**Architecture:** Keep `wf_api` neutral by adding an optional source diagnostics provider to `WorkflowSourceAdminApi`. Implement MCP-specific checks in `wf_mcp.broker.service.source_diagnostics`, wire that provider into MCP-backed `WorkflowServer`, and expose diagnostics over JSON-RPC and CLI via `wf source diagnose`. `source inspect` should include a compact `diagnostics` block when a provider exists, but local/static servers must keep working without MCP imports.
**Tech Stack:** Python 3.14, dataclasses/protocols, Pydantic JSON-RPC params, Typer CLI, pytest-asyncio, existing `AuthRecord`/`StoredAuthRecord` and MCP broker services.
---
## File Structure
- Modify `src/wf_api/source_admin.py`: add `WorkflowSourceDiagnosticsProvider` protocol, optional `diagnostics` provider, `diagnose_source()`, and optional diagnostics in `inspect_source()`.
- Modify `src/wf_api/surface.py`: add `diagnose_source()` to `WorkflowSourceAdminSurface`.
- Create `src/wf_mcp/broker/service/source_diagnostics.py`: MCP-specific diagnostics provider.
- Modify `src/wf_mcp/broker/server.py`: wire diagnostics provider when building MCP-backed server.
- Modify `src/wf_transport_rpc_http/models.py`: add `DiagnoseSourceParams`.
- Modify `src/wf_transport_rpc_http/methods/sources.py`: register `workflow.sources.diagnose`.
- Modify `src/wf_transport_rpc_http/client/sources.py`: add `diagnose_source()`.
- Modify `src/wf_cli/commands/sources.py`: add `wf source diagnose <source_id>`.
- Add/modify tests:
- `tests/wf_api/test_source_admin_api.py`
- `tests/wf_mcp/service/test_source_diagnostics.py`
- `tests/wf_mcp/test_mcp_workflow_server.py`
- `tests/wf_transport_rpc_http/test_app.py`
- `tests/wf_transport_rpc_http/test_client.py`
- `tests/wf_cli/test_remote_target.py`
- Modify docs:
- `docs/wf_cli.md`
- `docs/current_roadmap.md`
---
### Task 1: Neutral Source Diagnostics API
**Files:**
- Modify: `src/wf_api/source_admin.py`
- Modify: `src/wf_api/surface.py`
- Test: `tests/wf_api/test_source_admin_api.py`
- [ ] **Step 1: Write neutral API tests**
Create `tests/wf_api/test_source_admin_api.py` if it does not exist. Add:
```python
from __future__ import annotations
import pytest
from wf_api.operation_context import WorkflowOperationContext
from wf_api.source_admin import WorkflowSourceAdminApi
from wf_api.stores import memory_workflow_stores
from wf_platform import CapabilityBuckets, CapabilitySource
class _Specs:
def __init__(self) -> None:
self.capability_sources = {
"demo.source": CapabilitySource(
id="demo.source",
kind="connection",
capabilities=CapabilityBuckets(),
description="Demo source",
)
}
def get_qualified_spec(self, qualified_name: str):
raise KeyError(qualified_name)
class _Diagnostics:
def __init__(self) -> None:
self.calls: list[str] = []
def diagnose_source(self, source_id: str) -> dict[str, object]:
self.calls.append(source_id)
return {
"source_id": source_id,
"status": "ok",
"auth": {"record_present": True},
"diagnostics": [],
}
def _api(*, diagnostics: object | None = None) -> WorkflowSourceAdminApi:
stores = memory_workflow_stores()
context = WorkflowOperationContext(
artifact_store=stores.artifact_store,
draft_workspace_store=stores.draft_workspace_store,
run_store=stores.run_store,
events=None,
specs=_Specs(),
runtime=None,
live_sources=None,
)
return WorkflowSourceAdminApi(context, diagnostics=diagnostics)
@pytest.mark.asyncio
async def test_inspect_source_includes_optional_diagnostics() -> None:
provider = _Diagnostics()
payload = await _api(diagnostics=provider).inspect_source(
source_id="demo.source"
)
assert payload["id"] == "demo.source"
assert payload["diagnostics"]["source_id"] == "demo.source"
assert provider.calls == ["demo.source"]
@pytest.mark.asyncio
async def test_inspect_source_omits_diagnostics_without_provider() -> None:
payload = await _api().inspect_source(source_id="demo.source")
assert payload["id"] == "demo.source"
assert "diagnostics" not in payload
@pytest.mark.asyncio
async def test_diagnose_source_uses_provider() -> None:
payload = await _api(diagnostics=_Diagnostics()).diagnose_source(
source_id="demo.source"
)
assert payload["status"] == "ok"
assert payload["auth"]["record_present"] is True
@pytest.mark.asyncio
async def test_diagnose_source_without_provider_returns_basic_status() -> None:
payload = await _api().diagnose_source(source_id="demo.source")
assert payload == {
"source_id": "demo.source",
"status": "unknown",
"diagnostics": [],
"message": "No source diagnostics provider is configured.",
}
```
- [ ] **Step 2: Run tests and confirm failure**
Run:
```bash
uv run pytest tests/wf_api/test_source_admin_api.py -q
```
Expected: fails because `WorkflowSourceAdminApi.__init__()` has no `diagnostics` argument and `diagnose_source()` does not exist.
- [ ] **Step 3: Implement neutral protocol and API**
In `src/wf_api/source_admin.py`, add:
```python
from typing import Any, Protocol
```
Then add above `WorkflowSourceAdminApi`:
```python
class WorkflowSourceDiagnosticsProvider(Protocol):
"""Optional source-specific diagnostics provider.
Implementations may know about transport/auth/catalog details. The neutral
API only forwards source ids and serializes returned dictionaries.
"""
def diagnose_source(self, source_id: str) -> dict[str, Any]: ...
```
Change the constructor:
```python
def __init__(
self,
context: WorkflowOperationContext,
*,
diagnostics: WorkflowSourceDiagnosticsProvider | None = None,
) -> None:
self.context = context
self.diagnostics = diagnostics
```
Change `inspect_source()` to:
```python
async def inspect_source(self, *, source_id: str) -> dict[str, Any]:
try:
source = self.context.specs.capability_sources[source_id]
except KeyError as exc:
raise KeyError(f"unknown source {source_id!r}") from exc
payload = source.as_inventory().model_dump(mode="json")
if self.diagnostics is not None:
payload["diagnostics"] = self.diagnostics.diagnose_source(source_id)
return payload
```
Add:
```python
async def diagnose_source(self, *, source_id: str) -> dict[str, Any]:
try:
self.context.specs.capability_sources[source_id]
except KeyError as exc:
raise KeyError(f"unknown source {source_id!r}") from exc
if self.diagnostics is None:
return {
"source_id": source_id,
"status": "unknown",
"diagnostics": [],
"message": "No source diagnostics provider is configured.",
}
return self.diagnostics.diagnose_source(source_id)
```
In `src/wf_api/surface.py`, add to `WorkflowSourceAdminSurface`:
```python
async def diagnose_source(self, *, source_id: str) -> dict[str, Any]: ...
```
- [ ] **Step 4: Run tests and commit**
Run:
```bash
uv run pytest tests/wf_api/test_source_admin_api.py -q
uv run basedpyright --level error src/wf_api/source_admin.py src/wf_api/surface.py tests/wf_api/test_source_admin_api.py
```
Expected: tests pass and typecheck has 0 errors.
Commit:
```bash
git add src/wf_api/source_admin.py src/wf_api/surface.py tests/wf_api/test_source_admin_api.py
git commit -m "feat: add neutral source diagnostics api"
```
---
### Task 2: MCP Source Diagnostics Provider
**Files:**
- Create: `src/wf_mcp/broker/service/source_diagnostics.py`
- Modify: `src/wf_mcp/broker/server.py`
- Test: `tests/wf_mcp/service/test_source_diagnostics.py`
- Test: `tests/wf_mcp/test_mcp_workflow_server.py`
- [ ] **Step 1: Write provider tests**
Create `tests/wf_mcp/service/test_source_diagnostics.py`:
```python
from __future__ import annotations
from wf_api.auth import AuthRecord
from wf_mcp.broker.service.source_diagnostics import SourceDiagnosticsProvider
from wf_mcp.connections import ConnectionRegistry
from wf_mcp.models import ConnectionConfig
from wf_sources_mcp.catalog import CatalogSnapshot
from wf_sources_mcp.storage import FileAuthStore, FileCatalogStore
def _connection(**metadata: object) -> ConnectionConfig:
return ConnectionConfig(
id="demo.personal",
server="demo",
account="personal",
enabled=True,
metadata={"transport": "http", "url": "https://example.test/mcp", **metadata},
)
def _provider(tmp_path, connection: ConnectionConfig) -> SourceDiagnosticsProvider:
registry = ConnectionRegistry()
registry.register(connection)
return SourceDiagnosticsProvider(
connection_lookup=registry.get,
auth_store=FileAuthStore(tmp_path / "auth"),
catalog_store=FileCatalogStore(tmp_path / "catalog"),
)
def test_source_diagnostics_reports_present_auth(tmp_path) -> None:
connection = _connection(auth_ref="demo.creds")
provider = _provider(tmp_path, connection)
provider.auth_store.save_auth(
AuthRecord(
connection_id="demo.creds",
scheme="oauth_refresh_token",
payload={
"client_id": "client",
"client_secret": "secret",
"refresh_token": "refresh",
"token_url": "https://oauth2.example.test/token",
},
)
)
payload = provider.diagnose_source("demo.personal")
assert payload["source_id"] == "demo.personal"
assert payload["status"] == "ok"
assert payload["auth"]["auth_ref"] == "demo.creds"
assert payload["auth"]["record_present"] is True
assert payload["auth"]["scheme"] == "oauth_refresh_token"
assert payload["auth"]["transport_supported"] is True
assert payload["diagnostics"] == []
def test_source_diagnostics_reports_missing_auth(tmp_path) -> None:
payload = _provider(
tmp_path,
_connection(auth_ref="missing.creds"),
).diagnose_source("demo.personal")
assert payload["status"] == "error"
assert payload["auth"]["record_present"] is False
assert payload["diagnostics"][0]["code"] == "auth_not_found"
def test_source_diagnostics_reports_unsupported_transport_auth(tmp_path) -> None:
connection = ConnectionConfig(
id="demo.personal",
server="demo",
account="personal",
metadata={"transport": "stdio", "command": "demo", "auth_ref": "demo.creds"},
)
provider = _provider(tmp_path, connection)
provider.auth_store.save_auth(
AuthRecord(
connection_id="demo.creds",
scheme="oauth_refresh_token",
payload={
"client_id": "client",
"client_secret": "secret",
"refresh_token": "refresh",
"token_url": "https://oauth2.example.test/token",
},
)
)
payload = provider.diagnose_source("demo.personal")
assert payload["status"] == "error"
assert payload["auth"]["transport_supported"] is False
assert payload["diagnostics"][0]["code"] == "auth_scheme_not_supported"
def test_source_diagnostics_reports_catalog_snapshot(tmp_path) -> None:
provider = _provider(tmp_path, _connection())
provider.catalog_store.save_catalog(
CatalogSnapshot(
connection_id="demo.personal",
fetched_at_epoch_ms=123,
max_age_seconds=60,
)
)
payload = provider.diagnose_source("demo.personal")
assert payload["catalog"] == {
"has_snapshot": True,
"fetched_at_epoch_ms": 123,
"max_age_seconds": 60,
"node_count": 0,
"resource_count": 0,
"prompt_count": 0,
}
```
- [ ] **Step 2: Run tests and confirm failure**
Run:
```bash
uv run pytest tests/wf_mcp/service/test_source_diagnostics.py -q
```
Expected: fails because `source_diagnostics.py` does not exist.
- [ ] **Step 3: Implement provider**
Create `src/wf_mcp/broker/service/source_diagnostics.py`:
```python
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from wf_api.auth import AuthRecord
from wf_core import DependencyDiagnostic, DiagnosticSeverity
from wf_sources_mcp.auth import connection_auth_diagnostic
from wf_sources_mcp.storage import AuthStore, CatalogStore
from ...models import ConnectionConfig
ConnectionLookup = Callable[[str], ConnectionConfig]
def _auth_ref(connection: ConnectionConfig) -> str | None:
value = connection.metadata.get("auth_ref")
return value if isinstance(value, str) and value else None
def _transport_kind(connection: ConnectionConfig) -> str | None:
value = connection.metadata.get("transport")
return value if isinstance(value, str) and value else None
def _auth_scheme_supported(
*,
transport_kind: str | None,
auth: AuthRecord | None,
) -> bool:
if auth is None:
return True
if transport_kind == "stdio":
return auth.scheme == "env"
if transport_kind == "http":
return auth.scheme in {"bearer", "headers", "oauth_refresh_token"}
return False
def _unsupported_auth_diagnostic(
*,
source_id: str,
auth_ref: str,
scheme: str,
transport_kind: str | None,
) -> dict[str, Any]:
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="auth_scheme_not_supported",
logical_ref=auth_ref,
bound_source=source_id,
message=(
f"Source {source_id!r} uses {transport_kind or 'unknown'} transport, "
f"but auth record {auth_ref!r} has unsupported scheme {scheme!r}."
),
repair_hint=(
"Use env auth for stdio MCP sources, or bearer/headers/"
"oauth_refresh_token auth for HTTP MCP sources."
),
).model_dump(mode="json")
@dataclass(slots=True)
class SourceDiagnosticsProvider:
"""MCP broker diagnostics for source auth, transport, and catalog state."""
connection_lookup: ConnectionLookup
auth_store: AuthStore
catalog_store: CatalogStore
def diagnose_source(self, source_id: str) -> dict[str, Any]:
connection = self.connection_lookup(source_id)
auth_ref = _auth_ref(connection)
auth = self.auth_store.load_auth(auth_ref) if auth_ref else None
transport_kind = _transport_kind(connection)
snapshot = self.catalog_store.load_catalog(source_id)
diagnostics: list[dict[str, Any]] = []
missing_auth = connection_auth_diagnostic(
connection,
load_auth_ref=self.auth_store.load_auth,
)
if missing_auth is not None:
diagnostics.append(missing_auth.model_dump(mode="json"))
transport_supported = _auth_scheme_supported(
transport_kind=transport_kind,
auth=auth,
)
if auth_ref and auth is not None and not transport_supported:
diagnostics.append(
_unsupported_auth_diagnostic(
source_id=source_id,
auth_ref=auth_ref,
scheme=auth.scheme,
transport_kind=transport_kind,
)
)
return {
"source_id": source_id,
"status": "error" if diagnostics else "ok",
"enabled": connection.enabled,
"transport": {
"kind": transport_kind,
"configured": transport_kind is not None,
},
"auth": {
"auth_ref": auth_ref,
"record_present": auth is not None if auth_ref else None,
"scheme": None if auth is None else auth.scheme,
"transport_supported": transport_supported,
},
"catalog": {
"has_snapshot": snapshot is not None,
"fetched_at_epoch_ms": None
if snapshot is None
else snapshot.fetched_at_epoch_ms,
"max_age_seconds": None if snapshot is None else snapshot.max_age_seconds,
"node_count": 0 if snapshot is None else len(snapshot.nodes),
"resource_count": 0 if snapshot is None else len(snapshot.resources),
"prompt_count": 0 if snapshot is None else len(snapshot.prompts),
},
"diagnostics": diagnostics,
}
```
- [ ] **Step 4: Wire provider into MCP-backed server**
In `src/wf_mcp/broker/server.py`, import:
```python
from .service.source_diagnostics import SourceDiagnosticsProvider
```
Inside `workflow_server_from_service()`, construct:
```python
source_diagnostics = SourceDiagnosticsProvider(
connection_lookup=service.connections.get,
auth_store=service.auth_store,
catalog_store=service.catalog_store,
)
```
Change:
```python
source_admin = WorkflowSourceAdminApi(context)
```
to:
```python
source_admin = WorkflowSourceAdminApi(
context,
diagnostics=source_diagnostics,
)
```
If the function does not currently name `source_admin`, apply the same change where `WorkflowSourceAdminApi` is constructed.
- [ ] **Step 5: Add server wiring test**
In `tests/wf_mcp/test_mcp_workflow_server.py`, add:
```python
async def test_workflow_server_source_admin_reports_mcp_diagnostics(tmp_path) -> None:
service = _service(tmp_path)
payload = await service.workflow_server.source_admin.diagnose_source(
source_id="demo.personal"
)
assert payload["source_id"] == "demo.personal"
assert "auth" in payload
assert "catalog" in payload
```
If the local helper is named differently, use the existing helper that builds a `WfMcpService`/`WorkflowServer` with `demo.personal`; keep the assertions above.
- [ ] **Step 6: Run tests and commit**
Run:
```bash
uv run pytest tests/wf_mcp/service/test_source_diagnostics.py tests/wf_mcp/test_mcp_workflow_server.py -q
uv run basedpyright --level error src/wf_mcp/broker/service/source_diagnostics.py src/wf_mcp/broker/server.py tests/wf_mcp/service/test_source_diagnostics.py tests/wf_mcp/test_mcp_workflow_server.py
```
Expected: tests pass and typecheck has 0 errors.
Commit:
```bash
git add src/wf_mcp/broker/service/source_diagnostics.py src/wf_mcp/broker/server.py tests/wf_mcp/service/test_source_diagnostics.py tests/wf_mcp/test_mcp_workflow_server.py
git commit -m "feat: add mcp source diagnostics provider"
```
---
### Task 3: JSON-RPC Source Diagnose Method
**Files:**
- Modify: `src/wf_transport_rpc_http/models.py`
- Modify: `src/wf_transport_rpc_http/methods/sources.py`
- Modify: `src/wf_transport_rpc_http/client/sources.py`
- Test: `tests/wf_transport_rpc_http/test_app.py`
- Test: `tests/wf_transport_rpc_http/test_client.py`
- [ ] **Step 1: Add RPC tests**
In `tests/wf_transport_rpc_http/test_app.py`, add:
```python
async def test_rpc_diagnoses_source(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path)
client = TestClient(create_app(server))
response = client.post(
"/rpc",
json={
"jsonrpc": "2.0",
"id": 1,
"method": "workflow.sources.diagnose",
"params": {"source_id": "wf.std"},
},
)
payload = response.json()
assert response.status_code == 200
assert payload["result"]["source_id"] == "wf.std"
assert payload["result"]["status"] == "unknown"
```
In `tests/wf_transport_rpc_http/test_client.py`, add:
```python
async def test_rpc_client_diagnoses_source(tmp_path) -> None:
calls: list[tuple[str, dict[str, object]]] = []
class Client(RpcSourceAdminClientMixin):
async def _call(self, method: str, params: dict[str, object]):
calls.append((method, params))
return {"source_id": params["source_id"], "status": "ok"}
payload = await Client().diagnose_source(source_id="demo.personal")
assert payload == {"source_id": "demo.personal", "status": "ok"}
assert calls == [
("workflow.sources.diagnose", {"source_id": "demo.personal"})
]
```
- [ ] **Step 2: Run tests and confirm failure**
Run:
```bash
uv run pytest tests/wf_transport_rpc_http/test_app.py::test_rpc_diagnoses_source tests/wf_transport_rpc_http/test_client.py::test_rpc_client_diagnoses_source -q
```
Expected: fails because model/method/client are missing.
- [ ] **Step 3: Implement RPC model/method/client**
In `src/wf_transport_rpc_http/models.py`, add:
```python
class DiagnoseSourceParams(RpcParamsModel):
source_id: str = Field(min_length=1)
```
In `src/wf_transport_rpc_http/methods/sources.py`, import `DiagnoseSourceParams` and add:
```python
@entrypoint.method(name="workflow.sources.diagnose", errors=[WorkflowRpcError])
async def workflow_sources_diagnose(
params: DiagnoseSourceParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.source_admin.diagnose_source(
source_id=params.source_id
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
```
In `src/wf_transport_rpc_http/client/sources.py`, add:
```python
async def diagnose_source(self: RpcCaller, *, source_id: str) -> dict[str, Any]:
return await self._call(
"workflow.sources.diagnose",
{"source_id": source_id},
)
```
- [ ] **Step 4: Run tests and commit**
Run:
```bash
uv run pytest tests/wf_transport_rpc_http/test_app.py::test_rpc_diagnoses_source tests/wf_transport_rpc_http/test_client.py::test_rpc_client_diagnoses_source -q
uv run basedpyright --level error src/wf_transport_rpc_http/models.py src/wf_transport_rpc_http/methods/sources.py src/wf_transport_rpc_http/client/sources.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py
```
Expected: tests pass and typecheck has 0 errors.
Commit:
```bash
git add src/wf_transport_rpc_http/models.py src/wf_transport_rpc_http/methods/sources.py src/wf_transport_rpc_http/client/sources.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py
git commit -m "feat: expose source diagnostics over rpc"
```
---
### Task 4: CLI Source Diagnose Command
**Files:**
- Modify: `src/wf_cli/commands/sources.py`
- Test: `tests/wf_cli/test_remote_target.py`
- [ ] **Step 1: Write CLI tests**
In `tests/wf_cli/test_remote_target.py`, add:
```python
def test_wf_source_diagnose_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
captured: dict[str, str] = {}
class FakeSourceAdmin:
async def diagnose_source(self, *, source_id: str) -> dict[str, object]:
captured["source_id"] = source_id
return {
"source_id": source_id,
"status": "ok",
"auth": {
"auth_ref": "demo.creds",
"record_present": True,
"scheme": "bearer",
"transport_supported": True,
},
"diagnostics": [],
}
class FakeContext:
source_admin = FakeSourceAdmin()
monkeypatch.setattr(
"wf_cli.commands.sources.load_cli_context_from_typer",
lambda ctx: FakeContext(),
)
result = runner.invoke(
app,
["--url", "http://127.0.0.1:8765/rpc", "source", "diagnose", "demo.personal"],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert captured == {"source_id": "demo.personal"}
assert payload["status"] == "ok"
assert payload["auth"]["scheme"] == "bearer"
```
If the file already has a `runner` or `app` helper, use the existing helper names.
- [ ] **Step 2: Run test and confirm failure**
Run:
```bash
uv run pytest tests/wf_cli/test_remote_target.py::test_wf_source_diagnose_uses_rpc_url_override -q
```
Expected: fails because `wf source diagnose` is not registered.
- [ ] **Step 3: Implement CLI command**
In `src/wf_cli/commands/sources.py`, add:
```python
@app.command("diagnose")
def diagnose_source(
ctx: typer.Context,
source_id: Annotated[str, typer.Argument(help="Workflow source id.")],
) -> None:
"""Diagnose source transport, auth, and catalog state."""
context = load_cli_context_from_typer(ctx)
payload = run_cli_operation(
context,
context.source_admin.diagnose_source(source_id=source_id),
)
emit_json(payload)
```
- [ ] **Step 4: Run tests and commit**
Run:
```bash
uv run pytest tests/wf_cli/test_remote_target.py::test_wf_source_diagnose_uses_rpc_url_override -q
uv run basedpyright --level error src/wf_cli/commands/sources.py tests/wf_cli/test_remote_target.py
```
Expected: tests pass and typecheck has 0 errors.
Commit:
```bash
git add src/wf_cli/commands/sources.py tests/wf_cli/test_remote_target.py
git commit -m "feat: add source diagnose cli"
```
---
### Task 5: Docs And Final Verification
**Files:**
- Modify: `docs/wf_cli.md`
- Modify: `docs/current_roadmap.md`
- [ ] **Step 1: Update CLI docs**
In `docs/wf_cli.md`, add a source diagnostics subsection near the source commands:
```markdown
### Diagnose A Source
Use `wf source diagnose <source_id>` to inspect source health before calling
capabilities:
```bash
wf --config wf.config.json source diagnose gdrive.personal
```
The output reports transport kind, auth reference, whether the auth record
exists, whether the auth scheme is compatible with the transport, catalog
snapshot counts, and non-secret diagnostics. Secret payload values are never
printed.
```
```
- [ ] **Step 2: Update roadmap**
In `docs/current_roadmap.md`, add a completed item under the current source/auth roadmap section:
```markdown
- Completed source auth diagnostics: `wf source diagnose <source_id>` now reports
transport/auth/catalog state without exposing secret payloads.
```
- [ ] **Step 3: Run focused verification**
Run:
```bash
uv run pytest tests/wf_api/test_source_admin_api.py tests/wf_mcp/service/test_source_diagnostics.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py tests/wf_cli/test_remote_target.py -q
uv run ruff check src/wf_api/source_admin.py src/wf_api/surface.py src/wf_mcp/broker/service/source_diagnostics.py src/wf_mcp/broker/server.py src/wf_transport_rpc_http/models.py src/wf_transport_rpc_http/methods/sources.py src/wf_transport_rpc_http/client/sources.py src/wf_cli/commands/sources.py tests/wf_api/test_source_admin_api.py tests/wf_mcp/service/test_source_diagnostics.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py tests/wf_cli/test_remote_target.py
uv run basedpyright --level error src/wf_api/source_admin.py src/wf_api/surface.py src/wf_mcp/broker/service/source_diagnostics.py src/wf_mcp/broker/server.py src/wf_transport_rpc_http/models.py src/wf_transport_rpc_http/methods/sources.py src/wf_transport_rpc_http/client/sources.py src/wf_cli/commands/sources.py tests/wf_api/test_source_admin_api.py tests/wf_mcp/service/test_source_diagnostics.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py tests/wf_cli/test_remote_target.py
```
Expected:
- Focused tests pass.
- Ruff reports `All checks passed!`.
- Basedpyright reports `0 errors`.
- [ ] **Step 4: Commit docs**
```bash
git add docs/wf_cli.md docs/current_roadmap.md
git commit -m "docs: document source diagnostics"
```
---
## Self-Review
- Spec coverage: plan adds neutral API, MCP diagnostics provider, RPC/client method, CLI command, docs, and tests. It explicitly avoids secret payload output.
- Placeholder scan: no `TBD`, `TODO`, or "similar to" placeholders remain.
- Type consistency: method name is `diagnose_source` across API, surface, RPC client, CLI, and tests. RPC method name is `workflow.sources.diagnose`. Payload uses `diagnostics` for the diagnostic list and `auth`/`catalog` for structured summaries.
+14
View File
@@ -123,6 +123,20 @@ availability, durable run counts/latest run, admin counts, auth record count,
and desired registry count when the target exposes those surfaces. It does not and desired registry count when the target exposes those surfaces. It does not
return auth payload values, trace entries, or checkpoint state. return auth payload values, trace entries, or checkpoint state.
### Diagnose A Source
Use `wf source diagnose <source_id>` to inspect source health before calling
capabilities:
```bash
wf --config wf.config.json source diagnose gdrive.personal
```
The output reports transport kind, auth reference, whether the auth record
exists, whether the auth scheme is compatible with the transport, catalog
snapshot counts, and non-secret diagnostics. Secret payload values are never
printed.
## Output Policy ## Output Policy
JSON is the default output format for every command. JSON is the default output format for every command.
+42 -3
View File
@@ -1,12 +1,22 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any, Protocol
from wf_platform import page_items from wf_platform import page_items
from .operation_context import WorkflowOperationContext from .operation_context import WorkflowOperationContext
class WorkflowSourceDiagnosticsProvider(Protocol):
"""Optional source-specific diagnostics provider.
Implementations may know about transport/auth/catalog details. The neutral
API only forwards source ids and serializes returned dictionaries.
"""
def diagnose_source(self, source_id: str) -> dict[str, Any]: ...
class WorkflowSourceAdminApi: class WorkflowSourceAdminApi:
"""Read-only protocol-neutral source inventory operations. """Read-only protocol-neutral source inventory operations.
@@ -15,8 +25,14 @@ class WorkflowSourceAdminApi:
lifecycle execution. lifecycle execution.
""" """
def __init__(self, context: WorkflowOperationContext) -> None: def __init__(
self,
context: WorkflowOperationContext,
*,
diagnostics: WorkflowSourceDiagnosticsProvider | None = None,
) -> None:
self.context = context self.context = context
self.diagnostics = diagnostics
async def list_sources( async def list_sources(
self, self,
@@ -43,4 +59,27 @@ class WorkflowSourceAdminApi:
source = self.context.specs.capability_sources[source_id] source = self.context.specs.capability_sources[source_id]
except KeyError as exc: except KeyError as exc:
raise KeyError(f"unknown source {source_id!r}") from exc raise KeyError(f"unknown source {source_id!r}") from exc
return source.as_inventory().model_dump(mode="json") payload = source.as_inventory().model_dump(mode="json")
if self.diagnostics is not None:
try:
payload["diagnostics"] = self.diagnostics.diagnose_source(source_id)
except Exception:
payload["diagnostics"] = {
"status": "error",
"message": "Diagnostics unavailable",
}
return payload
async def diagnose_source(self, *, source_id: str) -> dict[str, Any]:
try:
self.context.specs.capability_sources[source_id]
except KeyError as exc:
raise KeyError(f"unknown source {source_id!r}") from exc
if self.diagnostics is None:
return {
"source_id": source_id,
"status": "unknown",
"diagnostics": [],
"message": "No source diagnostics provider is configured.",
}
return self.diagnostics.diagnose_source(source_id)
+6
View File
@@ -244,6 +244,12 @@ class WorkflowSourceAdminSurface(Protocol):
source_id: str, source_id: str,
) -> dict[str, Any]: ... ) -> dict[str, Any]: ...
async def diagnose_source(
self,
*,
source_id: str,
) -> dict[str, Any]: ...
class WorkflowAdminSurface(Protocol): class WorkflowAdminSurface(Protocol):
"""Read-only connection/config admin methods exposed by platform frontends.""" """Read-only connection/config admin methods exposed by platform frontends."""
+14
View File
@@ -56,3 +56,17 @@ def inspect_source(
context.source_admin.inspect_source(source_id=source_id), context.source_admin.inspect_source(source_id=source_id),
) )
emit_json(payload) emit_json(payload)
@app.command("diagnose")
def diagnose_source(
ctx: typer.Context,
source_id: Annotated[str, typer.Argument(help="Workflow source id.")],
) -> None:
"""Diagnose source transport, auth, and catalog state."""
context = load_cli_context_from_typer(ctx)
payload = run_cli_operation(
context,
context.source_admin.diagnose_source(source_id=source_id),
)
emit_json(payload)
+10 -1
View File
@@ -22,6 +22,7 @@ from .prompts import register_broker_prompts
from .resources import register_broker_resources from .resources import register_broker_resources
from .service import WfMcpService from .service import WfMcpService
from .service.auth_admin import McpAuthAdminProvider from .service.auth_admin import McpAuthAdminProvider
from .service.source_diagnostics import SourceDiagnosticsProvider
from .service.source_registry_admin import SourceRegistryAdminProvider from .service.source_registry_admin import SourceRegistryAdminProvider
from .service.workflow_operation_context import context_from_service from .service.workflow_operation_context import context_from_service
from .tools import register_broker_tools from .tools import register_broker_tools
@@ -64,7 +65,15 @@ def workflow_server_from_service(
context = context_from_service(service) context = context_from_service(service)
api: WorkflowApi = durable_workflow_api(context) api: WorkflowApi = durable_workflow_api(context)
source_admin = WorkflowSourceAdminApi(context) source_diagnostics = SourceDiagnosticsProvider(
connection_lookup=service.connections.get,
auth_store=service.auth_store or service.store,
catalog_store=service.catalog_store or service.store,
)
source_admin = WorkflowSourceAdminApi(
context,
diagnostics=source_diagnostics,
)
admin = WorkflowAdminApi( admin = WorkflowAdminApi(
connections=service.connection_service, connections=service.connection_service,
events=service.events, events=service.events,
@@ -0,0 +1,159 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from wf_artifacts import DependencyDiagnostic, DiagnosticSeverity
from wf_sources_mcp.storage import AuthStore, CatalogStore
from ...models import ConnectionConfig
ConnectionLookup = Callable[[str], ConnectionConfig]
def _auth_ref(connection: ConnectionConfig) -> str | None:
value = connection.metadata.get("auth_ref")
return value if isinstance(value, str) and value else None
def _transport_kind(connection: ConnectionConfig) -> str | None:
value = connection.metadata.get("transport")
return value if isinstance(value, str) and value else None
def _auth_scheme_supported(
*,
transport_kind: str | None,
scheme: str | None,
) -> bool:
if scheme is None:
return True
if transport_kind == "stdio":
return scheme == "env"
if transport_kind == "http":
return scheme in {"bearer", "headers", "oauth_refresh_token"}
return False
def _unsupported_auth_diagnostic(
*,
source_id: str,
auth_ref: str,
scheme: str,
transport_kind: str | None,
) -> dict[str, Any]:
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="auth_scheme_not_supported",
logical_ref=auth_ref,
bound_source=source_id,
message=(
f"Source {source_id!r} uses {transport_kind or 'unknown'} transport, "
f"but auth record {auth_ref!r} has unsupported scheme {scheme!r}."
),
repair_hint=(
"Use env auth for stdio MCP sources, or bearer/headers/"
"oauth_refresh_token auth for HTTP MCP sources."
),
).model_dump(mode="json")
def _missing_auth_diagnostic(
*,
source_id: str,
auth_ref: str,
) -> dict[str, Any]:
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="auth_not_found",
logical_ref=auth_ref,
bound_source=source_id,
message=(
f"Source {source_id!r} references auth record {auth_ref!r}, "
"but no auth record was found."
),
repair_hint=(
"Add an auth record for this auth_ref, update the source auth_ref, "
"or bind the deployment to a source that does not require it."
),
).model_dump(mode="json")
def _missing_transport_diagnostic(*, source_id: str) -> dict[str, Any]:
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="source_transport_missing",
logical_ref=source_id,
bound_source=source_id,
message=f"Source {source_id!r} has no MCP transport configured.",
repair_hint=(
"Configure the source with an MCP transport such as stdio or http, "
"then apply or restart the server."
),
).model_dump(mode="json")
@dataclass(slots=True)
class SourceDiagnosticsProvider:
"""MCP broker diagnostics for source auth, transport, and catalog state."""
connection_lookup: ConnectionLookup
auth_store: AuthStore
catalog_store: CatalogStore
def diagnose_source(self, source_id: str) -> dict[str, Any]:
connection = self.connection_lookup(source_id)
auth_ref = _auth_ref(connection)
auth = self.auth_store.load_auth(auth_ref) if auth_ref else None
transport_kind = _transport_kind(connection)
snapshot = self.catalog_store.load_catalog(source_id)
diagnostics: list[dict[str, Any]] = []
if transport_kind is None:
diagnostics.append(_missing_transport_diagnostic(source_id=source_id))
if auth_ref is not None and auth is None:
diagnostics.append(
_missing_auth_diagnostic(source_id=source_id, auth_ref=auth_ref)
)
transport_supported = _auth_scheme_supported(
transport_kind=transport_kind,
scheme=None if auth is None else auth.scheme,
)
if auth_ref and auth is not None and not transport_supported:
diagnostics.append(
_unsupported_auth_diagnostic(
source_id=source_id,
auth_ref=auth_ref,
scheme=auth.scheme,
transport_kind=transport_kind,
)
)
return {
"source_id": source_id,
"status": "error" if diagnostics else "ok",
"enabled": connection.enabled,
"transport": {
"kind": transport_kind,
"configured": transport_kind is not None,
},
"auth": {
"auth_ref": auth_ref,
"record_present": auth is not None if auth_ref else None,
"scheme": None if auth is None else auth.scheme,
"transport_supported": transport_supported,
},
"catalog": {
"has_snapshot": snapshot is not None,
"fetched_at_epoch_ms": None
if snapshot is None
else snapshot.fetched_at_epoch_ms,
"max_age_seconds": None if snapshot is None else snapshot.max_age_seconds,
"node_count": 0 if snapshot is None else len(snapshot.nodes),
"resource_count": 0 if snapshot is None else len(snapshot.resources),
"prompt_count": 0 if snapshot is None else len(snapshot.prompts),
},
"diagnostics": diagnostics,
}
@@ -27,3 +27,9 @@ class RpcSourceAdminClientMixin:
"workflow.sources.inspect", "workflow.sources.inspect",
{"source_id": source_id}, {"source_id": source_id},
) )
async def diagnose_source(self: RpcCaller, *, source_id: str) -> dict[str, Any]:
return await self._call(
"workflow.sources.diagnose",
{"source_id": source_id},
)
+12 -1
View File
@@ -7,7 +7,7 @@ import fastapi_jsonrpc as jsonrpc
from wf_server import WorkflowServer from wf_server import WorkflowServer
from ..errors import WorkflowRpcError, raise_workflow_rpc_error from ..errors import WorkflowRpcError, raise_workflow_rpc_error
from ..models import InspectSourceParams, ListSourcesParams from ..models import DiagnoseSourceParams, InspectSourceParams, ListSourcesParams
from ..params import RpcParams from ..params import RpcParams
@@ -37,3 +37,14 @@ def register_methods(
return await server.source_admin.inspect_source(source_id=params.source_id) return await server.source_admin.inspect_source(source_id=params.source_id)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc: except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc) raise_workflow_rpc_error(exc)
@entrypoint.method(name="workflow.sources.diagnose", errors=[WorkflowRpcError])
async def workflow_sources_diagnose(
params: DiagnoseSourceParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.source_admin.diagnose_source(
source_id=params.source_id
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
+4
View File
@@ -50,6 +50,10 @@ class InspectSourceParams(RpcParamsModel):
source_id: str = Field(min_length=1) source_id: str = Field(min_length=1)
class DiagnoseSourceParams(RpcParamsModel):
source_id: str = Field(min_length=1)
class InspectCapabilityParams(RpcParamsModel): class InspectCapabilityParams(RpcParamsModel):
qualified_name: str = Field(min_length=1) qualified_name: str = Field(min_length=1)
+113
View File
@@ -9,6 +9,7 @@ from wf_api import WorkflowSourceAdminApi, WorkflowSourceAdminSurface
from wf_api.models import RawWorkflowPlan from wf_api.models import RawWorkflowPlan
from wf_api.operation_context import WorkflowOperationContext from wf_api.operation_context import WorkflowOperationContext
from wf_api.saved_subgraphs import SavedSubgraphTree from wf_api.saved_subgraphs import SavedSubgraphTree
from wf_api.source_admin import WorkflowSourceDiagnosticsProvider
from wf_artifacts import WorkflowArtifact, WorkflowDeployment from wf_artifacts import WorkflowArtifact, WorkflowDeployment
from wf_authoring import NodeSpec from wf_authoring import NodeSpec
from wf_core import RunState from wf_core import RunState
@@ -152,3 +153,115 @@ def test_source_admin_api_satisfies_surface_protocol() -> None:
api: WorkflowSourceAdminSurface = _api(_source("demo.personal")) api: WorkflowSourceAdminSurface = _api(_source("demo.personal"))
assert api is not None assert api is not None
class _Diagnostics:
def __init__(self) -> None:
self.calls: list[str] = []
def diagnose_source(self, source_id: str) -> dict[str, object]:
self.calls.append(source_id)
return {
"source_id": source_id,
"status": "ok",
"auth": {"record_present": True},
"diagnostics": [],
}
def _api_with_diagnostics(
*sources: CapabilitySource,
diagnostics: WorkflowSourceDiagnosticsProvider | None = None,
) -> WorkflowSourceAdminApi:
provider = StaticSpecProvider({source.id: source for source in sources})
return WorkflowSourceAdminApi(
WorkflowOperationContext(
artifact_store=None,
draft_workspace_store=None,
run_store=None,
events=DummyEvents(),
specs=provider,
runtime=DummyRuntime(),
live_sources=None,
),
diagnostics=diagnostics,
)
def test_inspect_source_includes_optional_diagnostics() -> None:
provider = _Diagnostics()
payload = asyncio.run(
_api_with_diagnostics(
_source("demo.personal"),
diagnostics=provider,
).inspect_source(source_id="demo.personal")
)
assert payload["id"] == "demo.personal"
assert payload["diagnostics"]["source_id"] == "demo.personal"
assert provider.calls == ["demo.personal"]
def test_inspect_source_omits_diagnostics_without_provider() -> None:
payload = asyncio.run(
_api_with_diagnostics(_source("demo.personal")).inspect_source(
source_id="demo.personal"
)
)
assert payload["id"] == "demo.personal"
assert "diagnostics" not in payload
class _BrokenDiagnostics:
def diagnose_source(self, source_id: str) -> dict[str, object]:
raise RuntimeError("diagnostics exploded")
def test_inspect_source_tolerates_diagnostics_provider_failure() -> None:
payload = asyncio.run(
_api_with_diagnostics(
_source("demo.personal"),
diagnostics=_BrokenDiagnostics(),
).inspect_source(source_id="demo.personal")
)
assert payload["id"] == "demo.personal"
assert payload["diagnostics"]["status"] == "error"
assert "Diagnostics unavailable" in payload["diagnostics"]["message"]
def test_diagnose_source_uses_provider() -> None:
payload = asyncio.run(
_api_with_diagnostics(
_source("demo.personal"),
diagnostics=_Diagnostics(),
).diagnose_source(source_id="demo.personal")
)
assert payload["status"] == "ok"
assert payload["auth"]["record_present"] is True
def test_diagnose_source_without_provider_returns_basic_status() -> None:
payload = asyncio.run(
_api_with_diagnostics(_source("demo.personal")).diagnose_source(
source_id="demo.personal"
)
)
assert payload == {
"source_id": "demo.personal",
"status": "unknown",
"diagnostics": [],
"message": "No source diagnostics provider is configured.",
}
def test_diagnose_source_unknown_raises_key_error() -> None:
with pytest.raises(KeyError, match="unknown source 'missing.source'"):
asyncio.run(
_api_with_diagnostics(_source("demo.personal")).diagnose_source(
source_id="missing.source"
)
)
+19
View File
@@ -30,6 +30,9 @@ class BrokenSourceAdmin:
async def inspect_source(self, *, source_id: str) -> dict[str, Any]: async def inspect_source(self, *, source_id: str) -> dict[str, Any]:
raise RuntimeError(f"broken source admin for {source_id}") raise RuntimeError(f"broken source admin for {source_id}")
async def diagnose_source(self, *, source_id: str) -> dict[str, Any]:
raise RuntimeError(f"broken source admin for {source_id}")
def test_load_cli_context_uses_rpc_client_for_rpc_http_target(tmp_path) -> None: def test_load_cli_context_uses_rpc_client_for_rpc_http_target(tmp_path) -> None:
config_path = tmp_path / "wf.json" config_path = tmp_path / "wf.json"
@@ -815,3 +818,19 @@ def test_wf_draft_delete_succeeds_with_confirm(monkeypatch, tmp_path) -> None:
payload = json.loads(result.output) payload = json.loads(result.output)
assert payload["workspace_id"] == "delete-me" assert payload["workspace_id"] == "delete-me"
assert payload["deleted"] is True assert payload["deleted"] is True
def test_wf_source_diagnose_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
result = runner.invoke(app, [*base_args, "source", "diagnose", "wf.std"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["source_id"] == "wf.std"
assert payload["status"] == "unknown"
@@ -0,0 +1,131 @@
from __future__ import annotations
from wf_mcp.broker.service.source_diagnostics import SourceDiagnosticsProvider
from wf_mcp.connections import ConnectionRegistry
from wf_mcp.models import ConnectionConfig
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.catalog import CatalogSnapshot
from wf_sources_mcp.storage import FileAuthStore, FileCatalogStore
def _connection(**metadata: object) -> ConnectionConfig:
return ConnectionConfig(
id="demo.personal",
server="demo",
account="personal",
enabled=True,
metadata={"transport": "http", "url": "https://example.test/mcp", **metadata},
)
def _provider(tmp_path, connection: ConnectionConfig) -> SourceDiagnosticsProvider:
registry = ConnectionRegistry()
registry.register(connection)
return SourceDiagnosticsProvider(
connection_lookup=registry.get,
auth_store=FileAuthStore(tmp_path / "auth"),
catalog_store=FileCatalogStore(tmp_path / "catalog"),
)
def test_source_diagnostics_reports_present_auth(tmp_path) -> None:
connection = _connection(auth_ref="demo.creds")
provider = _provider(tmp_path, connection)
provider.auth_store.save_auth(
AuthRecord(
connection_id="demo.creds",
scheme="oauth_refresh_token",
payload={
"client_id": "client",
"client_secret": "secret",
"refresh_token": "refresh",
"token_url": "https://oauth2.example.test/token",
},
)
)
payload = provider.diagnose_source("demo.personal")
assert payload["source_id"] == "demo.personal"
assert payload["status"] == "ok"
assert payload["auth"]["auth_ref"] == "demo.creds"
assert payload["auth"]["record_present"] is True
assert payload["auth"]["scheme"] == "oauth_refresh_token"
assert payload["auth"]["transport_supported"] is True
assert payload["diagnostics"] == []
def test_source_diagnostics_reports_missing_auth(tmp_path) -> None:
payload = _provider(
tmp_path,
_connection(auth_ref="missing.creds"),
).diagnose_source("demo.personal")
assert payload["status"] == "error"
assert payload["auth"]["record_present"] is False
assert payload["diagnostics"][0]["code"] == "auth_not_found"
def test_source_diagnostics_reports_missing_transport(tmp_path) -> None:
connection = ConnectionConfig(
id="demo.personal",
server="demo",
account="personal",
metadata={},
)
payload = _provider(tmp_path, connection).diagnose_source("demo.personal")
assert payload["status"] == "error"
assert payload["transport"]["configured"] is False
assert payload["diagnostics"][0]["code"] == "source_transport_missing"
def test_source_diagnostics_reports_unsupported_transport_auth(tmp_path) -> None:
connection = ConnectionConfig(
id="demo.personal",
server="demo",
account="personal",
metadata={"transport": "stdio", "command": "demo", "auth_ref": "demo.creds"},
)
provider = _provider(tmp_path, connection)
provider.auth_store.save_auth(
AuthRecord(
connection_id="demo.creds",
scheme="oauth_refresh_token",
payload={
"client_id": "client",
"client_secret": "secret",
"refresh_token": "refresh",
"token_url": "https://oauth2.example.test/token",
},
)
)
payload = provider.diagnose_source("demo.personal")
assert payload["status"] == "error"
assert payload["auth"]["transport_supported"] is False
assert payload["diagnostics"][0]["code"] == "auth_scheme_not_supported"
def test_source_diagnostics_reports_catalog_snapshot(tmp_path) -> None:
provider = _provider(tmp_path, _connection())
provider.catalog_store.save_catalog(
CatalogSnapshot(
connection_id="demo.personal",
fetched_at_epoch_ms=123,
max_age_seconds=60,
)
)
payload = provider.diagnose_source("demo.personal")
assert payload["catalog"] == {
"has_snapshot": True,
"fetched_at_epoch_ms": 123,
"max_age_seconds": 60,
"node_count": 0,
"resource_count": 0,
"prompt_count": 0,
}
+21
View File
@@ -163,3 +163,24 @@ async def test_workflow_server_from_service_uses_focused_auth_store(tmp_path) ->
result = await server.admin.inspect_auth_record("drive.work") result = await server.admin.inspect_auth_record("drive.work")
assert result["id"] == "drive.work" assert result["id"] == "drive.work"
async def test_workflow_server_source_admin_reports_mcp_diagnostics(tmp_path) -> None:
config = BrokerConfig(
store_root=tmp_path / "store",
connections=[
ConnectionConfig(id="demo.default", server="demo", account="default")
],
)
service = build_service_from_config(config)
server = workflow_server_from_service(
service,
config=config,
source_registry_store=FileSourceRegistryStore(config.store_root),
)
payload = await server.source_admin.diagnose_source(source_id="demo.default")
assert payload["source_id"] == "demo.default"
assert "auth" in payload
assert "catalog" in payload
+15
View File
@@ -616,3 +616,18 @@ async def test_rpc_runs_workflow_from_python_source_capability(tmp_path) -> None
assert deployment["result"]["deployment_id"] == "python_echo.default" assert deployment["result"]["deployment_id"] == "python_echo.default"
assert run["result"]["outcome"] == "ok" assert run["result"]["outcome"] == "ok"
assert run["result"]["output"] == {"echoed": "hello workflow"} assert run["result"]["output"] == {"echoed": "hello workflow"}
async def test_rpc_diagnoses_source(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
payload = await _rpc(
client,
"workflow.sources.diagnose",
{"source_id": "wf.std"},
)
assert payload["result"]["source_id"] == "wf.std"
assert payload["result"]["status"] == "unknown"
@@ -7,6 +7,7 @@ from wf_api.surface import WorkflowDraftSurface
from wf_core import END from wf_core import END
from wf_server import build_local_static_workflow_server from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin
def _constant_plan() -> RawWorkflowPlan: def _constant_plan() -> RawWorkflowPlan:
@@ -412,3 +413,19 @@ async def test_rpc_client_lists_runs(tmp_path) -> None:
assert listed["total"] == 1 assert listed["total"] == 1
assert listed["runs"][0]["run_id"] == started["run_id"] assert listed["runs"][0]["run_id"] == started["run_id"]
async def test_rpc_client_diagnoses_source(tmp_path) -> None:
calls: list[tuple[str, dict[str, object]]] = []
class Client(RpcSourceAdminClientMixin):
async def _call(self, method: str, params: dict[str, object]):
calls.append((method, params))
return {"source_id": params["source_id"], "status": "ok"}
payload = await Client().diagnose_source(source_id="demo.personal")
assert payload == {"source_id": "demo.personal", "status": "ok"}
assert calls == [
("workflow.sources.diagnose", {"source_id": "demo.personal"})
]