@raghulm/aegis-mcp 1.0.4 → 1.0.7
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.
- package/README.md +286 -290
- package/audit/audit_logger.py +62 -62
- package/package.json +5 -6
- package/policies/roles.yaml +34 -34
- package/policies/scope_rules.yaml +16 -16
- package/requirements.txt +8 -7
- package/run_stdio.py +22 -22
- package/server/auth.py +69 -69
- package/server/config.py +82 -82
- package/server/health.py +19 -19
- package/server/logging.py +33 -33
- package/server/main.py +212 -144
- package/server/stdio.py +7 -7
- package/tools/aws/ec2.py +26 -26
- package/tools/aws/s3.py +54 -54
- package/tools/cicd/jenkins.py +256 -0
- package/tools/cicd/pipeline.py +33 -33
- package/tools/git/repo.py +22 -22
- package/tools/kubernetes/audit.py +108 -108
- package/tools/kubernetes/pods.py +27 -27
- package/tools/network/headers.py +99 -99
- package/tools/network/port_scanner.py +66 -66
- package/tools/network/ssl_checker.py +65 -65
- package/tools/security/deps.py +103 -103
- package/tools/security/secrets.py +91 -91
- package/tools/security/semgrep.py +261 -261
- package/tools/security/trivy.py +19 -19
package/audit/audit_logger.py
CHANGED
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from functools import wraps
|
|
4
|
-
from time import perf_counter
|
|
5
|
-
from typing import Any, Callable
|
|
6
|
-
|
|
7
|
-
from server.logging import get_logger
|
|
8
|
-
|
|
9
|
-
logger = get_logger("mcp.aegis.audit")
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def audit_tool_call(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
14
|
-
"""Decorator that emits structured audit records for every tool invocation."""
|
|
15
|
-
|
|
16
|
-
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
17
|
-
@wraps(func)
|
|
18
|
-
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
19
|
-
started = perf_counter()
|
|
20
|
-
logger.info(
|
|
21
|
-
"tool_call_started",
|
|
22
|
-
extra={
|
|
23
|
-
"extra_payload": {
|
|
24
|
-
"event": "tool_call_started",
|
|
25
|
-
"tool": tool_name,
|
|
26
|
-
"args": str(args),
|
|
27
|
-
"kwargs": kwargs,
|
|
28
|
-
}
|
|
29
|
-
},
|
|
30
|
-
)
|
|
31
|
-
try:
|
|
32
|
-
result = func(*args, **kwargs)
|
|
33
|
-
duration_ms = int((perf_counter() - started) * 1000)
|
|
34
|
-
logger.info(
|
|
35
|
-
"tool_call_succeeded",
|
|
36
|
-
extra={
|
|
37
|
-
"extra_payload": {
|
|
38
|
-
"event": "tool_call_succeeded",
|
|
39
|
-
"tool": tool_name,
|
|
40
|
-
"duration_ms": duration_ms,
|
|
41
|
-
}
|
|
42
|
-
},
|
|
43
|
-
)
|
|
44
|
-
return result
|
|
45
|
-
except Exception as exc: # noqa: BLE001
|
|
46
|
-
duration_ms = int((perf_counter() - started) * 1000)
|
|
47
|
-
logger.error(
|
|
48
|
-
"tool_call_failed",
|
|
49
|
-
extra={
|
|
50
|
-
"extra_payload": {
|
|
51
|
-
"event": "tool_call_failed",
|
|
52
|
-
"tool": tool_name,
|
|
53
|
-
"duration_ms": duration_ms,
|
|
54
|
-
"error": str(exc),
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
)
|
|
58
|
-
raise
|
|
59
|
-
|
|
60
|
-
return wrapper
|
|
61
|
-
|
|
62
|
-
return decorator
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from time import perf_counter
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from server.logging import get_logger
|
|
8
|
+
|
|
9
|
+
logger = get_logger("mcp.aegis.audit")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def audit_tool_call(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
14
|
+
"""Decorator that emits structured audit records for every tool invocation."""
|
|
15
|
+
|
|
16
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
17
|
+
@wraps(func)
|
|
18
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
19
|
+
started = perf_counter()
|
|
20
|
+
logger.info(
|
|
21
|
+
"tool_call_started",
|
|
22
|
+
extra={
|
|
23
|
+
"extra_payload": {
|
|
24
|
+
"event": "tool_call_started",
|
|
25
|
+
"tool": tool_name,
|
|
26
|
+
"args": str(args),
|
|
27
|
+
"kwargs": kwargs,
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
try:
|
|
32
|
+
result = func(*args, **kwargs)
|
|
33
|
+
duration_ms = int((perf_counter() - started) * 1000)
|
|
34
|
+
logger.info(
|
|
35
|
+
"tool_call_succeeded",
|
|
36
|
+
extra={
|
|
37
|
+
"extra_payload": {
|
|
38
|
+
"event": "tool_call_succeeded",
|
|
39
|
+
"tool": tool_name,
|
|
40
|
+
"duration_ms": duration_ms,
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
return result
|
|
45
|
+
except Exception as exc: # noqa: BLE001
|
|
46
|
+
duration_ms = int((perf_counter() - started) * 1000)
|
|
47
|
+
logger.error(
|
|
48
|
+
"tool_call_failed",
|
|
49
|
+
extra={
|
|
50
|
+
"extra_payload": {
|
|
51
|
+
"event": "tool_call_failed",
|
|
52
|
+
"tool": tool_name,
|
|
53
|
+
"duration_ms": duration_ms,
|
|
54
|
+
"error": str(exc),
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
raise
|
|
59
|
+
|
|
60
|
+
return wrapper
|
|
61
|
+
|
|
62
|
+
return decorator
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raghulm/aegis-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "DevSecOps-focused MCP server for AWS, Kubernetes, CI/CD, and security tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Raghul M",
|
|
@@ -39,15 +39,14 @@
|
|
|
39
39
|
"security",
|
|
40
40
|
"aws",
|
|
41
41
|
"kubernetes",
|
|
42
|
-
"claude"
|
|
42
|
+
"claude",
|
|
43
|
+
"jenkins"
|
|
43
44
|
],
|
|
44
45
|
"publishConfig": {
|
|
45
|
-
"access": "public"
|
|
46
|
+
"access": "public",
|
|
47
|
+
"registry": "https://npm.pkg.github.com"
|
|
46
48
|
},
|
|
47
49
|
"engines": {
|
|
48
50
|
"node": ">=18"
|
|
49
|
-
},
|
|
50
|
-
"dependencies": {
|
|
51
|
-
"@raghulm/aegis-mcp": "^1.0.3"
|
|
52
51
|
}
|
|
53
52
|
}
|
package/policies/roles.yaml
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
roles:
|
|
2
|
-
viewer:
|
|
3
|
-
- aws_list_ec2_instances
|
|
4
|
-
- k8s_list_pods
|
|
5
|
-
- git_recent_commits
|
|
6
|
-
- security_check_ssl_certificate
|
|
7
|
-
- security_check_http_headers
|
|
8
|
-
- network_port_scan
|
|
9
|
-
- security_semgrep_scan
|
|
10
|
-
security:
|
|
11
|
-
- k8s_security_audit
|
|
12
|
-
- security_run_trivy_scan
|
|
13
|
-
- git_recent_commits
|
|
14
|
-
- security_scan_secrets
|
|
15
|
-
- security_check_ssl_certificate
|
|
16
|
-
- security_check_dependencies
|
|
17
|
-
- security_check_http_headers
|
|
18
|
-
- security_semgrep_scan
|
|
19
|
-
- aws_check_s3_public_access
|
|
20
|
-
- network_port_scan
|
|
21
|
-
admin:
|
|
22
|
-
- aws_list_ec2_instances
|
|
23
|
-
- k8s_list_pods
|
|
24
|
-
- k8s_security_audit
|
|
25
|
-
- security_run_trivy_scan
|
|
26
|
-
- git_recent_commits
|
|
27
|
-
- cicd_pipeline_status
|
|
28
|
-
- security_scan_secrets
|
|
29
|
-
- security_check_ssl_certificate
|
|
30
|
-
- security_check_dependencies
|
|
31
|
-
- security_check_http_headers
|
|
32
|
-
- security_semgrep_scan
|
|
33
|
-
- aws_check_s3_public_access
|
|
34
|
-
- network_port_scan
|
|
1
|
+
roles:
|
|
2
|
+
viewer:
|
|
3
|
+
- aws_list_ec2_instances
|
|
4
|
+
- k8s_list_pods
|
|
5
|
+
- git_recent_commits
|
|
6
|
+
- security_check_ssl_certificate
|
|
7
|
+
- security_check_http_headers
|
|
8
|
+
- network_port_scan
|
|
9
|
+
- security_semgrep_scan
|
|
10
|
+
security:
|
|
11
|
+
- k8s_security_audit
|
|
12
|
+
- security_run_trivy_scan
|
|
13
|
+
- git_recent_commits
|
|
14
|
+
- security_scan_secrets
|
|
15
|
+
- security_check_ssl_certificate
|
|
16
|
+
- security_check_dependencies
|
|
17
|
+
- security_check_http_headers
|
|
18
|
+
- security_semgrep_scan
|
|
19
|
+
- aws_check_s3_public_access
|
|
20
|
+
- network_port_scan
|
|
21
|
+
admin:
|
|
22
|
+
- aws_list_ec2_instances
|
|
23
|
+
- k8s_list_pods
|
|
24
|
+
- k8s_security_audit
|
|
25
|
+
- security_run_trivy_scan
|
|
26
|
+
- git_recent_commits
|
|
27
|
+
- cicd_pipeline_status
|
|
28
|
+
- security_scan_secrets
|
|
29
|
+
- security_check_ssl_certificate
|
|
30
|
+
- security_check_dependencies
|
|
31
|
+
- security_check_http_headers
|
|
32
|
+
- security_semgrep_scan
|
|
33
|
+
- aws_check_s3_public_access
|
|
34
|
+
- network_port_scan
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
scopes:
|
|
2
|
-
aegis.read:
|
|
3
|
-
- aws_list_ec2_instances
|
|
4
|
-
- k8s_list_pods
|
|
5
|
-
- git_recent_commits
|
|
6
|
-
- cicd_pipeline_status
|
|
7
|
-
- security_check_ssl_certificate
|
|
8
|
-
- security_check_http_headers
|
|
9
|
-
- network_port_scan
|
|
10
|
-
- aws_check_s3_public_access
|
|
11
|
-
- security_semgrep_scan
|
|
12
|
-
aegis.security:
|
|
13
|
-
- security_run_trivy_scan
|
|
14
|
-
- security_scan_secrets
|
|
15
|
-
- security_check_dependencies
|
|
16
|
-
- security_semgrep_scan
|
|
1
|
+
scopes:
|
|
2
|
+
aegis.read:
|
|
3
|
+
- aws_list_ec2_instances
|
|
4
|
+
- k8s_list_pods
|
|
5
|
+
- git_recent_commits
|
|
6
|
+
- cicd_pipeline_status
|
|
7
|
+
- security_check_ssl_certificate
|
|
8
|
+
- security_check_http_headers
|
|
9
|
+
- network_port_scan
|
|
10
|
+
- aws_check_s3_public_access
|
|
11
|
+
- security_semgrep_scan
|
|
12
|
+
aegis.security:
|
|
13
|
+
- security_run_trivy_scan
|
|
14
|
+
- security_scan_secrets
|
|
15
|
+
- security_check_dependencies
|
|
16
|
+
- security_semgrep_scan
|
package/requirements.txt
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
mcp>=1.0.0
|
|
2
|
-
boto3>=1.34.0
|
|
3
|
-
requests>=2.32.0
|
|
4
|
-
fastapi>=0.111.0
|
|
5
|
-
uvicorn>=0.30.0
|
|
6
|
-
pyyaml>=6.0.1
|
|
7
|
-
semgrep>=1.60.0
|
|
1
|
+
mcp>=1.0.0
|
|
2
|
+
boto3>=1.34.0
|
|
3
|
+
requests>=2.32.0
|
|
4
|
+
fastapi>=0.111.0
|
|
5
|
+
uvicorn>=0.30.0
|
|
6
|
+
pyyaml>=6.0.1
|
|
7
|
+
semgrep>=1.60.0
|
|
8
|
+
python-jenkins>=1.8.0
|
package/run_stdio.py
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
"""Launcher script for Claude Desktop — ensures the project root is on sys.path."""
|
|
2
|
-
import sys
|
|
3
|
-
import os
|
|
4
|
-
|
|
5
|
-
# Set working directory and sys.path to the project root so that
|
|
6
|
-
# relative policy file paths (policies/roles.yaml etc.) resolve correctly
|
|
7
|
-
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
8
|
-
os.chdir(PROJECT_ROOT)
|
|
9
|
-
sys.path.insert(0, PROJECT_ROOT)
|
|
10
|
-
|
|
11
|
-
# Disable JWT auth for local stdio sessions (Claude Desktop cannot supply tokens)
|
|
12
|
-
os.environ.setdefault("MCP_AUTH_DISABLED", "true")
|
|
13
|
-
|
|
14
|
-
# Ensure the venv Scripts dir is on PATH so pysemgrep / semgrep-core are found
|
|
15
|
-
venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "Scripts")
|
|
16
|
-
if os.path.isdir(venv_scripts) and venv_scripts not in os.environ.get("PATH", ""):
|
|
17
|
-
os.environ["PATH"] = venv_scripts + os.pathsep + os.environ.get("PATH", "")
|
|
18
|
-
|
|
19
|
-
from server.main import mcp
|
|
20
|
-
|
|
21
|
-
if __name__ == "__main__":
|
|
22
|
-
mcp.run(transport="stdio")
|
|
1
|
+
"""Launcher script for Claude Desktop — ensures the project root is on sys.path."""
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
# Set working directory and sys.path to the project root so that
|
|
6
|
+
# relative policy file paths (policies/roles.yaml etc.) resolve correctly
|
|
7
|
+
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
8
|
+
os.chdir(PROJECT_ROOT)
|
|
9
|
+
sys.path.insert(0, PROJECT_ROOT)
|
|
10
|
+
|
|
11
|
+
# Disable JWT auth for local stdio sessions (Claude Desktop cannot supply tokens)
|
|
12
|
+
os.environ.setdefault("MCP_AUTH_DISABLED", "true")
|
|
13
|
+
|
|
14
|
+
# Ensure the venv Scripts dir is on PATH so pysemgrep / semgrep-core are found
|
|
15
|
+
venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "Scripts")
|
|
16
|
+
if os.path.isdir(venv_scripts) and venv_scripts not in os.environ.get("PATH", ""):
|
|
17
|
+
os.environ["PATH"] = venv_scripts + os.pathsep + os.environ.get("PATH", "")
|
|
18
|
+
|
|
19
|
+
from server.main import mcp
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
mcp.run(transport="stdio")
|
package/server/auth.py
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import base64
|
|
4
|
-
import json
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
|
|
7
|
-
from server.config import Settings
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass(frozen=True)
|
|
11
|
-
class Principal:
|
|
12
|
-
subject: str
|
|
13
|
-
role: str
|
|
14
|
-
scopes: list[str]
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class AuthorizationError(PermissionError):
|
|
18
|
-
pass
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _decode_jwt_payload(token: str) -> dict:
|
|
23
|
-
parts = token.split(".")
|
|
24
|
-
if len(parts) < 2:
|
|
25
|
-
return {}
|
|
26
|
-
payload = parts[1]
|
|
27
|
-
padding = "=" * ((4 - len(payload) % 4) % 4)
|
|
28
|
-
decoded = base64.urlsafe_b64decode(payload + padding)
|
|
29
|
-
return json.loads(decoded.decode("utf-8"))
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def decode_bearer_token(token: str, settings: Settings) -> Principal:
|
|
34
|
-
"""Decode claims from a bearer token payload.
|
|
35
|
-
|
|
36
|
-
This implementation parses JWT payload claims and is intended as a scaffold.
|
|
37
|
-
Replace with strict signature/JWKS validation in production.
|
|
38
|
-
"""
|
|
39
|
-
claims = _decode_jwt_payload(token)
|
|
40
|
-
|
|
41
|
-
if settings.oidc_issuer and claims.get("iss") != settings.oidc_issuer:
|
|
42
|
-
raise AuthorizationError("token issuer mismatch")
|
|
43
|
-
if settings.oidc_audience and settings.oidc_audience not in str(claims.get("aud", "")):
|
|
44
|
-
raise AuthorizationError("token audience mismatch")
|
|
45
|
-
|
|
46
|
-
role = str(claims.get("role", "viewer"))
|
|
47
|
-
scope_claim = claims.get("scope", "")
|
|
48
|
-
scopes = scope_claim.split() if isinstance(scope_claim, str) else []
|
|
49
|
-
return Principal(subject=str(claims.get("sub", "unknown")), role=role, scopes=scopes)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def authorize_tool(
|
|
54
|
-
principal: Principal,
|
|
55
|
-
tool_name: str,
|
|
56
|
-
role_policies: dict[str, list[str]],
|
|
57
|
-
scope_policies: dict[str, list[str]],
|
|
58
|
-
) -> None:
|
|
59
|
-
allowed_by_role = set(role_policies.get(principal.role, []))
|
|
60
|
-
allowed_by_scope: set[str] = set()
|
|
61
|
-
for scope in principal.scopes:
|
|
62
|
-
allowed_by_scope.update(scope_policies.get(scope, []))
|
|
63
|
-
|
|
64
|
-
if tool_name in allowed_by_role or tool_name in allowed_by_scope:
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
raise AuthorizationError(
|
|
68
|
-
f"principal '{principal.subject}' with role '{principal.role}' is not allowed to call '{tool_name}'"
|
|
69
|
-
)
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from server.config import Settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Principal:
|
|
12
|
+
subject: str
|
|
13
|
+
role: str
|
|
14
|
+
scopes: list[str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthorizationError(PermissionError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _decode_jwt_payload(token: str) -> dict:
|
|
23
|
+
parts = token.split(".")
|
|
24
|
+
if len(parts) < 2:
|
|
25
|
+
return {}
|
|
26
|
+
payload = parts[1]
|
|
27
|
+
padding = "=" * ((4 - len(payload) % 4) % 4)
|
|
28
|
+
decoded = base64.urlsafe_b64decode(payload + padding)
|
|
29
|
+
return json.loads(decoded.decode("utf-8"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def decode_bearer_token(token: str, settings: Settings) -> Principal:
|
|
34
|
+
"""Decode claims from a bearer token payload.
|
|
35
|
+
|
|
36
|
+
This implementation parses JWT payload claims and is intended as a scaffold.
|
|
37
|
+
Replace with strict signature/JWKS validation in production.
|
|
38
|
+
"""
|
|
39
|
+
claims = _decode_jwt_payload(token)
|
|
40
|
+
|
|
41
|
+
if settings.oidc_issuer and claims.get("iss") != settings.oidc_issuer:
|
|
42
|
+
raise AuthorizationError("token issuer mismatch")
|
|
43
|
+
if settings.oidc_audience and settings.oidc_audience not in str(claims.get("aud", "")):
|
|
44
|
+
raise AuthorizationError("token audience mismatch")
|
|
45
|
+
|
|
46
|
+
role = str(claims.get("role", "viewer"))
|
|
47
|
+
scope_claim = claims.get("scope", "")
|
|
48
|
+
scopes = scope_claim.split() if isinstance(scope_claim, str) else []
|
|
49
|
+
return Principal(subject=str(claims.get("sub", "unknown")), role=role, scopes=scopes)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def authorize_tool(
|
|
54
|
+
principal: Principal,
|
|
55
|
+
tool_name: str,
|
|
56
|
+
role_policies: dict[str, list[str]],
|
|
57
|
+
scope_policies: dict[str, list[str]],
|
|
58
|
+
) -> None:
|
|
59
|
+
allowed_by_role = set(role_policies.get(principal.role, []))
|
|
60
|
+
allowed_by_scope: set[str] = set()
|
|
61
|
+
for scope in principal.scopes:
|
|
62
|
+
allowed_by_scope.update(scope_policies.get(scope, []))
|
|
63
|
+
|
|
64
|
+
if tool_name in allowed_by_role or tool_name in allowed_by_scope:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
raise AuthorizationError(
|
|
68
|
+
f"principal '{principal.subject}' with role '{principal.role}' is not allowed to call '{tool_name}'"
|
|
69
|
+
)
|
package/server/config.py
CHANGED
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass(frozen=True)
|
|
11
|
-
class Settings:
|
|
12
|
-
"""Runtime configuration for the MCP service."""
|
|
13
|
-
|
|
14
|
-
service_name: str = "aegis"
|
|
15
|
-
environment: str = "dev"
|
|
16
|
-
policy_roles_path: Path = Path("policies/roles.yaml")
|
|
17
|
-
policy_scopes_path: Path = Path("policies/scope_rules.yaml")
|
|
18
|
-
oidc_issuer: str | None = None
|
|
19
|
-
oidc_audience: str | None = None
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _parse_simple_yaml(raw: str) -> dict[str, Any]:
|
|
24
|
-
"""Very small YAML subset parser for key/list policy files."""
|
|
25
|
-
root: dict[str, Any] = {}
|
|
26
|
-
current_section: dict[str, list[str]] | None = None
|
|
27
|
-
current_key: str | None = None
|
|
28
|
-
for line in raw.splitlines():
|
|
29
|
-
stripped = line.rstrip()
|
|
30
|
-
if not stripped or stripped.lstrip().startswith("#"):
|
|
31
|
-
continue
|
|
32
|
-
if not line.startswith(" ") and stripped.endswith(":"):
|
|
33
|
-
section = stripped[:-1]
|
|
34
|
-
root[section] = {}
|
|
35
|
-
current_section = root[section]
|
|
36
|
-
current_key = None
|
|
37
|
-
elif current_section is not None and line.startswith(" ") and stripped.endswith(":"):
|
|
38
|
-
current_key = stripped[:-1]
|
|
39
|
-
current_section[current_key] = []
|
|
40
|
-
elif current_section is not None and current_key and line.strip().startswith("-"):
|
|
41
|
-
value = line.split("-", maxsplit=1)[1].strip().strip('"')
|
|
42
|
-
current_section[current_key].append(value)
|
|
43
|
-
return root
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
48
|
-
if not path.exists():
|
|
49
|
-
return {}
|
|
50
|
-
content = path.read_text(encoding="utf-8")
|
|
51
|
-
try:
|
|
52
|
-
import yaml # type: ignore
|
|
53
|
-
|
|
54
|
-
return yaml.safe_load(content) or {}
|
|
55
|
-
except Exception:
|
|
56
|
-
return _parse_simple_yaml(content)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def load_settings() -> Settings:
|
|
61
|
-
return Settings(
|
|
62
|
-
service_name=os.getenv("MCP_SERVICE_NAME", "aegis"),
|
|
63
|
-
environment=os.getenv("MCP_ENV", "dev"),
|
|
64
|
-
policy_roles_path=Path(os.getenv("MCP_ROLES_FILE", "policies/roles.yaml")),
|
|
65
|
-
policy_scopes_path=Path(os.getenv("MCP_SCOPES_FILE", "policies/scope_rules.yaml")),
|
|
66
|
-
oidc_issuer=os.getenv("OIDC_ISSUER"),
|
|
67
|
-
oidc_audience=os.getenv("OIDC_AUDIENCE"),
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def load_role_policies(settings: Settings) -> dict[str, list[str]]:
|
|
73
|
-
raw = _load_yaml(settings.policy_roles_path)
|
|
74
|
-
roles = raw.get("roles", {})
|
|
75
|
-
return {str(k): [str(v) for v in values] for k, values in roles.items()}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def load_scope_policies(settings: Settings) -> dict[str, list[str]]:
|
|
80
|
-
raw = _load_yaml(settings.policy_scopes_path)
|
|
81
|
-
scopes = raw.get("scopes", {})
|
|
82
|
-
return {str(k): [str(v) for v in values] for k, values in scopes.items()}
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Settings:
|
|
12
|
+
"""Runtime configuration for the MCP service."""
|
|
13
|
+
|
|
14
|
+
service_name: str = "aegis"
|
|
15
|
+
environment: str = "dev"
|
|
16
|
+
policy_roles_path: Path = Path("policies/roles.yaml")
|
|
17
|
+
policy_scopes_path: Path = Path("policies/scope_rules.yaml")
|
|
18
|
+
oidc_issuer: str | None = None
|
|
19
|
+
oidc_audience: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_simple_yaml(raw: str) -> dict[str, Any]:
|
|
24
|
+
"""Very small YAML subset parser for key/list policy files."""
|
|
25
|
+
root: dict[str, Any] = {}
|
|
26
|
+
current_section: dict[str, list[str]] | None = None
|
|
27
|
+
current_key: str | None = None
|
|
28
|
+
for line in raw.splitlines():
|
|
29
|
+
stripped = line.rstrip()
|
|
30
|
+
if not stripped or stripped.lstrip().startswith("#"):
|
|
31
|
+
continue
|
|
32
|
+
if not line.startswith(" ") and stripped.endswith(":"):
|
|
33
|
+
section = stripped[:-1]
|
|
34
|
+
root[section] = {}
|
|
35
|
+
current_section = root[section]
|
|
36
|
+
current_key = None
|
|
37
|
+
elif current_section is not None and line.startswith(" ") and stripped.endswith(":"):
|
|
38
|
+
current_key = stripped[:-1]
|
|
39
|
+
current_section[current_key] = []
|
|
40
|
+
elif current_section is not None and current_key and line.strip().startswith("-"):
|
|
41
|
+
value = line.split("-", maxsplit=1)[1].strip().strip('"')
|
|
42
|
+
current_section[current_key].append(value)
|
|
43
|
+
return root
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
48
|
+
if not path.exists():
|
|
49
|
+
return {}
|
|
50
|
+
content = path.read_text(encoding="utf-8")
|
|
51
|
+
try:
|
|
52
|
+
import yaml # type: ignore
|
|
53
|
+
|
|
54
|
+
return yaml.safe_load(content) or {}
|
|
55
|
+
except Exception:
|
|
56
|
+
return _parse_simple_yaml(content)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_settings() -> Settings:
|
|
61
|
+
return Settings(
|
|
62
|
+
service_name=os.getenv("MCP_SERVICE_NAME", "aegis"),
|
|
63
|
+
environment=os.getenv("MCP_ENV", "dev"),
|
|
64
|
+
policy_roles_path=Path(os.getenv("MCP_ROLES_FILE", "policies/roles.yaml")),
|
|
65
|
+
policy_scopes_path=Path(os.getenv("MCP_SCOPES_FILE", "policies/scope_rules.yaml")),
|
|
66
|
+
oidc_issuer=os.getenv("OIDC_ISSUER"),
|
|
67
|
+
oidc_audience=os.getenv("OIDC_AUDIENCE"),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_role_policies(settings: Settings) -> dict[str, list[str]]:
|
|
73
|
+
raw = _load_yaml(settings.policy_roles_path)
|
|
74
|
+
roles = raw.get("roles", {})
|
|
75
|
+
return {str(k): [str(v) for v in values] for k, values in roles.items()}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_scope_policies(settings: Settings) -> dict[str, list[str]]:
|
|
80
|
+
raw = _load_yaml(settings.policy_scopes_path)
|
|
81
|
+
scopes = raw.get("scopes", {})
|
|
82
|
+
return {str(k): [str(v) for v in values] for k, values in scopes.items()}
|
package/server/health.py
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from fastapi import FastAPI
|
|
4
|
-
from fastapi.responses import JSONResponse
|
|
5
|
-
from mcp.server.fastmcp import FastMCP
|
|
6
|
-
|
|
7
|
-
from server.config import load_settings
|
|
8
|
-
|
|
9
|
-
settings = load_settings()
|
|
10
|
-
mcp = FastMCP(settings.service_name, json_response=True)
|
|
11
|
-
app = FastAPI(title="aegis-mcp")
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@app.get("/health")
|
|
15
|
-
def health() -> JSONResponse:
|
|
16
|
-
return JSONResponse({"status": "ok", "service": settings.service_name})
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
app.mount("/mcp", mcp.streamable_http_app())
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
from server.config import load_settings
|
|
8
|
+
|
|
9
|
+
settings = load_settings()
|
|
10
|
+
mcp = FastMCP(settings.service_name, json_response=True)
|
|
11
|
+
app = FastAPI(title="aegis-mcp")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.get("/health")
|
|
15
|
+
def health() -> JSONResponse:
|
|
16
|
+
return JSONResponse({"status": "ok", "service": settings.service_name})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
app.mount("/mcp", mcp.streamable_http_app())
|