feat: add source auth diagnostics
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user