aaa-server 0.1.0__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.
- aaa_server/__init__.py +48 -0
- aaa_server/a2a.py +351 -0
- aaa_server/admission.py +247 -0
- aaa_server/app.py +723 -0
- aaa_server/auth.py +255 -0
- aaa_server/cli.py +100 -0
- aaa_server/mcp_http.py +64 -0
- aaa_server/py.typed +0 -0
- aaa_server-0.1.0.dist-info/METADATA +67 -0
- aaa_server-0.1.0.dist-info/RECORD +13 -0
- aaa_server-0.1.0.dist-info/WHEEL +4 -0
- aaa_server-0.1.0.dist-info/entry_points.txt +2 -0
- aaa_server-0.1.0.dist-info/licenses/LICENSE +21 -0
aaa_server/auth.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Authentication: who is calling, and which tenant they belong to.
|
|
2
|
+
|
|
3
|
+
The tenant, user and scopes of a request come ONLY from the `Principal` an `Authenticator` returns. Nothing in
|
|
4
|
+
a request body, query string or header other than the credential is ever read as an identity."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from aaa.runtime.tenancy import valid_tenant
|
|
17
|
+
|
|
18
|
+
SCOPE_RUN = "run" # start runs, read events, cancel
|
|
19
|
+
SCOPE_APPROVE = "approve" # resolve tool approvals (a separate, rarer privilege)
|
|
20
|
+
SCOPE_SESSION = "session" # sessions and their messages
|
|
21
|
+
SCOPE_ADMIN = "admin" # read the tenant's usage and quota
|
|
22
|
+
ALL_SCOPES = frozenset({SCOPE_RUN, SCOPE_APPROVE, SCOPE_SESSION, SCOPE_ADMIN})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthError(Exception):
|
|
26
|
+
"""The credential is missing, malformed, unknown, expired or otherwise not acceptable.
|
|
27
|
+
|
|
28
|
+
The message is for server-side logs; the HTTP response is always the same generic 401."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Principal:
|
|
33
|
+
tenant: str
|
|
34
|
+
subject: str
|
|
35
|
+
scopes: frozenset[str] = frozenset({SCOPE_RUN, SCOPE_SESSION})
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
valid_tenant(self.tenant)
|
|
39
|
+
if not self.subject:
|
|
40
|
+
raise ValueError("a principal needs a subject")
|
|
41
|
+
|
|
42
|
+
def can(self, scope: str) -> bool:
|
|
43
|
+
return scope in self.scopes
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@runtime_checkable
|
|
47
|
+
class Authenticator(Protocol):
|
|
48
|
+
async def authenticate(self, headers: Mapping[str, str]) -> Principal:
|
|
49
|
+
"""Return the caller or raise `AuthError`. `headers` keys are lower-case."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def bearer_token(headers: Mapping[str, str]) -> str:
|
|
54
|
+
raw = headers.get("authorization", "")
|
|
55
|
+
scheme, _, token = raw.partition(" ")
|
|
56
|
+
if scheme.lower() != "bearer" or not token.strip():
|
|
57
|
+
raise AuthError("missing bearer token")
|
|
58
|
+
return token.strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def hash_api_key(key: str) -> str:
|
|
62
|
+
"""SHA-256 hex of an API key. Keys are long random strings, so a fast hash is the right tool (there is
|
|
63
|
+
nothing to brute-force); store this, never the key."""
|
|
64
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ApiKey:
|
|
69
|
+
key_hash: str
|
|
70
|
+
tenant: str
|
|
71
|
+
subject: str
|
|
72
|
+
scopes: frozenset[str] = frozenset({SCOPE_RUN, SCOPE_SESSION})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ApiKeyAuthenticator:
|
|
76
|
+
"""Static API keys, held as hashes. Every candidate is compared in constant time, so the response time does
|
|
77
|
+
not reveal how much of a key was right or which keys exist."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, keys: Sequence[ApiKey]) -> None:
|
|
80
|
+
self._keys = tuple(keys)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_keys(
|
|
84
|
+
cls,
|
|
85
|
+
mapping: Mapping[str, tuple[str, str] | tuple[str, str, Sequence[str]]],
|
|
86
|
+
) -> ApiKeyAuthenticator:
|
|
87
|
+
"""`{"<the key>": ("tenant", "subject"[, scopes])}` for tests and small deployments. Prefer
|
|
88
|
+
`ApiKey(key_hash=...)` entries loaded from a secret store."""
|
|
89
|
+
out = []
|
|
90
|
+
for key, spec in mapping.items():
|
|
91
|
+
tenant, subject = spec[0], spec[1]
|
|
92
|
+
scopes = frozenset(spec[2]) if len(spec) > 2 else frozenset({SCOPE_RUN, SCOPE_SESSION}) # type: ignore[misc]
|
|
93
|
+
out.append(ApiKey(hash_api_key(key), tenant, subject, scopes))
|
|
94
|
+
return cls(out)
|
|
95
|
+
|
|
96
|
+
async def authenticate(self, headers: Mapping[str, str]) -> Principal:
|
|
97
|
+
digest = hash_api_key(bearer_token(headers))
|
|
98
|
+
found: ApiKey | None = None
|
|
99
|
+
for k in self._keys: # no early exit: every entry is compared
|
|
100
|
+
if hmac.compare_digest(k.key_hash, digest):
|
|
101
|
+
found = k
|
|
102
|
+
if found is None:
|
|
103
|
+
raise AuthError("unknown api key")
|
|
104
|
+
return Principal(found.tenant, found.subject, found.scopes)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class JWKSKeys:
|
|
108
|
+
"""Signing keys fetched from a JWKS URL and cached; an unknown `kid` triggers one refetch (rotation),
|
|
109
|
+
rate-limited so a stream of bogus tokens cannot make the server hammer the identity provider."""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
url: str,
|
|
114
|
+
*,
|
|
115
|
+
client: httpx.AsyncClient | None = None,
|
|
116
|
+
ttl_s: float = 600.0,
|
|
117
|
+
min_refetch_s: float = 30.0,
|
|
118
|
+
clock: Callable[[], float] | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
import time
|
|
121
|
+
|
|
122
|
+
self.url, self._client, self.ttl_s, self.min_refetch_s = url, client, ttl_s, min_refetch_s
|
|
123
|
+
self._clock = clock or time.monotonic
|
|
124
|
+
self._keys: dict[str, Any] = {}
|
|
125
|
+
self._fetched = float("-inf")
|
|
126
|
+
|
|
127
|
+
async def _fetch(self) -> None:
|
|
128
|
+
from jwt import PyJWK
|
|
129
|
+
|
|
130
|
+
client = self._client or httpx.AsyncClient(timeout=10.0, trust_env=False)
|
|
131
|
+
try:
|
|
132
|
+
r = await client.get(self.url)
|
|
133
|
+
r.raise_for_status()
|
|
134
|
+
doc = r.json()
|
|
135
|
+
except (httpx.HTTPError, ValueError) as e:
|
|
136
|
+
raise AuthError(f"jwks fetch failed: {type(e).__name__}") from None
|
|
137
|
+
finally:
|
|
138
|
+
if self._client is None:
|
|
139
|
+
await client.aclose()
|
|
140
|
+
keys: dict[str, Any] = {}
|
|
141
|
+
for jwk in doc.get("keys", []):
|
|
142
|
+
if jwk.get("use") not in (None, "sig") or "kid" not in jwk:
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
keys[jwk["kid"]] = PyJWK(jwk).key
|
|
146
|
+
except Exception: # noqa: BLE001, S112 - an unusable key in the set must not break the others
|
|
147
|
+
continue
|
|
148
|
+
self._keys, self._fetched = keys, self._clock()
|
|
149
|
+
|
|
150
|
+
async def key(self, kid: str) -> Any:
|
|
151
|
+
now = self._clock()
|
|
152
|
+
if (kid not in self._keys and now - self._fetched >= self.min_refetch_s) or (
|
|
153
|
+
now - self._fetched >= self.ttl_s
|
|
154
|
+
):
|
|
155
|
+
await self._fetch()
|
|
156
|
+
try:
|
|
157
|
+
return self._keys[kid]
|
|
158
|
+
except KeyError:
|
|
159
|
+
raise AuthError("unknown signing key") from None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class JWTAuthenticator:
|
|
164
|
+
"""Verifies a bearer JWT. Safe by construction:
|
|
165
|
+
|
|
166
|
+
- the algorithm allow-list is fixed by YOU (asymmetric by default); the token's own `alg` header is never trusted,
|
|
167
|
+
and `none` and the HS* family are refused unless you configure a shared `secret` (and then only HS*);
|
|
168
|
+
- `exp`, `iss` and `aud` are required and checked;
|
|
169
|
+
- the tenant, subject and scopes come from claims you name, and a token without a tenant claim is rejected."""
|
|
170
|
+
|
|
171
|
+
issuer: str
|
|
172
|
+
audience: str
|
|
173
|
+
keys: JWKSKeys | None = None
|
|
174
|
+
secret: str | None = None
|
|
175
|
+
algorithms: tuple[str, ...] = ("RS256", "ES256", "EdDSA")
|
|
176
|
+
tenant_claim: str = "tenant"
|
|
177
|
+
scope_claim: str = "scope"
|
|
178
|
+
leeway_s: float = 30.0
|
|
179
|
+
default_scopes: frozenset[str] = field(
|
|
180
|
+
default_factory=lambda: frozenset({SCOPE_RUN, SCOPE_SESSION})
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def __post_init__(self) -> None:
|
|
184
|
+
if (self.keys is None) == (self.secret is None):
|
|
185
|
+
raise ValueError("give exactly one of `keys` (JWKS) or `secret` (HS256)")
|
|
186
|
+
if self.secret is not None:
|
|
187
|
+
self.algorithms = ("HS256",)
|
|
188
|
+
if len(self.secret.encode()) < 32:
|
|
189
|
+
raise ValueError("a shared JWT secret must be at least 32 bytes")
|
|
190
|
+
elif any(a.lower() == "none" or a.upper().startswith("HS") for a in self.algorithms):
|
|
191
|
+
raise ValueError("a JWKS-verified token must use an asymmetric algorithm")
|
|
192
|
+
|
|
193
|
+
async def authenticate(self, headers: Mapping[str, str]) -> Principal:
|
|
194
|
+
import jwt
|
|
195
|
+
|
|
196
|
+
token = bearer_token(headers)
|
|
197
|
+
try:
|
|
198
|
+
header = jwt.get_unverified_header(token)
|
|
199
|
+
if header.get("alg") not in self.algorithms:
|
|
200
|
+
raise AuthError("algorithm not allowed")
|
|
201
|
+
if self.keys is not None:
|
|
202
|
+
kid = header.get("kid")
|
|
203
|
+
if not isinstance(kid, str):
|
|
204
|
+
raise AuthError("token has no kid")
|
|
205
|
+
key: Any = await self.keys.key(kid)
|
|
206
|
+
else:
|
|
207
|
+
key = self.secret
|
|
208
|
+
claims = jwt.decode(
|
|
209
|
+
token,
|
|
210
|
+
key,
|
|
211
|
+
algorithms=list(self.algorithms),
|
|
212
|
+
audience=self.audience,
|
|
213
|
+
issuer=self.issuer,
|
|
214
|
+
leeway=self.leeway_s,
|
|
215
|
+
options={"require": ["exp", "iss", "aud", "sub"]},
|
|
216
|
+
)
|
|
217
|
+
except AuthError:
|
|
218
|
+
raise
|
|
219
|
+
except jwt.PyJWTError as e:
|
|
220
|
+
raise AuthError(f"invalid token: {type(e).__name__}") from None
|
|
221
|
+
tenant = claims.get(self.tenant_claim)
|
|
222
|
+
if not isinstance(tenant, str):
|
|
223
|
+
raise AuthError("token has no tenant")
|
|
224
|
+
try:
|
|
225
|
+
valid_tenant(tenant)
|
|
226
|
+
except ValueError:
|
|
227
|
+
raise AuthError("token tenant is invalid") from None
|
|
228
|
+
raw = claims.get(self.scope_claim)
|
|
229
|
+
if raw is None:
|
|
230
|
+
scopes = self.default_scopes
|
|
231
|
+
elif isinstance(raw, str):
|
|
232
|
+
scopes = frozenset(raw.split()) & ALL_SCOPES
|
|
233
|
+
elif isinstance(raw, list):
|
|
234
|
+
scopes = frozenset(str(s) for s in raw) & ALL_SCOPES
|
|
235
|
+
else:
|
|
236
|
+
raise AuthError("token scope is malformed")
|
|
237
|
+
return Principal(tenant, str(claims["sub"]), scopes)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
__all__ = [
|
|
241
|
+
"ALL_SCOPES",
|
|
242
|
+
"SCOPE_ADMIN",
|
|
243
|
+
"SCOPE_APPROVE",
|
|
244
|
+
"SCOPE_RUN",
|
|
245
|
+
"SCOPE_SESSION",
|
|
246
|
+
"ApiKey",
|
|
247
|
+
"ApiKeyAuthenticator",
|
|
248
|
+
"AuthError",
|
|
249
|
+
"Authenticator",
|
|
250
|
+
"JWKSKeys",
|
|
251
|
+
"JWTAuthenticator",
|
|
252
|
+
"Principal",
|
|
253
|
+
"bearer_token",
|
|
254
|
+
"hash_api_key",
|
|
255
|
+
]
|
aaa_server/cli.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""`aaa-server serve module:factory` - run the server for the configuration your factory builds.
|
|
2
|
+
|
|
3
|
+
The factory is a function (sync or async) returning a `ServerConfig`; it is where you choose the store, the LLM client, the
|
|
4
|
+
algorithms, the authenticator and the limits. The command only adds the network listener, and listens on loopback unless you
|
|
5
|
+
pass `--host`."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import importlib
|
|
12
|
+
import inspect
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .app import ServerConfig, create_app
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_factory(spec: str) -> Callable[[], Any]:
|
|
21
|
+
module, _, attr = spec.partition(":")
|
|
22
|
+
if not module or not attr:
|
|
23
|
+
raise SystemExit("expected module:factory, e.g. myapp.server:build")
|
|
24
|
+
sys.path.insert(0, "")
|
|
25
|
+
fn = getattr(importlib.import_module(module), attr)
|
|
26
|
+
return fn if callable(fn) else (lambda: fn)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _build(factory: Callable[[], Any]) -> ServerConfig:
|
|
30
|
+
cfg = factory()
|
|
31
|
+
if inspect.isawaitable(cfg):
|
|
32
|
+
cfg = await cfg
|
|
33
|
+
if not isinstance(cfg, ServerConfig):
|
|
34
|
+
raise SystemExit(f"the factory must return a ServerConfig, got {type(cfg).__name__}")
|
|
35
|
+
return cfg
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_config(spec: str) -> ServerConfig:
|
|
39
|
+
"""Build the configuration now (for tests and sync code). The server itself builds it inside its own event loop."""
|
|
40
|
+
return asyncio.run(_build(load_factory(spec)))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LazyApp:
|
|
44
|
+
"""An ASGI app that builds the configuration INSIDE the server's event loop, at startup. A factory that opens connection
|
|
45
|
+
pools (asyncpg, redis) must run in the loop that will use them, which `asyncio.run(factory())` before `uvicorn.run` is not."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, factory: Callable[[], Any]) -> None:
|
|
48
|
+
self.factory, self.app = factory, None
|
|
49
|
+
|
|
50
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
51
|
+
if scope["type"] == "lifespan":
|
|
52
|
+
while True:
|
|
53
|
+
msg = await receive()
|
|
54
|
+
if msg["type"] == "lifespan.startup":
|
|
55
|
+
try:
|
|
56
|
+
cfg = await _build(self.factory)
|
|
57
|
+
stack = cfg.engine
|
|
58
|
+
await stack.__aenter__()
|
|
59
|
+
self.app = create_app(cfg)
|
|
60
|
+
except BaseException as e: # noqa: BLE001 - report the failure to the server, which exits
|
|
61
|
+
await send(
|
|
62
|
+
{
|
|
63
|
+
"type": "lifespan.startup.failed",
|
|
64
|
+
"message": f"{type(e).__name__}: {e}",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
return
|
|
68
|
+
await send({"type": "lifespan.startup.complete"})
|
|
69
|
+
elif msg["type"] == "lifespan.shutdown":
|
|
70
|
+
await stack.__aexit__(None, None, None)
|
|
71
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
72
|
+
return
|
|
73
|
+
elif self.app is None:
|
|
74
|
+
raise RuntimeError("the server has not finished starting")
|
|
75
|
+
else:
|
|
76
|
+
await self.app(scope, receive, send)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
80
|
+
ap = argparse.ArgumentParser(prog="aaa-server")
|
|
81
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
82
|
+
sv = sub.add_parser("serve", help="run the HTTP server")
|
|
83
|
+
sv.add_argument("factory", help="module:function returning a ServerConfig")
|
|
84
|
+
sv.add_argument("--host", default="127.0.0.1", help="default: loopback only")
|
|
85
|
+
sv.add_argument("--port", type=int, default=8000)
|
|
86
|
+
a = ap.parse_args(argv)
|
|
87
|
+
import uvicorn
|
|
88
|
+
|
|
89
|
+
app = LazyApp(load_factory(a.factory))
|
|
90
|
+
if a.host not in ("127.0.0.1", "localhost", "::1"):
|
|
91
|
+
print(
|
|
92
|
+
f"warning: listening on {a.host}. Put TLS and a reverse proxy (body/header timeouts) in front; see the server guide.",
|
|
93
|
+
file=sys.stderr,
|
|
94
|
+
)
|
|
95
|
+
uvicorn.run(app, host=a.host, port=a.port, log_level="info", server_header=False)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
raise SystemExit(main())
|
aaa_server/mcp_http.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""MCP over Streamable HTTP, served by `aaa-server` so the same authentication, tenant and rate-limit rules apply.
|
|
2
|
+
|
|
3
|
+
This is the stateless subset of the transport: every POST carries one JSON-RPC message and gets one JSON answer (no sessions, no
|
|
4
|
+
server-initiated messages, so `GET` is `405`). That is enough for `initialize`, `ping`, `tools/list` and `tools/call`, which is
|
|
5
|
+
all a tool server needs, and it is what lets the tenant of every call come from the authenticated principal instead of from
|
|
6
|
+
anything the MCP client says."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from aaa.mcp.server import MCPToolServer
|
|
13
|
+
|
|
14
|
+
SUPPORTED = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
|
15
|
+
PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS = -32700, -32600, -32601, -32602
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _error(id_: Any, code: int, message: str) -> dict[str, Any]:
|
|
19
|
+
return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def handle(
|
|
23
|
+
core: MCPToolServer, message: Any, *, tenant: str, user: str
|
|
24
|
+
) -> dict[str, Any] | None:
|
|
25
|
+
"""Answer one JSON-RPC message, or `None` for a notification / client response (which get `202`)."""
|
|
26
|
+
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
|
|
27
|
+
return _error(None, INVALID_REQUEST, "expected a JSON-RPC 2.0 object")
|
|
28
|
+
method, id_, params = message.get("method"), message.get("id"), message.get("params") or {}
|
|
29
|
+
if method is None: # a response to something we never sent
|
|
30
|
+
return None
|
|
31
|
+
if id_ is None: # a notification
|
|
32
|
+
return None
|
|
33
|
+
if not isinstance(method, str) or not isinstance(params, dict):
|
|
34
|
+
return _error(id_, INVALID_REQUEST, "malformed request")
|
|
35
|
+
if method == "initialize":
|
|
36
|
+
asked = params.get("protocolVersion")
|
|
37
|
+
return {
|
|
38
|
+
"jsonrpc": "2.0",
|
|
39
|
+
"id": id_,
|
|
40
|
+
"result": {
|
|
41
|
+
"protocolVersion": asked if asked in SUPPORTED else SUPPORTED[0],
|
|
42
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
43
|
+
"serverInfo": {"name": core.name, "version": "0.1"},
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
if method == "ping":
|
|
47
|
+
return {"jsonrpc": "2.0", "id": id_, "result": {}}
|
|
48
|
+
if method == "tools/list":
|
|
49
|
+
tools = [
|
|
50
|
+
{"name": t.name, "description": t.description, "inputSchema": t.input_schema}
|
|
51
|
+
for t in core.list_tools()
|
|
52
|
+
]
|
|
53
|
+
return {"jsonrpc": "2.0", "id": id_, "result": {"tools": tools}}
|
|
54
|
+
if method == "tools/call":
|
|
55
|
+
name, args = params.get("name"), params.get("arguments") or {}
|
|
56
|
+
if not isinstance(name, str) or not isinstance(args, dict):
|
|
57
|
+
return _error(id_, INVALID_PARAMS, "tools/call needs a name and an arguments object")
|
|
58
|
+
r = await core.call(name, args, tenant=tenant, user=user)
|
|
59
|
+
return {
|
|
60
|
+
"jsonrpc": "2.0",
|
|
61
|
+
"id": id_,
|
|
62
|
+
"result": {"content": [{"type": "text", "text": r.text}], "isError": r.is_error},
|
|
63
|
+
}
|
|
64
|
+
return _error(id_, METHOD_NOT_FOUND, f"method {method!r} is not supported")
|
aaa_server/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aaa-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTTP/SSE server for AAA agents: authentication, tenant isolation, rate limits, quotas, approvals.
|
|
5
|
+
Project-URL: Homepage, https://github.com/innerkorehq/AutonomousAgentArchitecture
|
|
6
|
+
Project-URL: Documentation, https://innerkorehq.github.io/AutonomousAgentArchitecture/server/
|
|
7
|
+
Project-URL: Source, https://github.com/innerkorehq/AutonomousAgentArchitecture/tree/master/packages/aaa-server
|
|
8
|
+
Project-URL: Library, https://pypi.org/project/autonomous-agent-architecture/
|
|
9
|
+
Project-URL: Changelog, https://innerkorehq.github.io/AutonomousAgentArchitecture/about/changelog/
|
|
10
|
+
Project-URL: Threat model, https://innerkorehq.github.io/AutonomousAgentArchitecture/security/server/
|
|
11
|
+
Project-URL: Issues, https://github.com/innerkorehq/AutonomousAgentArchitecture/issues
|
|
12
|
+
Author: Innerkore Technologies
|
|
13
|
+
License-Expression: MIT
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Keywords: agents,asgi,llm,multi-tenant,server,sse
|
|
16
|
+
Classifier: Development Status :: 3 - Alpha
|
|
17
|
+
Classifier: Framework :: AsyncIO
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: autonomous-agent-architecture>=0.3.1
|
|
26
|
+
Requires-Dist: starlette>=0.37
|
|
27
|
+
Requires-Dist: uvicorn>=0.30
|
|
28
|
+
Provides-Extra: jwt
|
|
29
|
+
Requires-Dist: pyjwt[crypto]>=2.8; extra == 'jwt'
|
|
30
|
+
Provides-Extra: redis
|
|
31
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# aaa-server
|
|
35
|
+
|
|
36
|
+
HTTP/SSE server for [AAA](https://github.com/innerkorehq/AutonomousAgentArchitecture) agents ([`autonomous-agent-architecture`](https://pypi.org/project/autonomous-agent-architecture/)): bearer authentication, tenants taken only from the credential, rate limits and quotas, server-side budgets, human approvals over HTTP, resumable event streams, plus MCP and (experimental) A2A endpoints.
|
|
37
|
+
|
|
38
|
+
**Documentation:** [overview](https://innerkorehq.github.io/AutonomousAgentArchitecture/server/) · [server guide](https://innerkorehq.github.io/AutonomousAgentArchitecture/guides/server/) · [threat model](https://innerkorehq.github.io/AutonomousAgentArchitecture/security/server/) · [API reference](https://innerkorehq.github.io/AutonomousAgentArchitecture/reference/api/server/) · [library docs](https://innerkorehq.github.io/AutonomousAgentArchitecture/) · [changelog](https://innerkorehq.github.io/AutonomousAgentArchitecture/about/changelog/)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install aaa-server # needs autonomous-agent-architecture>=0.3.0 (installed automatically)
|
|
42
|
+
pip install "aaa-server[jwt,redis]" # + JWT/JWKS auth, + the shared Redis rate limiter
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# myapp/server.py
|
|
47
|
+
from aaa.algos import ToolLoop
|
|
48
|
+
from aaa.runtime import Budget, Engine
|
|
49
|
+
from aaa.tools import ToolRegistry
|
|
50
|
+
from aaa_server import ApiKeyAuthenticator, ServerConfig
|
|
51
|
+
|
|
52
|
+
async def build() -> ServerConfig:
|
|
53
|
+
engine = Engine() # + attach your LLM and tool handlers; use Postgres in production
|
|
54
|
+
return ServerConfig(
|
|
55
|
+
engine=engine,
|
|
56
|
+
authenticator=ApiKeyAuthenticator.from_keys({"s3cret-key": ("acme", "ann")}),
|
|
57
|
+
algorithms={"agent": ToolLoop(ToolRegistry([]))},
|
|
58
|
+
budget=Budget(max_steps=20, deadline_s=60),
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
aaa-server serve myapp.server:build # 127.0.0.1:8000 by default
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Read the [threat model](https://innerkorehq.github.io/AutonomousAgentArchitecture/security/server/) before exposing it: put a TLS-terminating reverse proxy in front.
|
|
67
|
+
Status: **alpha (0.1)**. MIT licensed. Source: <https://github.com/innerkorehq/AutonomousAgentArchitecture/tree/master/packages/aaa-server>.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
aaa_server/__init__.py,sha256=5Xg3Da6oOw6ySZ-EmZkPs1ki1DU7Qq2fHEhofOM51oA,978
|
|
2
|
+
aaa_server/a2a.py,sha256=pFUgvXeL-2BdZhkvfGYM4mMfUbEdL6PTXSEVZ9ScSO0,14380
|
|
3
|
+
aaa_server/admission.py,sha256=5zTN3zO5mKjT2eGzzQnVmweO7jvNS8bVvK-ufvAv4Kk,9086
|
|
4
|
+
aaa_server/app.py,sha256=Fh0e0PIhl0nX1wp2U-sFm4aNaixgwmIp9Eg5Ws5eJ9A,30738
|
|
5
|
+
aaa_server/auth.py,sha256=YsQ-B5sH5r9wDa3VlnjevEzAcU3zGaNb8YRisKkIghg,9303
|
|
6
|
+
aaa_server/cli.py,sha256=LKT_4vyIPY9VOWqXib5TXxlYh5uoaipFEsxqxy0eEow,4048
|
|
7
|
+
aaa_server/mcp_http.py,sha256=1QeVAdkv3MbVwAoN9-kpUdjYZzkLM_gIu5hD-l4DNDk,3039
|
|
8
|
+
aaa_server/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
aaa_server-0.1.0.dist-info/METADATA,sha256=78g8jFvhSWBPTFJakOe9LPZMFm8hM8nVpRy09X4wqAs,3759
|
|
10
|
+
aaa_server-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
aaa_server-0.1.0.dist-info/entry_points.txt,sha256=_pmXG6lotsoMixXcqcywxlfUqwybjCx61M6WyL75G3E,51
|
|
12
|
+
aaa_server-0.1.0.dist-info/licenses/LICENSE,sha256=inG0UPjirLkSwEpASZlLycVzvrsbuJmvStPTEUzEcys,1079
|
|
13
|
+
aaa_server-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Innerkore Technologies
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|