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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 15 additions & 30 deletions plan/agent-card-rfc.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ A loader validates fields based on `type` and loads a single file or a directory
optional/experimental and described in a separate spec.
AgentCards now support an optional `description` field used for tool descriptions when
agents are exposed as tools (MCP or agent-as-tool wiring).
AgentCards may enable local shell execution via `shell: true` with optional `cwd`.
CLI `--card-tool` loads AgentCards and exposes them as tools on the default agent.
CLI runs also auto-load cards from `.fast-agent/agent-cards/` (agents) and
`.fast-agent/tool-cards/` (tool cards) when those directories exist and contain
supported card files.
Expand Down Expand Up @@ -109,9 +111,10 @@ Allowed fields:
- `agents` (agents-as-tools)
- `servers`, `tools`, `resources`, `prompts`, `skills`
- `model`, `use_history`, `request_params`, `human_input`, `api_key`
- `history_source`, `history_merge_target`
- `history_mode`
- `max_parallel`, `child_timeout_sec`, `max_display_instances`
- `function_tools`, `tool_hooks` (see separate spec)
- `shell`, `cwd`
- `messages` (card-only history file)

### type: `chain` (maps to `@fast.chain`)
Expand Down Expand Up @@ -209,6 +212,7 @@ scope.
### Fields (AgentCard)
These fields are set on the **orchestrator** (parent) AgentCard because it
controls child invocation and the initial context passed to child agents.
They are **proposed** and not yet part of the validator or loader.

- `history_source`: `none` | `messages` | `child` | `orchestrator` | `cumulative` *)
- `history_merge_target`: `none` | `messages` **) | `child` | `orchestrator` | `cumulative` *)
Expand Down Expand Up @@ -371,8 +375,10 @@ You are a concise analyst.

### Runtime tool injection (optional)
- `/card --tool` exposes the loaded agent as a tool on the **current** agent.
- CLI: `fast-agent go --card-tool <path>` loads cards and exposes them as tools on the default agent.
- Tool names default to `agent__{name}`.
- Tool descriptions prefer `description`; fall back to the agent instruction.
- Tool calls use a single `message` argument.
- Default behavior is **stateless**: fresh clone per call with no history load or merge
(`history_source=none`, `history_merge_target=none`).

Expand Down Expand Up @@ -508,10 +514,6 @@ See [plan/agent-card-rfc-sample.md](plan/agent-card-rfc-sample.md).
set includes it. This creates a self-referential tool and can recurse if the
model calls it. Filter out the current agent and dedupe tool names.
(`src/fast_agent/ui/interactive_prompt.py`, `src/fast_agent/acp/slash_commands.py`)
- `add_agent_tool` forwards to the **live child instance** (`child.send`) rather
than a detached clone. This diverges from Agents-as-Tools isolation semantics
and can leak history/usage across parallel calls.
(`src/fast_agent/agents/tool_agent.py`)
- AgentCard `type` currently defaults to `agent` when missing. If strict validation
is expected, this should be an error (otherwise unrelated frontmatter files are
accepted silently).
Expand All @@ -521,31 +523,14 @@ See [plan/agent-card-rfc-sample.md](plan/agent-card-rfc-sample.md).
(`src/fast_agent/ui/interactive_prompt.py`, `src/fast_agent/acp/slash_commands.py`)

## Appendix: Code Review Fix Plan
General plan: extract the child-tool execution helpers from `AgentsAsToolsAgent`
and reuse them in the `/card --tool` flow so injected agent tools behave the same
as agents-as-tools (detached clones, optional history merge, usage rollup).

Proposed steps:
1) **Extract shared helpers** from `AgentsAsToolsAgent` into a small module, e.g.
`fast_agent/agents/agent_tool_helpers.py`:
- `serialize_tool_args(args) -> str`
- `spawn_child_clone(child, instance_name, history_source)`
- `invoke_child_tool(clone, args, suppress_display)`
- `merge_child_usage_and_history(child, clone, merge_target)`
2) **Refactor `AgentsAsToolsAgent`** to call these helpers without changing behavior.
This keeps parity with current features (history modes, progress, usage merge).
3) **Update `/card --tool` path** (TUI + ACP):
- Filter out the current agent from `loaded_names` to avoid self-tools.
- Use the shared helpers to create a tool wrapper that spawns detached clones
per call (not the live child instance).
- Deduplicate tools by name and surface a warning if a tool already exists.
4) **Add tests**:
- `/card --tool` does not inject self.
- Injected tools use detached clones (no shared history).
- History merge behavior respects `history_source` and `history_merge_target`.
5) **ACP coverage**:
- Ensure `/card` updates available commands and keeps session modes consistent.
- Validate tool injection works in ACP and TUI with identical behavior.
Current status:
- Tool injection uses detached clones (ToolAgent parity).
- `/card --tool` and `--card-tool` share tool-registration wiring.

Remaining work:
1) Filter out the current agent from `/card --tool` to avoid self-tools.
2) Deduplicate tool names on injection and surface a warning if a tool already exists.
3) Align `/card --tool` with Agents-as-Tools helpers if advanced history merge modes are implemented.

## Appendix: Next-stage Work Items
- **Cumulative session history**: no shared, merged transcript exists today; requires
Expand Down
5 changes: 5 additions & 0 deletions src/fast_agent/cli/commands/go.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ async def _run_agent(
watch: bool = False,
) -> None:
"""Async implementation to run an interactive agent."""
if mode == "serve" and transport in ["stdio", "acp"]:
from fast_agent.ui.console import configure_console_stream

configure_console_stream("stderr")

from fast_agent import FastAgent
from fast_agent.agents.llm_agent import LlmAgent
from fast_agent.mcp.prompts.prompt_load import load_prompt
Expand Down
1 change: 1 addition & 0 deletions src/fast_agent/core/fastagent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,7 @@ async def load_card_source(source: str) -> list[str]:
server_name=server_name or f"{self.name}-MCP-Server",
server_description=server_description,
tool_description=tool_description,
host=self.args.host,
get_registry_version=self._get_registry_version,
)

Expand Down
13 changes: 12 additions & 1 deletion src/fast_agent/llm/provider_key_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,22 @@ def get_api_key(provider_name: str, config: Any) -> str:
if provider_name == "fast-agent":
return ""

# Check for request-scoped token first (token passthrough from MCP server)
# This allows clients to pass their own HF token via Authorization header
if provider_name in {"hf", "huggingface"}:
from fast_agent.mcp.auth.context import request_bearer_token

ctx_token = request_bearer_token.get()
if ctx_token:
return ctx_token

# Google Vertex AI uses ADC/IAM and does not require an API key.
if provider_name == "google":
try:
cfg = config.model_dump() if isinstance(config, BaseModel) else config
if isinstance(cfg, dict) and bool((cfg.get("google") or {}).get("vertex_ai", {}).get("enabled")):
if isinstance(cfg, dict) and bool(
(cfg.get("google") or {}).get("vertex_ai", {}).get("enabled")
):
return ""
except Exception:
pass
Expand Down
7 changes: 7 additions & 0 deletions src/fast_agent/mcp/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Authentication modules for MCP server."""

from fast_agent.mcp.auth.context import request_bearer_token
from fast_agent.mcp.auth.middleware import HFAuthHeaderMiddleware
from fast_agent.mcp.auth.presence import PresenceTokenVerifier

__all__ = ["HFAuthHeaderMiddleware", "PresenceTokenVerifier", "request_bearer_token"]
8 changes: 8 additions & 0 deletions src/fast_agent/mcp/auth/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Context variables for request-scoped authentication."""

from contextvars import ContextVar

# Stores the bearer token for the current request.
# Used to pass through to LLM providers (e.g., HuggingFace).
# Each async task has its own isolated copy of this variable.
request_bearer_token: ContextVar[str | None] = ContextVar("request_bearer_token", default=None)
37 changes: 37 additions & 0 deletions src/fast_agent/mcp/auth/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Middleware for handling HuggingFace-specific authentication headers."""

from starlette.types import ASGIApp, Receive, Scope, Send


class HFAuthHeaderMiddleware:
"""
Middleware that copies X-HF-Authorization to Authorization header.

HuggingFace Spaces use X-HF-Authorization for authentication, but
FastMCP's BearerAuthBackend only checks the standard Authorization header.
This middleware normalizes the headers so both work.
"""

def __init__(self, app: ASGIApp):
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return

headers = scope.get("headers", [])

# Check if Authorization header already exists
has_auth = any(k.lower() == b"authorization" for k, _ in headers)

# If no Authorization but X-HF-Authorization exists, copy it
if not has_auth:
for key, value in headers:
if key.lower() == b"x-hf-authorization":
# Add as Authorization header
new_headers = list(headers) + [(b"authorization", value)]
scope = dict(scope, headers=new_headers)
break

await self.app(scope, receive, send)
42 changes: 42 additions & 0 deletions src/fast_agent/mcp/auth/presence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Presence-only token verifier for MCP server authentication."""

from mcp.server.auth.provider import AccessToken, TokenVerifier


class PresenceTokenVerifier(TokenVerifier):
"""
Simple token verifier that only checks token presence.

Does not validate the token against any external service - downstream
services (e.g., HuggingFace inference API) handle actual validation.
"""

def __init__(self, provider: str = "generic", scopes: list[str] | None = None):
"""
Initialize the presence token verifier.

Args:
provider: Name of the OAuth provider (for logging/debugging).
scopes: List of scopes to assign to valid tokens. Defaults to ["access"].
"""
self.provider = provider
self.scopes = scopes or ["access"]

async def verify_token(self, token: str) -> AccessToken | None:
"""
Verify that a token is present (non-empty).

Args:
token: The bearer token to verify.

Returns:
AccessToken if token is present, None otherwise.
"""
if not token or not token.strip():
return None

return AccessToken(
token=token,
client_id="bearer-client",
scopes=self.scopes,
)
Loading
Loading