vs-agent 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vs_agent/__init__.py +5 -0
- vs_agent/auth/__init__.py +0 -0
- vs_agent/auth/vs_action_security.py +16 -0
- vs_agent/client/__init__.py +0 -0
- vs_agent/client/vs_peer_agent_client.py +72 -0
- vs_agent/decorator/__init__.py +0 -0
- vs_agent/decorator/vs_action_decorator.py +23 -0
- vs_agent/discovery/__init__.py +0 -0
- vs_agent/discovery/vs_peer_agent_registry.py +73 -0
- vs_agent/discovery/vs_peer_agent_scheduler.py +34 -0
- vs_agent/discovery/vs_peer_capability_loader.py +81 -0
- vs_agent/registry/__init__.py +0 -0
- vs_agent/registry/vs_action_registry.py +51 -0
- vs_agent/schema/__init__.py +0 -0
- vs_agent/schema/vs_action_schema.py +21 -0
- vs_agent/server/__init__.py +0 -0
- vs_agent/server/vs_agent_server.py +182 -0
- vs_agent-0.1.1.dist-info/METADATA +692 -0
- vs_agent-0.1.1.dist-info/RECORD +21 -0
- vs_agent-0.1.1.dist-info/WHEEL +5 -0
- vs_agent-0.1.1.dist-info/top_level.txt +1 -0
vs_agent/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
|
|
3
|
+
from vs_security.guard.vs_security import get_auth_context
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VsActionSecurity:
|
|
7
|
+
|
|
8
|
+
def __init__(self, roles: Optional[List[str]] = None):
|
|
9
|
+
self._roles = roles or []
|
|
10
|
+
|
|
11
|
+
async def __call__(self) -> None:
|
|
12
|
+
ctx = get_auth_context()
|
|
13
|
+
if ctx is None:
|
|
14
|
+
raise PermissionError("Unauthenticated request")
|
|
15
|
+
if self._roles and not ctx.has_any_role(*self._roles):
|
|
16
|
+
raise PermissionError(f"Required roles: {self._roles}")
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import jwt
|
|
7
|
+
|
|
8
|
+
from vs_agent.discovery.vs_peer_agent_registry import VsPeerAgentRegistry
|
|
9
|
+
from vs_common.config.vs_base_config import VsBaseConfig
|
|
10
|
+
from vs_common.log.vs_log_manager import VsLogManager
|
|
11
|
+
|
|
12
|
+
_SERVICE_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, "vs-agent-peer-client"))
|
|
13
|
+
_logger = VsLogManager.get_instance("VsPeerAgentClient")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VsPeerAgentClient:
|
|
17
|
+
|
|
18
|
+
_secret: Optional[str] = None
|
|
19
|
+
_algorithm: str = "HS256"
|
|
20
|
+
_expiry_minutes: int = 2
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def init(cls, config: VsBaseConfig) -> None:
|
|
24
|
+
cls._secret = config.get("agent_client.secret_key", default=None)
|
|
25
|
+
cls._algorithm = config.get("agent_client.algorithm", default="HS256")
|
|
26
|
+
cls._expiry_minutes = config.get("agent_client.access_expiry_minutes", default=2, data_type=int)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def _mint_token(cls) -> Optional[str]:
|
|
30
|
+
if not cls._secret:
|
|
31
|
+
return None
|
|
32
|
+
now = datetime.now(timezone.utc)
|
|
33
|
+
payload = {
|
|
34
|
+
"sub": _SERVICE_ID,
|
|
35
|
+
"username": "vs-agent",
|
|
36
|
+
"roles": ["service"],
|
|
37
|
+
"provider": "internal",
|
|
38
|
+
"type": "access",
|
|
39
|
+
"iat": now,
|
|
40
|
+
"exp": now + timedelta(minutes=cls._expiry_minutes),
|
|
41
|
+
}
|
|
42
|
+
return jwt.encode(payload, cls._secret, algorithm=cls._algorithm)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def _headers(cls) -> Dict[str, str]:
|
|
46
|
+
token = cls._mint_token()
|
|
47
|
+
return {"Authorization": f"Bearer {token}"} if token else {}
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
async def call(
|
|
51
|
+
cls,
|
|
52
|
+
agent: str,
|
|
53
|
+
path: str,
|
|
54
|
+
payload: Dict[str, Any],
|
|
55
|
+
method: str = "POST",
|
|
56
|
+
timeout: float = 230.0,
|
|
57
|
+
) -> dict:
|
|
58
|
+
entry = VsPeerAgentRegistry.get(agent)
|
|
59
|
+
if entry is None:
|
|
60
|
+
raise ValueError(f"Agent '{agent}' is not registered.")
|
|
61
|
+
if entry.status == "offline":
|
|
62
|
+
raise RuntimeError(f"Agent '{agent}' is currently offline.")
|
|
63
|
+
|
|
64
|
+
url = f"{entry.url}{path}"
|
|
65
|
+
_logger.info(f"Calling peer agent '{agent}' → {method} {url}")
|
|
66
|
+
|
|
67
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
68
|
+
response = await getattr(client, method.lower())(
|
|
69
|
+
url, json=payload, headers=cls._headers()
|
|
70
|
+
)
|
|
71
|
+
response.raise_for_status()
|
|
72
|
+
return response.json()
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
from vs_agent.registry.vs_action_registry import VsActionRegistry
|
|
4
|
+
from vs_agent.schema.vs_action_schema import Intent
|
|
5
|
+
from vs_mcp_agent.registry.vs_tool_registry import VsToolRegistry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def action(
|
|
9
|
+
name: str,
|
|
10
|
+
description: str,
|
|
11
|
+
path: str,
|
|
12
|
+
method: str = "POST",
|
|
13
|
+
intents: Optional[List[Intent]] = None,
|
|
14
|
+
input_schema: Optional[Dict[str, Any]] = None,
|
|
15
|
+
guards: Optional[List[Callable]] = None,
|
|
16
|
+
):
|
|
17
|
+
def decorator(fn: Callable) -> Callable:
|
|
18
|
+
_guards = guards or []
|
|
19
|
+
_intents = intents or []
|
|
20
|
+
VsToolRegistry.register_fn(name, description, fn, guards=_guards)
|
|
21
|
+
VsActionRegistry.register(name, description, path, method, _intents, input_schema, _guards, fn)
|
|
22
|
+
return fn
|
|
23
|
+
return decorator
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class PeerAgentEntry:
|
|
9
|
+
name: str
|
|
10
|
+
url: str
|
|
11
|
+
status: str = "offline"
|
|
12
|
+
version: Optional[str] = None
|
|
13
|
+
capabilities: list = field(default_factory=list)
|
|
14
|
+
last_health_check: Optional[datetime] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_RESERVED_KEYS = {"health_check_interval"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VsPeerAgentRegistry:
|
|
21
|
+
|
|
22
|
+
_entries: Dict[str, PeerAgentEntry] = {}
|
|
23
|
+
_lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def register(cls, name: str, url: str) -> None:
|
|
27
|
+
with cls._lock:
|
|
28
|
+
if name not in cls._entries:
|
|
29
|
+
cls._entries[name] = PeerAgentEntry(name=name, url=url)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def set_online(cls, name: str, version: Optional[str], capabilities: list) -> None:
|
|
33
|
+
with cls._lock:
|
|
34
|
+
if name in cls._entries:
|
|
35
|
+
entry = cls._entries[name]
|
|
36
|
+
entry.status = "online"
|
|
37
|
+
entry.version = version
|
|
38
|
+
entry.capabilities = capabilities
|
|
39
|
+
entry.last_health_check = datetime.now(timezone.utc)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def set_version_unchanged(cls, name: str) -> None:
|
|
43
|
+
with cls._lock:
|
|
44
|
+
if name in cls._entries:
|
|
45
|
+
entry = cls._entries[name]
|
|
46
|
+
entry.status = "online"
|
|
47
|
+
entry.last_health_check = datetime.now(timezone.utc)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def set_offline(cls, name: str) -> None:
|
|
51
|
+
with cls._lock:
|
|
52
|
+
if name in cls._entries:
|
|
53
|
+
entry = cls._entries[name]
|
|
54
|
+
entry.status = "offline"
|
|
55
|
+
entry.last_health_check = datetime.now(timezone.utc)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def get(cls, name: str) -> Optional[PeerAgentEntry]:
|
|
59
|
+
return cls._entries.get(name)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def get_available(cls) -> List[PeerAgentEntry]:
|
|
63
|
+
with cls._lock:
|
|
64
|
+
return [e for e in cls._entries.values() if e.status == "online"]
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def get_all(cls) -> List[PeerAgentEntry]:
|
|
68
|
+
with cls._lock:
|
|
69
|
+
return list(cls._entries.values())
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def parse_agents_config(cls, section: Dict[str, str]) -> Dict[str, str]:
|
|
73
|
+
return {k: v for k, v in section.items() if k not in _RESERVED_KEYS}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from vs_common.log.vs_log_manager import VsLogManager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class VsPeerAgentScheduler:
|
|
8
|
+
|
|
9
|
+
def __init__(self, health_interval: int = 30):
|
|
10
|
+
self._health_interval = health_interval
|
|
11
|
+
self._task: Optional[asyncio.Task] = None
|
|
12
|
+
self._logger = VsLogManager.get_instance(self.__class__.__name__)
|
|
13
|
+
|
|
14
|
+
async def start(self) -> None:
|
|
15
|
+
self._task = asyncio.create_task(self._health_loop())
|
|
16
|
+
self._logger.info(f"Peer agent scheduler started (interval={self._health_interval}s)")
|
|
17
|
+
|
|
18
|
+
async def stop(self) -> None:
|
|
19
|
+
if self._task:
|
|
20
|
+
self._task.cancel()
|
|
21
|
+
try:
|
|
22
|
+
await self._task
|
|
23
|
+
except asyncio.CancelledError:
|
|
24
|
+
pass
|
|
25
|
+
self._logger.info("Peer agent scheduler stopped")
|
|
26
|
+
|
|
27
|
+
async def _health_loop(self) -> None:
|
|
28
|
+
from vs_agent.discovery.vs_peer_capability_loader import VsPeerCapabilityLoader
|
|
29
|
+
while True:
|
|
30
|
+
await asyncio.sleep(self._health_interval)
|
|
31
|
+
try:
|
|
32
|
+
await VsPeerCapabilityLoader.check_all()
|
|
33
|
+
except Exception as e:
|
|
34
|
+
self._logger.error(f"Health check cycle failed: {e}", exc_info=True)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from typing import Dict
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from vs_agent.discovery.vs_peer_agent_registry import VsPeerAgentRegistry
|
|
6
|
+
from vs_common.log.vs_log_manager import VsLogManager
|
|
7
|
+
|
|
8
|
+
_logger = VsLogManager.get_instance("VsPeerCapabilityLoader")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VsPeerCapabilityLoader:
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
async def load_all(cls, agents: Dict[str, str]) -> None:
|
|
15
|
+
_logger.info(f"Loading peer agents: {list(agents.keys())}")
|
|
16
|
+
for name, url in agents.items():
|
|
17
|
+
VsPeerAgentRegistry.register(name, url)
|
|
18
|
+
_logger.debug(f"[{name}] registered at {url}")
|
|
19
|
+
for name, url in agents.items():
|
|
20
|
+
await cls._check(name, url)
|
|
21
|
+
cls._log_summary()
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
async def check_all(cls) -> None:
|
|
25
|
+
_logger.debug("Running peer agent health check cycle")
|
|
26
|
+
for entry in VsPeerAgentRegistry.get_all():
|
|
27
|
+
await cls._check(entry.name, entry.url)
|
|
28
|
+
cls._log_summary()
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
async def _check(cls, name: str, url: str) -> None:
|
|
32
|
+
try:
|
|
33
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
34
|
+
resp = await client.get(f"{url}/health")
|
|
35
|
+
resp.raise_for_status()
|
|
36
|
+
health = resp.json()
|
|
37
|
+
|
|
38
|
+
new_version = health.get("version")
|
|
39
|
+
entry = VsPeerAgentRegistry.get(name)
|
|
40
|
+
was_offline = entry and entry.status == "offline"
|
|
41
|
+
|
|
42
|
+
needs_capabilities = (
|
|
43
|
+
entry is None
|
|
44
|
+
or was_offline
|
|
45
|
+
or entry.version != new_version
|
|
46
|
+
or not entry.capabilities
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if needs_capabilities:
|
|
50
|
+
reason = "came online" if was_offline else f"version changed {entry.version} → {new_version}" if entry and entry.version != new_version else "initial load"
|
|
51
|
+
_logger.info(f"[{name}] fetching capabilities ({reason})")
|
|
52
|
+
capabilities = await cls._fetch_capabilities(url)
|
|
53
|
+
VsPeerAgentRegistry.set_online(name, new_version, capabilities)
|
|
54
|
+
action_names = [a.get("name") for a in capabilities]
|
|
55
|
+
_logger.info(f"[{name}] online version={new_version} actions={action_names}")
|
|
56
|
+
else:
|
|
57
|
+
VsPeerAgentRegistry.set_version_unchanged(name)
|
|
58
|
+
_logger.debug(f"[{name}] online version={new_version} (no changes)")
|
|
59
|
+
|
|
60
|
+
except Exception as e:
|
|
61
|
+
entry = VsPeerAgentRegistry.get(name)
|
|
62
|
+
was_online = entry and entry.status == "online"
|
|
63
|
+
VsPeerAgentRegistry.set_offline(name)
|
|
64
|
+
if was_online:
|
|
65
|
+
_logger.warning(f"[{name}] went offline: {e}")
|
|
66
|
+
else:
|
|
67
|
+
_logger.debug(f"[{name}] still offline: {e}")
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
async def _fetch_capabilities(cls, url: str) -> list:
|
|
71
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
72
|
+
resp = await client.get(f"{url}/capabilities")
|
|
73
|
+
resp.raise_for_status()
|
|
74
|
+
return resp.json().get("actions", [])
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def _log_summary(cls) -> None:
|
|
78
|
+
all_entries = VsPeerAgentRegistry.get_all()
|
|
79
|
+
online = [e.name for e in all_entries if e.status == "online"]
|
|
80
|
+
offline = [e.name for e in all_entries if e.status == "offline"]
|
|
81
|
+
_logger.info(f"Peer agents — online: {online} offline: {offline}")
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from typing import Any, Callable, Dict, List, NamedTuple, Optional
|
|
3
|
+
|
|
4
|
+
from vs_agent.schema.vs_action_schema import Intent
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _ActionEntry(NamedTuple):
|
|
8
|
+
name: str
|
|
9
|
+
description: str
|
|
10
|
+
path: str
|
|
11
|
+
method: str
|
|
12
|
+
intents: List[Intent]
|
|
13
|
+
input_schema: Optional[Dict[str, Any]]
|
|
14
|
+
guards: List[Callable]
|
|
15
|
+
fn: Callable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VsActionRegistry:
|
|
19
|
+
_registry: Dict[str, _ActionEntry] = {}
|
|
20
|
+
_lock: threading.Lock = threading.Lock()
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def register(
|
|
24
|
+
cls,
|
|
25
|
+
name: str,
|
|
26
|
+
description: str,
|
|
27
|
+
path: str,
|
|
28
|
+
method: str,
|
|
29
|
+
intents: List[Intent],
|
|
30
|
+
input_schema: Optional[Dict[str, Any]],
|
|
31
|
+
guards: List[Callable],
|
|
32
|
+
fn: Callable,
|
|
33
|
+
) -> None:
|
|
34
|
+
with cls._lock:
|
|
35
|
+
if name in cls._registry:
|
|
36
|
+
raise ValueError(f"Action '{name}' is already registered")
|
|
37
|
+
cls._registry[name] = _ActionEntry(
|
|
38
|
+
name=name,
|
|
39
|
+
description=description,
|
|
40
|
+
path=path,
|
|
41
|
+
method=method,
|
|
42
|
+
intents=intents,
|
|
43
|
+
input_schema=input_schema,
|
|
44
|
+
guards=guards,
|
|
45
|
+
fn=fn,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def get_all(cls) -> Dict[str, _ActionEntry]:
|
|
50
|
+
with cls._lock:
|
|
51
|
+
return dict(cls._registry)
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Intent(BaseModel):
|
|
6
|
+
name: str
|
|
7
|
+
description: str
|
|
8
|
+
examples: Optional[List[str]] = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ActionCapability(BaseModel):
|
|
12
|
+
name: str
|
|
13
|
+
description: str
|
|
14
|
+
path: str
|
|
15
|
+
method: str
|
|
16
|
+
intents: List[Intent]
|
|
17
|
+
input_schema: Optional[Dict[str, Any]] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CapabilitiesResponse(BaseModel):
|
|
21
|
+
actions: List[ActionCapability]
|
|
File without changes
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from vs_agent.registry.vs_action_registry import VsActionRegistry
|
|
7
|
+
from vs_common.config.vs_base_config import VsBaseConfig
|
|
8
|
+
from vs_common.log.vs_log_manager import VsLogManager
|
|
9
|
+
from vs_server.server.vs_server import VsServer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VsAgentServer:
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
config: VsBaseConfig,
|
|
17
|
+
http: Optional[str] = "fastapi",
|
|
18
|
+
mcp: Optional[str] = None,
|
|
19
|
+
):
|
|
20
|
+
if not http and not mcp:
|
|
21
|
+
raise ValueError("At least one of 'http' or 'mcp' must be specified.")
|
|
22
|
+
|
|
23
|
+
self._config = config
|
|
24
|
+
self._logger = VsLogManager.get_instance(self.__class__.__name__)
|
|
25
|
+
self._http_server: Optional[VsServer] = None
|
|
26
|
+
self._mcp_server = None
|
|
27
|
+
|
|
28
|
+
if http:
|
|
29
|
+
from vs_server.factory.vs_server_factory import VsServerFactory
|
|
30
|
+
self._http_server = VsServerFactory.get(http, config)
|
|
31
|
+
|
|
32
|
+
if mcp:
|
|
33
|
+
from vs_mcp_agent.factory.vs_mcp_server_factory import VsMcpServerFactory
|
|
34
|
+
self._mcp_server = VsMcpServerFactory.get(mcp, config)
|
|
35
|
+
|
|
36
|
+
self._setup_peer_discovery(config)
|
|
37
|
+
|
|
38
|
+
# --- HTTP delegation ---
|
|
39
|
+
|
|
40
|
+
def add_controller(self, controller) -> "VsAgentServer":
|
|
41
|
+
self._require_http().add_controller(controller)
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def add_router(self, router) -> "VsAgentServer":
|
|
45
|
+
self._require_http().add_router(router)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def add_websocket(self, handler) -> "VsAgentServer":
|
|
49
|
+
self._require_http().add_websocket(handler)
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def add_sse(self, handler) -> "VsAgentServer":
|
|
53
|
+
self._require_http().add_sse(handler)
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def get_app(self):
|
|
57
|
+
return self._require_http().get_app()
|
|
58
|
+
|
|
59
|
+
# --- MCP accessor ---
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def mcp(self):
|
|
63
|
+
if self._mcp_server is None:
|
|
64
|
+
raise RuntimeError("No MCP server configured. Pass mcp='fastmcp' to VsAgentServer.")
|
|
65
|
+
return self._mcp_server.mcp
|
|
66
|
+
|
|
67
|
+
# --- Actions (HTTP + MCP dual-registration) ---
|
|
68
|
+
|
|
69
|
+
def add_actions(self) -> "VsAgentServer":
|
|
70
|
+
from vs_agent.registry.vs_action_registry import VsActionRegistry
|
|
71
|
+
|
|
72
|
+
entries = VsActionRegistry.get_all()
|
|
73
|
+
if not entries:
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
if self._http_server is not None:
|
|
77
|
+
self._register_http_actions(entries)
|
|
78
|
+
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def _register_http_actions(self, entries: dict) -> None:
|
|
82
|
+
from fastapi import APIRouter, Depends
|
|
83
|
+
from vs_agent.schema.vs_action_schema import ActionCapability, CapabilitiesResponse
|
|
84
|
+
|
|
85
|
+
security_deps = []
|
|
86
|
+
auth_enabled = self._config.get("agent.auth_enabled", default=True, data_type=bool)
|
|
87
|
+
if auth_enabled:
|
|
88
|
+
try:
|
|
89
|
+
from fastapi.security import HTTPBearer
|
|
90
|
+
from vs_security.guard.vs_security import VsSecurity
|
|
91
|
+
security_deps = [Depends(VsSecurity.from_manager()), Depends(HTTPBearer())]
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
router = APIRouter()
|
|
96
|
+
|
|
97
|
+
@router.get("/capabilities")
|
|
98
|
+
async def capabilities() -> CapabilitiesResponse:
|
|
99
|
+
return CapabilitiesResponse(actions=[
|
|
100
|
+
ActionCapability(
|
|
101
|
+
name=e.name,
|
|
102
|
+
description=e.description,
|
|
103
|
+
path=e.path,
|
|
104
|
+
method=e.method,
|
|
105
|
+
intents=e.intents,
|
|
106
|
+
input_schema=e.input_schema,
|
|
107
|
+
)
|
|
108
|
+
for e in VsActionRegistry.get_all().values()
|
|
109
|
+
])
|
|
110
|
+
|
|
111
|
+
for entry in entries.values():
|
|
112
|
+
fn = entry.fn
|
|
113
|
+
if entry.guards:
|
|
114
|
+
@functools.wraps(fn)
|
|
115
|
+
async def _handler(*args, _fn=fn, _guards=entry.guards, **kwargs):
|
|
116
|
+
for guard in _guards:
|
|
117
|
+
await guard()
|
|
118
|
+
return await _fn(*args, **kwargs)
|
|
119
|
+
_handler.__signature__ = inspect.signature(fn)
|
|
120
|
+
handler = _handler
|
|
121
|
+
else:
|
|
122
|
+
handler = fn
|
|
123
|
+
|
|
124
|
+
guard_deps = [Depends(g) for g in entry.guards]
|
|
125
|
+
getattr(router, entry.method.lower())(
|
|
126
|
+
entry.path,
|
|
127
|
+
dependencies=security_deps + guard_deps,
|
|
128
|
+
)(handler)
|
|
129
|
+
|
|
130
|
+
base_url = self._config.get("server.base_url", default="")
|
|
131
|
+
self._http_server.add_router(router, prefix=base_url)
|
|
132
|
+
|
|
133
|
+
# --- Run ---
|
|
134
|
+
|
|
135
|
+
def run(self) -> None:
|
|
136
|
+
if self._http_server and self._mcp_server:
|
|
137
|
+
mcp_thread = threading.Thread(target=self._mcp_server.run, daemon=True)
|
|
138
|
+
mcp_thread.start()
|
|
139
|
+
self._logger.info(f"[MCP] listening on port {self._mcp_server._config.port}")
|
|
140
|
+
self._logger.info(f"[HTTP] listening on port {self._config.get('server.port', 8000, int)}")
|
|
141
|
+
self._http_server.run()
|
|
142
|
+
elif self._mcp_server:
|
|
143
|
+
self._mcp_server.run()
|
|
144
|
+
else:
|
|
145
|
+
self._http_server.run()
|
|
146
|
+
|
|
147
|
+
# --- Peer discovery ---
|
|
148
|
+
|
|
149
|
+
def _setup_peer_discovery(self, config: VsBaseConfig) -> None:
|
|
150
|
+
agents_section = config.get_section("agents")
|
|
151
|
+
if not agents_section:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
from vs_agent.discovery.vs_peer_agent_registry import VsPeerAgentRegistry
|
|
155
|
+
from vs_agent.discovery.vs_peer_capability_loader import VsPeerCapabilityLoader
|
|
156
|
+
from vs_agent.discovery.vs_peer_agent_scheduler import VsPeerAgentScheduler
|
|
157
|
+
from vs_agent.client.vs_peer_agent_client import VsPeerAgentClient
|
|
158
|
+
from vs_server.lifecycle.vs_lifecycle import startup, shutdown
|
|
159
|
+
|
|
160
|
+
agents = VsPeerAgentRegistry.parse_agents_config(agents_section)
|
|
161
|
+
health_interval = int(agents_section.get("health_check_interval", 30))
|
|
162
|
+
|
|
163
|
+
scheduler = VsPeerAgentScheduler(health_interval=health_interval)
|
|
164
|
+
VsPeerAgentClient.init(config)
|
|
165
|
+
|
|
166
|
+
@startup
|
|
167
|
+
async def _peer_discovery_start():
|
|
168
|
+
await VsPeerCapabilityLoader.load_all(agents)
|
|
169
|
+
await scheduler.start()
|
|
170
|
+
|
|
171
|
+
@shutdown
|
|
172
|
+
async def _peer_discovery_stop():
|
|
173
|
+
await scheduler.stop()
|
|
174
|
+
|
|
175
|
+
self._logger.info(f"Peer discovery enabled for agents: {list(agents.keys())}")
|
|
176
|
+
|
|
177
|
+
# --- Internal ---
|
|
178
|
+
|
|
179
|
+
def _require_http(self) -> VsServer:
|
|
180
|
+
if self._http_server is None:
|
|
181
|
+
raise RuntimeError("No HTTP server configured. Pass http='fastapi' to VsAgentServer.")
|
|
182
|
+
return self._http_server
|
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vs-agent
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Full-stack agent framework for Viveka Sutra — HTTP + MCP server in one
|
|
5
|
+
Project-URL: Homepage, https://vivekasutra.com/
|
|
6
|
+
Project-URL: Source, https://github.com/vivekasutra/viveka-mula
|
|
7
|
+
Keywords: agent,mcp,fastapi,http,viveka,vs
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: Other/Proprietary License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: vs-common
|
|
20
|
+
Requires-Dist: vs-server
|
|
21
|
+
Provides-Extra: mcp
|
|
22
|
+
Requires-Dist: vs-mcp-agent; extra == "mcp"
|
|
23
|
+
Provides-Extra: security
|
|
24
|
+
Requires-Dist: vs-security; extra == "security"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: build; extra == "dev"
|
|
27
|
+
Requires-Dist: twine; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# vs-agent
|
|
32
|
+
|
|
33
|
+
Full-stack agent framework for Viveka Sutra — build agents that serve over HTTP, MCP, or both, from a single codebase.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Overview
|
|
38
|
+
|
|
39
|
+
`vs-agent` combines `vs-server` (HTTP/WebSocket/SSE) and `vs-mcp-agent` (MCP) into a single `VsAgentServer`. It lets you expose a capability once with `@action` and have it available simultaneously as an HTTP endpoint and an MCP tool — with the same auth, guards, and business logic.
|
|
40
|
+
|
|
41
|
+
The library is fully server-agnostic. `VsAgentServer` does not hardcode FastAPI or FastMCP — it uses `VsServerFactory` and `VsMcpServerFactory` to resolve the correct server implementation at startup. Swapping or adding a new server type requires no changes to application code.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## The Problem It Solves
|
|
46
|
+
|
|
47
|
+
An agent needs to be callable by both humans (via HTTP) and AI clients (via MCP). Without `vs-agent`, you register the same function twice, apply guards twice, and maintain two sets of route definitions.
|
|
48
|
+
|
|
49
|
+
### Without vs-agent
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
# HTTP route
|
|
53
|
+
@router.post("/v1/docs/search")
|
|
54
|
+
async def search_docs_http(query: str, auth=Depends(require_auth)):
|
|
55
|
+
return await _search(query)
|
|
56
|
+
|
|
57
|
+
# MCP tool — separate registration, separate guard wiring
|
|
58
|
+
@mcp.tool(name="search_docs")
|
|
59
|
+
async def search_docs_mcp(query: str) -> dict:
|
|
60
|
+
await require_auth()
|
|
61
|
+
return await _search(query)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### With vs-agent
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
@action(
|
|
68
|
+
name="search_docs",
|
|
69
|
+
description="Search VS library documentation",
|
|
70
|
+
path="/v1/docs/search",
|
|
71
|
+
guards=[VsActionSecurity(roles=["user"])],
|
|
72
|
+
)
|
|
73
|
+
async def search_docs(query: str) -> VsToolResponse:
|
|
74
|
+
return VsToolResponse(status="success", result=await _search(query))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
One function, one guard, available on both protocols.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Installation
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install vs-agent
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
With MCP support:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pip install vs-agent[mcp]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
With auth guard support:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pip install vs-agent[mcp,security]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Dependencies
|
|
102
|
+
|
|
103
|
+
| Library | Required | Purpose |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `vs-common` | Yes | Config, logging |
|
|
106
|
+
| `vs-server` | Yes | HTTP/WebSocket/SSE server |
|
|
107
|
+
| `vs-mcp-agent` | No — install with `[mcp]` extra | MCP server |
|
|
108
|
+
| `vs-security` | No — install with `[security]` extra | JWT auth and `VsActionSecurity` guard |
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Configuration
|
|
113
|
+
|
|
114
|
+
`VsAgentServer` reads HTTP config via `vs-server` and MCP config via `vs-mcp-agent`. Both use the same `config.ini` file — separated by section.
|
|
115
|
+
|
|
116
|
+
**HTTP section (from `vs-server`):**
|
|
117
|
+
|
|
118
|
+
| Key | Default | Description |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| `server.name` | `vs-agent` | Server name shown in logs and `/` response |
|
|
121
|
+
| `server.version` | `0.1.0` | Version shown in logs and `/` response |
|
|
122
|
+
| `server.host` | `0.0.0.0` | Host to bind |
|
|
123
|
+
| `server.port` | `8000` | HTTP port |
|
|
124
|
+
| `server.reload` | `false` | Enable hot reload (dev only) |
|
|
125
|
+
| `server.workers` | `1` | Number of worker processes |
|
|
126
|
+
| `server.cors_origins` | `*` | Comma-separated allowed CORS origins |
|
|
127
|
+
| `server.ssl_certfile` | — | Path to TLS certificate file |
|
|
128
|
+
| `server.ssl_keyfile` | — | Path to TLS private key file |
|
|
129
|
+
|
|
130
|
+
**MCP section (from `vs-mcp-agent`):**
|
|
131
|
+
|
|
132
|
+
| Key | Default | Description |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| `agent.name` | `vs-agent` | MCP server name |
|
|
135
|
+
| `agent.version` | `0.1.0` | MCP server version |
|
|
136
|
+
| `agent.transport` | `streamable-http` | Transport: `stdio`, `sse`, `streamable-http` |
|
|
137
|
+
| `agent.host` | `0.0.0.0` | MCP host |
|
|
138
|
+
| `agent.port` | `8080` | MCP port |
|
|
139
|
+
| `agent.auth_enabled` | `false` | Enable JWT auth middleware on MCP |
|
|
140
|
+
|
|
141
|
+
**`config.ini` example:**
|
|
142
|
+
|
|
143
|
+
```ini
|
|
144
|
+
[server]
|
|
145
|
+
name = my-agent
|
|
146
|
+
version = 1.0.0
|
|
147
|
+
host = 0.0.0.0
|
|
148
|
+
port = 8000
|
|
149
|
+
|
|
150
|
+
[agent]
|
|
151
|
+
name = my-agent
|
|
152
|
+
version = 1.0.0
|
|
153
|
+
transport = streamable-http
|
|
154
|
+
host = 0.0.0.0
|
|
155
|
+
port = 8080
|
|
156
|
+
auth_enabled = true
|
|
157
|
+
|
|
158
|
+
[auth]
|
|
159
|
+
secret_key = your-secret-key
|
|
160
|
+
algorithm = HS256
|
|
161
|
+
|
|
162
|
+
[logging]
|
|
163
|
+
level = INFO
|
|
164
|
+
file_path = ./logs/agent.log
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Quick Start
|
|
170
|
+
|
|
171
|
+
### HTTP + MCP (most common)
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from vs_common.config.vs_ini_config import VsIniConfig
|
|
175
|
+
from vs_common.log.vs_log_manager import VsLogManager
|
|
176
|
+
from vs_common.schema.vs_log_config import VsLogConfig
|
|
177
|
+
from vs_server.server.vs_fast_api_server import VsFastApiServer # noqa — auto-registers "fastapi"
|
|
178
|
+
from vs_mcp_agent.server.vs_fast_mcp_server import VsFastMcpServer # noqa — auto-registers "fastmcp"
|
|
179
|
+
from vs_agent.server.vs_agent_server import VsAgentServer
|
|
180
|
+
|
|
181
|
+
import my_agent.actions # noqa — registers @action functions
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def main():
|
|
185
|
+
config = VsIniConfig("config.ini")
|
|
186
|
+
VsLogManager.init(VsLogConfig(level=config.get("logging.level", default="INFO")))
|
|
187
|
+
|
|
188
|
+
server = VsAgentServer(config, http="fastapi", mcp="fastmcp")
|
|
189
|
+
server.add_actions()
|
|
190
|
+
server.run()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
main()
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### HTTP only
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
server = VsAgentServer(config, http="fastapi")
|
|
201
|
+
server.add_actions()
|
|
202
|
+
server.run()
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### MCP only
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from vs_mcp_agent.server.vs_fast_mcp_server import VsFastMcpServer # noqa — auto-registers "fastmcp"
|
|
209
|
+
|
|
210
|
+
server = VsAgentServer(config, mcp="fastmcp")
|
|
211
|
+
server.run()
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## How It All Fits Together
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
Application Startup
|
|
220
|
+
└── import VsFastApiServer # auto-registers "fastapi" into VsServerFactory
|
|
221
|
+
└── import VsFastMcpServer # auto-registers "fastmcp" into VsMcpServerFactory
|
|
222
|
+
└── import my_agent.actions # @action decorators self-register into VsActionRegistry + VsToolRegistry
|
|
223
|
+
|
|
224
|
+
VsAgentServer(config, http="fastapi", mcp="fastmcp")
|
|
225
|
+
├── VsServerFactory.get("fastapi", config) → VsFastApiServer
|
|
226
|
+
└── VsMcpServerFactory.get("fastmcp", config) → VsFastMcpServer
|
|
227
|
+
|
|
228
|
+
server.add_actions()
|
|
229
|
+
├── reads VsActionRegistry → wires HTTP routes + /capabilities
|
|
230
|
+
└── VsFastMcpServer already has tools from VsToolRegistry (wired at run())
|
|
231
|
+
|
|
232
|
+
server.run()
|
|
233
|
+
├── MCP server starts on background thread (port 8080)
|
|
234
|
+
└── HTTP server starts on main thread (port 8000)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## VsAgentServer
|
|
240
|
+
|
|
241
|
+
`VsAgentServer` is the central coordinator. It creates and manages HTTP and MCP server instances via their respective factories.
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from vs_agent.server.vs_agent_server import VsAgentServer
|
|
245
|
+
|
|
246
|
+
server = VsAgentServer(config, http="fastapi", mcp="fastmcp")
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**Constructor parameters:**
|
|
250
|
+
|
|
251
|
+
| Parameter | Type | Default | Description |
|
|
252
|
+
|---|---|---|---|
|
|
253
|
+
| `config` | `VsBaseConfig` | — | Application config |
|
|
254
|
+
| `http` | `Optional[str]` | `"fastapi"` | HTTP server key. Pass `None` for MCP-only mode. |
|
|
255
|
+
| `mcp` | `Optional[str]` | `None` | MCP server key. Pass `"fastmcp"` to enable MCP. |
|
|
256
|
+
|
|
257
|
+
At least one of `http` or `mcp` must be specified — both `None` raises `ValueError`.
|
|
258
|
+
|
|
259
|
+
**Methods:**
|
|
260
|
+
|
|
261
|
+
| Method | Description |
|
|
262
|
+
|---|---|
|
|
263
|
+
| `add_controller(controller)` | Register an HTTP `@controller` class. Delegates to the HTTP server. |
|
|
264
|
+
| `add_router(router)` | Register a raw router. Delegates to the HTTP server. |
|
|
265
|
+
| `add_websocket(handler)` | Register a `@websocket` handler. Delegates to the HTTP server. |
|
|
266
|
+
| `add_sse(handler)` | Register an `@sse` handler. Delegates to the HTTP server. |
|
|
267
|
+
| `add_actions()` | Wire all `@action` functions to HTTP routes and expose `/capabilities`. |
|
|
268
|
+
| `get_app()` | Return the underlying ASGI app (e.g. FastAPI instance). |
|
|
269
|
+
| `run()` | Start the server(s). MCP runs on a background daemon thread; HTTP runs on the main thread. |
|
|
270
|
+
|
|
271
|
+
**`mcp` property:**
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
server.mcp # returns the underlying FastMCP instance for advanced configuration
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Raises `RuntimeError` if no MCP server is configured.
|
|
278
|
+
|
|
279
|
+
**All `add_*` methods return `self` for chaining:**
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
server = (
|
|
283
|
+
VsAgentServer(config, http="fastapi", mcp="fastmcp")
|
|
284
|
+
.add_controller(HealthController())
|
|
285
|
+
.add_actions()
|
|
286
|
+
)
|
|
287
|
+
server.run()
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## @action
|
|
293
|
+
|
|
294
|
+
Registers a function simultaneously as:
|
|
295
|
+
- An MCP tool — in `VsToolRegistry`, picked up by `VsFastMcpServer` when `run()` is called
|
|
296
|
+
- An HTTP endpoint — in `VsActionRegistry`, wired by `server.add_actions()`
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
from vs_agent.decorator.vs_action_decorator import action
|
|
300
|
+
from vs_mcp_agent.schema.vs_tool_response import VsToolResponse
|
|
301
|
+
|
|
302
|
+
@action(
|
|
303
|
+
name="search_docs",
|
|
304
|
+
description="Search VS library documentation",
|
|
305
|
+
path="/v1/docs/search",
|
|
306
|
+
method="POST",
|
|
307
|
+
guards=[VsActionSecurity(roles=["user"])],
|
|
308
|
+
)
|
|
309
|
+
async def search_docs(query: str) -> VsToolResponse:
|
|
310
|
+
results = await _do_search(query)
|
|
311
|
+
return VsToolResponse(status="success", result=results, summary="Search complete")
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
**Parameters:**
|
|
315
|
+
|
|
316
|
+
| Parameter | Type | Required | Default | Description |
|
|
317
|
+
|---|---|---|---|---|
|
|
318
|
+
| `name` | `str` | Yes | — | MCP tool name and action identifier |
|
|
319
|
+
| `description` | `str` | Yes | — | Shown in MCP tool list and `/capabilities` |
|
|
320
|
+
| `path` | `str` | Yes | — | HTTP endpoint path |
|
|
321
|
+
| `method` | `str` | No | `"POST"` | HTTP method (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`) |
|
|
322
|
+
| `intents` | `List[Intent]` | No | `[]` | Semantic intents for agent routing |
|
|
323
|
+
| `input_schema` | `Dict[str, Any]` | No | `None` | JSON schema for the action input |
|
|
324
|
+
| `guards` | `List[Callable]` | No | `[]` | Guards applied on both HTTP and MCP |
|
|
325
|
+
|
|
326
|
+
**Import order matters.** `@action` self-registers at import time into both `VsActionRegistry` and `VsToolRegistry`. Import your action modules before calling `server.add_actions()` or `server.run()`.
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
import my_agent.actions.search # noqa — triggers @action registration
|
|
330
|
+
import my_agent.actions.summary # noqa
|
|
331
|
+
|
|
332
|
+
server.add_actions()
|
|
333
|
+
server.run()
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Authorization with VsActionSecurity
|
|
339
|
+
|
|
340
|
+
`VsActionSecurity` is a unified guard that works on both HTTP and MCP. It reads the auth context set by `VsSecurityFactory` (HTTP) or `VsMcpAuthMiddleware` (MCP).
|
|
341
|
+
|
|
342
|
+
```python
|
|
343
|
+
from vs_agent.auth.vs_action_security import VsActionSecurity
|
|
344
|
+
|
|
345
|
+
@action(
|
|
346
|
+
name="search_docs",
|
|
347
|
+
description="Search documentation",
|
|
348
|
+
path="/v1/docs/search",
|
|
349
|
+
guards=[VsActionSecurity(roles=["user"])],
|
|
350
|
+
)
|
|
351
|
+
async def search_docs(query: str) -> VsToolResponse:
|
|
352
|
+
...
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
**No roles (auth only):**
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
guards=[VsActionSecurity()] # rejects unauthenticated callers, any role allowed
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**Multiple roles (any match):**
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
guards=[VsActionSecurity(roles=["admin", "editor"])] # passes if caller has admin OR editor
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**Multiple guards (all must pass, in order):**
|
|
368
|
+
|
|
369
|
+
```python
|
|
370
|
+
guards=[VsActionSecurity(roles=["admin"]), require_verified_account]
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
**How guards run per protocol:**
|
|
374
|
+
|
|
375
|
+
| Protocol | Auth context source | Guard execution |
|
|
376
|
+
|---|---|---|
|
|
377
|
+
| HTTP | `VsSecurityFactory.get()` sets context via FastAPI `Depends` | Guards called in order after auth context is set |
|
|
378
|
+
| MCP | `VsMcpAuthMiddleware` sets context before tool dispatch | Guards called in order before tool function executes |
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## Lifecycle Hooks
|
|
383
|
+
|
|
384
|
+
Use lifecycle hooks to run code at server startup and shutdown — creating DB tables, warming caches, closing connections, etc.
|
|
385
|
+
|
|
386
|
+
### HTTP lifecycle (`vs-server`)
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
from vs_server.lifecycle.vs_lifecycle import startup, shutdown
|
|
390
|
+
|
|
391
|
+
@startup
|
|
392
|
+
async def warm_cache():
|
|
393
|
+
await VsCacheManager.set("ready", True)
|
|
394
|
+
|
|
395
|
+
@shutdown
|
|
396
|
+
async def flush_cache():
|
|
397
|
+
await VsCacheManager.delete("ready")
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Register hook modules with `server_registry`:
|
|
401
|
+
|
|
402
|
+
```python
|
|
403
|
+
from vs_server.decorator.vs_server_registry import server_registry
|
|
404
|
+
|
|
405
|
+
@server_registry(hooks="my_agent.lifecycle")
|
|
406
|
+
def main():
|
|
407
|
+
...
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### MCP lifecycle (`vs-mcp-agent`)
|
|
411
|
+
|
|
412
|
+
```python
|
|
413
|
+
from vs_mcp_agent.lifecycle.vs_mcp_lifecycle import mcp_startup, mcp_shutdown
|
|
414
|
+
|
|
415
|
+
@mcp_startup
|
|
416
|
+
async def init_mcp_resources():
|
|
417
|
+
await load_tool_index()
|
|
418
|
+
|
|
419
|
+
@mcp_shutdown
|
|
420
|
+
async def cleanup_mcp_resources():
|
|
421
|
+
await close_tool_connections()
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Register hook modules with `mcp_server_registry`:
|
|
425
|
+
|
|
426
|
+
```python
|
|
427
|
+
from vs_mcp_agent.decorator.vs_mcp_server_registry import mcp_server_registry
|
|
428
|
+
|
|
429
|
+
@mcp_server_registry(hooks="my_agent.mcp_lifecycle")
|
|
430
|
+
def main():
|
|
431
|
+
...
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
When both are used, HTTP and MCP hooks run independently — HTTP hooks fire when the HTTP server starts/stops, MCP hooks fire when the MCP server starts/stops.
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## HTTP-only Capabilities
|
|
439
|
+
|
|
440
|
+
For capabilities that should only be available over HTTP, use `@controller` from `vs-server` directly and register via `server.add_controller()`:
|
|
441
|
+
|
|
442
|
+
```python
|
|
443
|
+
from vs_server.decorator.vs_controller_decorator import controller, get, post
|
|
444
|
+
|
|
445
|
+
@controller("/v1/internal")
|
|
446
|
+
class InternalController:
|
|
447
|
+
|
|
448
|
+
@get("/status")
|
|
449
|
+
async def status(self):
|
|
450
|
+
return {"status": "ok"}
|
|
451
|
+
|
|
452
|
+
server.add_controller(InternalController())
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## MCP-only Capabilities
|
|
458
|
+
|
|
459
|
+
For capabilities that should only be available over MCP, use `@tool` from `vs-mcp-agent` directly:
|
|
460
|
+
|
|
461
|
+
```python
|
|
462
|
+
from vs_mcp_agent.decorator.tool import tool
|
|
463
|
+
from vs_mcp_agent.schema.vs_tool_response import VsToolResponse
|
|
464
|
+
|
|
465
|
+
@tool(name="internal_tool", description="Internal MCP tool only")
|
|
466
|
+
async def internal_tool(query: str) -> VsToolResponse:
|
|
467
|
+
...
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
These are picked up automatically by `VsFastMcpServer` at `run()` — no extra registration needed.
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
## /capabilities Endpoint
|
|
475
|
+
|
|
476
|
+
`server.add_actions()` automatically registers a `GET /capabilities` endpoint on the HTTP server. It returns all registered actions with their metadata — used by orchestrators to discover what the agent can do.
|
|
477
|
+
|
|
478
|
+
**Response:**
|
|
479
|
+
|
|
480
|
+
```json
|
|
481
|
+
{
|
|
482
|
+
"actions": [
|
|
483
|
+
{
|
|
484
|
+
"name": "search_docs",
|
|
485
|
+
"description": "Search VS library documentation",
|
|
486
|
+
"path": "/v1/docs/search",
|
|
487
|
+
"method": "POST",
|
|
488
|
+
"intents": [],
|
|
489
|
+
"input_schema": null
|
|
490
|
+
}
|
|
491
|
+
]
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## Intents
|
|
498
|
+
|
|
499
|
+
`Intent` gives each action semantic labels that orchestrators use for routing — matching a user request to the right action without exact string matching.
|
|
500
|
+
|
|
501
|
+
```python
|
|
502
|
+
from vs_agent.schema.vs_action_schema import Intent
|
|
503
|
+
|
|
504
|
+
@action(
|
|
505
|
+
name="search_docs",
|
|
506
|
+
description="Search VS library documentation",
|
|
507
|
+
path="/v1/docs/search",
|
|
508
|
+
intents=[
|
|
509
|
+
Intent(
|
|
510
|
+
name="search",
|
|
511
|
+
description="Find documentation matching a query",
|
|
512
|
+
examples=["how do I configure logging", "what does VsLogManager do"],
|
|
513
|
+
)
|
|
514
|
+
],
|
|
515
|
+
)
|
|
516
|
+
async def search_docs(query: str) -> VsToolResponse:
|
|
517
|
+
...
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Intents are returned in `/capabilities` and are available as metadata on `VsActionRegistry` entries.
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## Error Handling
|
|
525
|
+
|
|
526
|
+
Exceptions from `@action` functions propagate through both protocols:
|
|
527
|
+
- On HTTP, `vs-server`'s exception handlers convert them to HTTP responses.
|
|
528
|
+
- On MCP, `VsFastMcpServer` returns an error result to the MCP client.
|
|
529
|
+
|
|
530
|
+
Use exceptions from `vs-server` for standard HTTP error semantics — they are handled automatically:
|
|
531
|
+
|
|
532
|
+
```python
|
|
533
|
+
from vs_server.schema.exceptions import NotFoundException, ServiceUnavailableException
|
|
534
|
+
|
|
535
|
+
@action(name="get_doc", description="Get a document", path="/v1/docs/{doc_id}")
|
|
536
|
+
async def get_doc(doc_id: str) -> VsToolResponse:
|
|
537
|
+
doc = await repo.find(doc_id)
|
|
538
|
+
if doc is None:
|
|
539
|
+
raise NotFoundException(f"Document '{doc_id}' not found")
|
|
540
|
+
return VsToolResponse(status="success", result=doc)
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
## Class Reference
|
|
546
|
+
|
|
547
|
+
---
|
|
548
|
+
|
|
549
|
+
### VsAgentServer
|
|
550
|
+
|
|
551
|
+
Coordinates HTTP and MCP servers. Uses `VsServerFactory` and `VsMcpServerFactory` to resolve server implementations. Supports HTTP-only, MCP-only, or HTTP + MCP modes.
|
|
552
|
+
|
|
553
|
+
**Constructor:**
|
|
554
|
+
|
|
555
|
+
| Parameter | Type | Default | Description |
|
|
556
|
+
|---|---|---|---|
|
|
557
|
+
| `config` | `VsBaseConfig` | — | Application config |
|
|
558
|
+
| `http` | `Optional[str]` | `"fastapi"` | HTTP server key registered in `VsServerFactory`. Pass `None` for MCP-only. |
|
|
559
|
+
| `mcp` | `Optional[str]` | `None` | MCP server key registered in `VsMcpServerFactory`. Pass `"fastmcp"` to enable MCP. |
|
|
560
|
+
|
|
561
|
+
**Methods:**
|
|
562
|
+
|
|
563
|
+
| Method | Signature | Description |
|
|
564
|
+
|---|---|---|
|
|
565
|
+
| `add_controller` | `add_controller(controller) -> VsAgentServer` | Register an HTTP controller. Requires `http` mode. |
|
|
566
|
+
| `add_router` | `add_router(router) -> VsAgentServer` | Register a raw router. Requires `http` mode. |
|
|
567
|
+
| `add_websocket` | `add_websocket(handler) -> VsAgentServer` | Register a WebSocket handler. Requires `http` mode. |
|
|
568
|
+
| `add_sse` | `add_sse(handler) -> VsAgentServer` | Register an SSE handler. Requires `http` mode. |
|
|
569
|
+
| `add_actions` | `add_actions() -> VsAgentServer` | Wire all `@action` functions to HTTP routes and `/capabilities`. |
|
|
570
|
+
| `get_app` | `get_app() -> Any` | Return the underlying ASGI app. Requires `http` mode. |
|
|
571
|
+
| `run` | `run() -> None` | Start all servers. MCP on daemon thread, HTTP on main thread. |
|
|
572
|
+
|
|
573
|
+
**Property:**
|
|
574
|
+
|
|
575
|
+
| Property | Type | Description |
|
|
576
|
+
|---|---|---|
|
|
577
|
+
| `mcp` | `Any` | The underlying FastMCP instance. Raises `RuntimeError` if MCP not configured. |
|
|
578
|
+
|
|
579
|
+
**Notes:**
|
|
580
|
+
- All `add_*` methods call `_require_http()` internally and raise `RuntimeError` if `http=None`.
|
|
581
|
+
- In HTTP + MCP mode, `run()` starts MCP on a background daemon thread then blocks on the HTTP server.
|
|
582
|
+
- Import `VsFastApiServer` before constructing `VsAgentServer` to ensure `"fastapi"` is registered.
|
|
583
|
+
- Import `VsFastMcpServer` before constructing `VsAgentServer` to ensure `"fastmcp"` is registered.
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
### `@action`
|
|
588
|
+
|
|
589
|
+
Decorator. Registers a function as both an MCP tool (via `VsToolRegistry`) and an HTTP action (via `VsActionRegistry`). Self-registers at import time.
|
|
590
|
+
|
|
591
|
+
**Parameters:**
|
|
592
|
+
|
|
593
|
+
| Parameter | Type | Required | Default | Description |
|
|
594
|
+
|---|---|---|---|---|
|
|
595
|
+
| `name` | `str` | Yes | — | MCP tool name and action key |
|
|
596
|
+
| `description` | `str` | Yes | — | Shown in MCP tool list and `/capabilities` |
|
|
597
|
+
| `path` | `str` | Yes | — | HTTP endpoint path |
|
|
598
|
+
| `method` | `str` | No | `"POST"` | HTTP method |
|
|
599
|
+
| `intents` | `List[Intent]` | No | `[]` | Semantic intents for orchestrator routing |
|
|
600
|
+
| `input_schema` | `Dict[str, Any]` | No | `None` | JSON schema for the action input |
|
|
601
|
+
| `guards` | `List[Callable]` | No | `[]` | Applied on both HTTP and MCP, in order |
|
|
602
|
+
|
|
603
|
+
**Notes:**
|
|
604
|
+
- Each `name` must be unique across all `@action` registrations. Duplicate names raise `ValueError`.
|
|
605
|
+
- `method` is HTTP-only — MCP tools have no HTTP method concept.
|
|
606
|
+
- `guards` must be async callables.
|
|
607
|
+
|
|
608
|
+
---
|
|
609
|
+
|
|
610
|
+
### VsActionSecurity
|
|
611
|
+
|
|
612
|
+
Guard class for unified HTTP + MCP authorization. Reads from the auth context set by whichever protocol is active.
|
|
613
|
+
|
|
614
|
+
**Constructor:**
|
|
615
|
+
|
|
616
|
+
| Parameter | Type | Default | Description |
|
|
617
|
+
|---|---|---|---|
|
|
618
|
+
| `roles` | `Optional[List[str]]` | `[]` | Required roles. Caller must have at least one. Empty list = any authenticated caller. |
|
|
619
|
+
|
|
620
|
+
**Behaviour:**
|
|
621
|
+
|
|
622
|
+
| Condition | Result |
|
|
623
|
+
|---|---|
|
|
624
|
+
| No auth context | Raises `PermissionError("Unauthenticated request")` |
|
|
625
|
+
| Auth context present, no roles required | Passes |
|
|
626
|
+
| Auth context present, caller has required role | Passes |
|
|
627
|
+
| Auth context present, caller lacks required role | Raises `PermissionError` |
|
|
628
|
+
|
|
629
|
+
**Notes:**
|
|
630
|
+
- On HTTP, auth context is set by `VsSecurityFactory.get()` (from `vs-security`).
|
|
631
|
+
- On MCP, auth context is set by `VsMcpAuthMiddleware` (from `vs-mcp-agent`).
|
|
632
|
+
- `VsActionSecurity` reads from the same context variable regardless of protocol.
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
636
|
+
### VsActionRegistry
|
|
637
|
+
|
|
638
|
+
Class-level registry of all `@action`-registered functions. Thread-safe.
|
|
639
|
+
|
|
640
|
+
**Methods:**
|
|
641
|
+
|
|
642
|
+
| Method | Signature | Description |
|
|
643
|
+
|---|---|---|
|
|
644
|
+
| `register` | `register(name, description, path, method, intents, input_schema, guards, fn) -> None` | Register an action. Raises `ValueError` if name is already registered. |
|
|
645
|
+
| `get_all` | `get_all() -> Dict[str, _ActionEntry]` | Returns a snapshot of all registered actions. |
|
|
646
|
+
|
|
647
|
+
**Notes:**
|
|
648
|
+
- `@action` calls `VsActionRegistry.register()` and `VsToolRegistry.register_fn()` at decoration time.
|
|
649
|
+
- `server.add_actions()` reads from `VsActionRegistry.get_all()` to wire HTTP routes.
|
|
650
|
+
|
|
651
|
+
---
|
|
652
|
+
|
|
653
|
+
### Intent
|
|
654
|
+
|
|
655
|
+
Pydantic model. Semantic label for an action — used by orchestrators for routing.
|
|
656
|
+
|
|
657
|
+
**Fields:**
|
|
658
|
+
|
|
659
|
+
| Field | Type | Required | Description |
|
|
660
|
+
|---|---|---|---|
|
|
661
|
+
| `name` | `str` | Yes | Short intent identifier |
|
|
662
|
+
| `description` | `str` | Yes | What this intent means |
|
|
663
|
+
| `examples` | `Optional[List[str]]` | No | Example user phrases that trigger this intent |
|
|
664
|
+
|
|
665
|
+
---
|
|
666
|
+
|
|
667
|
+
### ActionCapability
|
|
668
|
+
|
|
669
|
+
Pydantic model. Metadata for a single action as returned by `/capabilities`.
|
|
670
|
+
|
|
671
|
+
**Fields:**
|
|
672
|
+
|
|
673
|
+
| Field | Type | Description |
|
|
674
|
+
|---|---|---|
|
|
675
|
+
| `name` | `str` | Action name |
|
|
676
|
+
| `description` | `str` | Action description |
|
|
677
|
+
| `path` | `str` | HTTP endpoint path |
|
|
678
|
+
| `method` | `str` | HTTP method |
|
|
679
|
+
| `intents` | `List[Intent]` | Semantic intents |
|
|
680
|
+
| `input_schema` | `Optional[Dict[str, Any]]` | JSON schema for input |
|
|
681
|
+
|
|
682
|
+
---
|
|
683
|
+
|
|
684
|
+
### CapabilitiesResponse
|
|
685
|
+
|
|
686
|
+
Pydantic model. Response from `GET /capabilities`.
|
|
687
|
+
|
|
688
|
+
**Fields:**
|
|
689
|
+
|
|
690
|
+
| Field | Type | Description |
|
|
691
|
+
|---|---|---|
|
|
692
|
+
| `actions` | `List[ActionCapability]` | All registered actions |
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
vs_agent/__init__.py,sha256=3yuN4M5I9y2QMcXQ5hhqE73XwH2O97mIH3QaJ-acnS4,94
|
|
2
|
+
vs_agent/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
vs_agent/auth/vs_action_security.py,sha256=jj6Hb8_CNO_1lCYoz9WsbfiJQ_V4ifz9YoqAoeoYYYw,502
|
|
4
|
+
vs_agent/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
vs_agent/client/vs_peer_agent_client.py,sha256=i_62CO4d-fLr47cqnLQc1RIMZX5I5DBOJKc6hTVscr0,2404
|
|
6
|
+
vs_agent/decorator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
vs_agent/decorator/vs_action_decorator.py,sha256=MM2Wk9vsLaTV8VtEBVBrcXH240oV5oWsWw5rGVLi2lY,801
|
|
8
|
+
vs_agent/discovery/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
vs_agent/discovery/vs_peer_agent_registry.py,sha256=xuiiJPNcogWmr51S8qRB_2N1DfXbhDGfU7VT7wzGhN4,2284
|
|
10
|
+
vs_agent/discovery/vs_peer_agent_scheduler.py,sha256=aGfOePuTjEezZitCdr8r3HT251wTMETXgwLrq2EuSAU,1207
|
|
11
|
+
vs_agent/discovery/vs_peer_capability_loader.py,sha256=_BK0vsRTgZh6hgfN2klpvCZBZDaDCZmTtGjZoiBk-8E,3304
|
|
12
|
+
vs_agent/registry/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
vs_agent/registry/vs_action_registry.py,sha256=agQ43fofEAwkWtjX9UZe5wLpgFO4GVoOKS8mZR1e-xo,1331
|
|
14
|
+
vs_agent/schema/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
vs_agent/schema/vs_action_schema.py,sha256=Se0XX3kYmDylYok2cn3yJ9jVPCjdXWrFIq6Va45nt64,434
|
|
16
|
+
vs_agent/server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
vs_agent/server/vs_agent_server.py,sha256=nVeUmkcfGnI1bz9VFJ_HP7-NFymxHjsWMdfuZbEwya4,6397
|
|
18
|
+
vs_agent-0.1.1.dist-info/METADATA,sha256=j2ZsFytzlrpFaG-AG2LSbjOM9j5Q8i0IIUAoiGXa5Sc,21506
|
|
19
|
+
vs_agent-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
20
|
+
vs_agent-0.1.1.dist-info/top_level.txt,sha256=lzOLrUrjfBxn4S1Evc_osgkXJpPbw62uonkfPrfx6uM,9
|
|
21
|
+
vs_agent-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vs_agent
|