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.
@@ -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"]