fix: token auth and reconfiguration
Rafael Uzarowski committed
Jul 31, 2025 at 19:12 UTC
f3605eb53bd358700ccd4774c593eef78498413f
4 files changed
+509
-204
python/helpers/fasta2a_server.py
+298
-139
@@ -4,132 +4,157 @@ import uuid
4
import atexit
5
from typing import Any, List
6
import contextlib
7
+import threading
8
9
from python.helpers import settings
9
-from starlette.middleware.base import BaseHTTPMiddleware
10
from starlette.requests import Request
11
-from starlette.responses import PlainTextResponse
12
-from starlette.middleware import Middleware
11
12
# Local imports
13
from python.helpers.print_style import PrintStyle
14
from agent import AgentContext, UserMessage
15
from initialize import initialize_agent
16
19
-# Attempt to import FastA2A – fall back to stubs so linters stay quiet
17
+# Import FastA2A
18
try:
21
- from fasta2a import Worker # type: ignore
19
+ from fasta2a import Worker, FastA2A # type: ignore
20
from fasta2a.broker import InMemoryBroker # type: ignore
21
from fasta2a.storage import InMemoryStorage # type: ignore
22
from fasta2a.schema import Message, Artifact, AgentProvider, Skill # type: ignore
25
-
23
FASTA2A_AVAILABLE = True
24
except ImportError: # pragma: no cover – library not installed
25
FASTA2A_AVAILABLE = False
26
+ # Minimal stubs for type checkers when FastA2A is not available
27
28
class Worker: # type: ignore
31
- """Stub so type-checkers don’t complain when FastA2A is absent."""
29
+ def __init__(self, **kwargs):
30
+ pass
31
+
32
+ async def run_task(self, params):
33
+ pass
34
+
35
+ async def cancel_task(self, params):
36
+ pass
37
38
+ def build_message_history(self, history):
39
+ return []
40
+
41
+ def build_artifacts(self, result):
42
+ return []
43
+
44
+ class FastA2A: # type: ignore
45
+ def __init__(self, **kwargs):
46
+ pass
47
+
48
+ async def __call__(self, scope, receive, send):
49
+ pass
50
+
51
+ class InMemoryBroker: # type: ignore
52
pass
53
35
- # Minimal stubs for type checkers
54
+ class InMemoryStorage: # type: ignore
55
+ async def update_task(self, **kwargs):
56
+ pass
57
+
58
Message = Artifact = AgentProvider = Skill = Any # type: ignore
37
- InMemoryBroker = InMemoryStorage = object # type: ignore
59
60
_PRINTER = PrintStyle(italic=True, font_color="purple", padding=False)
61
41
-if FASTA2A_AVAILABLE:
42
- class AgentZeroWorker(Worker): # type: ignore[misc]
43
- """Agent Zero implementation of FastA2A Worker."""
62
45
- async def run_task(self, params: Any) -> None: # params: TaskSendParams
46
- """Execute a task by processing the message through Agent Zero."""
47
- try:
48
- task_id = params['id']
49
- context_id = params['context_id']
50
- message = params['message']
51
-
52
- _PRINTER.print(f"[A2A] Processing task {task_id} in context {context_id}")
53
-
54
- # Convert A2A message to Agent Zero format
55
- agent_message = self._convert_message(message)
56
-
57
- # Get or create Agent Zero context
58
- context = AgentContext.get(context_id)
59
- if not context:
60
- # Create new context for this A2A conversation
61
- cfg = initialize_agent()
62
- context = AgentContext(cfg, id=context_id)
63
-
64
- # Log user message so it appears instantly in UI chat window
65
- context.log.log(
66
- type="user", # type: ignore[arg-type]
67
- heading="Remote user message",
68
- content=agent_message.message,
69
- kvps={"from": "A2A"},
70
- temp=False,
71
- )
63
+class AgentZeroWorker(Worker): # type: ignore[misc]
64
+ """Agent Zero implementation of FastA2A Worker."""
65
73
- # Process message through Agent Zero (includes response)
74
- task = context.communicate(agent_message)
75
- result_text = await task.result()
76
-
77
- # Build A2A message from result
78
- response_message: Message = { # type: ignore
79
- 'role': 'agent',
80
- 'parts': [{'kind': 'text', 'text': str(result_text)}],
81
- 'kind': 'message',
82
- 'message_id': str(uuid.uuid4())
83
- }
84
-
85
- await self.storage.update_task( # type: ignore[attr-defined]
86
- task_id=task_id,
87
- state='completed',
88
- new_messages=[response_message]
89
- )
66
+ def __init__(self, broker, storage):
67
+ super().__init__(broker=broker, storage=storage)
68
+ self.storage = storage
69
91
- _PRINTER.print(f"[A2A] Completed task {task_id}")
70
+ async def run_task(self, params: Any) -> None: # params: TaskSendParams
71
+ """Execute a task by processing the message through Agent Zero."""
72
+ try:
73
+ task_id = params['id']
74
+ context_id = params['context_id']
75
+ message = params['message']
76
+
77
+ _PRINTER.print(f"[A2A] Processing task {task_id} in context {context_id}")
78
+
79
+ # Convert A2A message to Agent Zero format
80
+ agent_message = self._convert_message(message)
81
+
82
+ # Get or create Agent Zero context
83
+ context = AgentContext.get(context_id)
84
+ if not context:
85
+ # Create new context for this A2A conversation
86
+ cfg = initialize_agent()
87
+ context = AgentContext(cfg, id=context_id)
88
+
89
+ # Log user message so it appears instantly in UI chat window
90
+ context.log.log(
91
+ type="user", # type: ignore[arg-type]
92
+ heading="Remote user message",
93
+ content=agent_message.message,
94
+ kvps={"from": "A2A"},
95
+ temp=False,
96
+ )
97
93
- except Exception as e:
94
- _PRINTER.print(f"[A2A] Error processing task {params.get('id', 'unknown')}: {e}")
95
- await self.storage.update_task(
96
- task_id=params.get('id', 'unknown'),
97
- state='failed'
98
- )
98
+ # Process message through Agent Zero (includes response)
99
+ task = context.communicate(agent_message)
100
+ result_text = await task.result()
101
100
- async def cancel_task(self, params: Any) -> None: # params: TaskIdParams
101
- """Cancel a running task."""
102
- task_id = params['id']
103
- _PRINTER.print(f"[A2A] Cancelling task {task_id}")
104
- await self.storage.update_task(task_id=task_id, state='canceled') # type: ignore[attr-defined]
102
+ # Build A2A message from result
103
+ response_message: Message = { # type: ignore
104
+ 'role': 'agent',
105
+ 'parts': [{'kind': 'text', 'text': str(result_text)}],
106
+ 'kind': 'message',
107
+ 'message_id': str(uuid.uuid4())
108
+ }
109
106
- def build_message_history(self, history: List[Any]) -> List[Message]: # type: ignore
107
- # Not used in this simplified implementation
108
- return []
110
+ await self.storage.update_task( # type: ignore[attr-defined]
111
+ task_id=task_id,
112
+ state='completed',
113
+ new_messages=[response_message]
114
+ )
115
110
- def build_artifacts(self, result: Any) -> List[Artifact]: # type: ignore
111
- # No artifacts for now
112
- return []
116
+ _PRINTER.print(f"[A2A] Completed task {task_id}")
117
114
- def _convert_message(self, a2a_message: Message) -> UserMessage: # type: ignore
115
- """Convert A2A message to Agent Zero UserMessage."""
116
- # Extract text from message parts
117
- text_parts = [part.get('text', '') for part in a2a_message.get('parts', []) if part.get('kind') == 'text']
118
- message_text = '\n'.join(text_parts)
119
-
120
- # Extract file attachments
121
- attachments = []
122
- for part in a2a_message.get('parts', []):
123
- if part.get('kind') == 'file':
124
- file_info = part.get('file', {})
125
- if 'uri' in file_info:
126
- attachments.append(file_info['uri'])
127
-
128
- return UserMessage(
129
- message=message_text,
130
- attachments=attachments
118
+ except Exception as e:
119
+ _PRINTER.print(f"[A2A] Error processing task {params.get('id', 'unknown')}: {e}")
120
+ await self.storage.update_task(
121
+ task_id=params.get('id', 'unknown'),
122
+ state='failed'
123
)
124
125
+ async def cancel_task(self, params: Any) -> None: # params: TaskIdParams
126
+ """Cancel a running task."""
127
+ task_id = params['id']
128
+ _PRINTER.print(f"[A2A] Cancelling task {task_id}")
129
+ await self.storage.update_task(task_id=task_id, state='canceled') # type: ignore[attr-defined]
130
+
131
+ def build_message_history(self, history: List[Any]) -> List[Message]: # type: ignore
132
+ # Not used in this simplified implementation
133
+ return []
134
+
135
+ def build_artifacts(self, result: Any) -> List[Artifact]: # type: ignore
136
+ # No artifacts for now
137
+ return []
138
+
139
+ def _convert_message(self, a2a_message: Message) -> UserMessage: # type: ignore
140
+ """Convert A2A message to Agent Zero UserMessage."""
141
+ # Extract text from message parts
142
+ text_parts = [part.get('text', '') for part in a2a_message.get('parts', []) if part.get('kind') == 'text']
143
+ message_text = '\n'.join(text_parts)
144
+
145
+ # Extract file attachments
146
+ attachments = []
147
+ for part in a2a_message.get('parts', []):
148
+ if part.get('kind') == 'file':
149
+ file_info = part.get('file', {})
150
+ if 'uri' in file_info:
151
+ attachments.append(file_info['uri'])
152
+
153
+ return UserMessage(
154
+ message=message_text,
155
+ attachments=attachments
156
+ )
157
+
158
159
class DynamicA2AProxy:
160
"""Dynamic proxy for FastA2A server that allows reconfiguration."""
@@ -138,13 +163,20 @@ class DynamicA2AProxy:
163
164
def __init__(self):
165
self.app = None
141
- self._async_lock = asyncio.Lock()
166
+ self.token = ""
167
+ self._lock = threading.Lock() # Use threading.Lock instead of asyncio.Lock
168
self._startup_done: bool = False
169
self._worker_bg_task: asyncio.Task | None = None
170
+ self._reconfigure_needed: bool = False # Flag for deferred reconfiguration
171
172
if FASTA2A_AVAILABLE:
173
+ # Initialize with default token
174
+ cfg = settings.get_settings()
175
+ self.token = cfg.get("mcp_server_token", "")
176
self._configure()
177
self._register_shutdown()
178
+ else:
179
+ _PRINTER.print("[A2A] FastA2A not available, server will return 503")
180
181
@staticmethod
182
def get_instance():
@@ -152,14 +184,19 @@ class DynamicA2AProxy:
184
DynamicA2AProxy._instance = DynamicA2AProxy()
185
return DynamicA2AProxy._instance
186
187
+ def reconfigure(self, token: str):
188
+ """Reconfigure the FastA2A server with new token."""
189
+ self.token = token
190
+ if FASTA2A_AVAILABLE:
191
+ with self._lock:
192
+ # Mark that reconfiguration is needed - will be done on next request
193
+ self._reconfigure_needed = True
194
+ self._startup_done = False # Force restart on next request
195
+ _PRINTER.print("[A2A] Reconfiguration scheduled for next request")
196
+
197
def _configure(self):
198
"""Configure the FastA2A application with Agent Zero integration."""
199
try:
158
- # Import inside the method to handle missing dependencies gracefully
159
- from fasta2a import FastA2A # type: ignore
160
- from fasta2a.broker import InMemoryBroker # type: ignore
161
- from fasta2a.storage import InMemoryStorage # type: ignore
162
-
200
storage = InMemoryStorage() # type: ignore[arg-type]
201
broker = InMemoryBroker() # type: ignore[arg-type]
202
@@ -185,32 +222,8 @@ class DynamicA2AProxy:
222
"url": "https://github.com/frdel/agent-zero"
223
}
224
188
- # Authentication middleware (Bearer or X-API-KEY)
189
- class A2AAuthMiddleware(BaseHTTPMiddleware):
190
- async def dispatch(self, request: Request, call_next): # type: ignore[override]
191
- cfg = settings.get_settings()
192
- expected = cfg.get("a2a_token") or cfg.get("mcp_server_token")
193
- if not expected:
194
- return await call_next(request) # no auth configured
195
-
196
- auth_header = request.headers.get("Authorization", "")
197
- if auth_header.startswith("Bearer ") and auth_header.split(" ", 1)[1] == expected:
198
- return await call_next(request)
199
-
200
- api_key = request.headers.get("X-API-KEY") or request.query_params.get("api_key")
201
- if api_key == expected:
202
- return await call_next(request)
203
-
204
- # Fallback: check token in mount path (root_path contains the prefix stripped by DispatcherMiddleware)
205
- root_path = request.scope.get("root_path", "") # type: ignore[attr-defined]
206
- if root_path and f"/t-{expected}" in root_path:
207
- return await call_next(request)
208
-
209
- return PlainTextResponse("Unauthorized", 401)
210
-
211
- middleware_list = [Middleware(A2AAuthMiddleware)]
212
-
213
- self.app = FastA2A( # type: ignore
225
+ # Create new FastA2A app with proper thread safety
226
+ new_app = FastA2A( # type: ignore
227
storage=storage,
228
broker=broker,
229
name="Agent Zero",
@@ -222,7 +235,7 @@ class DynamicA2AProxy:
235
provider=provider,
236
skills=skills,
237
lifespan=None, # We manage lifespan manually
225
- middleware=middleware_list,
238
+ middleware=[], # No middleware - we handle auth in wrapper
239
)
240
241
# Store for later lazy startup (needs active event-loop)
@@ -230,11 +243,15 @@ class DynamicA2AProxy:
243
self._broker = broker # type: ignore[attr-defined]
244
self._worker = AgentZeroWorker(broker=broker, storage=storage) # type: ignore[attr-defined]
245
246
+ # Atomic update of the app
247
+ self.app = new_app
248
+
249
_PRINTER.print("[A2A] FastA2A server configured successfully")
250
251
except Exception as e:
252
_PRINTER.print(f"[A2A] Failed to configure FastA2A server: {e}")
253
self.app = None
254
+ raise
255
256
# ---------------------------------------------------------------------
257
# Shutdown handling
@@ -262,10 +279,33 @@ class DynamicA2AProxy:
279
with contextlib.suppress(asyncio.CancelledError):
280
await self._worker_bg_task
281
try:
265
- await self.app.task_manager.__aexit__(None, None, None) # type: ignore[attr-defined]
282
+ if hasattr(self, 'app') and self.app:
283
+ await self.app.task_manager.__aexit__(None, None, None) # type: ignore[attr-defined]
284
except Exception:
285
pass
286
287
+ async def _async_reconfigure(self):
288
+ """Perform async reconfiguration with proper lifecycle management."""
289
+ _PRINTER.print("[A2A] Starting async reconfiguration")
290
+
291
+ # Shutdown existing components
292
+ await self._async_shutdown()
293
+
294
+ # Reset startup state
295
+ self._startup_done = False
296
+ self._worker_bg_task = None
297
+
298
+ # Reconfigure with new token
299
+ self._configure()
300
+
301
+ # Restart components
302
+ await self._startup()
303
+
304
+ # Clear reconfiguration flag
305
+ self._reconfigure_needed = False
306
+
307
+ _PRINTER.print("[A2A] Async reconfiguration completed")
308
+
309
async def _startup(self):
310
"""Ensure TaskManager and Worker are running inside current event-loop."""
311
if self._startup_done or not FASTA2A_AVAILABLE:
@@ -283,15 +323,9 @@ class DynamicA2AProxy:
323
self._worker_bg_task = asyncio.create_task(_worker_loop())
324
_PRINTER.print("[A2A] Worker & TaskManager started")
325
286
- async def reconfigure(self):
287
- """Reconfigure the FastA2A server (placeholder for future use)."""
288
- async with self._async_lock:
289
- if FASTA2A_AVAILABLE:
290
- self._configure()
291
-
326
async def __call__(self, scope, receive, send):
293
- """ASGI application interface."""
294
- if self.app is None:
327
+ """ASGI application interface with token-based routing."""
328
+ if not FASTA2A_AVAILABLE:
329
# FastA2A not available, return 503
330
response = b'HTTP/1.1 503 Service Unavailable\r\n\r\nFastA2A not available'
331
await send({
@@ -305,12 +339,139 @@ class DynamicA2AProxy:
339
})
340
return
341
342
+ # Check if reconfiguration is needed
343
+ if self._reconfigure_needed:
344
+ try:
345
+ await self._async_reconfigure()
346
+ except Exception as e:
347
+ _PRINTER.print(f"[A2A] Error during reconfiguration: {e}")
348
+ # Return 503 if reconfiguration failed
349
+ await send({
350
+ 'type': 'http.response.start',
351
+ 'status': 503,
352
+ 'headers': [[b'content-type', b'text/plain']],
353
+ })
354
+ await send({
355
+ 'type': 'http.response.body',
356
+ 'body': b'FastA2A reconfiguration failed',
357
+ })
358
+ return
359
+
360
+ if self.app is None:
361
+ # FastA2A not configured, return 503
362
+ response = b'HTTP/1.1 503 Service Unavailable\r\n\r\nFastA2A not configured'
363
+ await send({
364
+ 'type': 'http.response.start',
365
+ 'status': 503,
366
+ 'headers': [[b'content-type', b'text/plain']],
367
+ })
368
+ await send({
369
+ 'type': 'http.response.body',
370
+ 'body': response,
371
+ })
372
+ return
373
+
374
# Lazy-start background components the first time we get a request
309
- if FASTA2A_AVAILABLE and not self._startup_done:
310
- await self._startup()
375
+ if not self._startup_done:
376
+ try:
377
+ _PRINTER.print("[A2A] Starting up FastA2A components")
378
+ await self._startup()
379
+ except Exception as e:
380
+ _PRINTER.print(f"[A2A] Error during startup: {e}")
381
+ # Return 503 if startup failed
382
+ await send({
383
+ 'type': 'http.response.start',
384
+ 'status': 503,
385
+ 'headers': [[b'content-type', b'text/plain']],
386
+ })
387
+ await send({
388
+ 'type': 'http.response.body',
389
+ 'body': b'FastA2A startup failed',
390
+ })
391
+ return
392
+
393
+ # Handle token-based routing: /a2a/t-{token}/... or /t-{token}/...
394
+ path = scope.get('path', '')
395
+
396
+ # Strip /a2a prefix if present (DispatcherMiddleware doesn't always strip it)
397
+ if path.startswith('/a2a'):
398
+ path = path[4:] # Remove '/a2a' prefix
399
+
400
+ # Check if path matches token pattern /t-{token}/
401
+ if path.startswith('/t-') and '/' in path[3:]:
402
+ # Extract token from path
403
+ path_parts = path[3:].split('/', 1) # Remove '/t-' prefix
404
+ request_token = path_parts[0]
405
+ remaining_path = '/' + path_parts[1] if len(path_parts) > 1 else '/'
406
+
407
+ # Validate token
408
+ cfg = settings.get_settings()
409
+ expected_token = cfg.get("mcp_server_token")
410
+
411
+ if expected_token and request_token != expected_token:
412
+ # Invalid token, return 401
413
+ await send({
414
+ 'type': 'http.response.start',
415
+ 'status': 401,
416
+ 'headers': [[b'content-type', b'text/plain']],
417
+ })
418
+ await send({
419
+ 'type': 'http.response.body',
420
+ 'body': b'Unauthorized',
421
+ })
422
+ return
423
+
424
+ # Update scope with cleaned path
425
+ scope = dict(scope)
426
+ scope['path'] = remaining_path
427
+ else:
428
+ # No token in path, check other auth methods
429
+ request = Request(scope, receive=receive)
430
+
431
+ cfg = settings.get_settings()
432
+ expected = cfg.get("mcp_server_token")
433
+
434
+ if expected:
435
+ auth_header = request.headers.get("Authorization", "")
436
+ api_key = request.headers.get("X-API-KEY") or request.query_params.get("api_key")
437
+
438
+ is_authorized = (
439
+ (auth_header.startswith("Bearer ") and auth_header.split(" ", 1)[1] == expected) or
440
+ (api_key == expected)
441
+ )
442
312
- # Delegate to FastA2A app
313
- await self.app(scope, receive, send)
443
+ if not is_authorized:
444
+ # No valid auth, return 401
445
+ await send({
446
+ 'type': 'http.response.start',
447
+ 'status': 401,
448
+ 'headers': [[b'content-type', b'text/plain']],
449
+ })
450
+ await send({
451
+ 'type': 'http.response.body',
452
+ 'body': b'Unauthorized',
453
+ })
454
+ return
455
+ else:
456
+ _PRINTER.print("[A2A] No expected token found in settings")
457
+
458
+ # Delegate to FastA2A app with cleaned scope
459
+ with self._lock:
460
+ app = self.app
461
+ if app:
462
+ await app(scope, receive, send)
463
+ else:
464
+ # App not configured, return 503
465
+ await send({
466
+ 'type': 'http.response.start',
467
+ 'status': 503,
468
+ 'headers': [[b'content-type', b'text/plain']],
469
+ })
470
+ await send({
471
+ 'type': 'http.response.body',
472
+ 'body': b'FastA2A app not configured',
473
+ })
474
+ return
475
476
477
def is_available():
@@ -320,6 +481,4 @@ def is_available():
481
482
def get_proxy():
483
"""Get the FastA2A proxy instance."""
323
- if not FASTA2A_AVAILABLE:
324
- return None
484
return DynamicA2AProxy.get_instance()
python/helpers/settings.py
+12
@@ -1353,6 +1353,18 @@ def _apply_settings(previous: Settings | None):
1353
update_mcp_token, current_token
1354
) # TODO overkill, replace with background task
1355
1356
+ # update token in a2a server
1357
+ if not previous or current_token != previous["mcp_server_token"]:
1358
+
1359
+ async def update_a2a_token(token: str):
1360
+ from python.helpers.fasta2a_server import DynamicA2AProxy
1361
+
1362
+ DynamicA2AProxy.get_instance().reconfigure(token=token)
1363
+
1364
+ task4 = defer.DeferredTask().start_task(
1365
+ update_a2a_token, current_token
1366
+ ) # TODO overkill, replace with background task
1367
+
1368
1369
def _env_to_dict(data: str):
1370
env_dict = {}
run_ui.py
+3
-24
@@ -9,21 +9,13 @@ import threading
9
from flask import Flask, request, Response, session
10
from flask_basicauth import BasicAuth
11
import initialize
12
-from python.helpers import files, git, mcp_server
12
+from python.helpers import files, git, mcp_server, fasta2a_server
13
from python.helpers.files import get_abs_path
14
from python.helpers import runtime, dotenv, process
15
from python.helpers.extract_tools import load_classes_from_folder
16
from python.helpers.api import ApiHandler
17
from python.helpers.print_style import PrintStyle
18
19
-# Try to import fasta2a_server, but handle gracefully if it fails
20
-try:
21
- from python.helpers import fasta2a_server
22
- FASTA2A_AVAILABLE = True
23
-except ImportError:
24
- FASTA2A_AVAILABLE = False
25
- fasta2a_server = None
26
-
19
20
# Set the new timezone to 'UTC'
21
os.environ["TZ"] = "UTC"
@@ -223,26 +215,13 @@ def run():
215
# add the webapp, mcp, and a2a to the app
216
middleware_routes = {
217
"/mcp": ASGIMiddleware(app=mcp_server.DynamicMcpProxy.get_instance()), # type: ignore
218
+ "/a2a": ASGIMiddleware(app=fasta2a_server.DynamicA2AProxy.get_instance()), # type: ignore
219
}
220
228
- # Add A2A server if available
229
- if FASTA2A_AVAILABLE and fasta2a_server and fasta2a_server.is_available():
230
- a2a_proxy = fasta2a_server.get_proxy()
231
- if a2a_proxy:
232
- from python.helpers import settings as _s
233
- cfg = _s.get_settings()
234
- token = cfg.get("a2a_token") or cfg.get("mcp_server_token")
235
- mount_path = f"/a2a/t-{token}"
236
- middleware_routes[mount_path] = ASGIMiddleware(app=a2a_proxy) # type: ignore
237
- # Also mount at /a2a for agent card and convenience
238
- middleware_routes["/a2a"] = ASGIMiddleware(app=a2a_proxy) # type: ignore
239
- PrintStyle().print(f"FastA2A server enabled at {mount_path} and /a2a")
240
- else:
241
- PrintStyle().debug("FastA2A server not available")
242
-
221
app = DispatcherMiddleware(webapp, middleware_routes) # type: ignore
222
223
PrintStyle().debug(f"Starting server at http://{host}:{port} ...")
224
+ PrintStyle().debug("FastA2A mounted at: /a2a")
225
226
server = make_server(
227
host=host,
test_fasta2a_client.py
+196
-41
@@ -1,56 +1,211 @@
1
+#!/usr/bin/env python3
2
+"""
3
+Test script to verify FastA2A agent card routing and authentication.
4
+"""
5
+
6
import asyncio
7
import sys
3
-from typing import Any, Dict
8
+from python.helpers import settings
9
+
10
+
11
+def get_test_urls():
12
+ """Get the URLs to test based on current settings."""
13
+ try:
14
+ cfg = settings.get_settings()
15
+ token = cfg.get("mcp_server_token", "")
16
+
17
+ if not token:
18
+ print("❌ No mcp_server_token found in settings")
19
+ return None
20
+
21
+ base_url = "http://localhost:50101"
22
+
23
+ urls = {
24
+ "token_based": f"{base_url}/a2a/t-{token}/.well-known/agent.json",
25
+ "bearer_auth": f"{base_url}/a2a/.well-known/agent.json",
26
+ "api_key_header": f"{base_url}/a2a/.well-known/agent.json",
27
+ "api_key_query": f"{base_url}/a2a/.well-known/agent.json?api_key={token}"
28
+ }
29
5
-from python.helpers.fasta2a_client import (
6
- connect_to_agent,
7
- is_client_available,
8
-)
30
+ return {"token": token, "urls": urls}
31
32
+ except Exception as e:
33
+ print(f"❌ Error getting settings: {e}")
34
+ return None
35
11
-async def chat_loop(base_url: str):
12
- if not is_client_available():
13
- print("FastA2A client library not available – install `fasta2a httpx` first.")
36
+
37
+def print_test_commands():
38
+ """Print curl commands to test FastA2A authentication."""
39
+ data = get_test_urls()
40
+ if not data:
41
return
42
16
- # Establish connection (agent card is fetched internally)
17
- async with await connect_to_agent(base_url) as conn:
18
- print(f"[✓] Connected to {base_url}")
19
- print('Type "exit" to quit.')
43
+ token = data["token"]
44
+ urls = data["urls"]
45
21
- while True:
22
- try:
23
- user_msg = input("You: ").strip()
24
- except (EOFError, KeyboardInterrupt):
25
- print()
26
- break
46
+ print("🚀 FastA2A Agent Card Testing Commands")
47
+ print("=" * 60)
48
+ print(f"Current token: {token}")
49
+ print()
50
+
51
+ print("1️⃣ Token-based URL (recommended):")
52
+ print(f" curl -v '{urls['token_based']}'")
53
+ print()
54
+
55
+ print("2️⃣ Bearer authentication:")
56
+ print(f" curl -v -H 'Authorization: Bearer {token}' '{urls['bearer_auth']}'")
57
+ print()
58
+
59
+ print("3️⃣ API key header:")
60
+ print(f" curl -v -H 'X-API-KEY: {token}' '{urls['api_key_header']}'")
61
+ print()
62
+
63
+ print("4️⃣ API key query parameter:")
64
+ print(f" curl -v '{urls['api_key_query']}'")
65
+ print()
66
+
67
+ print("Expected response (if working):")
68
+ print(" HTTP/1.1 200 OK")
69
+ print(" Content-Type: application/json")
70
+ print(" {")
71
+ print(' "name": "Agent Zero",')
72
+ print(' "version": "1.0.0",')
73
+ print(' "skills": [...]')
74
+ print(" }")
75
+ print()
76
+
77
+ print("Expected error (if auth fails):")
78
+ print(" HTTP/1.1 401 Unauthorized")
79
+ print(" Unauthorized")
80
+ print()
81
+
82
+
83
+def print_troubleshooting():
84
+ """Print troubleshooting information."""
85
+ print("🔧 Troubleshooting FastA2A Issues")
86
+ print("=" * 40)
87
+ print()
88
+ print("1. Server not running:")
89
+ print(" - Make sure Agent Zero is running: python run_ui.py")
90
+ print(" - Check the correct port (default: 50101)")
91
+ print()
92
+
93
+ print("2. Authentication failures:")
94
+ print(" - Verify token matches in settings")
95
+ print(" - Check token format (should be 16 characters)")
96
+ print(" - Try different auth methods")
97
+ print()
98
+
99
+ print("3. FastA2A not available:")
100
+ print(" - Install FastA2A: pip install fasta2a")
101
+ print(" - Check server logs for FastA2A configuration errors")
102
+ print()
103
+
104
+ print("4. Routing issues:")
105
+ print(" - Verify /a2a prefix is working")
106
+ print(" - Check DispatcherMiddleware configuration")
107
+ print(" - Look for FastA2A startup messages in logs")
108
+ print()
109
+
110
+
111
+def validate_token_format():
112
+ """Validate that the token format is correct."""
113
+ try:
114
+ cfg = settings.get_settings()
115
+ token = cfg.get("mcp_server_token", "")
116
28
- if user_msg.lower() in {"exit", "quit"}:
29
- break
117
+ print("🔍 Token Validation")
118
+ print("=" * 25)
119
120
+ if not token:
121
+ print("❌ No token found")
122
+ return False
123
+
124
+ print(f"✅ Token found: {token}")
125
+ print(f"✅ Token length: {len(token)} characters")
126
+
127
+ if len(token) != 16:
128
+ print("⚠️ Warning: Expected token length is 16 characters")
129
+
130
+ # Check token characters
131
+ if token.isalnum():
132
+ print("✅ Token contains only alphanumeric characters")
133
+ else:
134
+ print("⚠️ Warning: Token contains non-alphanumeric characters")
135
+
136
+ return True
137
+
138
+ except Exception as e:
139
+ print(f"❌ Error validating token: {e}")
140
+ return False
141
+
142
+
143
+async def test_server_connectivity():
144
+ """Test basic server connectivity."""
145
+ try:
146
+ import httpx
147
+
148
+ print("🌐 Server Connectivity Test")
149
+ print("=" * 30)
150
+
151
+ async with httpx.AsyncClient() as client:
152
try:
32
- # Send message & get immediate task response
33
- task_response: Dict[str, Any] = await conn.send_message(message=user_msg)
34
- task_id = task_response.get("result", {}).get("id")
35
- if not task_id:
36
- print("[!] No task ID returned – response: ", task_response)
37
- continue
38
-
39
- # Wait for the task to complete and fetch final result
40
- final = await conn.wait_for_completion(task_id)
41
- latest_history = final["result"].get("history", [])
42
- assistant_parts = latest_history[-1].get("parts", []) if latest_history else []
43
- assistant_text = "\n".join(
44
- p.get("text", "") for p in assistant_parts if p.get("kind") == "text"
45
- )
46
- print(f"Assistant: {assistant_text}\n")
153
+ # Test basic server
154
+ await client.get("http://localhost:50101/", timeout=5.0)
155
+ print("✅ Agent Zero server is running")
156
+ return True
157
+ except httpx.ConnectError:
158
+ print("❌ Cannot connect to Agent Zero server")
159
+ print(" Make sure the server is running: python run_ui.py")
160
+ return False
161
except Exception as e:
48
- print(f"[!] Error: {e}")
162
+ print(f"❌ Server connectivity error: {e}")
163
+ return False
164
165
+ except ImportError:
166
+ print("ℹ️ httpx not available, skipping connectivity test")
167
+ print(" Install with: pip install httpx")
168
+ return None
169
51
-if __name__ == "__main__":
52
- if len(sys.argv) < 2:
53
- print("Usage: python test_fasta2a_client.py <AGENT_BASE_URL>")
54
- sys.exit(1)
170
56
- asyncio.run(chat_loop(sys.argv[1]))
171
+def main():
172
+ """Main test function."""
173
+ print("🧪 FastA2A Agent Card Testing Utility")
174
+ print("=" * 45)
175
+ print()
176
+
177
+ # Validate token
178
+ if not validate_token_format():
179
+ print()
180
+ print_troubleshooting()
181
+ return 1
182
+
183
+ print()
184
+
185
+ # Test connectivity if possible
186
+ try:
187
+ connectivity = asyncio.run(test_server_connectivity())
188
+ print()
189
+
190
+ if connectivity is False:
191
+ print_troubleshooting()
192
+ return 1
193
+
194
+ except Exception as e:
195
+ print(f"Error testing connectivity: {e}")
196
+ print()
197
+
198
+ # Print test commands
199
+ print_test_commands()
200
+
201
+ print("📋 Next Steps:")
202
+ print("1. Start Agent Zero server if not running")
203
+ print("2. Run one of the curl commands above")
204
+ print("3. Check for successful 200 response with agent card JSON")
205
+ print("4. If issues occur, see troubleshooting section")
206
+
207
+ return 0
208
+
209
+
210
+if __name__ == "__main__":
211
+ sys.exit(main())