a2a-handler 0.1.16__tar.gz → 0.1.18__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.
Files changed (72) hide show
  1. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/PKG-INFO +41 -48
  2. a2a_handler-0.1.18/README.md +76 -0
  3. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/pyproject.toml +22 -5
  4. a2a_handler-0.1.18/src/a2a_handler/auth.py +284 -0
  5. a2a_handler-0.1.18/src/a2a_handler/cli/__init__.py +173 -0
  6. a2a_handler-0.1.18/src/a2a_handler/cli/_helpers.py +207 -0
  7. a2a_handler-0.1.18/src/a2a_handler/cli/card.py +183 -0
  8. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/cli/mcp.py +7 -1
  9. a2a_handler-0.1.18/src/a2a_handler/cli/message.py +356 -0
  10. a2a_handler-0.1.18/src/a2a_handler/cli/schema.py +122 -0
  11. a2a_handler-0.1.18/src/a2a_handler/cli/server.py +669 -0
  12. a2a_handler-0.1.18/src/a2a_handler/cli/session.py +117 -0
  13. a2a_handler-0.1.18/src/a2a_handler/cli/task.py +374 -0
  14. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/common/__init__.py +2 -0
  15. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/common/config.py +3 -1
  16. a2a_handler-0.1.18/src/a2a_handler/common/dotenv.py +18 -0
  17. a2a_handler-0.1.18/src/a2a_handler/common/input_validation.py +189 -0
  18. a2a_handler-0.1.18/src/a2a_handler/common/output.py +66 -0
  19. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/mcp/server.py +261 -141
  20. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/server/agent.py +2 -2
  21. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/server/app.py +34 -2
  22. a2a_handler-0.1.18/src/a2a_handler/servers.py +614 -0
  23. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/service.py +195 -134
  24. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/session.py +114 -71
  25. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/__init__.py +3 -1
  26. a2a_handler-0.1.18/src/a2a_handler/tui/app.py +385 -0
  27. a2a_handler-0.1.18/src/a2a_handler/tui/app.tcss +845 -0
  28. a2a_handler-0.1.18/src/a2a_handler/tui/commands/__init__.py +6 -0
  29. a2a_handler-0.1.18/src/a2a_handler/tui/commands/palette.py +91 -0
  30. a2a_handler-0.1.18/src/a2a_handler/tui/commands/screens.py +87 -0
  31. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/__init__.py +3 -4
  32. a2a_handler-0.1.18/src/a2a_handler/tui/components/auth.py +251 -0
  33. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/card.py +11 -8
  34. a2a_handler-0.1.18/src/a2a_handler/tui/components/headers.py +58 -0
  35. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/input.py +14 -0
  36. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/messages.py +83 -88
  37. a2a_handler-0.1.18/src/a2a_handler/tui/server/__init__.py +6 -0
  38. a2a_handler-0.1.18/src/a2a_handler/tui/server/save.py +188 -0
  39. a2a_handler-0.1.18/src/a2a_handler/tui/server/session.py +43 -0
  40. a2a_handler-0.1.18/src/a2a_handler/tui/server/tab.py +870 -0
  41. a2a_handler-0.1.18/src/a2a_handler/tui/server/tabs.py +181 -0
  42. a2a_handler-0.1.18/src/a2a_handler/tui/server/types.py +85 -0
  43. a2a_handler-0.1.18/src/a2a_handler/tui/server/views.py +253 -0
  44. a2a_handler-0.1.18/src/a2a_handler/tui/server/workspace.py +74 -0
  45. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/validation.py +19 -1
  46. a2a_handler-0.1.16/README.md +0 -83
  47. a2a_handler-0.1.16/src/a2a_handler/auth.py +0 -79
  48. a2a_handler-0.1.16/src/a2a_handler/cli/__init__.py +0 -101
  49. a2a_handler-0.1.16/src/a2a_handler/cli/_config.py +0 -102
  50. a2a_handler-0.1.16/src/a2a_handler/cli/_helpers.py +0 -54
  51. a2a_handler-0.1.16/src/a2a_handler/cli/auth.py +0 -89
  52. a2a_handler-0.1.16/src/a2a_handler/cli/card.py +0 -101
  53. a2a_handler-0.1.16/src/a2a_handler/cli/message.py +0 -203
  54. a2a_handler-0.1.16/src/a2a_handler/cli/server.py +0 -59
  55. a2a_handler-0.1.16/src/a2a_handler/cli/session.py +0 -62
  56. a2a_handler-0.1.16/src/a2a_handler/cli/task.py +0 -269
  57. a2a_handler-0.1.16/src/a2a_handler/common/output.py +0 -155
  58. a2a_handler-0.1.16/src/a2a_handler/tui/app.py +0 -296
  59. a2a_handler-0.1.16/src/a2a_handler/tui/app.tcss +0 -457
  60. a2a_handler-0.1.16/src/a2a_handler/tui/components/auth.py +0 -99
  61. a2a_handler-0.1.16/src/a2a_handler/tui/components/contact.py +0 -168
  62. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/LICENSE +0 -0
  63. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/__init__.py +0 -0
  64. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/common/logging.py +0 -0
  65. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/mcp/__init__.py +0 -0
  66. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/server/__init__.py +0 -0
  67. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/server/card.py +0 -0
  68. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/server/ollama.py +0 -0
  69. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/artifacts.py +0 -0
  70. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/logs.py +0 -0
  71. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/tasks.py +0 -0
  72. {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/webhook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: a2a-handler
3
- Version: 0.1.16
3
+ Version: 0.1.18
4
4
  Summary: An A2A Protocol client TUI and CLI.
5
5
  Keywords: a2a,a2a-protocol,agent,agent-to-agent,ai,cli,tui
6
6
  Author: Al Duncanson
@@ -18,10 +18,9 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
20
  Classifier: Typing :: Typed
21
- Requires-Dist: a2a-sdk>=0.3.22
21
+ Requires-Dist: a2a-sdk>=0.3.24
22
22
  Requires-Dist: click>=8.0.0
23
- Requires-Dist: rich-click>=1.8.0
24
- Requires-Dist: google-adk>=0.5.0
23
+ Requires-Dist: google-adk>=1.26.0
25
24
  Requires-Dist: httpx>=0.27.0
26
25
  Requires-Dist: litellm>=1.0.0
27
26
  Requires-Dist: python-dotenv>=1.0.0
@@ -31,10 +30,11 @@ Requires-Dist: uvicorn>=0.30.0
31
30
  Requires-Dist: textual-serve>=1.1.0
32
31
  Requires-Dist: mcp[cli]>=1.2.0
33
32
  Requires-Dist: truststore>=0.9.0
33
+ Requires-Dist: platformdirs>=4.0.0
34
34
  Requires-Python: >=3.11
35
35
  Project-URL: Homepage, https://github.com/alDuncanson/handler
36
36
  Project-URL: Repository, https://github.com/alDuncanson/handler
37
- Project-URL: Documentation, https://github.com/alDuncanson/handler#readme
37
+ Project-URL: Documentation, https://github.com/alDuncanson/handler/tree/main/docs
38
38
  Project-URL: Issues, https://github.com/alDuncanson/handler/issues
39
39
  Project-URL: Changelog, https://github.com/alDuncanson/handler/releases
40
40
  Description-Content-Type: text/markdown
@@ -51,74 +51,67 @@ Description-Content-Type: text/markdown
51
51
 
52
52
  ![Handler TUI](https://github.com/alDuncanson/Handler/blob/main/assets/handler-tui.png?raw=true)
53
53
 
54
- Handler is an open-source [A2A Protocol](https://github.com/a2aproject/A2A)
55
- client and developer toolkit.
54
+ Handler is an open-source [A2A protocol](https://github.com/a2aproject/A2A) client for your terminal.
56
55
 
57
- It provides a CLI and TUI for communicating with remote agents, an MCP server
58
- for bridging AI assistants into the A2A ecosystem, a reference server agent
59
- implementation, push notification support, and agent card validation. Whether
60
- you're building agents, integrating with existing ones, or exploring
61
- agent-to-agent communication, Handler gives you the observability and control
62
- you need from your terminal.
56
+ Use the TUI or CLI to send messages, manage tasks, and inspect agent cards.
57
+ An included MCP server lets your AI assistant talk to A2A agents too.
63
58
 
64
- ## Who is Handler For?
59
+ ## Install
65
60
 
66
- Handler is for developers, researchers, and teams working with AI agents. If
67
- you're building agents that speak A2A, Handler helps you test and debug them. If
68
- you're integrating with existing A2A agents, Handler gives you a fast way to
69
- explore their capabilities. If you want your AI assistant to communicate with
70
- other agents, Handler's MCP server bridges that gap. And if you're just curious
71
- about agent-to-agent communication, Handler is a great place to start.
72
-
73
- ## Get Started
74
-
75
- ### Install
61
+ ```bash
62
+ uv tool install a2a-handler
63
+ ```
76
64
 
77
- Install with [uv](https://github.com/astral-sh/uv):
65
+ Or with [pipx](https://pipx.pypa.io/) / pip:
78
66
 
79
67
  ```bash
80
- uv tool install a2a-handler
68
+ pipx install a2a-handler # or: pip install a2a-handler
81
69
  ```
82
70
 
83
- Or with [pipx](https://pipx.pypa.io/):
71
+ ## Quick Start
84
72
 
85
73
  ```bash
86
- pipx install a2a-handler
74
+ handler tui # launch the interactive TUI
75
+ handler message send URL "hello" # send a message from the CLI
76
+ handler card get URL # fetch an agent card
77
+ handler mcp # start the MCP server
78
+ handler server run agent # run the bundled reference agent
87
79
  ```
88
80
 
89
- Or with pip:
81
+ ## Run Without Installing
90
82
 
91
83
  ```bash
92
- pip install a2a-handler
84
+ uvx --from a2a-handler handler # or: pipx run a2a-handler
93
85
  ```
94
86
 
95
- ### Run
87
+ ## Servers
96
88
 
97
- Or, run from an ephemeral environment:
89
+ Handler supports named servers in `$XDG_CONFIG_HOME/handler/servers.toml`
90
+ (global) and `.handler/servers.toml` (repo-local).
98
91
 
99
- ```bash
100
- # With uv
101
- uvx --from a2a-handler handler
92
+ ```toml
93
+ version = 1
94
+
95
+ [servers.local]
96
+ url = "http://localhost:8000"
102
97
 
103
- # With pipx
104
- pipx run a2a-handler
98
+ [servers.local.auth]
99
+ type = "bearer"
100
+ env = "HANDLER_LOCAL_TOKEN"
105
101
  ```
106
102
 
107
- ### Development Environment
103
+ See [docs/](docs) for the full auth reference covering bearer, API key, mTLS,
104
+ and OAuth2.
105
+
106
+ ## Development
108
107
 
109
- A [hermetically sealed](https://zero-to-nix.com/concepts/hermeticity/)
110
- development environment is available with
111
- [Nix](https://zero-to-nix.com/concepts/nix/):
108
+ A [hermetic](https://zero-to-nix.com/concepts/hermeticity/) dev environment
109
+ is available via [Nix](https://zero-to-nix.com/concepts/nix/):
112
110
 
113
111
  ```bash
114
112
  nix develop
115
113
  ```
116
114
 
117
- This provides Python, uv, and just with all commands ready to use.
118
-
119
- For usage documentation, see the
120
- [Handler docs](https://alduncanson.github.io/Handler/).
121
-
122
- ## Contributing
115
+ The Mintlify docs source lives in `docs/`.
123
116
 
124
- See [CONTRIBUTING.md](CONTRIBUTING.md).
117
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
@@ -0,0 +1,76 @@
1
+ # Handler
2
+
3
+ [![CI](https://github.com/alDuncanson/handler/actions/workflows/ci.yml/badge.svg)](https://github.com/alDuncanson/handler/actions/workflows/ci.yml)
4
+ [![A2A Protocol](https://img.shields.io/badge/A2A_Protocol-v0.3.0-blue)](https://a2a-protocol.org/latest/)
5
+ [![PyPI version](https://img.shields.io/pypi/v/a2a-handler)](https://pypi.org/project/a2a-handler/)
6
+ [![PyPI - Status](https://img.shields.io/pypi/status/a2a-handler)](https://pypi.org/project/a2a-handler/)
7
+ [![PyPI monthly downloads](https://img.shields.io/pypi/dm/a2a-handler)](https://pypi.org/project/a2a-handler/)
8
+ [![Pepy total downloads](https://img.shields.io/pepy/dt/a2a-handler?label=total%20downloads)](https://pepy.tech/projects/a2a-handler)
9
+ [![GitHub stars](https://img.shields.io/github/stars/alDuncanson/handler)](https://github.com/alDuncanson/handler/stargazers)
10
+
11
+ ![Handler TUI](https://github.com/alDuncanson/Handler/blob/main/assets/handler-tui.png?raw=true)
12
+
13
+ Handler is an open-source [A2A protocol](https://github.com/a2aproject/A2A) client for your terminal.
14
+
15
+ Use the TUI or CLI to send messages, manage tasks, and inspect agent cards.
16
+ An included MCP server lets your AI assistant talk to A2A agents too.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ uv tool install a2a-handler
22
+ ```
23
+
24
+ Or with [pipx](https://pipx.pypa.io/) / pip:
25
+
26
+ ```bash
27
+ pipx install a2a-handler # or: pip install a2a-handler
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```bash
33
+ handler tui # launch the interactive TUI
34
+ handler message send URL "hello" # send a message from the CLI
35
+ handler card get URL # fetch an agent card
36
+ handler mcp # start the MCP server
37
+ handler server run agent # run the bundled reference agent
38
+ ```
39
+
40
+ ## Run Without Installing
41
+
42
+ ```bash
43
+ uvx --from a2a-handler handler # or: pipx run a2a-handler
44
+ ```
45
+
46
+ ## Servers
47
+
48
+ Handler supports named servers in `$XDG_CONFIG_HOME/handler/servers.toml`
49
+ (global) and `.handler/servers.toml` (repo-local).
50
+
51
+ ```toml
52
+ version = 1
53
+
54
+ [servers.local]
55
+ url = "http://localhost:8000"
56
+
57
+ [servers.local.auth]
58
+ type = "bearer"
59
+ env = "HANDLER_LOCAL_TOKEN"
60
+ ```
61
+
62
+ See [docs/](docs) for the full auth reference covering bearer, API key, mTLS,
63
+ and OAuth2.
64
+
65
+ ## Development
66
+
67
+ A [hermetic](https://zero-to-nix.com/concepts/hermeticity/) dev environment
68
+ is available via [Nix](https://zero-to-nix.com/concepts/nix/):
69
+
70
+ ```bash
71
+ nix develop
72
+ ```
73
+
74
+ The Mintlify docs source lives in `docs/`.
75
+
76
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "a2a-handler"
3
- version = "0.1.16"
3
+ version = "0.1.18"
4
4
  description = "An A2A Protocol client TUI and CLI."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -32,10 +32,9 @@ classifiers = [
32
32
  "Typing :: Typed",
33
33
  ]
34
34
  dependencies = [
35
- "a2a-sdk>=0.3.22",
35
+ "a2a-sdk>=0.3.24",
36
36
  "click>=8.0.0",
37
- "rich-click>=1.8.0",
38
- "google-adk>=0.5.0",
37
+ "google-adk>=1.26.0",
39
38
  "httpx>=0.27.0",
40
39
  "litellm>=1.0.0",
41
40
  "python-dotenv>=1.0.0",
@@ -45,6 +44,7 @@ dependencies = [
45
44
  "textual-serve>=1.1.0",
46
45
  "mcp[cli]>=1.2.0",
47
46
  "truststore>=0.9.0",
47
+ "platformdirs>=4.0.0",
48
48
  ]
49
49
 
50
50
  [dependency-groups]
@@ -54,12 +54,14 @@ dev = [
54
54
  "pytest>=8.0.0",
55
55
  "pytest-asyncio>=0.23.0",
56
56
  "ty>=0.0.1a27",
57
+ "pytest-cov>=7.1.0",
58
+ "pytest-textual-snapshot>=1.0.0",
57
59
  ]
58
60
 
59
61
  [project.urls]
60
62
  Homepage = "https://github.com/alDuncanson/handler"
61
63
  Repository = "https://github.com/alDuncanson/handler"
62
- Documentation = "https://github.com/alDuncanson/handler#readme"
64
+ Documentation = "https://github.com/alDuncanson/handler/tree/main/docs"
63
65
  Issues = "https://github.com/alDuncanson/handler/issues"
64
66
  Changelog = "https://github.com/alDuncanson/handler/releases"
65
67
 
@@ -73,5 +75,20 @@ build-backend = "uv_build"
73
75
  [tool.uv.build-backend]
74
76
  module-root = "src"
75
77
 
78
+ [tool.pytest.ini_options]
79
+ asyncio_mode = "auto"
80
+
76
81
  [tool.ruff.lint.per-file-ignores]
77
82
  "src/a2a_handler/cli/__init__.py" = ["E402"]
83
+
84
+ [tool.coverage.run]
85
+ source = ["a2a_handler"]
86
+
87
+ [tool.coverage.report]
88
+ show_missing = true
89
+ skip_empty = true
90
+ exclude_lines = [
91
+ "pragma: no cover",
92
+ "if TYPE_CHECKING:",
93
+ "if __name__ == .__main__.",
94
+ ]
@@ -0,0 +1,284 @@
1
+ """Authentication support for A2A protocol.
2
+
3
+ Handles credential storage and HTTP authentication header generation.
4
+ Supports API key, HTTP bearer, mTLS (mutual TLS), and OAuth2 client credentials
5
+ authentication schemes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ssl
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from pathlib import Path
15
+
16
+
17
+ from a2a_handler.common.input_validation import (
18
+ check_key_file_permissions,
19
+ validate_header_name,
20
+ validate_token_url,
21
+ )
22
+
23
+ import httpx
24
+
25
+
26
+ _TOKEN_EXPIRY_MARGIN = 30 # seconds before expiry to trigger refresh
27
+
28
+
29
+ class AuthType(str, Enum):
30
+ """Supported authentication types."""
31
+
32
+ API_KEY = "api_key"
33
+ BEARER = "bearer"
34
+ MTLS = "mtls"
35
+ OAUTH2 = "oauth2"
36
+
37
+
38
+ @dataclass
39
+ class AuthCredentials:
40
+ """Authentication credentials for an agent.
41
+
42
+ Stores the credential value and metadata about how to apply it.
43
+ """
44
+
45
+ auth_type: AuthType
46
+ value: str = ""
47
+ header_name: str | None = None # For API key: custom header name
48
+ cert_path: str | None = None # For mTLS: client certificate path
49
+ key_path: str | None = None # For mTLS: client private key path
50
+ ca_cert_path: str | None = None # For mTLS: CA certificate path
51
+ token_url: str | None = None # For OAuth2: token endpoint
52
+ client_id: str | None = None # For OAuth2: client ID
53
+ client_secret: str | None = None # For OAuth2: client secret
54
+ scopes: list[str] | None = None # For OAuth2: optional scopes
55
+ custom_headers: dict[str, str] | None = None # Additional headers for any auth type
56
+ _token_expires_at: float | None = field(default=None, repr=False)
57
+
58
+ def __repr__(self) -> str:
59
+ """Redacted repr to prevent secret leakage in logs and tracebacks."""
60
+ redacted = {
61
+ "auth_type": self.auth_type.value,
62
+ "header_name": self.header_name,
63
+ "cert_path": self.cert_path,
64
+ "key_path": self.key_path,
65
+ "ca_cert_path": self.ca_cert_path,
66
+ "token_url": self.token_url,
67
+ }
68
+ for secret_field in ("value", "client_id", "client_secret"):
69
+ val = getattr(self, secret_field)
70
+ redacted[secret_field] = "***" if val else None
71
+ if self.custom_headers:
72
+ redacted["custom_headers"] = {k: "***" for k in self.custom_headers}
73
+ return f"AuthCredentials({redacted})"
74
+
75
+ def to_headers(self) -> dict[str, str]:
76
+ """Generate HTTP headers for this credential.
77
+
78
+ Returns:
79
+ Dictionary of headers to include in requests
80
+ """
81
+ headers: dict[str, str] = {}
82
+ if self.custom_headers:
83
+ headers.update(self.custom_headers)
84
+ if self.auth_type == AuthType.BEARER and self.value:
85
+ headers["Authorization"] = f"Bearer {self.value}"
86
+ elif self.auth_type == AuthType.OAUTH2 and self.value:
87
+ headers["Authorization"] = f"Bearer {self.value}"
88
+ elif self.auth_type == AuthType.API_KEY:
89
+ header = self.header_name or "X-API-Key"
90
+ headers[header] = self.value
91
+ return headers
92
+
93
+ def build_ssl_context(self) -> ssl.SSLContext:
94
+ """Build an SSL context for mTLS client certificate authentication."""
95
+ if self.auth_type != AuthType.MTLS:
96
+ raise ValueError("SSL context can only be built for mTLS credentials")
97
+ if not self.cert_path or not self.key_path:
98
+ raise ValueError("cert_path and key_path are required for mTLS")
99
+
100
+ if self.ca_cert_path:
101
+ ctx = ssl.create_default_context(cafile=self.ca_cert_path)
102
+ else:
103
+ ctx = ssl.create_default_context()
104
+
105
+ ctx.load_cert_chain(certfile=self.cert_path, keyfile=self.key_path)
106
+ return ctx
107
+
108
+ def is_token_expired(self) -> bool:
109
+ """Check whether the cached OAuth2 access token has expired.
110
+
111
+ Returns True when no token is present, when no expiry was recorded,
112
+ or when the token will expire within a 30-second safety margin.
113
+ """
114
+ if not self.value:
115
+ return True
116
+ if self._token_expires_at is None:
117
+ return True
118
+ return time.monotonic() >= self._token_expires_at
119
+
120
+ def clear_token(self) -> None:
121
+ """Clear the cached OAuth2 access token so the next call re-fetches."""
122
+ self.value = ""
123
+ self._token_expires_at = None
124
+
125
+ async def fetch_oauth2_token(self) -> str:
126
+ """Fetch an access token using OAuth2 client credentials grant.
127
+
128
+ Uses a short-lived HTTP client so that agent auth headers and custom
129
+ headers on the main client are never sent to the token endpoint.
130
+
131
+ Updates self.value with the new token and returns it.
132
+ Parses ``expires_in`` from the token response to track expiry.
133
+ """
134
+ if self.auth_type != AuthType.OAUTH2:
135
+ raise ValueError("Token fetch is only valid for OAuth2 credentials")
136
+ if not self.token_url or not self.client_id or not self.client_secret:
137
+ raise ValueError("token_url, client_id, and client_secret are required")
138
+ validate_token_url(self.token_url)
139
+
140
+ data: dict[str, str] = {
141
+ "grant_type": "client_credentials",
142
+ "client_id": self.client_id,
143
+ "client_secret": self.client_secret,
144
+ }
145
+ if self.scopes:
146
+ data["scope"] = " ".join(self.scopes)
147
+
148
+ async with httpx.AsyncClient(trust_env=False) as token_client:
149
+ response = await token_client.post(self.token_url, data=data)
150
+ response.raise_for_status()
151
+ token_data = response.json()
152
+ self.value = token_data["access_token"]
153
+
154
+ expires_in = token_data.get("expires_in")
155
+ if isinstance(expires_in, (int, float)) and expires_in > 0:
156
+ self._token_expires_at = (
157
+ time.monotonic() + expires_in - _TOKEN_EXPIRY_MARGIN
158
+ )
159
+ else:
160
+ self._token_expires_at = None
161
+
162
+ return self.value
163
+
164
+ def to_dict(self) -> dict:
165
+ """Serialize credentials for storage."""
166
+ data: dict = {
167
+ "auth_type": self.auth_type.value,
168
+ "value": self.value,
169
+ "header_name": self.header_name,
170
+ }
171
+ if self.cert_path:
172
+ data["cert_path"] = self.cert_path
173
+ if self.key_path:
174
+ data["key_path"] = self.key_path
175
+ if self.ca_cert_path:
176
+ data["ca_cert_path"] = self.ca_cert_path
177
+ if self.token_url:
178
+ data["token_url"] = self.token_url
179
+ if self.client_id:
180
+ data["client_id"] = self.client_id
181
+ if self.client_secret:
182
+ data["client_secret"] = self.client_secret
183
+ if self.scopes:
184
+ data["scopes"] = self.scopes
185
+ if self.custom_headers:
186
+ data["custom_headers"] = self.custom_headers
187
+ return data
188
+
189
+ @classmethod
190
+ def from_dict(cls, data: dict) -> "AuthCredentials":
191
+ """Deserialize credentials from storage."""
192
+ custom_headers_raw = data.get("custom_headers")
193
+ custom_headers = (
194
+ dict(custom_headers_raw) if isinstance(custom_headers_raw, dict) else None
195
+ )
196
+ return cls(
197
+ auth_type=AuthType(data["auth_type"]),
198
+ value=data.get("value") or "",
199
+ header_name=data.get("header_name"),
200
+ cert_path=data.get("cert_path"),
201
+ key_path=data.get("key_path"),
202
+ ca_cert_path=data.get("ca_cert_path"),
203
+ token_url=data.get("token_url"),
204
+ client_id=data.get("client_id"),
205
+ client_secret=data.get("client_secret"),
206
+ scopes=data.get("scopes"),
207
+ custom_headers=custom_headers,
208
+ )
209
+
210
+
211
+ def parse_header_string(header: str) -> tuple[str, str]:
212
+ """Parse a 'Name: Value' header string into a (name, value) tuple."""
213
+ if ":" not in header:
214
+ raise ValueError(f"Invalid header format (expected 'Name: Value'): {header}")
215
+ name, _, value = header.partition(":")
216
+ name = name.strip()
217
+ value = value.strip()
218
+ if not name:
219
+ raise ValueError(f"Empty header name in: {header}")
220
+ validate_header_name(name, "custom_header")
221
+ return name, value
222
+
223
+
224
+ def create_bearer_auth(token: str) -> AuthCredentials:
225
+ """Create bearer token authentication."""
226
+ return AuthCredentials(auth_type=AuthType.BEARER, value=token)
227
+
228
+
229
+ def create_api_key_auth(
230
+ key: str,
231
+ header_name: str = "X-API-Key",
232
+ ) -> AuthCredentials:
233
+ """Create API key authentication.
234
+
235
+ Args:
236
+ key: The API key value
237
+ header_name: Header name to use (default: X-API-Key)
238
+ """
239
+ validate_header_name(header_name, "header_name")
240
+ return AuthCredentials(
241
+ auth_type=AuthType.API_KEY,
242
+ value=key,
243
+ header_name=header_name,
244
+ )
245
+
246
+
247
+ def create_mtls_auth(
248
+ cert_path: str,
249
+ key_path: str,
250
+ ca_cert_path: str | None = None,
251
+ ) -> AuthCredentials:
252
+ """Create mTLS (mutual TLS) client certificate authentication."""
253
+ if not Path(cert_path).is_file():
254
+ raise FileNotFoundError(f"Client certificate not found: {cert_path}")
255
+ if not Path(key_path).is_file():
256
+ raise FileNotFoundError(f"Client private key not found: {key_path}")
257
+ if ca_cert_path and not Path(ca_cert_path).is_file():
258
+ raise FileNotFoundError(f"CA certificate not found: {ca_cert_path}")
259
+ check_key_file_permissions(key_path)
260
+
261
+ return AuthCredentials(
262
+ auth_type=AuthType.MTLS,
263
+ cert_path=cert_path,
264
+ key_path=key_path,
265
+ ca_cert_path=ca_cert_path,
266
+ )
267
+
268
+
269
+ def create_oauth2_auth(
270
+ token_url: str,
271
+ client_id: str,
272
+ client_secret: str,
273
+ scopes: list[str] | None = None,
274
+ access_token: str = "",
275
+ ) -> AuthCredentials:
276
+ """Create OAuth2 client credentials authentication."""
277
+ return AuthCredentials(
278
+ auth_type=AuthType.OAUTH2,
279
+ value=access_token,
280
+ token_url=token_url,
281
+ client_id=client_id,
282
+ client_secret=client_secret,
283
+ scopes=scopes,
284
+ )