Skip to content
Draft
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
6 changes: 5 additions & 1 deletion .ai/spec/what/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Cross-references: how options are consumed in code → `how/provider-architectur
| `LIGHTSPEED_PROVIDER_PROJECT` | When provider=`vertex` | Cloud project ID |
| `LIGHTSPEED_PROVIDER_REGION` | When provider=`vertex` or `bedrock` | Cloud region |
| `LIGHTSPEED_PROVIDER_API_VERSION` | When provider=`azure` | API version |
| `LIGHTSPEED_MCP_SERVERS` | No | JSON array of MCP server configs: `[{"name":"…","url":"…","headers":{}}]` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fix MCP headers contract to include list-format entries.

The spec currently documents headers as object-only, but the implementation and tests support both object and list formats ([{ "name": "...", "value" | "source": ... }]). This mismatch can break operator-side integration expectations.

Suggested spec wording update
-| `LIGHTSPEED_MCP_SERVERS` | No | JSON array of MCP server configs: `[{"name":"…","url":"…","headers":{}}]` |
+| `LIGHTSPEED_MCP_SERVERS` | No | JSON array of MCP server configs: `[{"name":"…","url":"…","headers":{}}]` or `[{"name":"…","url":"…","headers":[{"name":"h","value":"v"}]}]` |

-19. **MCP server configuration.** ... Each entry MUST have `name` (string) and `url` (string); `headers` (object) is optional.
+19. **MCP server configuration.** ... Each entry MUST have `name` (string) and `url` (string); `headers` is optional and may be an object (`{"h":"v"}`) or an array of objects (`[{"name":"h","value":"v"}]` or `[{"name":"h","source":"ServiceAccountToken"}]`).

-| `LIGHTSPEED_MCP_SERVERS` | Optional JSON array of MCP server endpoint configs (see rule 19). |
+| `LIGHTSPEED_MCP_SERVERS` | Optional JSON array of MCP endpoint configs; `headers` supports object and list-entry formats (see rule 19). |

Also applies to: 70-70, 87-87

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.ai/spec/what/configuration.md at line 20, The `headers` field in the
LIGHTSPEED_MCP_SERVERS configuration documentation only describes the object
format but the implementation supports both object and list formats. Update the
documentation for the `headers` parameter at line 20 to include both supported
formats: the object format (existing documentation) and the list format with
entries containing "name" and either "value" or "source" fields. Also apply the
same fix to the additional occurrences of this documentation at lines 70 and 87
to ensure consistency across the specification.


Credentials are mounted via `envFrom` (all secret keys as env vars) AND as files at `/var/run/secrets/llm-credentials/`.

Expand Down Expand Up @@ -66,7 +67,9 @@ Cross-references: how options are consumed in code → `how/provider-architectur

18. **Non-hermetic fallback.** When prefetch directories are absent, the container build recipe may fetch selected binaries from external URLs for developer builds.

19. **System packages — minimum expectations.** Runtime image includes Bash, Git, OpenShift CLI (`oc`), Kubernetes CLI (`kubectl`), ripgrep, Node.js (Claude Code CLI), and supporting OS utilities for debugging and archives per the container recipe.
19. **MCP server configuration.** `LIGHTSPEED_MCP_SERVERS` is an optional JSON-encoded array of MCP server endpoint configs. Each entry MUST have `name` (string) and `url` (string); `headers` (object) is optional. When set, `resolve_mcp_servers()` parses and validates the entries at startup. The resulting `McpServerConfig` tuple is carried on `ResolvedSDK.mcp_servers` and passed through to `ProviderQueryOptions.mcp_servers`. Each provider maps these to its SDK's native MCP integration (Claude: `ClaudeAgentOptions.mcp_servers` dict; Gemini: `McpToolset` with `StreamableHTTPConnectionParams`; OpenAI: `MCPServerStreamableHttp` instances). Only streamable HTTP transport is supported. Health probes (R3) check reachability of each configured MCP endpoint.

20. **System packages — minimum expectations.** Runtime image includes Bash, Git, OpenShift CLI (`oc`), Kubernetes CLI (`kubectl`), ripgrep, Node.js (Claude Code CLI), and supporting OS utilities for debugging and archives per the container recipe.

## Configuration Surface

Expand All @@ -81,6 +84,7 @@ Cross-references: how options are consumed in code → `how/provider-architectur
| `LIGHTSPEED_PROVIDER_API_VERSION` | API version from operator (see rule 1). |
| `ANTHROPIC_MODEL`, `GEMINI_MODEL`, `OPENAI_MODEL` | Internal: SDK-specific model vars. Set by configuration mapping (rule 2), not operator. |
| `LIGHTSPEED_SKILLS_DIR` | Skill root and provider working directory default. |
| `LIGHTSPEED_MCP_SERVERS` | Optional JSON array of MCP server endpoint configs (see rule 19). |
| `ANTHROPIC_API_KEY` | Claude SDK credential (from credentials secret envFrom). |
| `GOOGLE_API_KEY`, `GEMINI_API_KEY` | Google GenAI credential (from credentials secret envFrom). |
| `OPENAI_API_KEY` | OpenAI SDK credential (from credentials secret envFrom). |
Expand Down
4 changes: 2 additions & 2 deletions .ai/spec/what/health-probes.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Returns HTTP 200 when all checks pass, HTTP 503 when any check fails. Not under
| `gemini` | `https://generativelanguage.googleapis.com/` |
| `openai` | `OPENAI_BASE_URL` or `https://api.openai.com/` |

**R3 — MCP server reachability.** Same pattern as R2, for each configured MCP endpoint. [PLANNED: when MCP support lands]
**R3 — MCP server reachability.** Same pattern as R2, for each configured MCP endpoint (from `ResolvedSDK.mcp_servers`). Each MCP server URL is probed with an unauthenticated HTTP GET (3-second timeout). Checks are keyed as `mcp_{name}` in the readiness response. Skipped when no MCP servers are configured.

## Recommended Probe Config

Expand Down Expand Up @@ -96,4 +96,4 @@ Cross-reference: probes are **not** under `/v1/agent` → `run-api.md` rules 2,
| [test_ready.py](../../../tests/test_ready.py) | R1 (credential env per backend), R2 (endpoint probe semantics), healthy/unhealthy `/ready` responses | Mocks `probe_provider_endpoint` for route tests; does not hit live provider URLs |
| [sandbox_e2e.feature](../../../tests/e2e/features/sandbox_e2e.feature) (Readiness/liveness) | Liveness; readiness when container env satisfies R1 and R2 | E2e covers **happy path** only (200 on `/ready`); negative 503 scenarios stay unit-tested unless spike adds a deliberate misconfigured container |

R3 (MCP reachability) has no tests until MCP support lands.
R3 (MCP reachability) is covered by unit tests in `test_ready.py` (`check_mcp_endpoints`, `run_readiness_checks` with MCP, `/ready` route with failing MCP).
20 changes: 11 additions & 9 deletions .ai/spec/what/provider-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,25 @@ Cross-references: HTTP mapping of prompts and timeouts → `run-api.md`. Env and

16. **ProviderQueryOptions — `stream`.** When true, adapters that support partial streaming should yield deltas; when false, they may batch. The HTTP `POST /run` path does not set this flag from the request body.

17. **Thin-adapter principle.** Providers MUST delegate tool execution, command invocation, and skill discovery to their SDKs. Adapters MUST NOT implement custom tool executors that duplicate SDK behavior except for minimal glue (e.g., auto-confirm, path layout).
17. **ProviderQueryOptions — `mcp_servers`.** Tuple of `McpServerConfig` (name, URL, optional headers) for external MCP server endpoints. When non-empty, each adapter wires MCP tools into its SDK: Claude passes an `mcp_servers` dict on `ClaudeAgentOptions` with `type: "http"` entries and adds `mcp__{name}__*` patterns to `allowed_tools`; Gemini appends `McpToolset` instances with `StreamableHTTPConnectionParams` to the agent tools list; OpenAI creates `MCPServerStreamableHttp` instances, manages their connect/cleanup lifecycle, and passes them to `SandboxAgent`. Only streamable HTTP transport is supported.

18. **Structured output.** When `output_schema` is set: Claude uses the SDK’s JSON-schema output format; Gemini sets native response MIME type and response schema on the content config; OpenAI wraps the schema for the agents SDK output type with strict JSON-schema mode enabled for native OpenAI endpoints (api.openai.com) and disabled for custom endpoints (vLLM etc. via `OPENAI_BASE_URL`). When strict mode is enabled, the schema is transformed to add `additionalProperties: false` and list all properties as required at every object level, as OpenAI’s strict mode requires.
18. **Thin-adapter principle.** Providers MUST delegate tool execution, command invocation, and skill discovery to their SDKs. Adapters MUST NOT implement custom tool executors that duplicate SDK behavior except for minimal glue (e.g., auto-confirm, path layout).

19. **Skills.** Claude discovers skills via SDK skill settings and a writable symlink layout under the effective cwd when the skill root is read-only. Gemini loads a skill toolset from the skill directory listing. OpenAI uses lazy skill loading from a local directory source rooted at `cwd`.
19. **Structured output.** When `output_schema` is set: Claude uses the SDK’s JSON-schema output format; Gemini sets native response MIME type and response schema on the content config; OpenAI wraps the schema for the agents SDK output type with strict JSON-schema mode enabled for native OpenAI endpoints (api.openai.com) and disabled for custom endpoints (vLLM etc. via `OPENAI_BASE_URL`). When strict mode is enabled, the schema is transformed to add `additionalProperties: false` and list all properties as required at every object level, as OpenAI’s strict mode requires.

20. **Default allowed tools list.** Shared default names: `Bash`, `Read`, `Glob`, `Grep`, `Skill`. The HTTP route always passes this list unless a future contract exposes overrides. [PLANNED: OLS-3033]
20. **Skills.** Claude discovers skills via SDK skill settings and a writable symlink layout under the effective cwd when the skill root is read-only. Gemini loads a skill toolset from the skill directory listing. OpenAI uses lazy skill loading from a local directory source rooted at `cwd`.

21. **Event logging.** A phase-tagged logger buffers `thinking_delta` events, flushes when buffer size exceeds an internal threshold or on `content_block_stop` or tool/result events, and logs truncated thinking. Tool calls and results are logged with separate input/output truncation caps. The `result` event logs cost, combined token count, and truncated final text.
21. **Default allowed tools list.** Shared default names: `Bash`, `Read`, `Glob`, `Grep`, `Skill`. The HTTP route always passes this list unless a future contract exposes overrides. [PLANNED: OLS-3033]

22. **Stringifying tool I/O.** Non-string tool arguments and results are JSON-serialized for events when the SDK exposes structured objects.
22. **Event logging.** A phase-tagged logger buffers `thinking_delta` events, flushes when buffer size exceeds an internal threshold or on `content_block_stop` or tool/result events, and logs truncated thinking. Tool calls and results are logged with separate input/output truncation caps. The `result` event logs cost, combined token count, and truncated final text.

23. **Gemini / Vertex.** When Vertex mode is enabled via environment, search-style tools MUST NOT be combined with non-search tools in the same agent tool list; the adapter omits those search tools in that mode.
23. **Stringifying tool I/O.** Non-string tool arguments and results are JSON-serialized for events when the SDK exposes structured objects.

24. **Gemini / exit loop.** When no `output_schema` is set, the adapter registers an SDK exit-loop tool; when `output_schema` is set, that tool is omitted.
24. **Gemini / Vertex.** When Vertex mode is enabled via environment, search-style tools MUST NOT be combined with non-search tools in the same agent tool list; the adapter omits those search tools in that mode.

25. **OpenAI client.** The OpenAI adapter constructs an async OpenAI client with optional base URL override from environment (see `configuration.md`).
25. **Gemini / exit loop.** When no `output_schema` is set, the adapter registers an SDK exit-loop tool; when `output_schema` is set, that tool is omitted.

26. **OpenAI client.** The OpenAI adapter constructs an async OpenAI client with optional base URL override from environment (see `configuration.md`).

## Configuration Surface

Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ src/lightspeed_agentic/
| Skills | Native `skills="all"` | Native `SkillToolset` | Native `Skills` capability |
| Structured output | `output_format` JSON schema | Native response schema path | `output_type` wrapper |
| Streaming | Partial message stream events | `StreamingMode.SSE` | `Runner.run_streamed()` |
| MCP servers | `mcp_servers` dict on `ClaudeAgentOptions` | `McpToolset` with `StreamableHTTPConnectionParams` | `MCPServerStreamableHttp` instances |

Keep provider adapters thin. The SDK should own tool execution and skill
discovery; shared path logic belongs in `tools.py`, not in duplicated provider
Expand Down Expand Up @@ -229,6 +230,7 @@ The Konflux pipeline will prefetch the new versions on the next PR.
| `LIGHTSPEED_PROVIDER_REGION` | Cloud region (Vertex, Bedrock) |
| `LIGHTSPEED_PROVIDER_API_VERSION` | API version (Azure) |
| `LIGHTSPEED_SKILLS_DIR` | Skills root mounted by the FastAPI app, default `/app/skills` |
| `LIGHTSPEED_MCP_SERVERS` | Optional JSON array of MCP server configs: `[{"name":"…","url":"…","headers":{}}]` |
| `ANTHROPIC_MODEL` | Default Claude model for query routes |
| `GEMINI_MODEL` | Default Gemini model for query routes |
| `OPENAI_MODEL` | Default OpenAI model for query routes |
Expand Down
1 change: 1 addition & 0 deletions src/lightspeed_agentic/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
provider,
skills_dir=os.environ.get("LIGHTSPEED_SKILLS_DIR", "/app/skills"),
model=startup_model,
mcp_servers=sdk.mcp_servers,
)
app.include_router(router, prefix="/v1/agent")

Expand Down
96 changes: 95 additions & 1 deletion src/lightspeed_agentic/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@
from __future__ import annotations

import dataclasses
import json
import logging
import os
from typing import Any

logger = logging.getLogger(__name__)

LLM_CREDENTIALS_PATH = "/var/run/secrets/llm-credentials"
_SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token" # noqa: S105


def _llm_credentials_path() -> str:
Expand All @@ -22,6 +25,86 @@ def _llm_credentials_path() -> str:
return override or LLM_CREDENTIALS_PATH


@dataclasses.dataclass(frozen=True)
class McpServerConfig:
"""A single MCP server endpoint configured by the operator."""

name: str
url: str
headers: dict[str, str] = dataclasses.field(default_factory=dict)


def _resolve_header_source(source: str) -> str:
"""Resolve a dynamic header source to its value."""
if source == "ServiceAccountToken":
try:
with open(_SA_TOKEN_PATH) as f:
return f.read().strip()
except FileNotFoundError as err:
raise ValueError(
f"Header source 'ServiceAccountToken' requires {_SA_TOKEN_PATH}"
) from err
raise ValueError(f"Unknown header source: {source!r}")


def _resolve_headers(raw: Any, index: int) -> dict[str, str]:
"""Accept headers as a dict or as the operator's list-of-objects format.

Dict format (static): {"header-name": "value"}
List format (dynamic): [{"name": "header-name", "source": "ServiceAccountToken"}]
"""
if isinstance(raw, dict):
return raw
Comment on lines +56 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Validate header names and values consistently.

The dict path returns raw unchanged, while list-form value is coerced with str(). Non-string header names/values can reach provider HTTP clients despite headers: dict[str, str]; reject them at startup in both formats.

Proposed validation
     if isinstance(raw, dict):
-        return raw
+        resolved: dict[str, str] = {}
+        for hdr_name, hdr_value in raw.items():
+            if not hdr_name or not isinstance(hdr_name, str):
+                raise ValueError(
+                    f"LIGHTSPEED_MCP_SERVERS[{index}].headers has invalid header name"
+                )
+            if not isinstance(hdr_value, str):
+                raise ValueError(
+                    f"LIGHTSPEED_MCP_SERVERS[{index}].headers[{hdr_name!r}] must be a string"
+                )
+            resolved[hdr_name] = hdr_value
+        return resolved
...
             if "value" in item:
-                resolved[hdr_name] = str(item["value"])
+                value = item["value"]
+                if not isinstance(value, str):
+                    raise ValueError(
+                        f"LIGHTSPEED_MCP_SERVERS[{index}].headers[{j}].value must be a string"
+                    )
+                resolved[hdr_name] = value

As per path instructions, “Validate at trust boundaries with allow-lists, not deny-lists.”

Also applies to: 70-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lightspeed_agentic/config.py` around lines 56 - 57, The dict path
(returning raw) and the list-form path (coercing with str()) validate headers
inconsistently, allowing non-string header names or values to pass through
despite the type hint headers: dict[str, str]. Add validation in both paths to
ensure all header names and values are strings before returning, rejecting
non-string values at startup. Apply the same validation logic to both the dict
return statement around line 56-57 and the list-form value coercion around line
70-73 to maintain consistency.

Source: Path instructions

if isinstance(raw, list):
resolved: dict[str, str] = {}
for j, item in enumerate(raw):
if not isinstance(item, dict):
raise ValueError(
f"LIGHTSPEED_MCP_SERVERS[{index}].headers[{j}] must be an object"
)
hdr_name = item.get("name")
if not hdr_name or not isinstance(hdr_name, str):
raise ValueError(
f"LIGHTSPEED_MCP_SERVERS[{index}].headers[{j}] missing 'name'"
)
if "value" in item:
resolved[hdr_name] = str(item["value"])
elif "source" in item:
resolved[hdr_name] = _resolve_header_source(item["source"])
else:
raise ValueError(
f"LIGHTSPEED_MCP_SERVERS[{index}].headers[{j}] needs 'value' or 'source'"
)
return resolved
raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{index}] 'headers' must be an object or array")


def resolve_mcp_servers() -> tuple[McpServerConfig, ...]:
"""Parse LIGHTSPEED_MCP_SERVERS env var into validated configs."""
raw = os.environ.get("LIGHTSPEED_MCP_SERVERS", "").strip()
if not raw:
return ()

entries: list[Any] = json.loads(raw)
if not isinstance(entries, list):
raise ValueError("LIGHTSPEED_MCP_SERVERS must be a JSON array")

servers: list[McpServerConfig] = []
for i, entry in enumerate(entries):
if not isinstance(entry, dict):
raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] must be an object")
name = entry.get("name")
url = entry.get("url")
if not name or not isinstance(name, str):
raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] missing required 'name' string")
if not url or not isinstance(url, str):
raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] missing required 'url' string")
headers = _resolve_headers(entry.get("headers", {}), i)
servers.append(McpServerConfig(name=name, url=url, headers=headers))
Comment on lines +92 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject duplicate MCP server names.

Duplicate name values are accepted here, but downstream consumers key by name: Claude’s dict keeps only one server and health results overwrite mcp_{name}. Fail fast during parsing.

Proposed duplicate check
     servers: list[McpServerConfig] = []
+    seen_names: set[str] = set()
     for i, entry in enumerate(entries):
         if not isinstance(entry, dict):
             raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] must be an object")
         name = entry.get("name")
         url = entry.get("url")
@@
         if not url or not isinstance(url, str):
             raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] missing required 'url' string")
+        if name in seen_names:
+            raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] duplicate server name {name!r}")
+        seen_names.add(name)
         headers = _resolve_headers(entry.get("headers", {}), i)
         servers.append(McpServerConfig(name=name, url=url, headers=headers))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
servers: list[McpServerConfig] = []
for i, entry in enumerate(entries):
if not isinstance(entry, dict):
raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] must be an object")
name = entry.get("name")
url = entry.get("url")
if not name or not isinstance(name, str):
raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] missing required 'name' string")
if not url or not isinstance(url, str):
raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] missing required 'url' string")
headers = _resolve_headers(entry.get("headers", {}), i)
servers.append(McpServerConfig(name=name, url=url, headers=headers))
servers: list[McpServerConfig] = []
seen_names: set[str] = set()
for i, entry in enumerate(entries):
if not isinstance(entry, dict):
raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] must be an object")
name = entry.get("name")
url = entry.get("url")
if not name or not isinstance(name, str):
raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] missing required 'name' string")
if not url or not isinstance(url, str):
raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] missing required 'url' string")
if name in seen_names:
raise ValueError(f"LIGHTSPEED_MCP_SERVERS[{i}] duplicate server name {name!r}")
seen_names.add(name)
headers = _resolve_headers(entry.get("headers", {}), i)
servers.append(McpServerConfig(name=name, url=url, headers=headers))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lightspeed_agentic/config.py` around lines 92 - 103, Add validation to
detect and reject duplicate MCP server names during parsing. Initialize a set to
track seen server names before the loop that processes entries, then before
appending each McpServerConfig to the servers list, check if the current name
already exists in the seen names set. If a duplicate is found, raise a
ValueError with a message indicating that duplicate server names are not
allowed. Add the name to the seen names set after all validations pass but
before appending the server configuration.


return tuple(servers)


_DEFAULT_VERTEX_REGION = "us-east5"
_DEFAULT_BEDROCK_REGION = "us-east-1"

Expand All @@ -33,6 +116,7 @@ class ResolvedSDK:
name: str # "claude", "gemini", "openai"
expected_envs: tuple[str, ...] # credential env vars expected from envFrom
probe_url: str # R2 reachability probe base URL
mcp_servers: tuple[McpServerConfig, ...] = ()


def _setenv(key: str, value: str) -> None:
Expand Down Expand Up @@ -194,5 +278,15 @@ def resolve_sdk() -> ResolvedSDK:
"Supported: anthropic, vertex, openai, azure, bedrock"
)

logger.info("Resolved LIGHTSPEED_PROVIDER=%s → SDK=%s", provider, sdk.name)
mcp_servers = resolve_mcp_servers()
if mcp_servers:
sdk = dataclasses.replace(sdk, mcp_servers=mcp_servers)
logger.info(
"Resolved LIGHTSPEED_PROVIDER=%s → SDK=%s (MCP servers: %s)",
provider,
sdk.name,
", ".join(s.name for s in mcp_servers),
)
else:
logger.info("Resolved LIGHTSPEED_PROVIDER=%s → SDK=%s", provider, sdk.name)
return sdk
15 changes: 14 additions & 1 deletion src/lightspeed_agentic/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from fastapi import FastAPI
from fastapi.responses import JSONResponse

from lightspeed_agentic.config import ResolvedSDK
from lightspeed_agentic.config import McpServerConfig, ResolvedSDK

PROBE_TIMEOUT_SEC = 3.0

Expand Down Expand Up @@ -57,11 +57,24 @@ def check_provider_endpoint(probe_url: str) -> str:
return probe_provider_endpoint(url)


def check_mcp_endpoints(
servers: tuple[McpServerConfig, ...],
timeout: float = PROBE_TIMEOUT_SEC,
) -> dict[str, str]:
"""R3: probe each configured MCP server URL for reachability."""
results: dict[str, str] = {}
for server in servers:
results[f"mcp_{server.name}"] = probe_provider_endpoint(server.url, timeout)
return results


def run_readiness_checks(sdk: ResolvedSDK) -> tuple[bool, dict[str, str]]:
checks = {
"provider_env": check_provider_env(sdk.expected_envs),
"provider_endpoint": check_provider_endpoint(sdk.probe_url),
}
if sdk.mcp_servers:
checks.update(check_mcp_endpoints(sdk.mcp_servers))
return all(status == "ok" for status in checks.values()), checks


Expand Down
17 changes: 16 additions & 1 deletion src/lightspeed_agentic/providers/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,32 @@ async def query(self, options: ProviderQueryOptions) -> AsyncIterator[ProviderEv
"schema": options.output_schema,
}

mcp_servers: dict[str, object] | None = None
allowed_tools = list(options.allowed_tools)
if options.mcp_servers:
mcp_servers = {
server.name: {
"type": "http",
"url": server.url,
**({"headers": server.headers} if server.headers else {}),
}
for server in options.mcp_servers
}
for server in options.mcp_servers:
allowed_tools.append(f"mcp__{server.name}__*")

sdk_options = ClaudeAgentOptions(
model=options.model,
max_turns=options.max_turns,
max_budget_usd=options.max_budget_usd,
system_prompt=options.system_prompt,
allowed_tools=options.allowed_tools,
allowed_tools=allowed_tools,
permission_mode="bypassPermissions",
cwd=effective_cwd,
skills="all",
include_partial_messages=True,
output_format=output_format,
mcp_servers=mcp_servers,
)

_tool_name = ""
Expand Down
15 changes: 13 additions & 2 deletions src/lightspeed_agentic/providers/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,6 @@ async def _auto_confirm_run(*, args: Any, tool_context: Any) -> Any:

bash.run_async = _auto_confirm_run # type: ignore[method-assign]

# TODO: investigate more ADK built-in tools:
# load_artifacts, load_memory, computer_use, file_search, mcp_servers
is_vertex = os.environ.get("GOOGLE_GENAI_USE_VERTEXAI", "").upper() == "TRUE"
tools: list[Any] = [bash]
# Vertex AI rejects mixing search tools (google_search, url_context)
Expand All @@ -103,6 +101,19 @@ async def _auto_confirm_run(*, args: Any, tool_context: Any) -> Any:
if skill_toolset is not None:
tools.append(skill_toolset)

if options.mcp_servers:
from google.adk.tools.mcp_tool.mcp_session_manager import (
StreamableHTTPConnectionParams,
)
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset

for server in options.mcp_servers:
conn = StreamableHTTPConnectionParams(
url=server.url,
headers=server.headers or None,
)
tools.append(McpToolset(connection_params=conn))

if not options.output_schema:
tools.append(exit_loop)

Expand Down
Loading