alp-server 0.7.0__tar.gz
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.
- alp_server-0.7.0/.gitignore +65 -0
- alp_server-0.7.0/PKG-INFO +155 -0
- alp_server-0.7.0/README.md +125 -0
- alp_server-0.7.0/alp/__init__.py +28 -0
- alp_server-0.7.0/alp/card.py +75 -0
- alp_server-0.7.0/alp/fastapi.py +198 -0
- alp_server-0.7.0/alp/flask.py +203 -0
- alp_server-0.7.0/alp/mcp.py +79 -0
- alp_server-0.7.0/alp/sse.py +68 -0
- alp_server-0.7.0/alp/tools.py +87 -0
- alp_server-0.7.0/pyproject.toml +40 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# ALP — Agent Load Protocol
|
|
3
|
+
# .gitignore
|
|
4
|
+
# =============================================================================
|
|
5
|
+
|
|
6
|
+
# -----------------------------------------------------------------------------
|
|
7
|
+
# Python — local installs inside reference server
|
|
8
|
+
# -----------------------------------------------------------------------------
|
|
9
|
+
reference/server/python/Lib/
|
|
10
|
+
reference/server/python/Scripts/
|
|
11
|
+
reference/server/python/__pycache__/
|
|
12
|
+
reference/server/python/*.pyc
|
|
13
|
+
reference/server/python/*.pyo
|
|
14
|
+
reference/server/python/get-pip.py
|
|
15
|
+
|
|
16
|
+
# -----------------------------------------------------------------------------
|
|
17
|
+
# Python — general
|
|
18
|
+
# -----------------------------------------------------------------------------
|
|
19
|
+
__pycache__/
|
|
20
|
+
*.py[cod]
|
|
21
|
+
*.pyo
|
|
22
|
+
*.pyd
|
|
23
|
+
*.egg
|
|
24
|
+
*.egg-info/
|
|
25
|
+
dist/
|
|
26
|
+
build/
|
|
27
|
+
.eggs/
|
|
28
|
+
.venv/
|
|
29
|
+
venv/
|
|
30
|
+
env/
|
|
31
|
+
ENV/
|
|
32
|
+
|
|
33
|
+
# -----------------------------------------------------------------------------
|
|
34
|
+
# Node — local installs inside reference server
|
|
35
|
+
# -----------------------------------------------------------------------------
|
|
36
|
+
reference/server/node/node_modules/
|
|
37
|
+
|
|
38
|
+
# -----------------------------------------------------------------------------
|
|
39
|
+
# Environment variables — never commit secrets
|
|
40
|
+
# -----------------------------------------------------------------------------
|
|
41
|
+
.env
|
|
42
|
+
.env.local
|
|
43
|
+
.env.*.local
|
|
44
|
+
*.env
|
|
45
|
+
|
|
46
|
+
# -----------------------------------------------------------------------------
|
|
47
|
+
# OS files
|
|
48
|
+
# -----------------------------------------------------------------------------
|
|
49
|
+
.DS_Store
|
|
50
|
+
Thumbs.db
|
|
51
|
+
desktop.ini
|
|
52
|
+
|
|
53
|
+
# -----------------------------------------------------------------------------
|
|
54
|
+
# Editor files
|
|
55
|
+
# -----------------------------------------------------------------------------
|
|
56
|
+
.vscode/settings.json
|
|
57
|
+
.idea/
|
|
58
|
+
*.swp
|
|
59
|
+
*.swo
|
|
60
|
+
|
|
61
|
+
# -----------------------------------------------------------------------------
|
|
62
|
+
# Logs
|
|
63
|
+
# -----------------------------------------------------------------------------
|
|
64
|
+
*.log
|
|
65
|
+
logs/
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alp-server
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Agent Load Protocol — drop-in MCP/SSE middleware for any Python server
|
|
5
|
+
Project-URL: Homepage, https://github.com/RodrigoMvs123/agent-load-protocol
|
|
6
|
+
Project-URL: Repository, https://github.com/RodrigoMvs123/agent-load-protocol
|
|
7
|
+
Project-URL: Documentation, https://github.com/RodrigoMvs123/agent-load-protocol/blob/main/SPEC.md
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/RodrigoMvs123/agent-load-protocol/issues
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agent,alp,claude,fastapi,kiro,llm,mcp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: fastapi>=0.111.0
|
|
20
|
+
Requires-Dist: httpx>=0.27.0
|
|
21
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
22
|
+
Requires-Dist: uvicorn[standard]>=0.29.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
27
|
+
Provides-Extra: flask
|
|
28
|
+
Requires-Dist: flask>=3.0.0; extra == 'flask'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# alp-server
|
|
32
|
+
|
|
33
|
+
> Agent Load Protocol — drop-in MCP/SSE middleware for any Python server.
|
|
34
|
+
|
|
35
|
+
Add Kiro / Claude Code / MCP connectivity to any existing FastAPI or Flask app in 3 lines.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install alp-server
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## FastAPI
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from fastapi import FastAPI
|
|
47
|
+
from alp import ALPRouter
|
|
48
|
+
|
|
49
|
+
app = FastAPI()
|
|
50
|
+
|
|
51
|
+
# Adds /mcp, /agent, /tools, /persona, /agents, /health, /agent/refresh
|
|
52
|
+
alp = ALPRouter(card_path="agent.alp.json")
|
|
53
|
+
app.include_router(alp.router)
|
|
54
|
+
|
|
55
|
+
# Your existing routes — untouched
|
|
56
|
+
@app.get("/your/existing/route")
|
|
57
|
+
async def your_route():
|
|
58
|
+
return {"ok": True}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uvicorn main:app --port 8000
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Flask
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install alp-server[flask]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from flask import Flask
|
|
73
|
+
from alp.flask import ALPBlueprint
|
|
74
|
+
|
|
75
|
+
app = Flask(__name__)
|
|
76
|
+
|
|
77
|
+
alp = ALPBlueprint(card_path="agent.alp.json")
|
|
78
|
+
app.register_blueprint(alp.blueprint)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Custom tool registration
|
|
82
|
+
|
|
83
|
+
Register Python functions as tool handlers instead of using proxy URLs:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from fastapi import FastAPI
|
|
87
|
+
from alp import ALPRouter
|
|
88
|
+
|
|
89
|
+
app = FastAPI()
|
|
90
|
+
alp = ALPRouter(card_path="agent.alp.json")
|
|
91
|
+
|
|
92
|
+
@alp.tool("greet")
|
|
93
|
+
async def greet(input_data: dict) -> dict:
|
|
94
|
+
name = input_data.get("name", "stranger")
|
|
95
|
+
return {"message": f"Hello, {name}!"}
|
|
96
|
+
|
|
97
|
+
@alp.tool("search")
|
|
98
|
+
async def search(input_data: dict) -> dict:
|
|
99
|
+
results = my_search_function(input_data["query"])
|
|
100
|
+
return {"results": results}
|
|
101
|
+
|
|
102
|
+
app.include_router(alp.router)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Remote card (v0.6.0)
|
|
106
|
+
|
|
107
|
+
Load the Agent Card from a public GitHub URL:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
alp = ALPRouter(
|
|
111
|
+
card_url="https://raw.githubusercontent.com/YOUR-ORG/YOUR-REPO/main/agent.alp.json"
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Or via environment variable:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
AGENT_CARD_URL=https://raw.githubusercontent.com/YOUR-ORG/YOUR-REPO/main/agent.alp.json
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Connect to Kiro
|
|
122
|
+
|
|
123
|
+
Add to `.kiro/settings/mcp.json`:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"mcpServers": {
|
|
128
|
+
"my-agent": {
|
|
129
|
+
"url": "http://localhost:8000/mcp"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Endpoints added
|
|
136
|
+
|
|
137
|
+
| Endpoint | Description |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `GET /mcp` | MCP SSE stream — Kiro connects here |
|
|
140
|
+
| `POST /mcp` | MCP JSON-RPC receiver |
|
|
141
|
+
| `GET /agent` | Agent Card JSON |
|
|
142
|
+
| `GET /agent/refresh` | Re-fetch remote card |
|
|
143
|
+
| `GET /persona` | Agent persona for system prompt |
|
|
144
|
+
| `GET /tools` | Tool list |
|
|
145
|
+
| `POST /tools/{name}` | Execute a tool |
|
|
146
|
+
| `GET /agents` | All hosted cards |
|
|
147
|
+
| `GET /health` | Status check |
|
|
148
|
+
|
|
149
|
+
## Protocol
|
|
150
|
+
|
|
151
|
+
Part of the [Agent Load Protocol](https://github.com/RodrigoMvs123/agent-load-protocol).
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# alp-server
|
|
2
|
+
|
|
3
|
+
> Agent Load Protocol — drop-in MCP/SSE middleware for any Python server.
|
|
4
|
+
|
|
5
|
+
Add Kiro / Claude Code / MCP connectivity to any existing FastAPI or Flask app in 3 lines.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install alp-server
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## FastAPI
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
from alp import ALPRouter
|
|
18
|
+
|
|
19
|
+
app = FastAPI()
|
|
20
|
+
|
|
21
|
+
# Adds /mcp, /agent, /tools, /persona, /agents, /health, /agent/refresh
|
|
22
|
+
alp = ALPRouter(card_path="agent.alp.json")
|
|
23
|
+
app.include_router(alp.router)
|
|
24
|
+
|
|
25
|
+
# Your existing routes — untouched
|
|
26
|
+
@app.get("/your/existing/route")
|
|
27
|
+
async def your_route():
|
|
28
|
+
return {"ok": True}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uvicorn main:app --port 8000
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Flask
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install alp-server[flask]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from flask import Flask
|
|
43
|
+
from alp.flask import ALPBlueprint
|
|
44
|
+
|
|
45
|
+
app = Flask(__name__)
|
|
46
|
+
|
|
47
|
+
alp = ALPBlueprint(card_path="agent.alp.json")
|
|
48
|
+
app.register_blueprint(alp.blueprint)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Custom tool registration
|
|
52
|
+
|
|
53
|
+
Register Python functions as tool handlers instead of using proxy URLs:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from fastapi import FastAPI
|
|
57
|
+
from alp import ALPRouter
|
|
58
|
+
|
|
59
|
+
app = FastAPI()
|
|
60
|
+
alp = ALPRouter(card_path="agent.alp.json")
|
|
61
|
+
|
|
62
|
+
@alp.tool("greet")
|
|
63
|
+
async def greet(input_data: dict) -> dict:
|
|
64
|
+
name = input_data.get("name", "stranger")
|
|
65
|
+
return {"message": f"Hello, {name}!"}
|
|
66
|
+
|
|
67
|
+
@alp.tool("search")
|
|
68
|
+
async def search(input_data: dict) -> dict:
|
|
69
|
+
results = my_search_function(input_data["query"])
|
|
70
|
+
return {"results": results}
|
|
71
|
+
|
|
72
|
+
app.include_router(alp.router)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Remote card (v0.6.0)
|
|
76
|
+
|
|
77
|
+
Load the Agent Card from a public GitHub URL:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
alp = ALPRouter(
|
|
81
|
+
card_url="https://raw.githubusercontent.com/YOUR-ORG/YOUR-REPO/main/agent.alp.json"
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or via environment variable:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
AGENT_CARD_URL=https://raw.githubusercontent.com/YOUR-ORG/YOUR-REPO/main/agent.alp.json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Connect to Kiro
|
|
92
|
+
|
|
93
|
+
Add to `.kiro/settings/mcp.json`:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"mcpServers": {
|
|
98
|
+
"my-agent": {
|
|
99
|
+
"url": "http://localhost:8000/mcp"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Endpoints added
|
|
106
|
+
|
|
107
|
+
| Endpoint | Description |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `GET /mcp` | MCP SSE stream — Kiro connects here |
|
|
110
|
+
| `POST /mcp` | MCP JSON-RPC receiver |
|
|
111
|
+
| `GET /agent` | Agent Card JSON |
|
|
112
|
+
| `GET /agent/refresh` | Re-fetch remote card |
|
|
113
|
+
| `GET /persona` | Agent persona for system prompt |
|
|
114
|
+
| `GET /tools` | Tool list |
|
|
115
|
+
| `POST /tools/{name}` | Execute a tool |
|
|
116
|
+
| `GET /agents` | All hosted cards |
|
|
117
|
+
| `GET /health` | Status check |
|
|
118
|
+
|
|
119
|
+
## Protocol
|
|
120
|
+
|
|
121
|
+
Part of the [Agent Load Protocol](https://github.com/RodrigoMvs123/agent-load-protocol).
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
alp-server
|
|
3
|
+
----------
|
|
4
|
+
Agent Load Protocol — drop-in MCP/SSE middleware for any Python server.
|
|
5
|
+
|
|
6
|
+
Quick start (FastAPI):
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from alp import ALPRouter
|
|
10
|
+
|
|
11
|
+
app = FastAPI()
|
|
12
|
+
alp = ALPRouter(card_path="agent.alp.json")
|
|
13
|
+
app.include_router(alp.router)
|
|
14
|
+
|
|
15
|
+
Quick start (Flask):
|
|
16
|
+
|
|
17
|
+
from flask import Flask
|
|
18
|
+
from alp.flask import ALPBlueprint
|
|
19
|
+
|
|
20
|
+
app = Flask(__name__)
|
|
21
|
+
alp = ALPBlueprint(card_path="agent.alp.json")
|
|
22
|
+
app.register_blueprint(alp.blueprint)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .fastapi import ALPRouter
|
|
26
|
+
|
|
27
|
+
__all__ = ["ALPRouter"]
|
|
28
|
+
__version__ = "0.7.0"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
alp.card
|
|
3
|
+
--------
|
|
4
|
+
Agent Card loader + validator.
|
|
5
|
+
Supports local file (card_path) and remote URL (card_url).
|
|
6
|
+
Includes in-memory cache with refresh support.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_cache: dict[str, dict] = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_local(card_path: str) -> dict:
|
|
20
|
+
"""Load an Agent Card from a local file."""
|
|
21
|
+
path = Path(card_path)
|
|
22
|
+
if not path.exists():
|
|
23
|
+
raise FileNotFoundError(f"Agent card not found: {card_path}")
|
|
24
|
+
with open(path) as f:
|
|
25
|
+
return json.load(f)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def load_remote(url: str) -> dict:
|
|
29
|
+
"""Load an Agent Card from a public URL."""
|
|
30
|
+
try:
|
|
31
|
+
async with httpx.AsyncClient() as client:
|
|
32
|
+
response = await client.get(url, timeout=10.0)
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
return response.json()
|
|
35
|
+
except httpx.HTTPStatusError as exc:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
f"Failed to fetch card from '{url}': HTTP {exc.response.status_code}"
|
|
38
|
+
)
|
|
39
|
+
except httpx.RequestError as exc:
|
|
40
|
+
raise RuntimeError(
|
|
41
|
+
f"Connection error fetching card from '{url}': {str(exc)}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def get_card(
|
|
46
|
+
card_path: str = "agent.alp.json",
|
|
47
|
+
card_url: Optional[str] = None,
|
|
48
|
+
cache_key: str = "__primary__",
|
|
49
|
+
use_cache: bool = True,
|
|
50
|
+
) -> dict:
|
|
51
|
+
"""
|
|
52
|
+
Return an Agent Card.
|
|
53
|
+
Priority: card_url (remote) > card_path (local file).
|
|
54
|
+
Results are cached in memory; pass use_cache=False to force reload.
|
|
55
|
+
"""
|
|
56
|
+
if use_cache and cache_key in _cache:
|
|
57
|
+
return _cache[cache_key]
|
|
58
|
+
|
|
59
|
+
if card_url:
|
|
60
|
+
card = await load_remote(card_url)
|
|
61
|
+
else:
|
|
62
|
+
card = load_local(card_path)
|
|
63
|
+
|
|
64
|
+
_cache[cache_key] = card
|
|
65
|
+
return card
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def invalidate(cache_key: str = "__primary__") -> None:
|
|
69
|
+
"""Remove a card from the cache so it will be reloaded on next access."""
|
|
70
|
+
_cache.pop(cache_key, None)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_all_cached() -> dict[str, dict]:
|
|
74
|
+
"""Return all cached cards."""
|
|
75
|
+
return dict(_cache)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
alp.fastapi
|
|
3
|
+
-----------
|
|
4
|
+
Drop-in FastAPI router. Mounts all ALP + MCP endpoints on any FastAPI app.
|
|
5
|
+
|
|
6
|
+
Usage (3 lines):
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from alp import ALPRouter
|
|
10
|
+
|
|
11
|
+
app = FastAPI()
|
|
12
|
+
alp = ALPRouter(card_path="agent.alp.json")
|
|
13
|
+
app.include_router(alp.router)
|
|
14
|
+
|
|
15
|
+
Custom tool registration:
|
|
16
|
+
|
|
17
|
+
alp = ALPRouter(card_path="agent.alp.json")
|
|
18
|
+
|
|
19
|
+
@alp.tool("greet")
|
|
20
|
+
async def greet(input_data: dict) -> dict:
|
|
21
|
+
return {"message": f"Hello, {input_data.get('name', 'stranger')}!"}
|
|
22
|
+
|
|
23
|
+
app.include_router(alp.router)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from typing import Any, Callable, Optional
|
|
28
|
+
|
|
29
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
30
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
31
|
+
from pydantic import BaseModel
|
|
32
|
+
|
|
33
|
+
from . import card as card_module
|
|
34
|
+
from . import mcp as mcp_module
|
|
35
|
+
from . import sse as sse_module
|
|
36
|
+
from . import tools as tools_module
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ALPRouter:
|
|
40
|
+
"""
|
|
41
|
+
ALP FastAPI router.
|
|
42
|
+
Mount on any FastAPI app to add /mcp, /agent, /tools, /persona, /agents,
|
|
43
|
+
/health, /agent/refresh endpoints.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
card_path: str = "agent.alp.json",
|
|
49
|
+
card_url: Optional[str] = None,
|
|
50
|
+
port: int = None,
|
|
51
|
+
prefix: str = "",
|
|
52
|
+
):
|
|
53
|
+
self.card_path = card_url or os.environ.get("AGENT_CARD_URL") or card_path
|
|
54
|
+
self.card_url = card_url or os.environ.get("AGENT_CARD_URL")
|
|
55
|
+
self.port = port or int(os.environ.get("PORT", 8000))
|
|
56
|
+
self.router = APIRouter(prefix=prefix)
|
|
57
|
+
self._register_routes()
|
|
58
|
+
|
|
59
|
+
def tool(self, tool_name: str) -> Callable:
|
|
60
|
+
"""Decorator to register a local Python function as a tool handler."""
|
|
61
|
+
def decorator(fn: Callable) -> Callable:
|
|
62
|
+
tools_module.register(tool_name, fn)
|
|
63
|
+
return fn
|
|
64
|
+
return decorator
|
|
65
|
+
|
|
66
|
+
async def _get_card(self) -> dict:
|
|
67
|
+
return await card_module.get_card(
|
|
68
|
+
card_path=self.card_path,
|
|
69
|
+
card_url=self.card_url,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _register_routes(self) -> None:
|
|
73
|
+
|
|
74
|
+
router = self.router
|
|
75
|
+
|
|
76
|
+
# --- Health ---
|
|
77
|
+
|
|
78
|
+
@router.get("/health")
|
|
79
|
+
async def health():
|
|
80
|
+
return {"status": "ok", "alp_version": "0.7.0"}
|
|
81
|
+
|
|
82
|
+
# --- Agent Card ---
|
|
83
|
+
|
|
84
|
+
@router.get("/agent")
|
|
85
|
+
async def get_agent():
|
|
86
|
+
try:
|
|
87
|
+
return await self._get_card()
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
90
|
+
|
|
91
|
+
@router.get("/agent/refresh")
|
|
92
|
+
async def refresh_agent():
|
|
93
|
+
if not self.card_url:
|
|
94
|
+
raise HTTPException(
|
|
95
|
+
status_code=400,
|
|
96
|
+
detail="card_url is not set — nothing to refresh"
|
|
97
|
+
)
|
|
98
|
+
card_module.invalidate()
|
|
99
|
+
try:
|
|
100
|
+
card = await self._get_card()
|
|
101
|
+
return {
|
|
102
|
+
"refreshed": True,
|
|
103
|
+
"id": card.get("id"),
|
|
104
|
+
"name": card.get("name"),
|
|
105
|
+
"alp_version": card.get("alp_version"),
|
|
106
|
+
"source": self.card_url,
|
|
107
|
+
}
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
raise HTTPException(status_code=502, detail=str(exc))
|
|
110
|
+
|
|
111
|
+
# --- Persona ---
|
|
112
|
+
|
|
113
|
+
@router.get("/persona")
|
|
114
|
+
async def get_persona():
|
|
115
|
+
card = await self._get_card()
|
|
116
|
+
persona = card.get("persona")
|
|
117
|
+
if not persona:
|
|
118
|
+
raise HTTPException(status_code=404, detail="No persona defined")
|
|
119
|
+
return {"persona": persona, "id": card.get("id"), "name": card.get("name")}
|
|
120
|
+
|
|
121
|
+
# --- Agents catalog ---
|
|
122
|
+
|
|
123
|
+
@router.get("/agents")
|
|
124
|
+
async def list_agents():
|
|
125
|
+
card = await self._get_card()
|
|
126
|
+
all_cached = card_module.get_all_cached()
|
|
127
|
+
cards = [v for k, v in all_cached.items() if k != "__primary__"]
|
|
128
|
+
if not cards:
|
|
129
|
+
cards = [card]
|
|
130
|
+
return {"agents": cards}
|
|
131
|
+
|
|
132
|
+
# --- Tools ---
|
|
133
|
+
|
|
134
|
+
@router.get("/tools")
|
|
135
|
+
async def list_tools():
|
|
136
|
+
card = await self._get_card()
|
|
137
|
+
return {"tools": card.get("tools", [])}
|
|
138
|
+
|
|
139
|
+
class ToolInput(BaseModel):
|
|
140
|
+
input: dict[str, Any] = {}
|
|
141
|
+
|
|
142
|
+
@router.post("/tools/{tool_name}")
|
|
143
|
+
async def execute_tool(tool_name: str, body: ToolInput):
|
|
144
|
+
card = await self._get_card()
|
|
145
|
+
try:
|
|
146
|
+
result = await tools_module.execute(tool_name, body.input, card)
|
|
147
|
+
return result
|
|
148
|
+
except KeyError as e:
|
|
149
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
150
|
+
except RuntimeError as e:
|
|
151
|
+
raise HTTPException(status_code=502, detail=str(e))
|
|
152
|
+
|
|
153
|
+
# --- MCP SSE ---
|
|
154
|
+
|
|
155
|
+
@router.get("/mcp")
|
|
156
|
+
async def mcp_sse(request: Request):
|
|
157
|
+
"""MCP SSE transport — Kiro connects here."""
|
|
158
|
+
card = await self._get_card()
|
|
159
|
+
session_id, queue = sse_module.create_session()
|
|
160
|
+
post_url = f"http://localhost:{self.port}/mcp?session_id={session_id}"
|
|
161
|
+
stream = sse_module.event_stream(session_id, queue, post_url)
|
|
162
|
+
return StreamingResponse(
|
|
163
|
+
stream,
|
|
164
|
+
media_type="text/event-stream",
|
|
165
|
+
headers={
|
|
166
|
+
"Cache-Control": "no-cache",
|
|
167
|
+
"Connection": "keep-alive",
|
|
168
|
+
"X-Accel-Buffering": "no",
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
@router.post("/mcp")
|
|
173
|
+
async def mcp_post(request: Request):
|
|
174
|
+
"""MCP JSON-RPC receiver — Kiro POSTs here."""
|
|
175
|
+
card = await self._get_card()
|
|
176
|
+
session_id = request.query_params.get("session_id")
|
|
177
|
+
try:
|
|
178
|
+
body = await request.json()
|
|
179
|
+
except Exception:
|
|
180
|
+
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
|
181
|
+
|
|
182
|
+
messages = body if isinstance(body, list) else [body]
|
|
183
|
+
last_response = None
|
|
184
|
+
|
|
185
|
+
for msg in messages:
|
|
186
|
+
response = await mcp_module.handle(msg, card)
|
|
187
|
+
if response is None:
|
|
188
|
+
continue
|
|
189
|
+
if session_id:
|
|
190
|
+
pushed = await sse_module.push(session_id, response)
|
|
191
|
+
if not pushed:
|
|
192
|
+
last_response = response
|
|
193
|
+
else:
|
|
194
|
+
last_response = response
|
|
195
|
+
|
|
196
|
+
if last_response:
|
|
197
|
+
return JSONResponse(last_response)
|
|
198
|
+
return JSONResponse({"ok": True})
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
alp.flask
|
|
3
|
+
---------
|
|
4
|
+
Drop-in Flask blueprint. Mounts all ALP + MCP endpoints on any Flask app.
|
|
5
|
+
|
|
6
|
+
Usage (3 lines):
|
|
7
|
+
|
|
8
|
+
from flask import Flask
|
|
9
|
+
from alp.flask import ALPBlueprint
|
|
10
|
+
|
|
11
|
+
app = Flask(__name__)
|
|
12
|
+
alp = ALPBlueprint(card_path="agent.alp.json")
|
|
13
|
+
app.register_blueprint(alp.blueprint)
|
|
14
|
+
|
|
15
|
+
Note: SSE requires an async-capable Flask setup (Flask 2.x + asgiref or gevent).
|
|
16
|
+
For pure sync Flask, the /mcp SSE endpoint uses threading.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import queue
|
|
23
|
+
import threading
|
|
24
|
+
from typing import Any, Callable, Optional
|
|
25
|
+
|
|
26
|
+
from . import card as card_module
|
|
27
|
+
from . import mcp as mcp_module
|
|
28
|
+
from . import tools as tools_module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run_async(coro):
|
|
32
|
+
"""Run an async coroutine from sync Flask context."""
|
|
33
|
+
try:
|
|
34
|
+
loop = asyncio.get_event_loop()
|
|
35
|
+
if loop.is_running():
|
|
36
|
+
import concurrent.futures
|
|
37
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
38
|
+
future = pool.submit(asyncio.run, coro)
|
|
39
|
+
return future.result()
|
|
40
|
+
return loop.run_until_complete(coro)
|
|
41
|
+
except RuntimeError:
|
|
42
|
+
return asyncio.run(coro)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ALPBlueprint:
|
|
46
|
+
"""
|
|
47
|
+
ALP Flask blueprint.
|
|
48
|
+
Register on any Flask app to add /mcp, /agent, /tools, /persona,
|
|
49
|
+
/agents, /health, /agent/refresh endpoints.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
card_path: str = "agent.alp.json",
|
|
55
|
+
card_url: Optional[str] = None,
|
|
56
|
+
port: int = None,
|
|
57
|
+
url_prefix: str = "",
|
|
58
|
+
):
|
|
59
|
+
try:
|
|
60
|
+
from flask import Blueprint
|
|
61
|
+
except ImportError:
|
|
62
|
+
raise ImportError(
|
|
63
|
+
"Flask is not installed. Run: pip install flask"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self.card_path = card_path
|
|
67
|
+
self.card_url = card_url or os.environ.get("AGENT_CARD_URL")
|
|
68
|
+
self.port = port or int(os.environ.get("PORT", 8000))
|
|
69
|
+
self.blueprint = Blueprint("alp", __name__, url_prefix=url_prefix)
|
|
70
|
+
self._sse_clients: dict[str, queue.Queue] = {}
|
|
71
|
+
self._register_routes()
|
|
72
|
+
|
|
73
|
+
def tool(self, tool_name: str) -> Callable:
|
|
74
|
+
"""Decorator to register a local Python function as a tool handler."""
|
|
75
|
+
def decorator(fn: Callable) -> Callable:
|
|
76
|
+
tools_module.register(tool_name, fn)
|
|
77
|
+
return fn
|
|
78
|
+
return decorator
|
|
79
|
+
|
|
80
|
+
def _get_card(self) -> dict:
|
|
81
|
+
return _run_async(
|
|
82
|
+
card_module.get_card(
|
|
83
|
+
card_path=self.card_path,
|
|
84
|
+
card_url=self.card_url,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _register_routes(self) -> None:
|
|
89
|
+
from flask import request, jsonify, Response
|
|
90
|
+
|
|
91
|
+
bp = self.blueprint
|
|
92
|
+
|
|
93
|
+
@bp.get("/health")
|
|
94
|
+
def health():
|
|
95
|
+
return jsonify({"status": "ok", "alp_version": "0.7.0"})
|
|
96
|
+
|
|
97
|
+
@bp.get("/agent")
|
|
98
|
+
def get_agent():
|
|
99
|
+
try:
|
|
100
|
+
return jsonify(self._get_card())
|
|
101
|
+
except Exception as e:
|
|
102
|
+
return jsonify({"error": str(e)}), 404
|
|
103
|
+
|
|
104
|
+
@bp.get("/agent/refresh")
|
|
105
|
+
def refresh_agent():
|
|
106
|
+
if not self.card_url:
|
|
107
|
+
return jsonify({"error": "card_url is not set"}), 400
|
|
108
|
+
card_module.invalidate()
|
|
109
|
+
try:
|
|
110
|
+
card = self._get_card()
|
|
111
|
+
return jsonify({
|
|
112
|
+
"refreshed": True,
|
|
113
|
+
"id": card.get("id"),
|
|
114
|
+
"name": card.get("name"),
|
|
115
|
+
"alp_version": card.get("alp_version"),
|
|
116
|
+
"source": self.card_url,
|
|
117
|
+
})
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
return jsonify({"error": str(exc)}), 502
|
|
120
|
+
|
|
121
|
+
@bp.get("/persona")
|
|
122
|
+
def get_persona():
|
|
123
|
+
card = self._get_card()
|
|
124
|
+
persona = card.get("persona")
|
|
125
|
+
if not persona:
|
|
126
|
+
return jsonify({"error": "No persona defined"}), 404
|
|
127
|
+
return jsonify({"persona": persona, "id": card.get("id"), "name": card.get("name")})
|
|
128
|
+
|
|
129
|
+
@bp.get("/agents")
|
|
130
|
+
def list_agents():
|
|
131
|
+
card = self._get_card()
|
|
132
|
+
return jsonify({"agents": [card]})
|
|
133
|
+
|
|
134
|
+
@bp.get("/tools")
|
|
135
|
+
def list_tools():
|
|
136
|
+
card = self._get_card()
|
|
137
|
+
return jsonify({"tools": card.get("tools", [])})
|
|
138
|
+
|
|
139
|
+
@bp.post("/tools/<tool_name>")
|
|
140
|
+
def execute_tool(tool_name):
|
|
141
|
+
card = self._get_card()
|
|
142
|
+
body = request.get_json(silent=True) or {}
|
|
143
|
+
input_data = body.get("input", body)
|
|
144
|
+
try:
|
|
145
|
+
result = _run_async(tools_module.execute(tool_name, input_data, card))
|
|
146
|
+
return jsonify(result)
|
|
147
|
+
except KeyError as e:
|
|
148
|
+
return jsonify({"error": str(e)}), 404
|
|
149
|
+
except RuntimeError as e:
|
|
150
|
+
return jsonify({"error": str(e)}), 502
|
|
151
|
+
|
|
152
|
+
@bp.get("/mcp")
|
|
153
|
+
def mcp_sse():
|
|
154
|
+
import uuid
|
|
155
|
+
session_id = str(uuid.uuid4())
|
|
156
|
+
q: queue.Queue = queue.Queue()
|
|
157
|
+
self._sse_clients[session_id] = q
|
|
158
|
+
|
|
159
|
+
def stream():
|
|
160
|
+
try:
|
|
161
|
+
post_url = f"http://localhost:{self.port}/mcp?session_id={session_id}"
|
|
162
|
+
yield f"event: endpoint\ndata: {post_url}\n\n"
|
|
163
|
+
while True:
|
|
164
|
+
try:
|
|
165
|
+
msg = q.get(timeout=15)
|
|
166
|
+
yield f"event: message\ndata: {json.dumps(msg)}\n\n"
|
|
167
|
+
except queue.Empty:
|
|
168
|
+
yield ": ping\n\n"
|
|
169
|
+
finally:
|
|
170
|
+
self._sse_clients.pop(session_id, None)
|
|
171
|
+
|
|
172
|
+
return Response(
|
|
173
|
+
stream(),
|
|
174
|
+
mimetype="text/event-stream",
|
|
175
|
+
headers={
|
|
176
|
+
"Cache-Control": "no-cache",
|
|
177
|
+
"X-Accel-Buffering": "no",
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@bp.post("/mcp")
|
|
182
|
+
def mcp_post():
|
|
183
|
+
card = self._get_card()
|
|
184
|
+
session_id = request.args.get("session_id")
|
|
185
|
+
body = request.get_json(silent=True)
|
|
186
|
+
if body is None:
|
|
187
|
+
return jsonify({"error": "invalid JSON"}), 400
|
|
188
|
+
|
|
189
|
+
messages = body if isinstance(body, list) else [body]
|
|
190
|
+
last_response = None
|
|
191
|
+
|
|
192
|
+
for msg in messages:
|
|
193
|
+
response = _run_async(mcp_module.handle(msg, card))
|
|
194
|
+
if response is None:
|
|
195
|
+
continue
|
|
196
|
+
if session_id and session_id in self._sse_clients:
|
|
197
|
+
self._sse_clients[session_id].put(response)
|
|
198
|
+
else:
|
|
199
|
+
last_response = response
|
|
200
|
+
|
|
201
|
+
if last_response:
|
|
202
|
+
return jsonify(last_response)
|
|
203
|
+
return jsonify({"ok": True})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
alp.mcp
|
|
3
|
+
-------
|
|
4
|
+
MCP JSON-RPC 2.0 message handler.
|
|
5
|
+
Supports: initialize, tools/list, tools/call, notifications/*.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from . import tools as tool_registry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def handle(msg: dict, card: dict) -> Optional[dict]:
|
|
15
|
+
"""
|
|
16
|
+
Handle a single MCP JSON-RPC 2.0 message.
|
|
17
|
+
Returns the response dict, or None for notifications.
|
|
18
|
+
"""
|
|
19
|
+
method = msg.get("method", "")
|
|
20
|
+
msg_id = msg.get("id")
|
|
21
|
+
params = msg.get("params", {})
|
|
22
|
+
|
|
23
|
+
# initialize
|
|
24
|
+
if method == "initialize":
|
|
25
|
+
return {
|
|
26
|
+
"jsonrpc": "2.0",
|
|
27
|
+
"id": msg_id,
|
|
28
|
+
"result": {
|
|
29
|
+
"protocolVersion": "2024-11-05",
|
|
30
|
+
"capabilities": {"tools": {}},
|
|
31
|
+
"serverInfo": {
|
|
32
|
+
"name": card.get("name", "ALP Agent"),
|
|
33
|
+
"version": card.get("alp_version", "0.7.0"),
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# tools/list
|
|
39
|
+
if method == "tools/list":
|
|
40
|
+
return {
|
|
41
|
+
"jsonrpc": "2.0",
|
|
42
|
+
"id": msg_id,
|
|
43
|
+
"result": {"tools": tool_registry.list_mcp(card)},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# tools/call
|
|
47
|
+
if method == "tools/call":
|
|
48
|
+
tool_name = params.get("name", "")
|
|
49
|
+
tool_input = params.get("arguments", params.get("input", {}))
|
|
50
|
+
try:
|
|
51
|
+
result = await tool_registry.execute(tool_name, tool_input, card)
|
|
52
|
+
return {
|
|
53
|
+
"jsonrpc": "2.0",
|
|
54
|
+
"id": msg_id,
|
|
55
|
+
"result": {
|
|
56
|
+
"content": [{"type": "text", "text": json.dumps(result)}],
|
|
57
|
+
"isError": False,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
return {
|
|
62
|
+
"jsonrpc": "2.0",
|
|
63
|
+
"id": msg_id,
|
|
64
|
+
"result": {
|
|
65
|
+
"content": [{"type": "text", "text": str(exc)}],
|
|
66
|
+
"isError": True,
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# notifications — no response
|
|
71
|
+
if method.startswith("notifications/"):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# unknown method
|
|
75
|
+
return {
|
|
76
|
+
"jsonrpc": "2.0",
|
|
77
|
+
"id": msg_id,
|
|
78
|
+
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
|
79
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
alp.sse
|
|
3
|
+
-------
|
|
4
|
+
SSE (Server-Sent Events) transport layer for MCP.
|
|
5
|
+
Manages per-session queues and the event stream generator.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import AsyncGenerator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# session_id -> asyncio.Queue
|
|
15
|
+
_clients: dict[str, asyncio.Queue] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_session() -> tuple[str, asyncio.Queue]:
|
|
19
|
+
"""Create a new SSE session. Returns (session_id, queue)."""
|
|
20
|
+
session_id = str(uuid.uuid4())
|
|
21
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
22
|
+
_clients[session_id] = queue
|
|
23
|
+
return session_id, queue
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def remove_session(session_id: str) -> None:
|
|
27
|
+
"""Remove a session from the client registry."""
|
|
28
|
+
_clients.pop(session_id, None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_queue(session_id: str) -> asyncio.Queue | None:
|
|
32
|
+
"""Return the queue for a session, or None if not found."""
|
|
33
|
+
return _clients.get(session_id)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def push(session_id: str, message: dict) -> bool:
|
|
37
|
+
"""Push a message to a session's SSE queue. Returns False if session not found."""
|
|
38
|
+
queue = get_queue(session_id)
|
|
39
|
+
if queue is None:
|
|
40
|
+
return False
|
|
41
|
+
await queue.put(message)
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def event_stream(
|
|
46
|
+
session_id: str,
|
|
47
|
+
queue: asyncio.Queue,
|
|
48
|
+
post_url: str,
|
|
49
|
+
ping_interval: float = 15.0,
|
|
50
|
+
) -> AsyncGenerator[str, None]:
|
|
51
|
+
"""
|
|
52
|
+
Async generator that yields SSE-formatted strings.
|
|
53
|
+
Sends the endpoint event first, then relays queued messages with heartbeat pings.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
# MCP endpoint discovery event
|
|
57
|
+
yield f"event: endpoint\ndata: {post_url}\n\n"
|
|
58
|
+
|
|
59
|
+
while True:
|
|
60
|
+
try:
|
|
61
|
+
message = await asyncio.wait_for(queue.get(), timeout=ping_interval)
|
|
62
|
+
yield f"event: message\ndata: {json.dumps(message)}\n\n"
|
|
63
|
+
except asyncio.TimeoutError:
|
|
64
|
+
yield ": ping\n\n"
|
|
65
|
+
except asyncio.CancelledError:
|
|
66
|
+
pass
|
|
67
|
+
finally:
|
|
68
|
+
remove_session(session_id)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
alp.tools
|
|
3
|
+
---------
|
|
4
|
+
Tool registry + proxy executor.
|
|
5
|
+
|
|
6
|
+
- Tools with a full URL endpoint are proxied via HTTP (v0.5.0)
|
|
7
|
+
- Tools with a relative endpoint are executed via registered Python functions
|
|
8
|
+
- The @alp.tool() decorator registers local Python functions as tools
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Any, Callable, Optional
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Global tool function registry: tool_name -> async callable
|
|
16
|
+
_tool_registry: dict[str, Callable] = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(tool_name: str, fn: Callable) -> None:
|
|
20
|
+
"""Register a Python function as the handler for a named tool."""
|
|
21
|
+
_tool_registry[tool_name] = fn
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_registered(tool_name: str) -> Optional[Callable]:
|
|
25
|
+
"""Return the registered function for a tool, or None."""
|
|
26
|
+
return _tool_registry.get(tool_name)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def list_mcp(card: dict) -> list[dict]:
|
|
30
|
+
"""Return the MCP-formatted tool list from an Agent Card."""
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
"name": t["name"],
|
|
34
|
+
"description": t.get("description", ""),
|
|
35
|
+
"inputSchema": t.get("input_schema", {"type": "object", "properties": {}}),
|
|
36
|
+
}
|
|
37
|
+
for t in card.get("tools", [])
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def execute(tool_name: str, input_data: dict, card: dict) -> Any:
|
|
42
|
+
"""
|
|
43
|
+
Execute a tool.
|
|
44
|
+
|
|
45
|
+
Resolution order:
|
|
46
|
+
1. Registered Python function (@alp.tool decorator)
|
|
47
|
+
2. Full URL endpoint → proxy via HTTP (v0.5.0)
|
|
48
|
+
3. Relative endpoint → stub response
|
|
49
|
+
"""
|
|
50
|
+
tools = {t["name"]: t for t in card.get("tools", [])}
|
|
51
|
+
|
|
52
|
+
if tool_name not in tools:
|
|
53
|
+
raise KeyError(f"Tool '{tool_name}' not found in Agent Card")
|
|
54
|
+
|
|
55
|
+
# 1. Registered local function
|
|
56
|
+
fn = get_registered(tool_name)
|
|
57
|
+
if fn is not None:
|
|
58
|
+
return await fn(input_data)
|
|
59
|
+
|
|
60
|
+
tool = tools[tool_name]
|
|
61
|
+
endpoint = tool.get("endpoint", "")
|
|
62
|
+
|
|
63
|
+
# 2. Proxy execution (v0.5.0)
|
|
64
|
+
if endpoint.startswith("http://") or endpoint.startswith("https://"):
|
|
65
|
+
try:
|
|
66
|
+
async with httpx.AsyncClient() as client:
|
|
67
|
+
response = await client.post(
|
|
68
|
+
endpoint,
|
|
69
|
+
json={"input": input_data},
|
|
70
|
+
timeout=30.0,
|
|
71
|
+
)
|
|
72
|
+
response.raise_for_status()
|
|
73
|
+
return response.json()
|
|
74
|
+
except httpx.HTTPStatusError as exc:
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
f"Proxy error from '{endpoint}': HTTP {exc.response.status_code}"
|
|
77
|
+
)
|
|
78
|
+
except httpx.RequestError as exc:
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
f"Proxy connection error to '{endpoint}': {str(exc)}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# 3. Local stub — override by registering a function with @alp.tool()
|
|
84
|
+
return {
|
|
85
|
+
"result": f"Tool '{tool_name}' executed with input: {input_data}",
|
|
86
|
+
"error": None,
|
|
87
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "alp-server"
|
|
7
|
+
version = "0.7.0"
|
|
8
|
+
description = "Agent Load Protocol — drop-in MCP/SSE middleware for any Python server"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
keywords = ["mcp", "agent", "alp", "fastapi", "llm", "kiro", "claude"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"fastapi>=0.111.0",
|
|
24
|
+
"uvicorn[standard]>=0.29.0",
|
|
25
|
+
"httpx>=0.27.0",
|
|
26
|
+
"python-dotenv>=1.0.1",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
flask = ["flask>=3.0.0"]
|
|
31
|
+
dev = ["pytest", "pytest-asyncio", "httpx"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/RodrigoMvs123/agent-load-protocol"
|
|
35
|
+
Repository = "https://github.com/RodrigoMvs123/agent-load-protocol"
|
|
36
|
+
Documentation = "https://github.com/RodrigoMvs123/agent-load-protocol/blob/main/SPEC.md"
|
|
37
|
+
"Bug Tracker" = "https://github.com/RodrigoMvs123/agent-load-protocol/issues"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["alp"]
|