policygate 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sergei Konovalov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.3
2
+ Name: policygate
3
+ Version: 0.1.0
4
+ Summary: MCP server gateway for task-specific AI rules and scripts stored in GitHub
5
+ Keywords: mcp,policy,gateway,ai,github,automation
6
+ Author: Sergei Konovalov
7
+ License: MIT License
8
+
9
+ Copyright (c) 2025 Sergei Konovalov
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ Classifier: Development Status :: 3 - Alpha
29
+ Classifier: Intended Audience :: Developers
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Operating System :: OS Independent
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: Programming Language :: Python :: 3.11
34
+ Classifier: Programming Language :: Python :: 3.12
35
+ Classifier: Programming Language :: Python :: 3.13
36
+ Classifier: Topic :: Software Development :: Libraries
37
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
38
+ Requires-Dist: pydantic-settings>=2.0.0
39
+ Requires-Dist: structlog>=25.1.0
40
+ Requires-Dist: pydantic>=2.0.0
41
+ Requires-Dist: fastmcp>=2.0.0
42
+ Requires-Dist: httpx>=0.27.0
43
+ Requires-Dist: pyyaml>=6.0.0
44
+ Maintainer: Sergei Konovalov
45
+ Requires-Python: >=3.11
46
+ Project-URL: Homepage, https://github.com/l0kifs/policygate
47
+ Project-URL: Repository, https://github.com/l0kifs/policygate
48
+ Project-URL: Issues, https://github.com/l0kifs/policygate/issues
49
+ Description-Content-Type: text/markdown
50
+
51
+ <p align="center">
52
+ <img src="https://capsule-render.vercel.app/api?type=waving&color=0:4F46E5,100:06B6D4&height=200&section=header&text=policygate&fontSize=56&fontColor=ffffff&animation=fadeIn&fontAlignY=38&desc=MCP%20server%20gateway%20for%20task-specific%20AI%20rules%20and%20scripts&descAlignY=58&descSize=16" alt="policygate banner" />
53
+ </p>
54
+
55
+ <p align="center">
56
+ <a href="https://github.com/l0kifs/policygate/actions/workflows/publish-to-pypi.yml"><img src="https://img.shields.io/github/actions/workflow/status/l0kifs/policygate/publish-to-pypi.yml?branch=main&label=publish" alt="Publish workflow" /></a>
57
+ <a href="https://pypi.org/project/policygate/"><img src="https://img.shields.io/pypi/v/policygate" alt="PyPI version" /></a>
58
+ <a href="https://pypi.org/project/policygate/"><img src="https://img.shields.io/pypi/pyversions/policygate" alt="Python versions" /></a>
59
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license" /></a>
60
+ </p>
61
+
62
+ # policygate
63
+
64
+ Policygate is an MCP server gateway for task-specific AI rules and scripts stored in a GitHub repository.
65
+
66
+ ## Features
67
+
68
+ - Syncs repository content into a local cache at `~/.policygate/repo_data`
69
+ - Parses and validates `router.yaml`
70
+ - Exposes MCP tools:
71
+ - `sync_repository`
72
+ - `outline_router`
73
+ - `read_rules`
74
+ - `copy_scripts`
75
+
76
+ Detailed usage reference: [docs/REFERENCE.md](docs/REFERENCE.md)
77
+
78
+ ## Required repository structure
79
+
80
+ ```text
81
+ rules/
82
+ scripts/
83
+ router.yaml
84
+ ```
85
+
86
+ `router.yaml` structure:
87
+
88
+ ```yaml
89
+ tasks:
90
+ task1:
91
+ description: "Short description of task 1"
92
+ rules:
93
+ - rule1
94
+ scripts:
95
+ - script1
96
+
97
+ rules:
98
+ rule1:
99
+ path: rules/rule1.md
100
+ description: "Short description of rule 1"
101
+
102
+ scripts:
103
+ script1:
104
+ path: scripts/script1.py
105
+ description: "Short description of script 1"
106
+ ```
107
+
108
+ ## Configuration
109
+
110
+ Set environment variables:
111
+
112
+ - `POLICYGATE__GITHUB_REPOSITORY_URL`
113
+ - `POLICYGATE__GITHUB_ACCESS_TOKEN`
114
+ - `POLICYGATE__LOCAL_REPO_DATA_DIR` (optional, default `~/.policygate/repo_data`)
115
+ - `POLICYGATE__REPOSITORY_REFRESH_INTERVAL_SECONDS` (optional, default `60`)
116
+
117
+ ## Run MCP server
118
+
119
+ Run with:
120
+
121
+ ```bash
122
+ uv run policygate-mcp
123
+ ```
124
+
125
+ VS Code workspace MCP config example (`.vscode/mcp.json`):
126
+
127
+ ```json
128
+ {
129
+ "inputs": [
130
+ {
131
+ "id": "POLICYGATE__GITHUB_REPOSITORY_URL",
132
+ "type": "promptString",
133
+ "description": "GitHub repository URL",
134
+ "password": false
135
+ },
136
+ {
137
+ "id": "POLICYGATE__GITHUB_ACCESS_TOKEN",
138
+ "type": "promptString",
139
+ "description": "GitHub access token",
140
+ "password": true
141
+ }
142
+ ],
143
+ "servers": {
144
+ "policygate": {
145
+ "type": "stdio",
146
+ "command": "uvx",
147
+ "args": ["--from", "policygate:latest", "policygate-mcp"],
148
+ "env": {
149
+ "POLICYGATE__GITHUB_REPOSITORY_URL": "${input:POLICYGATE__GITHUB_REPOSITORY_URL}",
150
+ "POLICYGATE__GITHUB_ACCESS_TOKEN": "${input:POLICYGATE__GITHUB_ACCESS_TOKEN}"
151
+ },
152
+ }
153
+ }
154
+ }
155
+ ```
156
+
157
+ For local testing from the current workspace (after `uv sync --all-groups`):
158
+
159
+ ```json
160
+ {
161
+ "inputs": [
162
+ {
163
+ "id": "POLICYGATE__GITHUB_REPOSITORY_URL",
164
+ "type": "promptString",
165
+ "description": "GitHub repository URL",
166
+ "password": false
167
+ },
168
+ {
169
+ "id": "POLICYGATE__GITHUB_ACCESS_TOKEN",
170
+ "type": "promptString",
171
+ "description": "GitHub access token",
172
+ "password": true
173
+ }
174
+ ],
175
+ "servers": {
176
+ "policygate-local": {
177
+ "type": "stdio",
178
+ "command": "uv",
179
+ "args": ["run", "policygate-mcp"],
180
+ "env": {
181
+ "POLICYGATE__GITHUB_REPOSITORY_URL": "${input:POLICYGATE__GITHUB_REPOSITORY_URL}",
182
+ "POLICYGATE__GITHUB_ACCESS_TOKEN": "${input:POLICYGATE__GITHUB_ACCESS_TOKEN}"
183
+ },
184
+ "cwd": "${workspaceFolder}"
185
+ }
186
+ }
187
+ }
188
+ ```
189
+
190
+ ## Testing
191
+
192
+ Run feature-organized end-to-end suites:
193
+
194
+ ```bash
195
+ uv run pytest --maxfail=1 --tb=short
196
+ ```
@@ -0,0 +1,146 @@
1
+ <p align="center">
2
+ <img src="https://capsule-render.vercel.app/api?type=waving&color=0:4F46E5,100:06B6D4&height=200&section=header&text=policygate&fontSize=56&fontColor=ffffff&animation=fadeIn&fontAlignY=38&desc=MCP%20server%20gateway%20for%20task-specific%20AI%20rules%20and%20scripts&descAlignY=58&descSize=16" alt="policygate banner" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://github.com/l0kifs/policygate/actions/workflows/publish-to-pypi.yml"><img src="https://img.shields.io/github/actions/workflow/status/l0kifs/policygate/publish-to-pypi.yml?branch=main&label=publish" alt="Publish workflow" /></a>
7
+ <a href="https://pypi.org/project/policygate/"><img src="https://img.shields.io/pypi/v/policygate" alt="PyPI version" /></a>
8
+ <a href="https://pypi.org/project/policygate/"><img src="https://img.shields.io/pypi/pyversions/policygate" alt="Python versions" /></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license" /></a>
10
+ </p>
11
+
12
+ # policygate
13
+
14
+ Policygate is an MCP server gateway for task-specific AI rules and scripts stored in a GitHub repository.
15
+
16
+ ## Features
17
+
18
+ - Syncs repository content into a local cache at `~/.policygate/repo_data`
19
+ - Parses and validates `router.yaml`
20
+ - Exposes MCP tools:
21
+ - `sync_repository`
22
+ - `outline_router`
23
+ - `read_rules`
24
+ - `copy_scripts`
25
+
26
+ Detailed usage reference: [docs/REFERENCE.md](docs/REFERENCE.md)
27
+
28
+ ## Required repository structure
29
+
30
+ ```text
31
+ rules/
32
+ scripts/
33
+ router.yaml
34
+ ```
35
+
36
+ `router.yaml` structure:
37
+
38
+ ```yaml
39
+ tasks:
40
+ task1:
41
+ description: "Short description of task 1"
42
+ rules:
43
+ - rule1
44
+ scripts:
45
+ - script1
46
+
47
+ rules:
48
+ rule1:
49
+ path: rules/rule1.md
50
+ description: "Short description of rule 1"
51
+
52
+ scripts:
53
+ script1:
54
+ path: scripts/script1.py
55
+ description: "Short description of script 1"
56
+ ```
57
+
58
+ ## Configuration
59
+
60
+ Set environment variables:
61
+
62
+ - `POLICYGATE__GITHUB_REPOSITORY_URL`
63
+ - `POLICYGATE__GITHUB_ACCESS_TOKEN`
64
+ - `POLICYGATE__LOCAL_REPO_DATA_DIR` (optional, default `~/.policygate/repo_data`)
65
+ - `POLICYGATE__REPOSITORY_REFRESH_INTERVAL_SECONDS` (optional, default `60`)
66
+
67
+ ## Run MCP server
68
+
69
+ Run with:
70
+
71
+ ```bash
72
+ uv run policygate-mcp
73
+ ```
74
+
75
+ VS Code workspace MCP config example (`.vscode/mcp.json`):
76
+
77
+ ```json
78
+ {
79
+ "inputs": [
80
+ {
81
+ "id": "POLICYGATE__GITHUB_REPOSITORY_URL",
82
+ "type": "promptString",
83
+ "description": "GitHub repository URL",
84
+ "password": false
85
+ },
86
+ {
87
+ "id": "POLICYGATE__GITHUB_ACCESS_TOKEN",
88
+ "type": "promptString",
89
+ "description": "GitHub access token",
90
+ "password": true
91
+ }
92
+ ],
93
+ "servers": {
94
+ "policygate": {
95
+ "type": "stdio",
96
+ "command": "uvx",
97
+ "args": ["--from", "policygate:latest", "policygate-mcp"],
98
+ "env": {
99
+ "POLICYGATE__GITHUB_REPOSITORY_URL": "${input:POLICYGATE__GITHUB_REPOSITORY_URL}",
100
+ "POLICYGATE__GITHUB_ACCESS_TOKEN": "${input:POLICYGATE__GITHUB_ACCESS_TOKEN}"
101
+ },
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ For local testing from the current workspace (after `uv sync --all-groups`):
108
+
109
+ ```json
110
+ {
111
+ "inputs": [
112
+ {
113
+ "id": "POLICYGATE__GITHUB_REPOSITORY_URL",
114
+ "type": "promptString",
115
+ "description": "GitHub repository URL",
116
+ "password": false
117
+ },
118
+ {
119
+ "id": "POLICYGATE__GITHUB_ACCESS_TOKEN",
120
+ "type": "promptString",
121
+ "description": "GitHub access token",
122
+ "password": true
123
+ }
124
+ ],
125
+ "servers": {
126
+ "policygate-local": {
127
+ "type": "stdio",
128
+ "command": "uv",
129
+ "args": ["run", "policygate-mcp"],
130
+ "env": {
131
+ "POLICYGATE__GITHUB_REPOSITORY_URL": "${input:POLICYGATE__GITHUB_REPOSITORY_URL}",
132
+ "POLICYGATE__GITHUB_ACCESS_TOKEN": "${input:POLICYGATE__GITHUB_ACCESS_TOKEN}"
133
+ },
134
+ "cwd": "${workspaceFolder}"
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ ## Testing
141
+
142
+ Run feature-organized end-to-end suites:
143
+
144
+ ```bash
145
+ uv run pytest --maxfail=1 --tb=short
146
+ ```
@@ -0,0 +1,70 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.9.30"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "policygate"
7
+ version = "0.1.0"
8
+ description = "MCP server gateway for task-specific AI rules and scripts stored in GitHub"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ { name = "Sergei Konovalov" },
14
+ ]
15
+ maintainers = [
16
+ { name = "Sergei Konovalov" },
17
+ ]
18
+ keywords = [
19
+ "mcp",
20
+ "policy",
21
+ "gateway",
22
+ "ai",
23
+ "github",
24
+ "automation",
25
+ ]
26
+ classifiers = [
27
+ "Development Status :: 3 - Alpha",
28
+ "Intended Audience :: Developers",
29
+ "License :: OSI Approved :: MIT License",
30
+ "Operating System :: OS Independent",
31
+ "Programming Language :: Python :: 3",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Programming Language :: Python :: 3.13",
35
+ "Topic :: Software Development :: Libraries",
36
+ "Topic :: Software Development :: Libraries :: Python Modules",
37
+ ]
38
+ dependencies = [
39
+ # Configuration and logging
40
+ "pydantic-settings>=2.0.0",
41
+ "structlog>=25.1.0",
42
+
43
+ # Data validation
44
+ "pydantic>=2.0.0",
45
+
46
+ # MCP and integration
47
+ "fastmcp>=2.0.0",
48
+ "httpx>=0.27.0",
49
+ "pyyaml>=6.0.0",
50
+ ]
51
+
52
+ [project.urls]
53
+ Homepage = "https://github.com/l0kifs/policygate"
54
+ Repository = "https://github.com/l0kifs/policygate"
55
+ Issues = "https://github.com/l0kifs/policygate/issues"
56
+
57
+ [project.scripts]
58
+ policygate-mcp = "policygate.entry_points.mcp_server:run"
59
+
60
+ [dependency-groups]
61
+ dev = [
62
+ "pytest>=9.0.0",
63
+ "pytest-xdist>=3.0.0",
64
+ "pytest-cov>=7.0.0",
65
+ "ruff>=0.14.5",
66
+ "ty>=0.0.11",
67
+ ]
68
+
69
+ [tool.uv]
70
+ package = true
@@ -0,0 +1,3 @@
1
+ """
2
+ Project package root.
3
+ """
@@ -0,0 +1,9 @@
1
+ """
2
+ Project configuration package.
3
+
4
+ Rules:
5
+ - Contains application settings, constants, and logging configuration.
6
+ - No technical implementation details (database engines, clients, etc.).
7
+ - No business logic.
8
+ - Should not import from domains, infrastructure or entry_points.
9
+ """
@@ -0,0 +1,48 @@
1
+ from pydantic import Field
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+
4
+
5
+ class Settings(BaseSettings):
6
+ """
7
+ Configuration settings.
8
+ """
9
+
10
+ model_config = SettingsConfigDict(
11
+ env_prefix="POLICYGATE__",
12
+ env_nested_delimiter="__",
13
+ env_file=".env",
14
+ env_file_encoding="utf-8",
15
+ case_sensitive=False,
16
+ extra="ignore",
17
+ )
18
+
19
+ # Application settings
20
+ app_name: str = Field(default="policygate", description="Application name")
21
+ app_version: str = Field(default="0.1.0", description="Application version")
22
+
23
+ # GitHub repository integration
24
+ github_repository_url: str = Field(
25
+ default="",
26
+ description="GitHub repository URL containing router.yaml, rules/, and scripts/",
27
+ )
28
+ github_access_token: str = Field(
29
+ default="",
30
+ description="GitHub access token for repository access",
31
+ )
32
+
33
+ # Local repository cache
34
+ local_repo_data_dir: str = Field(
35
+ default="~/.policygate/repo_data",
36
+ description="Local repository cache path",
37
+ )
38
+ repository_refresh_interval_seconds: int = Field(
39
+ default=1800,
40
+ description="Minimal interval between remote refresh checks",
41
+ )
42
+
43
+
44
+ def get_settings() -> Settings:
45
+ """
46
+ Get configuration settings.
47
+ """
48
+ return Settings()
@@ -0,0 +1,10 @@
1
+ """
2
+ Domain layer containing business logic.
3
+
4
+ Rules:
5
+ - Pure Python code (no framework dependencies).
6
+ - Contains entities, value objects, and domain services.
7
+ - Models may use Pydantic for validation.
8
+ - Logging via available logging libraries is allowed.
9
+ - No imports from infrastructure or entry_points.
10
+ """
@@ -0,0 +1 @@
1
+ """Gateway domain package."""
@@ -0,0 +1,17 @@
1
+ """Domain exceptions for policy gateway flows."""
2
+
3
+
4
+ class PolicyGateError(Exception):
5
+ """Base exception for gateway operations."""
6
+
7
+
8
+ class RouterValidationError(PolicyGateError):
9
+ """Raised when router.yaml content is invalid."""
10
+
11
+
12
+ class RouterReferenceError(PolicyGateError):
13
+ """Raised when requested aliases are missing in router.yaml."""
14
+
15
+
16
+ class RepositorySyncError(PolicyGateError):
17
+ """Raised when repository sync cannot complete."""
@@ -0,0 +1,40 @@
1
+ """Domain models for repository routing configuration."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class TaskConfig(BaseModel):
7
+ """Task mapping in router configuration."""
8
+
9
+ description: str = Field(description="Task description")
10
+ rules: list[str] = Field(default_factory=list, description="Rule aliases")
11
+ scripts: list[str] = Field(default_factory=list, description="Script aliases")
12
+
13
+
14
+ class RuleConfig(BaseModel):
15
+ """Rule descriptor from router configuration."""
16
+
17
+ path: str = Field(description="Relative path to markdown rule file")
18
+ description: str = Field(description="Rule description")
19
+
20
+
21
+ class ScriptConfig(BaseModel):
22
+ """Script descriptor from router configuration."""
23
+
24
+ path: str = Field(description="Relative path to script file")
25
+ description: str = Field(description="Script description")
26
+
27
+
28
+ class RouterConfig(BaseModel):
29
+ """Top-level router.yaml document."""
30
+
31
+ tasks: dict[str, TaskConfig] = Field(default_factory=dict)
32
+ rules: dict[str, RuleConfig] = Field(default_factory=dict)
33
+ scripts: dict[str, ScriptConfig] = Field(default_factory=dict)
34
+
35
+
36
+ class CopiedScriptsResult(BaseModel):
37
+ """Result payload for copied scripts."""
38
+
39
+ destination_directory: str
40
+ copied_files: list[str] = Field(default_factory=list)
@@ -0,0 +1,149 @@
1
+ """Application service for policy gateway use-cases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from typing import Protocol
7
+
8
+ import yaml
9
+ from pydantic import ValidationError
10
+
11
+ from policygate.domains.gateway.exceptions import (
12
+ RepositorySyncError,
13
+ RouterReferenceError,
14
+ RouterValidationError,
15
+ )
16
+ from policygate.domains.gateway.models import CopiedScriptsResult, RouterConfig
17
+
18
+
19
+ class RepositoryGateway(Protocol):
20
+ """Port for repository synchronization and file access."""
21
+
22
+ def refresh_if_needed(self) -> None: ...
23
+
24
+ def force_refresh(self) -> None: ...
25
+
26
+ def read_text(self, relative_path: str) -> str: ...
27
+
28
+ def read_many_texts(self, relative_paths: list[str]) -> dict[str, str]: ...
29
+
30
+ def copy_many_files(
31
+ self,
32
+ relative_paths: list[str],
33
+ destination_directory: str,
34
+ ) -> list[str]: ...
35
+
36
+
37
+ class PolicyGatewayService:
38
+ """Use-case service for router outline, rules reading, and scripts copying."""
39
+
40
+ def __init__(self, repository_gateway: RepositoryGateway) -> None:
41
+ self._repository_gateway = repository_gateway
42
+
43
+ def outline_router(self) -> str:
44
+ """Return parsed and validated router.yaml content as markdown text."""
45
+ router = self._load_router()
46
+ return self._router_to_markdown(router)
47
+
48
+ def sync_repository(self) -> dict[str, str]:
49
+ """Force synchronization of remote repository to local cache."""
50
+ self._repository_gateway.force_refresh()
51
+ return {"status": "synced"}
52
+
53
+ def read_rules(self, rule_names: list[str]) -> str:
54
+ """Return rule markdown content by aliases from router.yaml as markdown text."""
55
+ if not rule_names:
56
+ return ""
57
+
58
+ router = self._load_router()
59
+ missing = [name for name in rule_names if name not in router.rules]
60
+ if missing:
61
+ joined = ", ".join(missing)
62
+ raise RouterReferenceError(f"unknown rule aliases: {joined}")
63
+
64
+ names_to_paths = {name: router.rules[name].path for name in rule_names}
65
+ contents_by_path = self._repository_gateway.read_many_texts(
66
+ list(names_to_paths.values())
67
+ )
68
+ sections: list[str] = []
69
+ for name, path in names_to_paths.items():
70
+ if path not in contents_by_path:
71
+ continue
72
+ sections.append(f"<{name}>\n{contents_by_path[path].rstrip()}\n</{name}>")
73
+
74
+ return "\n\n".join(sections)
75
+
76
+ def copy_scripts(self, script_names: list[str]) -> CopiedScriptsResult:
77
+ """Copy script files by aliases to a temporary directory."""
78
+ if not script_names:
79
+ destination = tempfile.mkdtemp(prefix="policygate-scripts-")
80
+ return CopiedScriptsResult(
81
+ destination_directory=destination,
82
+ copied_files=[],
83
+ )
84
+
85
+ router = self._load_router()
86
+ missing = [name for name in script_names if name not in router.scripts]
87
+ if missing:
88
+ joined = ", ".join(missing)
89
+ raise RouterReferenceError(f"unknown script aliases: {joined}")
90
+
91
+ destination = tempfile.mkdtemp(prefix="policygate-scripts-")
92
+ paths = [router.scripts[name].path for name in script_names]
93
+ copied_files = self._repository_gateway.copy_many_files(
94
+ relative_paths=paths,
95
+ destination_directory=destination,
96
+ )
97
+
98
+ return CopiedScriptsResult(
99
+ destination_directory=destination,
100
+ copied_files=copied_files,
101
+ )
102
+
103
+ def _load_router(self) -> RouterConfig:
104
+ try:
105
+ self._repository_gateway.refresh_if_needed()
106
+ router_raw = self._repository_gateway.read_text("router.yaml")
107
+ parsed = yaml.safe_load(router_raw)
108
+ if not isinstance(parsed, dict):
109
+ raise RouterValidationError(
110
+ "router.yaml must contain a top-level object"
111
+ )
112
+ return RouterConfig.model_validate(parsed)
113
+ except ValidationError as error:
114
+ raise RouterValidationError(str(error)) from error
115
+ except OSError as error:
116
+ raise RepositorySyncError(str(error)) from error
117
+
118
+ def _router_to_markdown(self, router: RouterConfig) -> str:
119
+ sections: list[str] = ["# Router"]
120
+
121
+ sections.append("## Tasks")
122
+ if not router.tasks:
123
+ sections.append("- _none_")
124
+ else:
125
+ for name, task in router.tasks.items():
126
+ sections.append(f"### {name}")
127
+ sections.append(f"- Description: {task.description}")
128
+ sections.append(
129
+ f"- Rules: {', '.join(task.rules) if task.rules else '_none_'}"
130
+ )
131
+ sections.append(
132
+ f"- Scripts: {', '.join(task.scripts) if task.scripts else '_none_'}"
133
+ )
134
+
135
+ sections.append("## Rules")
136
+ if not router.rules:
137
+ sections.append("- _none_")
138
+ else:
139
+ for name, rule in router.rules.items():
140
+ sections.append(f"- **{name}**: `{rule.path}` — {rule.description}")
141
+
142
+ sections.append("## Scripts")
143
+ if not router.scripts:
144
+ sections.append("- _none_")
145
+ else:
146
+ for name, script in router.scripts.items():
147
+ sections.append(f"- **{name}**: `{script.path}` — {script.description}")
148
+
149
+ return "\n".join(sections)
@@ -0,0 +1,10 @@
1
+ """
2
+ Application entry points (CLI, API, workers).
3
+
4
+ Rules:
5
+ - Handles application bootstrapping and dependency injection.
6
+ - Contains controllers/handlers for external interfaces.
7
+ - Orchestrates interaction between outer world and domain layer.
8
+ - Can import from domains and infrastructure.
9
+ - No business logic.
10
+ """
@@ -0,0 +1,120 @@
1
+ """MCP server entry point for policy routing gateway."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, is_dataclass
6
+ from typing import Annotated, Any
7
+
8
+ from fastmcp import FastMCP
9
+ from pydantic import Field
10
+
11
+ from policygate.config.settings import get_settings
12
+ from policygate.domains.gateway.services import PolicyGatewayService
13
+ from policygate.infrastructure.repository.github_repository_gateway import (
14
+ GitHubRepositoryGateway,
15
+ )
16
+
17
+
18
+ def _to_serializable(value: Any) -> Any:
19
+ if isinstance(value, list):
20
+ return [_to_serializable(item) for item in value]
21
+ if isinstance(value, dict):
22
+ return {key: _to_serializable(item) for key, item in value.items()}
23
+ if is_dataclass(value):
24
+ return _to_serializable(asdict(value))
25
+ if hasattr(value, "model_dump"):
26
+ return value.model_dump(mode="json")
27
+ return value
28
+
29
+
30
+ mcp = FastMCP(
31
+ name="policygate",
32
+ instructions=(
33
+ "Policy gateway for task routing. Use router outline first, then read rules, "
34
+ "and copy scripts only for scripts explicitly mapped in router.yaml."
35
+ ),
36
+ version=get_settings().app_version,
37
+ on_duplicate="error",
38
+ mask_error_details=False,
39
+ )
40
+
41
+
42
+ def build_service() -> PolicyGatewayService:
43
+ """Build service graph with GitHub-backed repository gateway."""
44
+ settings = get_settings()
45
+ return PolicyGatewayService(
46
+ repository_gateway=GitHubRepositoryGateway(
47
+ repository_url=settings.github_repository_url,
48
+ access_token=settings.github_access_token,
49
+ local_repo_data_dir=settings.local_repo_data_dir,
50
+ refresh_interval_seconds=settings.repository_refresh_interval_seconds,
51
+ )
52
+ )
53
+
54
+
55
+ @mcp.tool(
56
+ annotations={
57
+ "readOnlyHint": True,
58
+ "idempotentHint": True,
59
+ "openWorldHint": False,
60
+ }
61
+ )
62
+ def outline_router() -> str:
63
+ """Parse and return router.yaml contents as markdown text."""
64
+ return build_service().outline_router()
65
+
66
+
67
+ @mcp.tool(
68
+ annotations={
69
+ "readOnlyHint": False,
70
+ "idempotentHint": True,
71
+ "openWorldHint": False,
72
+ }
73
+ )
74
+ def sync_repository() -> dict[str, str]:
75
+ """Force repository synchronization to refresh local cache now."""
76
+ return build_service().sync_repository()
77
+
78
+
79
+ @mcp.tool(
80
+ annotations={
81
+ "readOnlyHint": True,
82
+ "idempotentHint": True,
83
+ "openWorldHint": False,
84
+ }
85
+ )
86
+ def read_rules(
87
+ rule_names: Annotated[
88
+ list[str],
89
+ Field(
90
+ description=(
91
+ "Rule aliases from router.yaml rules section. "
92
+ "Example: [\"rule1\", \"rule_security\"]"
93
+ )
94
+ ),
95
+ ],
96
+ ) -> str:
97
+ """Read selected rules and return a combined markdown document."""
98
+ return build_service().read_rules(rule_names=rule_names)
99
+
100
+
101
+ @mcp.tool(
102
+ annotations={
103
+ "readOnlyHint": False,
104
+ "destructiveHint": False,
105
+ "openWorldHint": False,
106
+ }
107
+ )
108
+ def copy_scripts(
109
+ script_names: Annotated[
110
+ list[str],
111
+ Field(description="Script aliases from router.yaml scripts section."),
112
+ ],
113
+ ) -> dict[str, Any]:
114
+ """Copy selected scripts to a temporary directory for execution."""
115
+ return _to_serializable(build_service().copy_scripts(script_names=script_names))
116
+
117
+
118
+ def run() -> None:
119
+ """Run MCP server."""
120
+ mcp.run()
@@ -0,0 +1,10 @@
1
+ """
2
+ Infrastructure layer containing technical implementation details.
3
+
4
+ Rules:
5
+ - Implements interfaces defined in the domain or supports domain logic.
6
+ - Contains database repositories, API clients, file storage, etc.
7
+ - Handles interactions with external systems (SQLAlchemy, httpx, etc.).
8
+ - Can import from domains.
9
+ - No business logic.
10
+ """
@@ -0,0 +1 @@
1
+ """Repository infrastructure adapters."""
@@ -0,0 +1,257 @@
1
+ """GitHub repository gateway with local caching for policy assets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import tarfile
8
+ import tempfile
9
+ import threading
10
+ import time
11
+ from io import BytesIO
12
+ from pathlib import Path
13
+ from urllib.parse import urlparse
14
+
15
+ import httpx
16
+
17
+ from policygate.domains.gateway.exceptions import RepositorySyncError
18
+
19
+
20
+ class GitHubRepositoryGateway:
21
+ """Synchronize a GitHub repository and expose files from local cache."""
22
+
23
+ def __init__(
24
+ self,
25
+ repository_url: str,
26
+ access_token: str,
27
+ local_repo_data_dir: str,
28
+ refresh_interval_seconds: int = 60,
29
+ ) -> None:
30
+ if not repository_url:
31
+ raise RepositorySyncError("github_repository_url is not configured")
32
+ if not access_token:
33
+ raise RepositorySyncError("github_access_token is not configured")
34
+
35
+ self._repository_url = repository_url
36
+ self._access_token = access_token
37
+ self._local_repo_data_dir = Path(local_repo_data_dir).expanduser().resolve()
38
+ self._refresh_interval_seconds = max(refresh_interval_seconds, 1)
39
+ self._last_refresh_check_at = 0.0
40
+ self._refresh_lock = threading.Lock()
41
+
42
+ self._owner, self._repo = self._parse_owner_repo(repository_url)
43
+ self._metadata_file = self._local_repo_data_dir / ".policygate_sync.json"
44
+
45
+ def refresh_if_needed(self) -> None:
46
+ """Refresh local cache if check interval elapsed and commit changed."""
47
+ with self._refresh_lock:
48
+ now = time.time()
49
+ if (
50
+ now - self._last_refresh_check_at < self._refresh_interval_seconds
51
+ and self._local_repo_data_dir.exists()
52
+ ):
53
+ return
54
+
55
+ self._last_refresh_check_at = now
56
+ self._refresh(force=not self._local_repo_data_dir.exists())
57
+
58
+ def force_refresh(self) -> None:
59
+ """Force synchronization regardless of refresh interval and cached SHA."""
60
+ with self._refresh_lock:
61
+ self._refresh(force=True)
62
+ self._last_refresh_check_at = time.time()
63
+
64
+ def read_text(self, relative_path: str) -> str:
65
+ """Read text file from synchronized local repository cache."""
66
+ target = self._resolve_relative_path(relative_path)
67
+ return target.read_text(encoding="utf-8")
68
+
69
+ def read_many_texts(self, relative_paths: list[str]) -> dict[str, str]:
70
+ """Read multiple files from local repository cache."""
71
+ content_by_path: dict[str, str] = {}
72
+ for relative_path in relative_paths:
73
+ target = self._resolve_relative_path(relative_path)
74
+ content_by_path[relative_path] = target.read_text(encoding="utf-8")
75
+ return content_by_path
76
+
77
+ def copy_many_files(
78
+ self,
79
+ relative_paths: list[str],
80
+ destination_directory: str,
81
+ ) -> list[str]:
82
+ """Copy files from local cache to destination directory."""
83
+ destination = Path(destination_directory).resolve()
84
+ destination.mkdir(parents=True, exist_ok=True)
85
+
86
+ copied: list[str] = []
87
+ for relative_path in relative_paths:
88
+ source = self._resolve_relative_path(relative_path)
89
+ target = destination / Path(relative_path).name
90
+ shutil.copy2(source, target)
91
+ copied.append(str(target))
92
+ return copied
93
+
94
+ def _refresh(self, force: bool = False) -> None:
95
+ default_branch, latest_sha, tarball_url = self._get_repository_state()
96
+ cached_sha = self._read_cached_sha()
97
+
98
+ if not force and cached_sha == latest_sha:
99
+ return
100
+
101
+ self._download_and_extract(tarball_url=tarball_url)
102
+ self._write_metadata(
103
+ {
104
+ "repository": f"{self._owner}/{self._repo}",
105
+ "default_branch": default_branch,
106
+ "sha": latest_sha,
107
+ "synced_at": int(time.time()),
108
+ }
109
+ )
110
+
111
+ def _get_repository_state(self) -> tuple[str, str, str]:
112
+ headers = self._build_headers()
113
+
114
+ with httpx.Client(timeout=30.0, headers=headers) as client:
115
+ repository_response = client.get(
116
+ f"https://api.github.com/repos/{self._owner}/{self._repo}"
117
+ )
118
+ repository_response.raise_for_status()
119
+ repository_payload = repository_response.json()
120
+
121
+ default_branch = repository_payload["default_branch"]
122
+ tarball_url = self._resolve_tarball_url(
123
+ repository_payload=repository_payload,
124
+ default_branch=default_branch,
125
+ )
126
+
127
+ commit_response = client.get(
128
+ f"https://api.github.com/repos/{self._owner}/{self._repo}/commits/{default_branch}"
129
+ )
130
+ commit_response.raise_for_status()
131
+ latest_sha = commit_response.json()["sha"]
132
+
133
+ return default_branch, latest_sha, tarball_url
134
+
135
+ def _resolve_tarball_url(
136
+ self,
137
+ repository_payload: dict,
138
+ default_branch: str,
139
+ ) -> str:
140
+ tarball_url = repository_payload.get("tarball_url")
141
+ if isinstance(tarball_url, str) and tarball_url:
142
+ if "{/ref}" in tarball_url:
143
+ return tarball_url.replace("{/ref}", f"/{default_branch}")
144
+ return tarball_url
145
+
146
+ archive_url = repository_payload.get("archive_url")
147
+ if isinstance(archive_url, str) and archive_url:
148
+ url = archive_url.replace("{/archive_format}", "/tarball")
149
+ url = url.replace("{archive_format}", "tarball")
150
+ if "{/ref}" in url:
151
+ return url.replace("{/ref}", f"/{default_branch}")
152
+ return url.rstrip("/") + f"/{default_branch}"
153
+
154
+ return f"https://api.github.com/repos/{self._owner}/{self._repo}/tarball/{default_branch}"
155
+
156
+ def _download_and_extract(self, tarball_url: str) -> None:
157
+ headers = self._build_headers()
158
+ with httpx.Client(
159
+ timeout=60.0, headers=headers, follow_redirects=True
160
+ ) as client:
161
+ archive_response = client.get(tarball_url)
162
+ archive_response.raise_for_status()
163
+ archive_bytes = archive_response.content
164
+
165
+ with tempfile.TemporaryDirectory(prefix="policygate-sync-") as temp_dir:
166
+ temp_path = Path(temp_dir)
167
+ with tarfile.open(fileobj=BytesIO(archive_bytes), mode="r:gz") as archive:
168
+ archive.extractall(path=temp_path)
169
+
170
+ extracted_roots = [child for child in temp_path.iterdir() if child.is_dir()]
171
+ if not extracted_roots:
172
+ raise RepositorySyncError("unable to extract repository archive")
173
+
174
+ source_root = extracted_roots[0]
175
+ self._copy_repository_entries(source_root)
176
+
177
+ def _copy_repository_entries(self, source_root: Path) -> None:
178
+ required_entries = ["router.yaml", "rules"]
179
+ optional_entries = ["scripts"]
180
+
181
+ self._local_repo_data_dir.mkdir(parents=True, exist_ok=True)
182
+ for child in list(self._local_repo_data_dir.iterdir()):
183
+ if child.name == self._metadata_file.name:
184
+ continue
185
+ if child.is_dir():
186
+ shutil.rmtree(child)
187
+ else:
188
+ child.unlink()
189
+
190
+ for entry in required_entries:
191
+ source_path = source_root / entry
192
+ if not source_path.exists():
193
+ raise RepositorySyncError(
194
+ f"repository is missing required entry: {entry}"
195
+ )
196
+ self._copy_entry(source_path=source_path, target_name=entry)
197
+
198
+ for entry in optional_entries:
199
+ source_path = source_root / entry
200
+ if not source_path.exists():
201
+ continue
202
+ self._copy_entry(source_path=source_path, target_name=entry)
203
+
204
+ def _copy_entry(self, source_path: Path, target_name: str) -> None:
205
+ target_path = self._local_repo_data_dir / target_name
206
+ if source_path.is_dir():
207
+ shutil.copytree(source_path, target_path, dirs_exist_ok=True)
208
+ else:
209
+ shutil.copy2(source_path, target_path)
210
+
211
+ def _resolve_relative_path(self, relative_path: str) -> Path:
212
+ if not relative_path:
213
+ raise RepositorySyncError("relative path cannot be empty")
214
+
215
+ candidate = (self._local_repo_data_dir / relative_path).resolve()
216
+ base = self._local_repo_data_dir.resolve()
217
+ if base not in candidate.parents and candidate != base:
218
+ raise RepositorySyncError("path traversal is not allowed")
219
+ if not candidate.exists() or not candidate.is_file():
220
+ raise RepositorySyncError(f"file not found: {relative_path}")
221
+ return candidate
222
+
223
+ def _read_cached_sha(self) -> str | None:
224
+ if not self._metadata_file.exists():
225
+ return None
226
+ try:
227
+ payload = json.loads(self._metadata_file.read_text(encoding="utf-8"))
228
+ return payload.get("sha")
229
+ except (json.JSONDecodeError, OSError):
230
+ return None
231
+
232
+ def _write_metadata(self, payload: dict[str, str | int]) -> None:
233
+ self._metadata_file.parent.mkdir(parents=True, exist_ok=True)
234
+ self._metadata_file.write_text(
235
+ json.dumps(payload, ensure_ascii=False, indent=2),
236
+ encoding="utf-8",
237
+ )
238
+
239
+ def _parse_owner_repo(self, repository_url: str) -> tuple[str, str]:
240
+ parsed = urlparse(repository_url)
241
+ path = parsed.path.strip("/")
242
+ if path.endswith(".git"):
243
+ path = path[:-4]
244
+
245
+ segments = path.split("/")
246
+ if len(segments) < 2:
247
+ raise RepositorySyncError(
248
+ "github_repository_url must include owner and repository name"
249
+ )
250
+ return segments[0], segments[1]
251
+
252
+ def _build_headers(self) -> dict[str, str]:
253
+ return {
254
+ "Authorization": f"Bearer {self._access_token}",
255
+ "Accept": "application/vnd.github+json",
256
+ "X-GitHub-Api-Version": "2022-11-28",
257
+ }