refactor: introduce wf sources mcp auth storage
This commit is contained in:
@@ -273,11 +273,12 @@ implementation state.
|
|||||||
as workflow capabilities. The current `wf_mcp` package still contains both
|
as workflow capabilities. The current `wf_mcp` package still contains both
|
||||||
roles plus compatibility entrypoints; new server/transport work should avoid
|
roles plus compatibility entrypoints; new server/transport work should avoid
|
||||||
depending on that combined facade.
|
depending on that combined facade.
|
||||||
Next concrete slice: create `wf_sources_mcp` and move leaf upstream-source
|
First `wf_sources_mcp` slice complete: MCP auth helpers and focused
|
||||||
pieces first, starting with MCP auth helpers and focused auth/catalog stores.
|
auth/catalog stores now live in `wf_sources_mcp`, with `wf_mcp` compatibility
|
||||||
Keep `wf_mcp` re-export shims for compatibility and add import-direction
|
shims preserved. Runtime/session/source-registry moves remain future slices.
|
||||||
tests so `wf_sources_mcp` does not depend on workflow/admin surface,
|
Keep `wf_mcp` re-export shims for compatibility and add import-direction
|
||||||
frontend server, or proxy modules.
|
tests so `wf_sources_mcp` does not depend on workflow/admin surface,
|
||||||
|
frontend server, or proxy modules.
|
||||||
The `wf-mcp` script is now a legacy/special-purpose MCP entrypoint, not the
|
The `wf-mcp` script is now a legacy/special-purpose MCP entrypoint, not the
|
||||||
preferred durable workflow server. New product paths should target
|
preferred durable workflow server. New product paths should target
|
||||||
`wf-rpc-server` plus neutral `wf_config`/`wf_server` composition, then keep
|
`wf-rpc-server` plus neutral `wf_config`/`wf_server` composition, then keep
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ packages, or compatibility shims.
|
|||||||
|
|
||||||
First slices should move leaf modules only and leave `wf_mcp` re-export shims:
|
First slices should move leaf modules only and leave `wf_mcp` re-export shims:
|
||||||
|
|
||||||
1. MCP auth helpers and focused auth/catalog stores.
|
1. Complete: MCP auth helpers and focused auth/catalog stores moved to
|
||||||
|
`wf_sources_mcp`, with `wf_mcp` shims preserved.
|
||||||
2. MCP source registry models/conversion.
|
2. MCP source registry models/conversion.
|
||||||
3. Upstream transport/discovery/session services.
|
3. Upstream transport/discovery/session services.
|
||||||
|
|
||||||
|
|||||||
+15
-133
@@ -1,138 +1,20 @@
|
|||||||
|
"""Compatibility shim for MCP source auth helpers.
|
||||||
|
|
||||||
|
Canonical implementation lives in `wf_sources_mcp.auth`.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from wf_sources_mcp.auth import (
|
||||||
from dataclasses import dataclass, field
|
AuthRecord,
|
||||||
from typing import TYPE_CHECKING, Any
|
auth_missing_diagnostic,
|
||||||
|
auth_ref_for_connection,
|
||||||
from wf_api.auth import AuthRecord as NeutralAuthRecord
|
connection_auth_diagnostic,
|
||||||
from wf_artifacts import DependencyDiagnostic, DiagnosticSeverity
|
mcp_auth_env,
|
||||||
|
mcp_auth_from_neutral,
|
||||||
if TYPE_CHECKING:
|
mcp_auth_headers,
|
||||||
from .broker.models import ConnectionConfig
|
neutral_auth_from_mcp,
|
||||||
|
)
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class AuthRecord:
|
|
||||||
connection_id: str
|
|
||||||
scheme: str
|
|
||||||
payload: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
def mcp_auth_from_neutral(record: NeutralAuthRecord) -> AuthRecord:
|
|
||||||
"""Adapt neutral auth to the current MCP compatibility record."""
|
|
||||||
|
|
||||||
return AuthRecord(
|
|
||||||
connection_id=record.id,
|
|
||||||
scheme=record.scheme,
|
|
||||||
payload=dict(record.payload),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def neutral_auth_from_mcp(record: AuthRecord) -> NeutralAuthRecord:
|
|
||||||
"""Adapt legacy MCP auth into the neutral record shape."""
|
|
||||||
|
|
||||||
return NeutralAuthRecord(
|
|
||||||
id=record.connection_id,
|
|
||||||
scheme=record.scheme,
|
|
||||||
payload=dict(record.payload),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def mcp_auth_headers(auth: AuthRecord | None) -> dict[str, str]:
|
|
||||||
"""Return HTTP headers understood by MCP HTTP transports.
|
|
||||||
|
|
||||||
This is intentionally MCP-specific. Neutral code must not inspect payload
|
|
||||||
keys such as `headers` or `token`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if auth is None:
|
|
||||||
return {}
|
|
||||||
raw_headers = auth.payload.get("headers", {})
|
|
||||||
headers = (
|
|
||||||
{
|
|
||||||
str(key): str(value)
|
|
||||||
for key, value in raw_headers.items()
|
|
||||||
if isinstance(key, str) and isinstance(value, str)
|
|
||||||
}
|
|
||||||
if isinstance(raw_headers, dict)
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
token = auth.payload.get("token")
|
|
||||||
if isinstance(token, str) and "Authorization" not in headers:
|
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
def mcp_auth_env(auth: AuthRecord | None) -> dict[str, str]:
|
|
||||||
"""Return environment variables understood by MCP stdio transports."""
|
|
||||||
|
|
||||||
if auth is None:
|
|
||||||
return {}
|
|
||||||
raw_env = auth.payload.get("env", {})
|
|
||||||
if not isinstance(raw_env, dict):
|
|
||||||
return {}
|
|
||||||
return {
|
|
||||||
str(key): str(value)
|
|
||||||
for key, value in raw_env.items()
|
|
||||||
if isinstance(key, str) and isinstance(value, str)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def auth_ref_for_connection(connection: ConnectionConfig) -> str | None:
|
|
||||||
"""Return the explicit auth ref for one source connection, if present."""
|
|
||||||
|
|
||||||
auth_ref = connection.metadata.get("auth_ref")
|
|
||||||
return auth_ref if isinstance(auth_ref, str) else None
|
|
||||||
|
|
||||||
|
|
||||||
def auth_missing_diagnostic(
|
|
||||||
*,
|
|
||||||
auth_ref: str,
|
|
||||||
source_id: str,
|
|
||||||
logical_ref: str | None = None,
|
|
||||||
) -> DependencyDiagnostic:
|
|
||||||
"""Build a stable diagnostic without including secret payload data."""
|
|
||||||
|
|
||||||
return DependencyDiagnostic(
|
|
||||||
severity=DiagnosticSeverity.ERROR,
|
|
||||||
code="auth_not_found",
|
|
||||||
logical_ref=logical_ref or "",
|
|
||||||
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."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def connection_auth_diagnostic(
|
|
||||||
connection: ConnectionConfig,
|
|
||||||
*,
|
|
||||||
load_auth_ref: Callable[[str], AuthRecord | None],
|
|
||||||
logical_ref: str | None = None,
|
|
||||||
) -> DependencyDiagnostic | None:
|
|
||||||
"""Return an auth diagnostic for explicit auth_ref misses.
|
|
||||||
|
|
||||||
Connections without explicit auth_ref keep legacy no-auth behavior. This
|
|
||||||
makes the new auth boundary observable without treating every unauthenticated
|
|
||||||
MCP source as an error.
|
|
||||||
"""
|
|
||||||
|
|
||||||
auth_ref = auth_ref_for_connection(connection)
|
|
||||||
if auth_ref is None:
|
|
||||||
return None
|
|
||||||
if load_auth_ref(auth_ref) is not None:
|
|
||||||
return None
|
|
||||||
return auth_missing_diagnostic(
|
|
||||||
auth_ref=auth_ref,
|
|
||||||
source_id=connection.id,
|
|
||||||
logical_ref=logical_ref,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuthRecord",
|
"AuthRecord",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from wf_api import file_workflow_stores
|
from wf_api import file_workflow_stores
|
||||||
from wf_config import WorkflowConfigFile
|
from wf_config import WorkflowConfigFile
|
||||||
from wf_config.models import FilesystemStoreConfig, McpSourceConfig, ServerConfig
|
from wf_config.models import FilesystemStoreConfig, McpSourceConfig, ServerConfig
|
||||||
|
from wf_sources_mcp.storage import FileAuthStore, FileCatalogStore, FileStore
|
||||||
|
|
||||||
from ..control import BrokerConfigFile, ConnectionConfigFile
|
from ..control import BrokerConfigFile, ConnectionConfigFile
|
||||||
from ..models import BrokerConfig
|
from ..models import BrokerConfig
|
||||||
@@ -15,7 +16,6 @@ from ..source_registry import (
|
|||||||
FileSourceRegistryStore,
|
FileSourceRegistryStore,
|
||||||
workflow_mcp_source_to_connection_config,
|
workflow_mcp_source_to_connection_config,
|
||||||
)
|
)
|
||||||
from ..storage import FileAuthStore, FileCatalogStore, FileStore
|
|
||||||
from .models import BrokerStoreRoots
|
from .models import BrokerStoreRoots
|
||||||
from .service import WfMcpService
|
from .service import WfMcpService
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ from typing import Any
|
|||||||
|
|
||||||
from wf_api import WorkflowAdminAuthProvider
|
from wf_api import WorkflowAdminAuthProvider
|
||||||
from wf_api.auth import AuthRecord as NeutralAuthRecord
|
from wf_api.auth import AuthRecord as NeutralAuthRecord
|
||||||
|
from wf_sources_mcp.storage import AuthStore
|
||||||
from ...storage import AuthStore
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ from wf_mcp.capabilities import (
|
|||||||
from wf_platform import (
|
from wf_platform import (
|
||||||
CapabilitySource,
|
CapabilitySource,
|
||||||
)
|
)
|
||||||
|
from wf_sources_mcp.auth import AuthRecord
|
||||||
|
from wf_sources_mcp.storage import AuthStore, CatalogStore, Store
|
||||||
|
|
||||||
from ...auth import AuthRecord
|
|
||||||
from ...connections import ConnectionRegistry
|
from ...connections import ConnectionRegistry
|
||||||
from ...events import EventBus, McpEvent
|
from ...events import EventBus, McpEvent
|
||||||
from ...models import (
|
from ...models import (
|
||||||
@@ -37,7 +38,6 @@ from ...models import (
|
|||||||
from ...runtime import ToolExecutor
|
from ...runtime import ToolExecutor
|
||||||
from ...sdk import BackendAdapter
|
from ...sdk import BackendAdapter
|
||||||
from ...source_registry import SourceRegistryStore
|
from ...source_registry import SourceRegistryStore
|
||||||
from ...storage import AuthStore, CatalogStore, Store
|
|
||||||
from ..admin_capabilities import admin_source
|
from ..admin_capabilities import admin_source
|
||||||
from ..catalog import CombinedCatalog
|
from ..catalog import CombinedCatalog
|
||||||
from .builtins import builtin_sources
|
from .builtins import builtin_sources
|
||||||
|
|||||||
@@ -22,15 +22,15 @@ from wf_platform import (
|
|||||||
SourceVisibility,
|
SourceVisibility,
|
||||||
page_items,
|
page_items,
|
||||||
)
|
)
|
||||||
|
from wf_sources_mcp.auth import AuthRecord
|
||||||
|
from wf_sources_mcp.storage import CatalogStore
|
||||||
|
|
||||||
from ...auth import AuthRecord
|
|
||||||
from ...connections import ConnectionConfig, qualify_node_name
|
from ...connections import ConnectionConfig, qualify_node_name
|
||||||
from ...events import McpEvent, make_event
|
from ...events import McpEvent, make_event
|
||||||
from ...models import (
|
from ...models import (
|
||||||
CatalogSnapshot,
|
CatalogSnapshot,
|
||||||
)
|
)
|
||||||
from ...runtime import ToolExecutor
|
from ...runtime import ToolExecutor
|
||||||
from ...storage import CatalogStore
|
|
||||||
from ...workflow.wrappers import _model_from_schema
|
from ...workflow.wrappers import _model_from_schema
|
||||||
from ..catalog import CombinedCatalog, snapshot_from_specs
|
from ..catalog import CombinedCatalog, snapshot_from_specs
|
||||||
from .specs import get_qualified_spec, qualify_spec
|
from .specs import get_qualified_spec, qualify_spec
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from wf_artifacts import (
|
|||||||
WorkflowArtifact,
|
WorkflowArtifact,
|
||||||
WorkflowDeployment,
|
WorkflowDeployment,
|
||||||
)
|
)
|
||||||
from wf_mcp.auth import AuthRecord
|
|
||||||
from wf_mcp.broker.catalog import snapshot_from_specs
|
from wf_mcp.broker.catalog import snapshot_from_specs
|
||||||
from wf_mcp.broker.discovery import (
|
from wf_mcp.broker.discovery import (
|
||||||
discover_connection_capabilities,
|
discover_connection_capabilities,
|
||||||
@@ -28,9 +27,9 @@ from wf_mcp.models import CatalogSnapshot, ConnectionConfig
|
|||||||
from wf_mcp.runtime import ToolExecutor
|
from wf_mcp.runtime import ToolExecutor
|
||||||
from wf_mcp.sdk import BackendAdapter
|
from wf_mcp.sdk import BackendAdapter
|
||||||
from wf_mcp.shared.errors import error_payload
|
from wf_mcp.shared.errors import error_payload
|
||||||
from wf_mcp.storage import AuthStore, CatalogStore
|
from wf_sources_mcp.auth import AuthRecord, connection_auth_diagnostic
|
||||||
|
from wf_sources_mcp.storage import AuthStore, CatalogStore
|
||||||
|
|
||||||
from ...auth import connection_auth_diagnostic
|
|
||||||
from .adapters import require_adapter
|
from .adapters import require_adapter
|
||||||
from .source_catalog import SourceCatalogService
|
from .source_catalog import SourceCatalogService
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ from mcp.client.stdio import StdioServerParameters, stdio_client
|
|||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
from mcp.types import CallToolResult
|
from mcp.types import CallToolResult
|
||||||
|
|
||||||
from ..auth import AuthRecord, mcp_auth_env, mcp_auth_headers
|
from wf_sources_mcp.auth import AuthRecord, mcp_auth_env, mcp_auth_headers
|
||||||
|
|
||||||
from ..models import ConnectionConfig
|
from ..models import ConnectionConfig
|
||||||
from .session import PersistentMcpSession
|
from .session import PersistentMcpSession
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ from mcp.types import (
|
|||||||
)
|
)
|
||||||
from pydantic import AnyUrl
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
from ..auth import AuthRecord, mcp_auth_env, mcp_auth_headers
|
from wf_sources_mcp.auth import AuthRecord, mcp_auth_env, mcp_auth_headers
|
||||||
|
|
||||||
from ..capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
|
from ..capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
|
||||||
from ..models import ConnectionConfig
|
from ..models import ConnectionConfig
|
||||||
from .base import BackendAdapter, ToolCallResult
|
from .base import BackendAdapter, ToolCallResult
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from .store import (
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from wf_sources_mcp.storage import (
|
||||||
AuthStore,
|
AuthStore,
|
||||||
CatalogStore,
|
CatalogStore,
|
||||||
FileAuthStore,
|
FileAuthStore,
|
||||||
|
|||||||
+20
-222
@@ -1,226 +1,24 @@
|
|||||||
|
"""Compatibility shim for MCP source auth/catalog stores.
|
||||||
|
|
||||||
|
Canonical implementation lives in `wf_sources_mcp.storage.store`.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
from wf_sources_mcp.storage.store import (
|
||||||
from pathlib import Path
|
AuthStore,
|
||||||
|
CatalogStore,
|
||||||
from wf_api.auth import AuthRecord as NeutralAuthRecord
|
FileAuthStore,
|
||||||
from wf_api.auth import validate_auth_id
|
FileCatalogStore,
|
||||||
from wf_mcp.capabilities import (
|
FileStore,
|
||||||
CatalogNodeEntry,
|
Store,
|
||||||
CatalogPromptEntry,
|
|
||||||
CatalogResourceEntry,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..auth import AuthRecord, mcp_auth_from_neutral, neutral_auth_from_mcp
|
__all__ = [
|
||||||
from ..connections import parse_connection_id
|
"AuthStore",
|
||||||
from ..models import (
|
"CatalogStore",
|
||||||
CatalogSnapshot,
|
"FileAuthStore",
|
||||||
dump_catalog_snapshot,
|
"FileCatalogStore",
|
||||||
)
|
"FileStore",
|
||||||
|
"Store",
|
||||||
|
]
|
||||||
class AuthStore:
|
|
||||||
def save_auth(self, record: AuthRecord) -> None:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def load_auth(self, connection_id: str) -> AuthRecord | None:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def list_auth_refs(self) -> list[str]:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def save_auth_record(self, record: NeutralAuthRecord) -> None:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def load_auth_record(self, auth_ref: str) -> NeutralAuthRecord | None:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def delete_auth(self, connection_id: str) -> bool:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def delete_auth_record(self, auth_ref: str) -> bool:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class CatalogStore:
|
|
||||||
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def load_catalog(self, connection_id: str) -> CatalogSnapshot | None:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class Store(AuthStore, CatalogStore):
|
|
||||||
"""Compatibility store combining MCP auth and catalog/cache storage."""
|
|
||||||
|
|
||||||
|
|
||||||
class FileAuthStore(AuthStore):
|
|
||||||
def __init__(self, root: Path) -> None:
|
|
||||||
self.root = root
|
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.auth_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def auth_dir(self) -> Path:
|
|
||||||
return self.root / "auth"
|
|
||||||
|
|
||||||
def _auth_path(self, auth_ref: str) -> Path:
|
|
||||||
"""Map one auth ref to one file.
|
|
||||||
|
|
||||||
Auth refs used to be connection ids, but neutral auth refs now carry no
|
|
||||||
provider/account semantics. Keep catalog paths on connection-id
|
|
||||||
validation while auth storage accepts the wider auth-id contract.
|
|
||||||
"""
|
|
||||||
|
|
||||||
validate_auth_id(auth_ref)
|
|
||||||
root = self.auth_dir.resolve()
|
|
||||||
path = (self.auth_dir / f"{auth_ref}.json").resolve()
|
|
||||||
if path.parent != root:
|
|
||||||
raise ValueError(f"auth ref escapes store directory: {auth_ref!r}")
|
|
||||||
return path
|
|
||||||
|
|
||||||
def save_auth(self, record: AuthRecord) -> None:
|
|
||||||
self._auth_path(record.connection_id).write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"connection_id": record.connection_id,
|
|
||||||
"scheme": record.scheme,
|
|
||||||
"payload": record.payload,
|
|
||||||
},
|
|
||||||
indent=2,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
def load_auth(self, connection_id: str) -> AuthRecord | None:
|
|
||||||
path = self._auth_path(connection_id)
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
return AuthRecord(**data)
|
|
||||||
|
|
||||||
def list_auth_refs(self) -> list[str]:
|
|
||||||
"""Return auth refs present in the local file auth store."""
|
|
||||||
|
|
||||||
return sorted(path.stem for path in self.auth_dir.glob("*.json"))
|
|
||||||
|
|
||||||
def save_auth_record(self, record: NeutralAuthRecord) -> None:
|
|
||||||
"""Save neutral auth through the legacy MCP file shape."""
|
|
||||||
|
|
||||||
self.save_auth(mcp_auth_from_neutral(record))
|
|
||||||
|
|
||||||
def load_auth_record(self, auth_ref: str) -> NeutralAuthRecord | None:
|
|
||||||
"""Load neutral auth from the legacy MCP file shape."""
|
|
||||||
|
|
||||||
record = self.load_auth(auth_ref)
|
|
||||||
if record is None:
|
|
||||||
return None
|
|
||||||
return neutral_auth_from_mcp(record)
|
|
||||||
|
|
||||||
def delete_auth(self, connection_id: str) -> bool:
|
|
||||||
path = self._auth_path(connection_id)
|
|
||||||
if not path.exists():
|
|
||||||
return False
|
|
||||||
path.unlink()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def delete_auth_record(self, auth_ref: str) -> bool:
|
|
||||||
"""Delete neutral auth through the legacy MCP file shape."""
|
|
||||||
return self.delete_auth(auth_ref)
|
|
||||||
|
|
||||||
|
|
||||||
class FileCatalogStore(CatalogStore):
|
|
||||||
def __init__(self, root: Path) -> None:
|
|
||||||
self.root = root
|
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.catalog_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def catalog_dir(self) -> Path:
|
|
||||||
return self.root / "catalog"
|
|
||||||
|
|
||||||
def _catalog_path(self, connection_id: str) -> Path:
|
|
||||||
return self._connection_path(self.catalog_dir, connection_id)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _connection_path(directory: Path, connection_id: str) -> Path:
|
|
||||||
"""Map one validated connection id to one file inside a store directory."""
|
|
||||||
parse_connection_id(connection_id)
|
|
||||||
root = directory.resolve()
|
|
||||||
path = (directory / f"{connection_id}.json").resolve()
|
|
||||||
if path.parent != root:
|
|
||||||
raise ValueError(
|
|
||||||
f"connection id escapes store directory: {connection_id!r}"
|
|
||||||
)
|
|
||||||
return path
|
|
||||||
|
|
||||||
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
|
|
||||||
self._catalog_path(snapshot.connection_id).write_text(
|
|
||||||
json.dumps(dump_catalog_snapshot(snapshot), indent=2),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
def load_catalog(self, connection_id: str) -> CatalogSnapshot | None:
|
|
||||||
path = self._catalog_path(connection_id)
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
return CatalogSnapshot(
|
|
||||||
connection_id=data["connection_id"],
|
|
||||||
fetched_at_epoch_ms=data["fetched_at_epoch_ms"],
|
|
||||||
max_age_seconds=data["max_age_seconds"],
|
|
||||||
nodes=[CatalogNodeEntry(**node) for node in data.get("nodes", [])],
|
|
||||||
resources=[
|
|
||||||
CatalogResourceEntry(**resource)
|
|
||||||
for resource in data.get("resources", [])
|
|
||||||
],
|
|
||||||
prompts=[
|
|
||||||
CatalogPromptEntry(**prompt) for prompt in data.get("prompts", [])
|
|
||||||
],
|
|
||||||
metadata=data.get("metadata", {}),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FileStore(Store):
|
|
||||||
"""Compatibility file store that combines auth and catalog stores."""
|
|
||||||
|
|
||||||
def __init__(self, root: Path) -> None:
|
|
||||||
self.root = root
|
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._auth = FileAuthStore(root)
|
|
||||||
self._catalog = FileCatalogStore(root)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def auth_dir(self) -> Path:
|
|
||||||
return self._auth.auth_dir
|
|
||||||
|
|
||||||
@property
|
|
||||||
def catalog_dir(self) -> Path:
|
|
||||||
return self._catalog.catalog_dir
|
|
||||||
|
|
||||||
def save_auth(self, record: AuthRecord) -> None:
|
|
||||||
self._auth.save_auth(record)
|
|
||||||
|
|
||||||
def load_auth(self, connection_id: str) -> AuthRecord | None:
|
|
||||||
return self._auth.load_auth(connection_id)
|
|
||||||
|
|
||||||
def list_auth_refs(self) -> list[str]:
|
|
||||||
return self._auth.list_auth_refs()
|
|
||||||
|
|
||||||
def save_auth_record(self, record: NeutralAuthRecord) -> None:
|
|
||||||
self._auth.save_auth_record(record)
|
|
||||||
|
|
||||||
def load_auth_record(self, auth_ref: str) -> NeutralAuthRecord | None:
|
|
||||||
return self._auth.load_auth_record(auth_ref)
|
|
||||||
|
|
||||||
def delete_auth(self, connection_id: str) -> bool:
|
|
||||||
return self._auth.delete_auth(connection_id)
|
|
||||||
|
|
||||||
def delete_auth_record(self, auth_ref: str) -> bool:
|
|
||||||
return self._auth.delete_auth_record(auth_ref)
|
|
||||||
|
|
||||||
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
|
|
||||||
self._catalog.save_catalog(snapshot)
|
|
||||||
|
|
||||||
def load_catalog(self, connection_id: str) -> CatalogSnapshot | None:
|
|
||||||
return self._catalog.load_catalog(connection_id)
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .auth import (
|
||||||
|
AuthRecord,
|
||||||
|
auth_missing_diagnostic,
|
||||||
|
auth_ref_for_connection,
|
||||||
|
connection_auth_diagnostic,
|
||||||
|
mcp_auth_env,
|
||||||
|
mcp_auth_from_neutral,
|
||||||
|
mcp_auth_headers,
|
||||||
|
neutral_auth_from_mcp,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuthRecord",
|
||||||
|
"auth_missing_diagnostic",
|
||||||
|
"auth_ref_for_connection",
|
||||||
|
"connection_auth_diagnostic",
|
||||||
|
"mcp_auth_env",
|
||||||
|
"mcp_auth_from_neutral",
|
||||||
|
"mcp_auth_headers",
|
||||||
|
"neutral_auth_from_mcp",
|
||||||
|
]
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""MCP upstream-source auth helpers.
|
||||||
|
|
||||||
|
This module is canonical for MCP-as-source auth interpretation. The temporary
|
||||||
|
TYPE_CHECKING dependency on `wf_mcp.broker.models.ConnectionConfig` exists until
|
||||||
|
connection runtime DTOs move out of the compatibility MCP facade.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from wf_api.auth import AuthRecord as NeutralAuthRecord
|
||||||
|
from wf_artifacts import DependencyDiagnostic, DiagnosticSeverity
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from wf_mcp.broker.models import ConnectionConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuthRecord:
|
||||||
|
connection_id: str
|
||||||
|
scheme: str
|
||||||
|
payload: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_auth_from_neutral(record: NeutralAuthRecord) -> AuthRecord:
|
||||||
|
"""Adapt neutral auth to the current MCP compatibility record."""
|
||||||
|
|
||||||
|
return AuthRecord(
|
||||||
|
connection_id=record.id,
|
||||||
|
scheme=record.scheme,
|
||||||
|
payload=dict(record.payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def neutral_auth_from_mcp(record: AuthRecord) -> NeutralAuthRecord:
|
||||||
|
"""Adapt legacy MCP auth into the neutral record shape."""
|
||||||
|
|
||||||
|
return NeutralAuthRecord(
|
||||||
|
id=record.connection_id,
|
||||||
|
scheme=record.scheme,
|
||||||
|
payload=dict(record.payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_auth_headers(auth: AuthRecord | None) -> dict[str, str]:
|
||||||
|
"""Return HTTP headers understood by MCP HTTP transports.
|
||||||
|
|
||||||
|
This is intentionally MCP-specific. Neutral code must not inspect payload
|
||||||
|
keys such as `headers` or `token`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if auth is None:
|
||||||
|
return {}
|
||||||
|
raw_headers = auth.payload.get("headers", {})
|
||||||
|
headers = (
|
||||||
|
{
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in raw_headers.items()
|
||||||
|
if isinstance(key, str) and isinstance(value, str)
|
||||||
|
}
|
||||||
|
if isinstance(raw_headers, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
token = auth.payload.get("token")
|
||||||
|
if isinstance(token, str) and "Authorization" not in headers:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_auth_env(auth: AuthRecord | None) -> dict[str, str]:
|
||||||
|
"""Return environment variables understood by MCP stdio transports."""
|
||||||
|
|
||||||
|
if auth is None:
|
||||||
|
return {}
|
||||||
|
raw_env = auth.payload.get("env", {})
|
||||||
|
if not isinstance(raw_env, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in raw_env.items()
|
||||||
|
if isinstance(key, str) and isinstance(value, str)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def auth_ref_for_connection(connection: ConnectionConfig) -> str | None:
|
||||||
|
"""Return the explicit auth ref for one source connection, if present."""
|
||||||
|
|
||||||
|
auth_ref = connection.metadata.get("auth_ref")
|
||||||
|
return auth_ref if isinstance(auth_ref, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def auth_missing_diagnostic(
|
||||||
|
*,
|
||||||
|
auth_ref: str,
|
||||||
|
source_id: str,
|
||||||
|
logical_ref: str | None = None,
|
||||||
|
) -> DependencyDiagnostic:
|
||||||
|
"""Build a stable diagnostic without including secret payload data."""
|
||||||
|
|
||||||
|
return DependencyDiagnostic(
|
||||||
|
severity=DiagnosticSeverity.ERROR,
|
||||||
|
code="auth_not_found",
|
||||||
|
logical_ref=logical_ref or "",
|
||||||
|
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."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def connection_auth_diagnostic(
|
||||||
|
connection: ConnectionConfig,
|
||||||
|
*,
|
||||||
|
load_auth_ref: Callable[[str], AuthRecord | None],
|
||||||
|
logical_ref: str | None = None,
|
||||||
|
) -> DependencyDiagnostic | None:
|
||||||
|
"""Return an auth diagnostic for explicit auth_ref misses.
|
||||||
|
|
||||||
|
Connections without explicit auth_ref keep legacy no-auth behavior. This
|
||||||
|
makes the new auth boundary observable without treating every unauthenticated
|
||||||
|
MCP source as an error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
auth_ref = auth_ref_for_connection(connection)
|
||||||
|
if auth_ref is None:
|
||||||
|
return None
|
||||||
|
if load_auth_ref(auth_ref) is not None:
|
||||||
|
return None
|
||||||
|
return auth_missing_diagnostic(
|
||||||
|
auth_ref=auth_ref,
|
||||||
|
source_id=connection.id,
|
||||||
|
logical_ref=logical_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuthRecord",
|
||||||
|
"auth_missing_diagnostic",
|
||||||
|
"auth_ref_for_connection",
|
||||||
|
"connection_auth_diagnostic",
|
||||||
|
"mcp_auth_env",
|
||||||
|
"mcp_auth_from_neutral",
|
||||||
|
"mcp_auth_headers",
|
||||||
|
"neutral_auth_from_mcp",
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .store import (
|
||||||
|
AuthStore,
|
||||||
|
CatalogStore,
|
||||||
|
FileAuthStore,
|
||||||
|
FileCatalogStore,
|
||||||
|
FileStore,
|
||||||
|
Store,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuthStore",
|
||||||
|
"CatalogStore",
|
||||||
|
"FileAuthStore",
|
||||||
|
"FileCatalogStore",
|
||||||
|
"FileStore",
|
||||||
|
"Store",
|
||||||
|
]
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""MCP upstream-source auth and catalog file stores.
|
||||||
|
|
||||||
|
These stores preserve the current MCP compatibility JSON shapes. Catalog entry
|
||||||
|
types still come from `wf_mcp` until catalog DTOs finish moving to a neutral or
|
||||||
|
source-provider package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from wf_api.auth import AuthRecord as NeutralAuthRecord
|
||||||
|
from wf_api.auth import validate_auth_id
|
||||||
|
from wf_sources_mcp.auth import AuthRecord, mcp_auth_from_neutral, neutral_auth_from_mcp
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from wf_mcp.catalog.models import CatalogSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
class AuthStore:
|
||||||
|
def save_auth(self, record: AuthRecord) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def load_auth(self, connection_id: str) -> AuthRecord | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def list_auth_refs(self) -> list[str]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def save_auth_record(self, record: NeutralAuthRecord) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def load_auth_record(self, auth_ref: str) -> NeutralAuthRecord | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def delete_auth(self, connection_id: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def delete_auth_record(self, auth_ref: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogStore:
|
||||||
|
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def load_catalog(self, connection_id: str) -> CatalogSnapshot | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class Store(AuthStore, CatalogStore):
|
||||||
|
"""Compatibility store combining MCP auth and catalog/cache storage."""
|
||||||
|
|
||||||
|
|
||||||
|
class FileAuthStore(AuthStore):
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = root
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.auth_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_dir(self) -> Path:
|
||||||
|
return self.root / "auth"
|
||||||
|
|
||||||
|
def _auth_path(self, auth_ref: str) -> Path:
|
||||||
|
validate_auth_id(auth_ref)
|
||||||
|
root = self.auth_dir.resolve()
|
||||||
|
path = (self.auth_dir / f"{auth_ref}.json").resolve()
|
||||||
|
if path.parent != root:
|
||||||
|
raise ValueError(f"auth ref escapes store directory: {auth_ref!r}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def save_auth(self, record: AuthRecord) -> None:
|
||||||
|
self._auth_path(record.connection_id).write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"connection_id": record.connection_id,
|
||||||
|
"scheme": record.scheme,
|
||||||
|
"payload": record.payload,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_auth(self, connection_id: str) -> AuthRecord | None:
|
||||||
|
path = self._auth_path(connection_id)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return AuthRecord(**data)
|
||||||
|
|
||||||
|
def list_auth_refs(self) -> list[str]:
|
||||||
|
return sorted(path.stem for path in self.auth_dir.glob("*.json"))
|
||||||
|
|
||||||
|
def save_auth_record(self, record: NeutralAuthRecord) -> None:
|
||||||
|
self.save_auth(mcp_auth_from_neutral(record))
|
||||||
|
|
||||||
|
def load_auth_record(self, auth_ref: str) -> NeutralAuthRecord | None:
|
||||||
|
record = self.load_auth(auth_ref)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
return neutral_auth_from_mcp(record)
|
||||||
|
|
||||||
|
def delete_auth(self, connection_id: str) -> bool:
|
||||||
|
path = self._auth_path(connection_id)
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_auth_record(self, auth_ref: str) -> bool:
|
||||||
|
return self.delete_auth(auth_ref)
|
||||||
|
|
||||||
|
|
||||||
|
class FileCatalogStore(CatalogStore):
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = root
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.catalog_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def catalog_dir(self) -> Path:
|
||||||
|
return self.root / "catalog"
|
||||||
|
|
||||||
|
def _catalog_path(self, connection_id: str) -> Path:
|
||||||
|
return self._connection_path(self.catalog_dir, connection_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _connection_path(directory: Path, connection_id: str) -> Path:
|
||||||
|
from wf_mcp.connections import parse_connection_id
|
||||||
|
|
||||||
|
parse_connection_id(connection_id)
|
||||||
|
root = directory.resolve()
|
||||||
|
path = (directory / f"{connection_id}.json").resolve()
|
||||||
|
if path.parent != root:
|
||||||
|
raise ValueError(
|
||||||
|
f"connection id escapes store directory: {connection_id!r}"
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
|
||||||
|
from wf_mcp.catalog.models import dump_catalog_snapshot
|
||||||
|
|
||||||
|
self._catalog_path(snapshot.connection_id).write_text(
|
||||||
|
json.dumps(dump_catalog_snapshot(snapshot), indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_catalog(self, connection_id: str) -> CatalogSnapshot | None:
|
||||||
|
from wf_mcp.capabilities import (
|
||||||
|
CatalogNodeEntry,
|
||||||
|
CatalogPromptEntry,
|
||||||
|
CatalogResourceEntry,
|
||||||
|
)
|
||||||
|
from wf_mcp.catalog.models import CatalogSnapshot as CatalogSnapshotType
|
||||||
|
|
||||||
|
path = self._catalog_path(connection_id)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return CatalogSnapshotType(
|
||||||
|
connection_id=data["connection_id"],
|
||||||
|
fetched_at_epoch_ms=data["fetched_at_epoch_ms"],
|
||||||
|
max_age_seconds=data["max_age_seconds"],
|
||||||
|
nodes=[CatalogNodeEntry(**node) for node in data.get("nodes", [])],
|
||||||
|
resources=[
|
||||||
|
CatalogResourceEntry(**resource)
|
||||||
|
for resource in data.get("resources", [])
|
||||||
|
],
|
||||||
|
prompts=[
|
||||||
|
CatalogPromptEntry(**prompt) for prompt in data.get("prompts", [])
|
||||||
|
],
|
||||||
|
metadata=data.get("metadata", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FileStore(Store):
|
||||||
|
"""Compatibility file store that combines auth and catalog stores."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = root
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._auth = FileAuthStore(root)
|
||||||
|
self._catalog = FileCatalogStore(root)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_dir(self) -> Path:
|
||||||
|
return self._auth.auth_dir
|
||||||
|
|
||||||
|
@property
|
||||||
|
def catalog_dir(self) -> Path:
|
||||||
|
return self._catalog.catalog_dir
|
||||||
|
|
||||||
|
def save_auth(self, record: AuthRecord) -> None:
|
||||||
|
self._auth.save_auth(record)
|
||||||
|
|
||||||
|
def load_auth(self, connection_id: str) -> AuthRecord | None:
|
||||||
|
return self._auth.load_auth(connection_id)
|
||||||
|
|
||||||
|
def list_auth_refs(self) -> list[str]:
|
||||||
|
return self._auth.list_auth_refs()
|
||||||
|
|
||||||
|
def save_auth_record(self, record: NeutralAuthRecord) -> None:
|
||||||
|
self._auth.save_auth_record(record)
|
||||||
|
|
||||||
|
def load_auth_record(self, auth_ref: str) -> NeutralAuthRecord | None:
|
||||||
|
return self._auth.load_auth_record(auth_ref)
|
||||||
|
|
||||||
|
def delete_auth(self, connection_id: str) -> bool:
|
||||||
|
return self._auth.delete_auth(connection_id)
|
||||||
|
|
||||||
|
def delete_auth_record(self, auth_ref: str) -> bool:
|
||||||
|
return self._auth.delete_auth_record(auth_ref)
|
||||||
|
|
||||||
|
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
|
||||||
|
self._catalog.save_catalog(snapshot)
|
||||||
|
|
||||||
|
def load_catalog(self, connection_id: str) -> CatalogSnapshot | None:
|
||||||
|
return self._catalog.load_catalog(connection_id)
|
||||||
@@ -51,3 +51,21 @@ def test_models_module_reexports_canonical_model_owners() -> None:
|
|||||||
assert CompatBrokerConfig is BrokerConfig
|
assert CompatBrokerConfig is BrokerConfig
|
||||||
assert CompatCatalogSnapshot is CatalogSnapshot
|
assert CompatCatalogSnapshot is CatalogSnapshot
|
||||||
assert CompatConnectionConfig is ConnectionConfig
|
assert CompatConnectionConfig is ConnectionConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_mcp_auth_shim_reexports_wf_sources_mcp_auth() -> None:
|
||||||
|
from wf_mcp.auth import AuthRecord as CompatAuthRecord
|
||||||
|
from wf_sources_mcp.auth import AuthRecord
|
||||||
|
|
||||||
|
assert CompatAuthRecord is AuthRecord
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_mcp_storage_shim_reexports_wf_sources_mcp_storage() -> None:
|
||||||
|
from wf_mcp.storage import FileAuthStore as CompatFileAuthStore
|
||||||
|
from wf_mcp.storage import FileCatalogStore as CompatFileCatalogStore
|
||||||
|
from wf_mcp.storage import FileStore as CompatFileStore
|
||||||
|
from wf_sources_mcp.storage import FileAuthStore, FileCatalogStore, FileStore
|
||||||
|
|
||||||
|
assert CompatFileAuthStore is FileAuthStore
|
||||||
|
assert CompatFileCatalogStore is FileCatalogStore
|
||||||
|
assert CompatFileStore is FileStore
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from wf_api.auth import AuthRecord as NeutralAuthRecord
|
||||||
|
from wf_mcp.catalog.models import CatalogSnapshot
|
||||||
|
from wf_sources_mcp.auth import (
|
||||||
|
AuthRecord,
|
||||||
|
mcp_auth_env,
|
||||||
|
mcp_auth_from_neutral,
|
||||||
|
mcp_auth_headers,
|
||||||
|
neutral_auth_from_mcp,
|
||||||
|
)
|
||||||
|
from wf_sources_mcp.storage import FileAuthStore, FileCatalogStore, FileStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_sources_mcp_auth_round_trips_neutral_record() -> None:
|
||||||
|
neutral = NeutralAuthRecord(
|
||||||
|
id="github.work",
|
||||||
|
scheme="bearer",
|
||||||
|
payload={"token": "secret", "env": {"GITHUB_TOKEN": "secret"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
mcp = mcp_auth_from_neutral(neutral)
|
||||||
|
round_trip = neutral_auth_from_mcp(mcp)
|
||||||
|
|
||||||
|
assert isinstance(mcp, AuthRecord)
|
||||||
|
assert mcp.connection_id == "github.work"
|
||||||
|
assert round_trip.id == "github.work"
|
||||||
|
assert round_trip.scheme == "bearer"
|
||||||
|
assert round_trip.payload["token"] == "secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_sources_mcp_auth_adapters_interpret_mcp_payload() -> None:
|
||||||
|
auth = AuthRecord(
|
||||||
|
connection_id="github.work",
|
||||||
|
scheme="bearer",
|
||||||
|
payload={
|
||||||
|
"token": "secret",
|
||||||
|
"headers": {"X-Test": "yes"},
|
||||||
|
"env": {"GITHUB_TOKEN": "secret"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mcp_auth_headers(auth) == {
|
||||||
|
"X-Test": "yes",
|
||||||
|
"Authorization": "Bearer secret",
|
||||||
|
}
|
||||||
|
assert mcp_auth_env(auth) == {"GITHUB_TOKEN": "secret"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_sources_mcp_file_stores_keep_existing_disk_shape(tmp_path) -> None:
|
||||||
|
auth_store = FileAuthStore(tmp_path / "auth-root")
|
||||||
|
catalog_store = FileCatalogStore(tmp_path / "catalog-root")
|
||||||
|
combined_store = FileStore(tmp_path / "combined-root")
|
||||||
|
auth = AuthRecord(connection_id="demo.personal", scheme="bearer")
|
||||||
|
snapshot = CatalogSnapshot(
|
||||||
|
connection_id="demo.personal",
|
||||||
|
fetched_at_epoch_ms=1,
|
||||||
|
max_age_seconds=300,
|
||||||
|
nodes=[],
|
||||||
|
resources=[],
|
||||||
|
prompts=[],
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_store.save_auth(auth)
|
||||||
|
catalog_store.save_catalog(snapshot)
|
||||||
|
combined_store.save_auth(auth)
|
||||||
|
combined_store.save_catalog(snapshot)
|
||||||
|
|
||||||
|
assert (tmp_path / "auth-root" / "auth" / "demo.personal.json").exists()
|
||||||
|
assert (tmp_path / "catalog-root" / "catalog" / "demo.personal.json").exists()
|
||||||
|
assert (tmp_path / "combined-root" / "auth" / "demo.personal.json").exists()
|
||||||
|
assert (tmp_path / "combined-root" / "catalog" / "demo.personal.json").exists()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FORBIDDEN_WF_MCP_PREFIXES = (
|
||||||
|
"wf_mcp.admin_surface",
|
||||||
|
"wf_mcp.workflow_surface",
|
||||||
|
"wf_mcp.server",
|
||||||
|
"wf_mcp.proxy",
|
||||||
|
"wf_mcp.cli",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_sources_mcp_does_not_import_frontend_mcp_modules() -> None:
|
||||||
|
root = Path(__file__).resolve().parents[2] / "src" / "wf_sources_mcp"
|
||||||
|
violations: list[str] = []
|
||||||
|
|
||||||
|
for py_file in sorted(root.rglob("*.py")):
|
||||||
|
rel = py_file.relative_to(root.parent)
|
||||||
|
module = str(rel.with_suffix("")).replace("/", ".").replace("\\", ".")
|
||||||
|
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom) and node.module is not None:
|
||||||
|
if node.module.startswith(FORBIDDEN_WF_MCP_PREFIXES):
|
||||||
|
violations.append(
|
||||||
|
f"{module}:{node.lineno}: from {node.module} import ..."
|
||||||
|
)
|
||||||
|
elif isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
if alias.name.startswith(FORBIDDEN_WF_MCP_PREFIXES):
|
||||||
|
violations.append(
|
||||||
|
f"{module}:{node.lineno}: import {alias.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert violations == [], (
|
||||||
|
"wf_sources_mcp imports frontend/proxy MCP modules:\n"
|
||||||
|
+ "\n".join(f" {violation}" for violation in violations)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user