Skip to content
Open
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
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,19 @@ The runner can drive three SDKs behind a common interface:
multi-personality handoffs, both `chat_completions` and `responses`
`api_type`, `temperature`, `parallel_tool_calls`,
`exclude_from_context`, and MCP over stdio, SSE, and streamable HTTP.
- **`copilot_sdk`** — the GitHub Copilot Python SDK. Supports streaming,
- **`copilot_sdk`** — the GitHub Copilot Python SDK (1.x). Supports streaming,
`reasoning_effort`, MCP over stdio/SSE/HTTP, and per-tool permission
gating. The SDK selects its own wire protocol per model, so the YAML
gating. Authentication uses the env var named by `token` in
`model_settings` (default `COPILOT_GITHUB_TOKEN`); a named but empty
variable is an error. Without a token override, the SDK's default
authentication chain applies. The requested model must be available to
the account; runtime switches are rejected unless `auto` was explicitly
selected. Only taskflow-configured MCP tools are exposed, through the
existing SecLab wrappers: `blocked_tools`, toolbox confirmations, and
the explicit `headless` opt-in retain their usual behavior. Other SDK
tools and permission kinds are denied. `exclude_from_context` uses
terminal tools so successful tool results are not sent back to the model.
The SDK selects its own wire protocol per model, so the YAML
`api_type` field is not honoured; multi-personality handoffs,
`temperature`, and `parallel_tool_calls` are likewise not available.
Taskflows that use unsupported fields fail at load time with a
Expand Down Expand Up @@ -261,7 +271,8 @@ The two models are defined in [multi_model.yaml](examples/model_configs/multi_mo

## Requirements

Python >= 3.10 or Docker
Python >= 3.11 or Docker. Copilot SDK 1.x requires Python 3.11+, so this
upgrade explicitly ends Python 3.10 support for the package.

## Configuration

Expand Down
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "seclab-taskflow-agent"
dynamic = ["version"]
description = "A taskflow agent for the SecLab project, enabling secure and automated workflow execution."
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.11"
license = "MIT"
keywords = [
"agentic-workflows",
Expand All @@ -24,7 +24,6 @@ authors = [
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand Down Expand Up @@ -56,7 +55,7 @@ dependencies = [
"email-validator==2.3.0",
"exceptiongroup==1.3.0",
"fastmcp==3.2.0",
"github-copilot-sdk>=0.2.2,<0.3",
"github-copilot-sdk==1.0.28",
"griffelib==2.1.0",
"h11==0.16.0",
"httpcore==1.0.9",
Expand Down
27 changes: 14 additions & 13 deletions src/seclab_taskflow_agent/mcp_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
import socket
import subprocess
import time
from collections.abc import Callable
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from threading import Event, Thread
from typing import IO, Any
from urllib.parse import urlparse
Expand Down Expand Up @@ -291,23 +292,23 @@ async def cleanup(self) -> None:
"""No-op — cleanup happens per-call."""
logging.debug("Ignoring mcp cleanup request on purpose")

async def list_tools(self, *args: Any, **kwargs: Any) -> Any:
"""Connect, list tools, then disconnect."""
@asynccontextmanager
async def tool_session(self) -> AsyncIterator[Any]:
"""Hold one serialized connection for an entire tool operation."""
async with self.reconnecting_lock:
await super().connect()
try:
result = await super().list_tools(*args, **kwargs)
await super().connect()
yield self.session
finally:
await super().cleanup()
return result

async def list_tools(self, *args: Any, **kwargs: Any) -> Any:
"""Connect, list tools, then disconnect."""
async with self.tool_session():
return await super().list_tools(*args, **kwargs)

async def call_tool(self, *args: Any, **kwargs: Any) -> Any:
"""Connect, call tool, then disconnect."""
logging.debug("Using reconnecting call_tool for stdio mcp")
async with self.reconnecting_lock:
await super().connect()
try:
result = await super().call_tool(*args, **kwargs)
finally:
await super().cleanup()
return result
async with self.tool_session():
return await super().call_tool(*args, **kwargs)
50 changes: 38 additions & 12 deletions src/seclab_taskflow_agent/mcp_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
__all__ = [
"COMPRESSED_NAME_LENGTH",
"DEFAULT_MCP_CLIENT_SESSION_TIMEOUT",
"MCPConfirmationUnavailableError",
"MCPNamespaceWrap",
"compress_name",
"mcp_client_params",
Expand Down Expand Up @@ -70,6 +71,10 @@ def compress_name(name: str) -> str:
return COMPRESSED_NAME_PREFIX + m.hexdigest()[:COMPRESSED_NAME_LENGTH]


class MCPConfirmationUnavailableError(RuntimeError):
"""A required tool confirmation could not be obtained."""


class MCPNamespaceWrap:
"""MCP client wrapper that prefixes tool names with a namespace hash.

Expand Down Expand Up @@ -109,38 +114,54 @@ async def list_tools(self, *args: Any, **kwargs: Any) -> list[Any]:
return namespaced_tools

async def list_tools_unfiltered(self) -> list[Any]:
"""List tools directly from the MCP session, namespace-prefixed.
"""List every page of tools from the MCP session, namespace-prefixed.

Bypasses any tool_filter configured on the wrapped openai-agents
server (which would require ``run_context`` and ``agent`` arguments
that aren't available when listing tools outside the openai-agents
run loop -- e.g. when handing tools to a different SDK at build
time).

Reconnecting transports hold one connection open for all pages.

Prefixing is idempotent: if a tool's name already starts with this
wrapper's namespace (e.g. because the underlying session returned a
previously-namespaced object), the existing prefix is stripped
before re-applying so calling this method multiple times never
yields ``<ns><ns>name``.

Raises ``RuntimeError`` if the underlying server has no active
Raises ``RuntimeError`` if a persistent server has no active
MCP session yet (caller should ensure the server is connected
before calling this).
before calling this), or if a pagination cursor repeats.
"""
session = getattr(self._obj, "session", None)
if isinstance(self._obj, ReconnectingMCPServerStdio):
async with self._obj.tool_session() as session:
return await self._list_session_tools(session)
return await self._list_session_tools(getattr(self._obj, "session", None))

async def _list_session_tools(self, session: Any) -> list[Any]:
if session is None:
raise RuntimeError(
f"MCPNamespaceWrap({self._obj!r}): underlying server has no "
"active MCP session; cannot list tools unfiltered"
)
result = await session.list_tools()
namespaced_tools: list[Any] = []
for tool in result.tools:
tool_copy = tool.copy() if hasattr(tool, "copy") else tool
# Idempotent: strip existing prefix before re-applying
base_name = tool_copy.name.removeprefix(self.namespace)
tool_copy.name = f"{self.namespace}{base_name}"
namespaced_tools.append(tool_copy)
seen_cursors: set[str] = set()
while True:
for tool in result.tools:
tool_copy = tool.copy() if hasattr(tool, "copy") else tool
# Idempotent: strip existing prefix before re-applying
base_name = tool_copy.name.removeprefix(self.namespace)
tool_copy.name = f"{self.namespace}{base_name}"
namespaced_tools.append(tool_copy)
cursor = getattr(result, "nextCursor", None)
if cursor is None:
break
if cursor in seen_cursors:
raise RuntimeError("MCP server returned a repeated tool-list cursor")
seen_cursors.add(cursor)
result = await session.list_tools(cursor=cursor)
return namespaced_tools

def confirm_tool(self, tool_name: str, args: list[Any]) -> bool:
Expand Down Expand Up @@ -169,9 +190,14 @@ async def call_tool(self, *args: Any, **kwargs: Any) -> Any:
tool_name = tool_name.removeprefix(self.namespace)
# to run headless, just make confirms an empty list
if self.confirms and tool_name in self.confirms:
if not self.confirm_tool(tool_name, _args[1:]):
try:
approved = self.confirm_tool(tool_name, _args[1:])
except (EOFError, OSError) as exc:
raise MCPConfirmationUnavailableError("Required toolbox confirmation is unavailable") from exc
if not approved:
result = CallToolResult(
content=[TextContent(type="text", text="Tool call not allowed.", annotations=None, meta=None)]
content=[TextContent(type="text", text="Tool call not allowed.", annotations=None, meta=None)],
isError=True,
)
return result
_args[0] = tool_name
Expand Down
4 changes: 1 addition & 3 deletions src/seclab_taskflow_agent/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,9 +723,7 @@ async def deploy_task_agents(
mcp_params = mcp_client_params(available_tools, toolboxes)
server_prompts = [sp for _, (_, _, sp, _) in mcp_params.items()]

# Wrap each built MCP server in a neutral spec. The openai
# adapter unwraps ``params["_native"]``; the Copilot adapter reads
# ``kind`` plus the raw transport keys.
# Share the connected, policy-enforcing wrapper with each adapter.
mcp_specs: list[MCPServerSpec] = []
for entry in entries:
raw_params, *_ = mcp_params.get(entry.server.name, ({}, [], "", 0.0))
Expand Down
7 changes: 3 additions & 4 deletions src/seclab_taskflow_agent/sdk/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,9 @@ class TokenUsage:
class MCPServerSpec:
"""Backend-neutral MCP server descriptor.

The openai-agents adapter only needs the built native server
handle that the runner stores under ``params["_native"]``. The
Copilot SDK adapter consumes ``kind`` plus the transport keys
(``command``/``args``/``env``/``url``/``headers``).
The runner stores the connected, policy-enforcing server wrapper under
``params["_native"]``. Adapters invoke tools through that wrapper so
transport lifecycle and toolbox confirmations remain runner-owned.
"""

name: str
Expand Down
Loading