feat: add neutral auth store boundary

This commit is contained in:
lda
2026-06-06 10:18:16 +07:00 Verified
parent 71facde1a2
commit 34726433a8
15 changed files with 456 additions and 41 deletions
+5
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from .auth import AUTH_ID_PATTERN, AuthRecord, AuthStore, validate_auth_id
from .listing import matches_query, paged_list_payload
from .admin import (
WorkflowAdminApi,
@@ -66,7 +67,10 @@ from .stores import WorkflowStores, file_workflow_stores
from .durable_context import durable_workflow_api, require_workflow_stores
__all__ = [
"AUTH_ID_PATTERN",
"DEFAULT_CALL_STEP_ID",
"AuthRecord",
"AuthStore",
"builtin_sources",
"get_qualified_spec",
"matches_query",
@@ -119,6 +123,7 @@ __all__ = [
"WrapperHintConfidence",
"WrapperOutcomePolicy",
"parse_workflow_surface_capability_id",
"validate_auth_id",
"workflow_output_schema_for_authoring",
"wrapper_hints_for_capability",
"resolve_runtime_dependencies",
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Protocol
AUTH_ID_PATTERN = r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$"
def validate_auth_id(value: str) -> str:
"""Validate auth refs that are safe as store keys and path segments.
Auth refs deliberately carry no provider semantics. Source providers decide
how a resolved auth record is interpreted.
"""
if not re.fullmatch(AUTH_ID_PATTERN, value):
raise ValueError(
"auth id must start with alphanumeric or underscore and contain "
"only [A-Za-z0-9_.-]"
)
return value
@dataclass(frozen=True, slots=True)
class AuthRecord:
"""Neutral credential record resolved by auth ref.
`scheme + payload` is a compatibility bridge, not the long-term taxonomy.
Keep payload interpretation inside provider adapters so a future
discriminated union can replace this without touching workflow/config code.
"""
id: str
scheme: str
payload: Mapping[str, object]
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
validate_auth_id(self.id)
if not self.scheme:
raise ValueError("auth scheme must be non-empty")
class AuthStore(Protocol):
"""Read-only runtime credential lookup by auth ref."""
def load_auth(self, auth_ref: str) -> AuthRecord | None: ...
__all__ = [
"AUTH_ID_PATTERN",
"AuthRecord",
"AuthStore",
"validate_auth_id",
]
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
from wf_api.auth import AuthRecord as NeutralAuthRecord
from .models import AuthRecord as McpAuthRecord
def mcp_auth_from_neutral(record: NeutralAuthRecord) -> McpAuthRecord:
"""Adapt neutral auth to the current MCP compatibility record."""
return McpAuthRecord(
connection_id=record.id,
scheme=record.scheme,
payload=dict(record.payload),
)
def neutral_auth_from_mcp(record: McpAuthRecord) -> 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: McpAuthRecord | 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: McpAuthRecord | 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)
}
__all__ = [
"mcp_auth_env",
"mcp_auth_from_neutral",
"mcp_auth_headers",
"neutral_auth_from_mcp",
]
+1 -1
View File
@@ -91,7 +91,7 @@ class WfMcpService:
connection_list_enabled=self.connection_service.list_enabled,
connection_list_all=self.connection_service.list_all,
tool_executor_for=self.upstream.tool_executor_for,
load_auth=self.upstream.load_auth,
load_auth=self.upstream.load_connection_auth,
emit_event=self.events.record_event,
default_catalog_max_age_seconds=self.default_catalog_max_age_seconds,
)
+2 -2
View File
@@ -36,7 +36,7 @@ from .specs import get_qualified_spec, qualify_spec
ConnectionLookup = Callable[[str], ConnectionConfig]
ConnectionList = Callable[[], list[ConnectionConfig]]
ToolExecutorLookup = Callable[[ConnectionConfig], ToolExecutor]
AuthLoader = Callable[[str], AuthRecord | None]
AuthLoader = Callable[[ConnectionConfig], AuthRecord | None]
EventEmitter = Callable[[McpEvent], None]
@@ -270,7 +270,7 @@ class SourceCatalogService:
async def invoke_tool(payload: BaseModel) -> NodeReturn[BaseModel]:
connection = self.connection_lookup(entry.connection_id)
auth = self.load_auth(entry.connection_id)
auth = self.load_auth(connection)
result = await self.tool_executor_for(connection).call_tool(
connection,
auth,
@@ -63,6 +63,19 @@ class UpstreamTransportService:
def load_auth(self, connection_id: str) -> AuthRecord | None:
return self.store.load_auth(connection_id)
def load_connection_auth(self, connection: ConnectionConfig) -> AuthRecord | None:
"""Resolve auth for a connection, preferring explicit source auth_ref.
Legacy MCP auth records are keyed by connection id. New source registry
and neutral config entries carry `auth_ref`; keep both paths until the
old compatibility surface has no callers.
"""
auth_ref = connection.metadata.get("auth_ref")
if isinstance(auth_ref, str):
return self.load_auth(auth_ref)
return self.load_auth(connection.id)
def tool_executor_for(self, connection: ConnectionConfig) -> ToolExecutor:
"""Return the executor used by generated workflow NodeSpecs.
@@ -81,7 +94,7 @@ class UpstreamTransportService:
uri: str,
) -> dict[str, Any]:
adapter = require_adapter(connection, self.adapters)
auth = self.load_auth(connection.id)
auth = self.load_connection_auth(connection)
self.event_sink(
make_event(
"resource_read_started",
@@ -109,7 +122,7 @@ class UpstreamTransportService:
arguments: dict[str, str] | None = None,
) -> dict[str, Any]:
adapter = require_adapter(connection, self.adapters)
auth = self.load_auth(connection.id)
auth = self.load_connection_auth(connection)
self.event_sink(
make_event(
"prompt_get_started",
@@ -137,7 +150,7 @@ class UpstreamTransportService:
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
adapter = require_adapter(connection, self.adapters)
auth = self.load_auth(connection.id)
auth = self.load_connection_auth(connection)
self.event_sink(
make_event(
"raw_method_started",
@@ -165,7 +178,7 @@ class UpstreamTransportService:
params: dict[str, Any] | None = None,
) -> None:
adapter = require_adapter(connection, self.adapters)
auth = self.load_auth(connection.id)
auth = self.load_connection_auth(connection)
self.event_sink(
make_event(
"raw_notification_started",
@@ -193,7 +206,7 @@ class UpstreamTransportService:
default_catalog_max_age_seconds: int = 300,
record_catalog_change_events: Callable[[str, CatalogSnapshot, str], None],
) -> None:
auth = self.load_auth(connection.id)
auth = self.load_connection_auth(connection)
self.event_sink(
make_event(
"catalog_refresh_started",
@@ -295,7 +308,7 @@ class UpstreamTransportService:
continue
try:
adapter = require_adapter(connection, self.adapters)
auth = self.load_auth(source_id)
auth = self.load_connection_auth(connection)
await asyncio.wait_for(
adapter.list_tools(connection, auth),
timeout=LIVE_SOURCE_CHECK_TIMEOUT_SECONDS,
+5 -15
View File
@@ -10,20 +10,11 @@ from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client
from mcp.types import CallToolResult
from ..auth import mcp_auth_env, mcp_auth_headers
from ..models import AuthRecord, ConnectionConfig
from .session import PersistentMcpSession
def _auth_headers(auth: AuthRecord | None) -> dict[str, str]:
if auth is None:
return {}
headers = dict(auth.payload.get("headers", {}))
token = auth.payload.get("token")
if isinstance(token, str) and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {token}"
return headers
@dataclass(slots=True)
class PersistentSessionFactory:
"""Create initialized persistent MCP sessions for configured connections.
@@ -57,10 +48,9 @@ class PersistentSessionFactory:
transport = connection.metadata.get("transport", "stdio")
if transport == "stdio":
env = connection.metadata.get("env")
if auth is not None:
auth_env = auth.payload.get("env")
if isinstance(auth_env, dict):
env = {**(env or {}), **auth_env}
auth_env = mcp_auth_env(auth)
if auth_env:
env = {**(env or {}), **auth_env}
params = StdioServerParameters(
command=connection.metadata["command"],
args=list(connection.metadata.get("args", [])),
@@ -78,7 +68,7 @@ class PersistentSessionFactory:
if transport == "streamable_http":
http_client = await stack.enter_async_context(
httpx.AsyncClient(headers=_auth_headers(auth) or None)
httpx.AsyncClient(headers=mcp_auth_headers(auth) or None)
)
(
read_stream,
+5 -15
View File
@@ -17,6 +17,7 @@ from mcp.types import (
)
from pydantic import AnyUrl
from ..auth import mcp_auth_env, mcp_auth_headers
from ..capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from ..models import AuthRecord, ConnectionConfig
from .base import BackendAdapter, ToolCallResult
@@ -28,16 +29,6 @@ from .converters import (
)
def _auth_headers(auth: AuthRecord | None) -> dict[str, str]:
if auth is None:
return {}
headers = dict(auth.payload.get("headers", {}))
token = auth.payload.get("token")
if isinstance(token, str) and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {token}"
return headers
class McpSdkAdapter(BackendAdapter):
@asynccontextmanager
async def _session(
@@ -51,10 +42,9 @@ class McpSdkAdapter(BackendAdapter):
args = list(connection.metadata.get("args", []))
env = connection.metadata.get("env")
cwd = connection.metadata.get("cwd")
if auth is not None:
auth_env = auth.payload.get("env")
if isinstance(auth_env, dict):
env = {**(env or {}), **auth_env}
auth_env = mcp_auth_env(auth)
if auth_env:
env = {**(env or {}), **auth_env}
params = StdioServerParameters(
command=command,
args=args,
@@ -69,7 +59,7 @@ class McpSdkAdapter(BackendAdapter):
if transport == "streamable_http":
url = connection.metadata["url"]
headers = _auth_headers(auth)
headers = mcp_auth_headers(auth)
http_client = httpx.AsyncClient(headers=headers or None)
async with http_client:
async with streamable_http_client(
+22
View File
@@ -3,6 +3,9 @@ from __future__ import annotations
import json
from pathlib import Path
from wf_api.auth import AuthRecord as NeutralAuthRecord
from ..auth import mcp_auth_from_neutral, neutral_auth_from_mcp
from ..connections import parse_connection_id
from ..models import (
AuthRecord,
@@ -21,6 +24,12 @@ class Store:
def load_auth(self, connection_id: str) -> AuthRecord | None:
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 save_catalog(self, snapshot: CatalogSnapshot) -> None:
raise NotImplementedError
@@ -81,6 +90,19 @@ class FileStore(Store):
data = json.loads(path.read_text(encoding="utf-8"))
return AuthRecord(**data)
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 save_catalog(self, snapshot: CatalogSnapshot) -> None:
self._catalog_path(snapshot.connection_id).write_text(
json.dumps(dump_catalog_snapshot(snapshot), indent=2),