akeyless-agentcore-runtime 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- akeyless_agentcore/__init__.py +17 -0
- akeyless_agentcore/auth.py +134 -0
- akeyless_agentcore/cache.py +40 -0
- akeyless_agentcore/client.py +363 -0
- akeyless_agentcore/config.py +168 -0
- akeyless_agentcore/paths.py +79 -0
- akeyless_agentcore/tools/__init__.py +31 -0
- akeyless_agentcore/tools/gateway.py +103 -0
- akeyless_agentcore/tools/mcp.py +70 -0
- akeyless_agentcore/tools/service.py +144 -0
- akeyless_agentcore/tools/strands.py +44 -0
- akeyless_agentcore_runtime-0.2.0.dist-info/METADATA +257 -0
- akeyless_agentcore_runtime-0.2.0.dist-info/RECORD +16 -0
- akeyless_agentcore_runtime-0.2.0.dist-info/WHEEL +4 -0
- akeyless_agentcore_runtime-0.2.0.dist-info/entry_points.txt +2 -0
- akeyless_agentcore_runtime-0.2.0.dist-info/licenses/LICENSE +19 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Configuration from environment variables and explicit overrides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from akeyless_agentcore.paths import default_agentcore_secret_prefix
|
|
10
|
+
|
|
11
|
+
AccessType = Literal[
|
|
12
|
+
"access_key",
|
|
13
|
+
"api_key",
|
|
14
|
+
"aws_iam",
|
|
15
|
+
"azure_ad",
|
|
16
|
+
"gcp",
|
|
17
|
+
"universal_identity",
|
|
18
|
+
"jwt",
|
|
19
|
+
]
|
|
20
|
+
CloudProvider = Literal["aws_iam", "azure_ad", "gcp"]
|
|
21
|
+
|
|
22
|
+
DEFAULT_GATEWAY = "https://api.akeyless.io"
|
|
23
|
+
DEFAULT_SECRET_CACHE_TTL_SECONDS = 5 * 60
|
|
24
|
+
DEFAULT_TOKEN_EXPIRY_MARGIN_SECONDS = 60
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_env(name: str) -> str | None:
|
|
28
|
+
value = os.environ.get(name)
|
|
29
|
+
if value is None:
|
|
30
|
+
return None
|
|
31
|
+
trimmed = value.strip()
|
|
32
|
+
return trimmed or None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_positive_float(raw: str | None, fallback: float) -> float:
|
|
36
|
+
if raw is None:
|
|
37
|
+
return fallback
|
|
38
|
+
try:
|
|
39
|
+
parsed = float(raw)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return fallback
|
|
42
|
+
return parsed if parsed >= 0 else fallback
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_access_type(raw: str | None) -> AccessType:
|
|
46
|
+
value = (raw or "aws_iam").lower()
|
|
47
|
+
allowed: tuple[AccessType, ...] = (
|
|
48
|
+
"access_key",
|
|
49
|
+
"api_key",
|
|
50
|
+
"aws_iam",
|
|
51
|
+
"azure_ad",
|
|
52
|
+
"gcp",
|
|
53
|
+
"universal_identity",
|
|
54
|
+
"jwt",
|
|
55
|
+
)
|
|
56
|
+
if value not in allowed:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f'Unsupported AKEYLESS_ACCESS_TYPE "{raw}". '
|
|
59
|
+
"Expected access_key, api_key, aws_iam, azure_ad, gcp, universal_identity, or jwt."
|
|
60
|
+
)
|
|
61
|
+
return value # type: ignore[return-value]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cloud_provider_for_access_type(access_type: AccessType) -> CloudProvider | None:
|
|
65
|
+
if access_type in ("aws_iam", "azure_ad", "gcp"):
|
|
66
|
+
return access_type
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class AkeylessRuntimeConfig:
|
|
72
|
+
gateway_url: str = DEFAULT_GATEWAY
|
|
73
|
+
secret_prefix: str = "/"
|
|
74
|
+
access_type: AccessType = "aws_iam"
|
|
75
|
+
access_id: str | None = None
|
|
76
|
+
access_key: str | None = None
|
|
77
|
+
uid_token: str | None = None
|
|
78
|
+
jwt: str | None = None
|
|
79
|
+
token: str | None = None
|
|
80
|
+
cloud_id: str | None = None
|
|
81
|
+
cloud_provider: CloudProvider | None = None
|
|
82
|
+
secret_cache_ttl_seconds: float = DEFAULT_SECRET_CACHE_TTL_SECONDS
|
|
83
|
+
token_expiry_margin_seconds: float = DEFAULT_TOKEN_EXPIRY_MARGIN_SECONDS
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def config_from_env(**overrides: object) -> AkeylessRuntimeConfig:
|
|
87
|
+
access_type = overrides.get("access_type") or _parse_access_type(
|
|
88
|
+
_read_env("AKEYLESS_ACCESS_TYPE")
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
secret_prefix = overrides.get("secret_prefix")
|
|
92
|
+
if secret_prefix is None:
|
|
93
|
+
secret_prefix = _read_env("AKEYLESS_SECRET_PREFIX") or default_agentcore_secret_prefix() or "/"
|
|
94
|
+
|
|
95
|
+
cloud_provider = overrides.get("cloud_provider")
|
|
96
|
+
if cloud_provider is None:
|
|
97
|
+
cloud_provider = _cloud_provider_for_access_type(access_type)
|
|
98
|
+
|
|
99
|
+
return AkeylessRuntimeConfig(
|
|
100
|
+
gateway_url=overrides.get("gateway_url")
|
|
101
|
+
or _read_env("AKEYLESS_GATEWAY_URL")
|
|
102
|
+
or DEFAULT_GATEWAY,
|
|
103
|
+
secret_prefix=str(secret_prefix),
|
|
104
|
+
access_type=access_type, # type: ignore[arg-type]
|
|
105
|
+
access_id=overrides.get("access_id") or _read_env("AKEYLESS_ACCESS_ID"),
|
|
106
|
+
access_key=overrides.get("access_key")
|
|
107
|
+
or _read_env("AKEYLESS_ACCESS_KEY")
|
|
108
|
+
or _read_env("AKEYLESS_API_KEY"),
|
|
109
|
+
uid_token=overrides.get("uid_token")
|
|
110
|
+
or _read_env("AKEYLESS_UID_TOKEN")
|
|
111
|
+
or _read_env("AKEYLESS_UNIVERSAL_IDENTITY_TOKEN"),
|
|
112
|
+
jwt=overrides.get("jwt") or _read_env("AKEYLESS_JWT"),
|
|
113
|
+
token=overrides.get("token") or _read_env("AKEYLESS_TOKEN"),
|
|
114
|
+
cloud_id=overrides.get("cloud_id") or _read_env("AKEYLESS_CLOUD_ID"),
|
|
115
|
+
cloud_provider=cloud_provider, # type: ignore[arg-type]
|
|
116
|
+
secret_cache_ttl_seconds=overrides.get("secret_cache_ttl_seconds")
|
|
117
|
+
or _parse_positive_float(
|
|
118
|
+
_read_env("AKEYLESS_SECRET_CACHE_TTL_SECONDS"),
|
|
119
|
+
DEFAULT_SECRET_CACHE_TTL_SECONDS,
|
|
120
|
+
),
|
|
121
|
+
token_expiry_margin_seconds=overrides.get("token_expiry_margin_seconds")
|
|
122
|
+
or _parse_positive_float(
|
|
123
|
+
_read_env("AKEYLESS_TOKEN_EXPIRY_MARGIN_SECONDS"),
|
|
124
|
+
DEFAULT_TOKEN_EXPIRY_MARGIN_SECONDS,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def validate_config(config: AkeylessRuntimeConfig) -> None:
|
|
130
|
+
if not config.gateway_url.strip():
|
|
131
|
+
raise ValueError("gateway_url is required")
|
|
132
|
+
|
|
133
|
+
if config.token and config.token.strip():
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
if config.access_type in ("access_key", "api_key"):
|
|
137
|
+
if not config.access_id or not config.access_key:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
"access_id and access_key are required for access_key/api_key authentication "
|
|
140
|
+
"(or set token / AKEYLESS_TOKEN)"
|
|
141
|
+
)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
if config.access_type == "universal_identity":
|
|
145
|
+
if not config.uid_token:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"uid_token is required for universal_identity authentication "
|
|
148
|
+
"(or set token / AKEYLESS_TOKEN)"
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
if config.access_type == "jwt":
|
|
153
|
+
if not config.access_id or not config.jwt:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"access_id and jwt are required for jwt authentication "
|
|
156
|
+
"(or set token / AKEYLESS_TOKEN)"
|
|
157
|
+
)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
if config.access_type in ("aws_iam", "azure_ad", "gcp"):
|
|
161
|
+
if not config.access_id:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"{config.access_type} authentication requires access_id "
|
|
164
|
+
"(or set token / AKEYLESS_TOKEN)"
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
raise ValueError(f"Unsupported access type: {config.access_type}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Path helpers for resolving Akeyless secret names on AgentCore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_path(path: str) -> str:
|
|
11
|
+
trimmed = path.strip()
|
|
12
|
+
if not trimmed:
|
|
13
|
+
return "/"
|
|
14
|
+
with_leading = trimmed if trimmed.startswith("/") else f"/{trimmed}"
|
|
15
|
+
collapsed = "/".join(part for part in with_leading.split("/") if part)
|
|
16
|
+
return f"/{collapsed}" if collapsed else "/"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def join_secret_path(prefix: str, name: str) -> str:
|
|
20
|
+
base = normalize_path(prefix)
|
|
21
|
+
segment = name.strip().lstrip("/")
|
|
22
|
+
if not segment:
|
|
23
|
+
return base
|
|
24
|
+
if base == "/":
|
|
25
|
+
return f"/{segment}"
|
|
26
|
+
return f"{base}/{segment}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def stringify_secret_value(value: Any) -> str:
|
|
30
|
+
if value is None:
|
|
31
|
+
return ""
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
return value
|
|
34
|
+
if isinstance(value, (int, float, bool)):
|
|
35
|
+
return str(value)
|
|
36
|
+
if isinstance(value, (bytes, bytearray)):
|
|
37
|
+
return bytes(value).decode("utf-8")
|
|
38
|
+
return json.dumps(value)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def pick_secret_from_response(requested_path: str, data: dict[str, Any] | None) -> str:
|
|
42
|
+
if not data:
|
|
43
|
+
raise ValueError("Akeyless returned an empty secret payload")
|
|
44
|
+
|
|
45
|
+
trimmed = requested_path.strip()
|
|
46
|
+
candidates = [
|
|
47
|
+
trimmed,
|
|
48
|
+
normalize_path(trimmed),
|
|
49
|
+
trimmed.lstrip("/"),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
for key in candidates:
|
|
53
|
+
if key in data:
|
|
54
|
+
return stringify_secret_value(data[key])
|
|
55
|
+
|
|
56
|
+
raise ValueError(f'No value for "{requested_path}" in Akeyless response')
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_structured_response(data: Any) -> str:
|
|
60
|
+
if data is None:
|
|
61
|
+
raise ValueError("Akeyless returned an empty response")
|
|
62
|
+
if isinstance(data, str):
|
|
63
|
+
return data
|
|
64
|
+
return json.dumps(data)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def agentcore_environment() -> str:
|
|
68
|
+
for key in ("AKEYLESS_ENV", "AGENTCORE_ENV", "ENVIRONMENT"):
|
|
69
|
+
value = os.environ.get(key, "").strip().lower()
|
|
70
|
+
if value:
|
|
71
|
+
return value
|
|
72
|
+
return "production"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def default_agentcore_secret_prefix() -> str | None:
|
|
76
|
+
agent_name = os.environ.get("AGENTCORE_AGENT_NAME", "").strip()
|
|
77
|
+
if not agent_name:
|
|
78
|
+
return None
|
|
79
|
+
return f"/bedrock-agentcore/{agent_name}/{agentcore_environment()}"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""AgentCore tool integrations for Akeyless secrets."""
|
|
2
|
+
|
|
3
|
+
from akeyless_agentcore.tools.service import SecretToolService
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"GATEWAY_TOOL_SCHEMA",
|
|
7
|
+
"SecretToolService",
|
|
8
|
+
"create_mcp_server",
|
|
9
|
+
"create_strands_tools",
|
|
10
|
+
"gateway_lambda_handler",
|
|
11
|
+
"run_mcp_server",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def __getattr__(name: str):
|
|
16
|
+
if name in ("create_mcp_server", "run_mcp_server"):
|
|
17
|
+
from akeyless_agentcore.tools.mcp import create_mcp_server, run_mcp_server
|
|
18
|
+
|
|
19
|
+
return {"create_mcp_server": create_mcp_server, "run_mcp_server": run_mcp_server}[name]
|
|
20
|
+
if name == "create_strands_tools":
|
|
21
|
+
from akeyless_agentcore.tools.strands import create_strands_tools
|
|
22
|
+
|
|
23
|
+
return create_strands_tools
|
|
24
|
+
if name in ("gateway_lambda_handler", "GATEWAY_TOOL_SCHEMA"):
|
|
25
|
+
from akeyless_agentcore.tools.gateway import GATEWAY_TOOL_SCHEMA, gateway_lambda_handler
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
"gateway_lambda_handler": gateway_lambda_handler,
|
|
29
|
+
"GATEWAY_TOOL_SCHEMA": GATEWAY_TOOL_SCHEMA,
|
|
30
|
+
}[name]
|
|
31
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""AgentCore Gateway Lambda handler and tool schema for Akeyless secrets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from akeyless_agentcore.tools.service import SecretToolService, SecretType
|
|
9
|
+
|
|
10
|
+
GATEWAY_TOOL_SCHEMA: dict[str, Any] = {
|
|
11
|
+
"inlinePayload": [
|
|
12
|
+
{
|
|
13
|
+
"name": "list_akeyless_secrets",
|
|
14
|
+
"description": (
|
|
15
|
+
"List Akeyless secret names under a path prefix. "
|
|
16
|
+
"Returns names only, never secret values."
|
|
17
|
+
),
|
|
18
|
+
"inputSchema": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"prefix": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Optional Akeyless path prefix (defaults to AKEYLESS_SECRET_PREFIX)",
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "get_akeyless_secret",
|
|
30
|
+
"description": (
|
|
31
|
+
"Fetch a secret value from Akeyless using cloud identity authentication. "
|
|
32
|
+
"Use json_key to return a single field from JSON secrets."
|
|
33
|
+
),
|
|
34
|
+
"inputSchema": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {
|
|
37
|
+
"name": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"description": "Secret name or full path starting with /",
|
|
40
|
+
},
|
|
41
|
+
"path": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "Optional full Akeyless path overriding prefix + name",
|
|
44
|
+
},
|
|
45
|
+
"secret_type": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"enum": ["static", "dynamic", "rotated"],
|
|
48
|
+
"description": "Secret type (default: static)",
|
|
49
|
+
},
|
|
50
|
+
"json_key": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "Return only this key from a JSON secret",
|
|
53
|
+
},
|
|
54
|
+
"ignore_cache": {
|
|
55
|
+
"type": "boolean",
|
|
56
|
+
"description": "Bypass in-memory secret cache",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
"required": ["name"],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def gateway_lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
|
|
67
|
+
"""Lambda entrypoint for AgentCore Gateway MCP tool invocations."""
|
|
68
|
+
service = SecretToolService()
|
|
69
|
+
tool_name = "unknown"
|
|
70
|
+
if context and getattr(context, "client_context", None):
|
|
71
|
+
custom = getattr(context.client_context, "custom", None) or {}
|
|
72
|
+
tool_name = custom.get("bedrockAgentCoreToolName", "unknown")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
if "list_akeyless_secrets" in tool_name:
|
|
76
|
+
result = service.list_secrets(prefix=event.get("prefix"))
|
|
77
|
+
elif "get_akeyless_secret" in tool_name:
|
|
78
|
+
secret_type = event.get("secret_type", "static")
|
|
79
|
+
if secret_type not in ("static", "dynamic", "rotated"):
|
|
80
|
+
secret_type = "static"
|
|
81
|
+
result = service.get_secret(
|
|
82
|
+
event["name"],
|
|
83
|
+
path=event.get("path"),
|
|
84
|
+
secret_type=secret_type, # type: ignore[arg-type]
|
|
85
|
+
json_key=event.get("json_key"),
|
|
86
|
+
ignore_cache=bool(event.get("ignore_cache", False)),
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
result = service.get_secret(event.get("name", "")) if event.get("name") else None
|
|
90
|
+
if result is None:
|
|
91
|
+
return _lambda_response({"ok": False, "error": f"Unknown tool: {tool_name}"}, status=400)
|
|
92
|
+
|
|
93
|
+
status = 200 if result.ok else 400
|
|
94
|
+
body = json.loads(result.to_json())
|
|
95
|
+
return _lambda_response(body, status=status)
|
|
96
|
+
except KeyError as exc:
|
|
97
|
+
return _lambda_response({"ok": False, "error": f"Missing required field: {exc}"}, status=400)
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
return _lambda_response({"ok": False, "error": str(exc)}, status=500)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _lambda_response(body: dict[str, Any], status: int = 200) -> dict[str, Any]:
|
|
103
|
+
return {"statusCode": status, "body": json.dumps(body)}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""FastMCP server exposing Akeyless secrets as AgentCore Runtime MCP tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from akeyless_agentcore.tools.service import SecretToolService, SecretType
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
12
|
+
raise ImportError(
|
|
13
|
+
"MCP support requires the 'mcp' package. Install with: pip install 'akeyless-agentcore-runtime[mcp]'"
|
|
14
|
+
) from exc
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_mcp_server(
|
|
18
|
+
*,
|
|
19
|
+
name: str = "akeyless-secrets",
|
|
20
|
+
service: SecretToolService | None = None,
|
|
21
|
+
host: str = "0.0.0.0",
|
|
22
|
+
stateless_http: bool = True,
|
|
23
|
+
) -> FastMCP:
|
|
24
|
+
"""Create a FastMCP server with Akeyless secret tools."""
|
|
25
|
+
tool_service = service or SecretToolService()
|
|
26
|
+
mcp = FastMCP(name, host=host, stateless_http=stateless_http)
|
|
27
|
+
|
|
28
|
+
@mcp.tool()
|
|
29
|
+
def list_akeyless_secrets(prefix: str | None = None) -> str:
|
|
30
|
+
"""List Akeyless secret names under a path prefix. Returns names only, never values.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
prefix: Optional Akeyless path prefix. Defaults to AKEYLESS_SECRET_PREFIX.
|
|
34
|
+
"""
|
|
35
|
+
return tool_service.list_secrets(prefix=prefix).to_json()
|
|
36
|
+
|
|
37
|
+
@mcp.tool()
|
|
38
|
+
def get_akeyless_secret(
|
|
39
|
+
name: str,
|
|
40
|
+
path: str | None = None,
|
|
41
|
+
secret_type: SecretType = "static",
|
|
42
|
+
json_key: str | None = None,
|
|
43
|
+
ignore_cache: bool = False,
|
|
44
|
+
) -> str:
|
|
45
|
+
"""Fetch a secret value from Akeyless. Authenticates with cloud identity (AWS IAM by default).
|
|
46
|
+
|
|
47
|
+
Use json_key when the secret is JSON and you only need one field (e.g. OPENAI_API_KEY).
|
|
48
|
+
Use secret_type='dynamic' or 'rotated' for non-static secrets.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
name: Short secret name (resolved with AKEYLESS_SECRET_PREFIX) or full path starting with /
|
|
52
|
+
path: Optional full Akeyless path; overrides prefix + name resolution
|
|
53
|
+
secret_type: static, dynamic, or rotated
|
|
54
|
+
json_key: Return only this key from a JSON secret
|
|
55
|
+
ignore_cache: Bypass the in-memory secret cache
|
|
56
|
+
"""
|
|
57
|
+
return tool_service.get_secret(
|
|
58
|
+
name,
|
|
59
|
+
path=path,
|
|
60
|
+
secret_type=secret_type,
|
|
61
|
+
json_key=json_key,
|
|
62
|
+
ignore_cache=ignore_cache,
|
|
63
|
+
).to_json()
|
|
64
|
+
|
|
65
|
+
return mcp
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_mcp_server(**kwargs: Any) -> None:
|
|
69
|
+
"""Run the Akeyless MCP server with streamable HTTP (AgentCore-compatible)."""
|
|
70
|
+
create_mcp_server(**kwargs).run(transport="streamable-http")
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Shared secret tool logic for MCP, Strands, and Gateway deployments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from akeyless_agentcore.client import AkeylessRuntimeClient, GetSecretOptions
|
|
10
|
+
|
|
11
|
+
SecretType = Literal["static", "dynamic", "rotated"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ToolResponse:
|
|
16
|
+
ok: bool
|
|
17
|
+
data: dict[str, Any]
|
|
18
|
+
error: str | None = None
|
|
19
|
+
|
|
20
|
+
def to_json(self) -> str:
|
|
21
|
+
payload = {"ok": self.ok, **self.data}
|
|
22
|
+
if self.error:
|
|
23
|
+
payload["error"] = self.error
|
|
24
|
+
return json.dumps(payload)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SecretToolService:
|
|
28
|
+
"""Implements secret operations exposed as AgentCore tools."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, client: AkeylessRuntimeClient | None = None) -> None:
|
|
31
|
+
self._client = client or AkeylessRuntimeClient()
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def client(self) -> AkeylessRuntimeClient:
|
|
35
|
+
return self._client
|
|
36
|
+
|
|
37
|
+
def list_secrets(self, prefix: str | None = None) -> ToolResponse:
|
|
38
|
+
"""List secret names under a prefix. Never returns secret values."""
|
|
39
|
+
try:
|
|
40
|
+
names = self._client.list_secrets_sync(prefix=prefix)
|
|
41
|
+
return ToolResponse(
|
|
42
|
+
ok=True,
|
|
43
|
+
data={
|
|
44
|
+
"secrets": names,
|
|
45
|
+
"count": len(names),
|
|
46
|
+
"prefix": prefix or self._client.config.secret_prefix,
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
return ToolResponse(ok=False, data={}, error=str(exc))
|
|
51
|
+
|
|
52
|
+
def get_secret(
|
|
53
|
+
self,
|
|
54
|
+
name: str,
|
|
55
|
+
*,
|
|
56
|
+
path: str | None = None,
|
|
57
|
+
secret_type: SecretType = "static",
|
|
58
|
+
json_key: str | None = None,
|
|
59
|
+
ignore_cache: bool = False,
|
|
60
|
+
) -> ToolResponse:
|
|
61
|
+
"""Fetch a secret value. Use json_key to return a single field from JSON secrets."""
|
|
62
|
+
try:
|
|
63
|
+
raw = self._fetch_by_type(
|
|
64
|
+
name=name,
|
|
65
|
+
path=path,
|
|
66
|
+
secret_type=secret_type,
|
|
67
|
+
ignore_cache=ignore_cache,
|
|
68
|
+
)
|
|
69
|
+
if json_key:
|
|
70
|
+
value = self._extract_json_key(raw, json_key)
|
|
71
|
+
return ToolResponse(
|
|
72
|
+
ok=True,
|
|
73
|
+
data={
|
|
74
|
+
"name": name,
|
|
75
|
+
"path": path or self._client.resolve_path(name),
|
|
76
|
+
"json_key": json_key,
|
|
77
|
+
"value": value,
|
|
78
|
+
"secret_type": secret_type,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return ToolResponse(
|
|
83
|
+
ok=True,
|
|
84
|
+
data={
|
|
85
|
+
"name": name,
|
|
86
|
+
"path": path or self._client.resolve_path(name),
|
|
87
|
+
"value": raw,
|
|
88
|
+
"secret_type": secret_type,
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
return ToolResponse(ok=False, data={"name": name}, error=str(exc))
|
|
93
|
+
|
|
94
|
+
def _fetch_by_type(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
name: str,
|
|
98
|
+
path: str | None,
|
|
99
|
+
secret_type: SecretType,
|
|
100
|
+
ignore_cache: bool,
|
|
101
|
+
) -> str:
|
|
102
|
+
if secret_type == "dynamic":
|
|
103
|
+
return self._client.get_dynamic_secret_sync(
|
|
104
|
+
name,
|
|
105
|
+
path=path,
|
|
106
|
+
ignore_cache=ignore_cache,
|
|
107
|
+
)
|
|
108
|
+
if secret_type == "rotated":
|
|
109
|
+
return self._client.get_rotated_secret_sync(
|
|
110
|
+
name,
|
|
111
|
+
path=path,
|
|
112
|
+
ignore_cache=ignore_cache,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return self._client.get_secret_sync(
|
|
116
|
+
name,
|
|
117
|
+
GetSecretOptions(
|
|
118
|
+
path=path,
|
|
119
|
+
ignore_cache=ignore_cache,
|
|
120
|
+
allow_dynamic_fallback=True,
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _extract_json_key(raw: str, json_key: str) -> str:
|
|
126
|
+
try:
|
|
127
|
+
data = json.loads(raw)
|
|
128
|
+
except json.JSONDecodeError as exc:
|
|
129
|
+
raise ValueError(f"Secret is not valid JSON; cannot extract key {json_key!r}") from exc
|
|
130
|
+
if not isinstance(data, dict):
|
|
131
|
+
raise ValueError("Secret JSON must be an object to extract a key")
|
|
132
|
+
if json_key not in data:
|
|
133
|
+
raise ValueError(f"Key {json_key!r} not found in secret JSON")
|
|
134
|
+
return stringify_value(data[json_key])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def stringify_value(value: Any) -> str:
|
|
138
|
+
if value is None:
|
|
139
|
+
return ""
|
|
140
|
+
if isinstance(value, str):
|
|
141
|
+
return value
|
|
142
|
+
if isinstance(value, (int, float, bool)):
|
|
143
|
+
return str(value)
|
|
144
|
+
return json.dumps(value)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""In-process Strands tools for agents that call Akeyless without Gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from akeyless_agentcore.tools.service import SecretToolService, SecretType
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from strands import tool
|
|
11
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
12
|
+
raise ImportError(
|
|
13
|
+
"Strands tools require strands-agents. Install with: "
|
|
14
|
+
"pip install 'akeyless-agentcore-runtime[strands]'"
|
|
15
|
+
) from exc
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_strands_tools(service: SecretToolService | None = None) -> list[Any]:
|
|
19
|
+
"""Return Strands tool callables backed by the shared SecretToolService."""
|
|
20
|
+
tool_service = service or SecretToolService()
|
|
21
|
+
|
|
22
|
+
@tool
|
|
23
|
+
def list_akeyless_secrets(prefix: str | None = None) -> str:
|
|
24
|
+
"""List Akeyless secret names under a path prefix. Returns names only, never values."""
|
|
25
|
+
return tool_service.list_secrets(prefix=prefix).to_json()
|
|
26
|
+
|
|
27
|
+
@tool
|
|
28
|
+
def get_akeyless_secret(
|
|
29
|
+
name: str,
|
|
30
|
+
path: str | None = None,
|
|
31
|
+
secret_type: SecretType = "static",
|
|
32
|
+
json_key: str | None = None,
|
|
33
|
+
ignore_cache: bool = False,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Fetch a secret from Akeyless. Use json_key for a single field from JSON secrets."""
|
|
36
|
+
return tool_service.get_secret(
|
|
37
|
+
name,
|
|
38
|
+
path=path,
|
|
39
|
+
secret_type=secret_type,
|
|
40
|
+
json_key=json_key,
|
|
41
|
+
ignore_cache=ignore_cache,
|
|
42
|
+
).to_json()
|
|
43
|
+
|
|
44
|
+
return [list_akeyless_secrets, get_akeyless_secret]
|