rosentic-mcp 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,245 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
208
+ SESSION-SUMMARY*.md
209
+
210
+ .rosentic/
211
+
212
+ # macOS
213
+ .DS_Store
214
+
215
+ # Node / local browser tooling
216
+ /node_modules/
217
+
218
+ # Cloudflare Wrangler local state
219
+ /.wrangler/
220
+ api/.wrangler/
221
+ daily-digest/.wrangler/
222
+ email-capture/.wrangler/
223
+
224
+ # Local scan artifacts
225
+ /scans/*.json
226
+ /scans/*/
227
+ /scans-before-identity/
228
+ /rosentic-report-*.html
229
+
230
+ # Outreach pipeline generated data
231
+ /outreach/*.jsonl
232
+ /outreach/engine-observations.uploaded-lines.txt
233
+ /outreach/drafts/
234
+ /outreach/logs/
235
+ /outreach/*.csv
236
+ /outreach/*.docx
237
+ /outreach-log.csv
238
+ /maintainer-emails.csv
239
+
240
+ # Prospecting notes / local research archives
241
+ /research/
242
+ /issues/
243
+
244
+ # Separate GitHub Action checkout; do not commit as a nested repo.
245
+ /rosentic-action/
@@ -0,0 +1,8 @@
1
+ Business Source License 1.1
2
+
3
+ Copyright (c) 2026 Rosentic. All rights reserved.
4
+
5
+ Rosentic is licensed under the Business Source License 1.1 unless a separate
6
+ written agreement with Rosentic says otherwise.
7
+
8
+ For licensing inquiries, visit https://rosentic.com or contact hello@rosentic.com.
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: rosentic-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Rosentic — deterministic cross-branch conflict detection for AI coding agents
5
+ Project-URL: Homepage, https://rosentic.com
6
+ Project-URL: Repository, https://github.com/Rosentic/rosentic-engine
7
+ Project-URL: Issues, https://github.com/Rosentic/rosentic-engine/issues
8
+ Author-email: Rosentic <hello@rosentic.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,code-review,git,mcp,merge-conflicts,tree-sitter
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Software Development :: Quality Assurance
15
+ Classifier: Topic :: Software Development :: Version Control :: Git
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: fastmcp>=3.1.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Rosentic MCP Server
21
+
22
+ CI checks each PR against main. It doesn't check open PRs against each other.
23
+
24
+ Rosentic does. Install one package. Your coding agent checks for cross-branch conflicts before you merge.
25
+
26
+ One agent changes a function signature. Another agent still calls the old version. Both pass CI. Git merges the text cleanly. Main breaks.
27
+
28
+ Rosentic compares every open PR against every other open PR. Deterministic static analysis. No LLM. 12 languages. Code never leaves your machine.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install rosentic-mcp
34
+ ```
35
+
36
+ ## Configure
37
+
38
+ ### Claude Code
39
+
40
+ Add to `~/.claude.json`:
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "rosentic": {
46
+ "command": "rosentic-mcp"
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ ### Cursor / VS Code
53
+
54
+ Add to MCP settings:
55
+
56
+ ```json
57
+ {
58
+ "rosentic": {
59
+ "command": "rosentic-mcp"
60
+ }
61
+ }
62
+ ```
63
+
64
+ ## Tools
65
+
66
+ ### `check_conflicts`
67
+
68
+ Scan a git repo for cross-branch conflicts affecting your current branch.
69
+
70
+ **Parameters:**
71
+ - `repo_path` (required) — path to local git repository
72
+ - `branch` — branch to check (defaults to current branch)
73
+ - `base` — base branch to compare against (defaults to `main`)
74
+ - `format` — `"summary"` (natural language) or `"json"` (structured findings)
75
+
76
+ **Example response:**
77
+ > Rosentic found 4 unique conflict(s) for feature/auth-refactor: 2 UNSAFE, 2 WARNING. Layer breakdown: 3 L1 signature, 1 L2 route. Most critical: createUser() in src/auth/users.ts — changed from 2 to 3 required params. Recommendation: inspect the UNSAFE findings first and update the stale branch before merging.
78
+
79
+ ### `explain_conflict`
80
+
81
+ Get detailed explanation of a specific finding, including affected branches, consumer locations, and remediation steps.
82
+
83
+ **Parameters:**
84
+ - `repo_path` (required) — path to local git repository
85
+ - `finding_id` (required) — ID from a previous `check_conflicts` result
86
+
87
+ ### `list_branches`
88
+
89
+ List local branches sorted by most recent commit date.
90
+
91
+ **Parameters:**
92
+ - `repo_path` (required) — path to local git repository
93
+
94
+ ## What it detects
95
+
96
+ | Layer | What | Example |
97
+ |-------|------|---------|
98
+ | L1 | Function signature mismatches | Branch A adds required param, Branch B still calls with old arity |
99
+ | L2 | HTTP route contract breaks | Route path or method changes, consumers still call old endpoint |
100
+ | L3 | Schema incompatibilities | Proto field removed, GraphQL type changed, OpenAPI contract broken |
101
+
102
+ ## How it works
103
+
104
+ - **Deterministic** — no LLM in the scan path. Tree-sitter AST parsing + evidence-gated verdicts.
105
+ - **Fast** — scans 30 branches in under 30 seconds for most repos.
106
+ - **Evidence-based** — every finding includes proof: which symbol changed, where it's called, why it's linked.
107
+
108
+ ## Requirements
109
+
110
+ - Python 3.11+
111
+ - Git repository with branches to scan
112
+ - Works on macOS, Linux, Windows (WSL)
113
+
114
+ ## Links
115
+
116
+ - [Website](https://rosentic.com)
117
+ - [GitHub](https://github.com/Rosentic/rosentic-engine)
118
+ - [GitHub Action](https://github.com/Rosentic/rosentic-engine#github-action)
@@ -0,0 +1,99 @@
1
+ # Rosentic MCP Server
2
+
3
+ CI checks each PR against main. It doesn't check open PRs against each other.
4
+
5
+ Rosentic does. Install one package. Your coding agent checks for cross-branch conflicts before you merge.
6
+
7
+ One agent changes a function signature. Another agent still calls the old version. Both pass CI. Git merges the text cleanly. Main breaks.
8
+
9
+ Rosentic compares every open PR against every other open PR. Deterministic static analysis. No LLM. 12 languages. Code never leaves your machine.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install rosentic-mcp
15
+ ```
16
+
17
+ ## Configure
18
+
19
+ ### Claude Code
20
+
21
+ Add to `~/.claude.json`:
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "rosentic": {
27
+ "command": "rosentic-mcp"
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ ### Cursor / VS Code
34
+
35
+ Add to MCP settings:
36
+
37
+ ```json
38
+ {
39
+ "rosentic": {
40
+ "command": "rosentic-mcp"
41
+ }
42
+ }
43
+ ```
44
+
45
+ ## Tools
46
+
47
+ ### `check_conflicts`
48
+
49
+ Scan a git repo for cross-branch conflicts affecting your current branch.
50
+
51
+ **Parameters:**
52
+ - `repo_path` (required) — path to local git repository
53
+ - `branch` — branch to check (defaults to current branch)
54
+ - `base` — base branch to compare against (defaults to `main`)
55
+ - `format` — `"summary"` (natural language) or `"json"` (structured findings)
56
+
57
+ **Example response:**
58
+ > Rosentic found 4 unique conflict(s) for feature/auth-refactor: 2 UNSAFE, 2 WARNING. Layer breakdown: 3 L1 signature, 1 L2 route. Most critical: createUser() in src/auth/users.ts — changed from 2 to 3 required params. Recommendation: inspect the UNSAFE findings first and update the stale branch before merging.
59
+
60
+ ### `explain_conflict`
61
+
62
+ Get detailed explanation of a specific finding, including affected branches, consumer locations, and remediation steps.
63
+
64
+ **Parameters:**
65
+ - `repo_path` (required) — path to local git repository
66
+ - `finding_id` (required) — ID from a previous `check_conflicts` result
67
+
68
+ ### `list_branches`
69
+
70
+ List local branches sorted by most recent commit date.
71
+
72
+ **Parameters:**
73
+ - `repo_path` (required) — path to local git repository
74
+
75
+ ## What it detects
76
+
77
+ | Layer | What | Example |
78
+ |-------|------|---------|
79
+ | L1 | Function signature mismatches | Branch A adds required param, Branch B still calls with old arity |
80
+ | L2 | HTTP route contract breaks | Route path or method changes, consumers still call old endpoint |
81
+ | L3 | Schema incompatibilities | Proto field removed, GraphQL type changed, OpenAPI contract broken |
82
+
83
+ ## How it works
84
+
85
+ - **Deterministic** — no LLM in the scan path. Tree-sitter AST parsing + evidence-gated verdicts.
86
+ - **Fast** — scans 30 branches in under 30 seconds for most repos.
87
+ - **Evidence-based** — every finding includes proof: which symbol changed, where it's called, why it's linked.
88
+
89
+ ## Requirements
90
+
91
+ - Python 3.11+
92
+ - Git repository with branches to scan
93
+ - Works on macOS, Linux, Windows (WSL)
94
+
95
+ ## Links
96
+
97
+ - [Website](https://rosentic.com)
98
+ - [GitHub](https://github.com/Rosentic/rosentic-engine)
99
+ - [GitHub Action](https://github.com/Rosentic/rosentic-engine#github-action)
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "rosentic-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for Rosentic — deterministic cross-branch conflict detection for AI coding agents"
9
+ readme = "README-mcp.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Rosentic", email = "hello@rosentic.com" }]
13
+ keywords = ["mcp", "git", "merge-conflicts", "ai-agents", "code-review", "tree-sitter"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: Software Development :: Quality Assurance",
18
+ "Topic :: Software Development :: Version Control :: Git",
19
+ ]
20
+ dependencies = [
21
+ "fastmcp>=3.1.0",
22
+ ]
23
+
24
+ [project.scripts]
25
+ rosentic-mcp = "rosentic.mcp_server:main"
26
+
27
+ [project.urls]
28
+ Homepage = "https://rosentic.com"
29
+ Repository = "https://github.com/Rosentic/rosentic-engine"
30
+ Issues = "https://github.com/Rosentic/rosentic-engine/issues"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["rosentic"]
34
+
35
+ [tool.hatch.build.targets.sdist]
36
+ include = ["rosentic/", "LICENSE", "README-mcp.md"]
@@ -0,0 +1 @@
1
+ """Rosentic — deterministic cross-branch conflict detection."""
@@ -0,0 +1,577 @@
1
+ """Rosentic MCP server - cross-branch conflict detection for coding agents.
2
+
3
+ Runs the Rosentic engine via Docker (ghcr.io/rosentic/rosentic-engine).
4
+ No engine source is distributed with this package.
5
+
6
+ Install: pip install rosentic-mcp
7
+ Run: rosentic-mcp
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ import urllib.parse
22
+ import urllib.request
23
+ from typing import Annotated, Any, Literal
24
+
25
+ from fastmcp import FastMCP
26
+ from pydantic import Field
27
+
28
+ _DOCKER_IMAGE = "ghcr.io/rosentic/rosentic-engine:latest"
29
+ _SCAN_TIMEOUT_SECONDS = 300
30
+ _CACHE_TTL_SECONDS = 60
31
+
32
+ mcp = FastMCP(
33
+ name="rosentic",
34
+ instructions=(
35
+ "Use Rosentic to check a git repository for deterministic cross-branch "
36
+ "merge conflicts across function signatures, HTTP routes, and schemas."
37
+ ),
38
+ )
39
+
40
+ _scan_cache: dict[str, dict[str, Any]] = {}
41
+
42
+
43
+ def _find_docker() -> str:
44
+ for cmd in ("docker", "podman"):
45
+ if shutil.which(cmd):
46
+ return cmd
47
+ raise RuntimeError(
48
+ "Docker is required but not found on PATH. "
49
+ "Install Docker Desktop (https://docker.com/products/docker-desktop) "
50
+ "or Podman, then try again."
51
+ )
52
+
53
+
54
+ def _run_git(repo_path: str, args: list[str], timeout: int = 20) -> str:
55
+ try:
56
+ proc = subprocess.run(
57
+ ["git", "-C", repo_path, *args],
58
+ text=True,
59
+ stdout=subprocess.PIPE,
60
+ stderr=subprocess.PIPE,
61
+ timeout=timeout,
62
+ check=False,
63
+ )
64
+ except FileNotFoundError as exc:
65
+ raise RuntimeError("git executable was not found on PATH") from exc
66
+ except subprocess.TimeoutExpired as exc:
67
+ raise TimeoutError(f"git command timed out after {timeout}s") from exc
68
+ if proc.returncode != 0:
69
+ detail = proc.stderr.strip() or proc.stdout.strip() or f"git exited {proc.returncode}"
70
+ raise ValueError(detail)
71
+ return proc.stdout
72
+
73
+
74
+ def _require_repo(repo_path: str) -> str:
75
+ path = os.path.abspath(os.path.expanduser(repo_path))
76
+ if not os.path.isdir(path):
77
+ raise ValueError(f"repo_path does not exist: {path}")
78
+ if not os.path.isdir(os.path.join(path, ".git")):
79
+ raise ValueError(f"repo_path is not a git repository: {path}")
80
+ return path
81
+
82
+
83
+ def _current_branch(repo_path: str) -> str:
84
+ return _run_git(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"]).strip()
85
+
86
+
87
+ def _require_branch(repo_path: str, branch: str, label: str = "branch") -> None:
88
+ try:
89
+ _run_git(repo_path, ["rev-parse", "--verify", "--quiet", branch])
90
+ except ValueError as exc:
91
+ raise ValueError(f"{label} does not exist in this repository: {branch}") from exc
92
+
93
+
94
+ def _repo_name(repo_path: str) -> tuple[str, str]:
95
+ try:
96
+ url = _run_git(repo_path, ["config", "--get", "remote.origin.url"]).strip()
97
+ except Exception:
98
+ return "local", os.path.basename(repo_path.rstrip(os.sep)) or "repo"
99
+
100
+ name = url.rstrip("/")
101
+ if name.endswith(".git"):
102
+ name = name[:-4]
103
+ if ":" in name and "/" in name and not name.startswith("http"):
104
+ name = name.split(":", 1)[1]
105
+ else:
106
+ name = urllib.parse.urlparse(name).path.lstrip("/")
107
+ parts = [p for p in name.split("/") if p]
108
+ if len(parts) >= 2:
109
+ return parts[-2], parts[-1]
110
+ return "local", parts[-1] if parts else (os.path.basename(repo_path) or "repo")
111
+
112
+
113
+ def _branch_count(repo_path: str) -> int:
114
+ try:
115
+ out = _run_git(repo_path, ["branch", "--list"])
116
+ return len([line for line in out.splitlines() if line.strip()])
117
+ except Exception:
118
+ return 0
119
+
120
+
121
+ def _cache_key(repo_path: str, base: str, target_branch: str | None,
122
+ branch_list: list[str] | None = None) -> str:
123
+ bl = ",".join(sorted(branch_list)) if branch_list else ""
124
+ return f"{os.path.abspath(repo_path)}::{base}::{target_branch or '*'}::{bl}"
125
+
126
+
127
+ def _cached_scan(repo_path: str, base: str, target_branch: str | None,
128
+ branch_list: list[str] | None = None) -> dict[str, Any] | None:
129
+ entry = _scan_cache.get(_cache_key(repo_path, base, target_branch, branch_list))
130
+ if not entry:
131
+ return None
132
+ if time.time() - entry.get("timestamp", 0) > _CACHE_TTL_SECONDS:
133
+ return None
134
+ return entry.get("data")
135
+
136
+
137
+ def _store_scan(repo_path: str, base: str, target_branch: str | None, data: dict[str, Any],
138
+ branch_list: list[str] | None = None) -> None:
139
+ _scan_cache[_cache_key(repo_path, base, target_branch, branch_list)] = {
140
+ "timestamp": time.time(),
141
+ "data": data,
142
+ }
143
+
144
+
145
+ def _pair_involves_branch(pair_key: str, branch: str) -> bool:
146
+ parts = [p.strip() for p in pair_key.split("<>")]
147
+ if len(parts) != 2:
148
+ return False
149
+ short = branch.split("/", 1)[-1]
150
+ return any(part == branch or part.split("/", 1)[-1] == short for part in parts)
151
+
152
+
153
+ def _filter_scan_to_branch(data: dict[str, Any], branch: str) -> dict[str, Any]:
154
+ pairs = data.get("pairs", {})
155
+ filtered_pairs = {
156
+ pair: conflicts
157
+ for pair, conflicts in pairs.items()
158
+ if _pair_involves_branch(pair, branch)
159
+ }
160
+
161
+ if not filtered_pairs and pairs:
162
+ return {
163
+ **data,
164
+ "pairs": {},
165
+ "unique_findings": [],
166
+ "unique_summary": {
167
+ "unique_total": 0,
168
+ "unique_unsafe": 0,
169
+ "unique_warning": 0,
170
+ "by_layer": {},
171
+ },
172
+ "mcp_note": f"No scanned branch pair involved '{branch}'.",
173
+ }
174
+
175
+ finding_ids = {
176
+ c.get("finding_id")
177
+ for conflicts in filtered_pairs.values()
178
+ if isinstance(conflicts, list)
179
+ for c in conflicts
180
+ if c.get("finding_id")
181
+ }
182
+ unique = [
183
+ f for f in data.get("unique_findings", [])
184
+ if f.get("finding_id") in finding_ids
185
+ or any(inst.get("pair") in filtered_pairs for inst in f.get("instances", []))
186
+ ]
187
+
188
+ return {
189
+ **data,
190
+ "pairs": filtered_pairs,
191
+ "unique_findings": unique,
192
+ "unique_summary": _summarize_unique(unique),
193
+ }
194
+
195
+
196
+ def _summarize_unique(findings: list[dict[str, Any]]) -> dict[str, Any]:
197
+ by_layer: dict[str, int] = {}
198
+ unsafe = 0
199
+ warning = 0
200
+ for finding in findings:
201
+ verdict = finding.get("verdict")
202
+ if verdict == "UNSAFE":
203
+ unsafe += 1
204
+ elif verdict == "WARNING":
205
+ warning += 1
206
+ layer = _finding_layer(finding)
207
+ by_layer[layer] = by_layer.get(layer, 0) + 1
208
+ return {
209
+ "unique_total": len(findings),
210
+ "unique_unsafe": unsafe,
211
+ "unique_warning": warning,
212
+ "by_layer": by_layer,
213
+ }
214
+
215
+
216
+ def _run_scan(repo_path: str, base: str, target_branch: str | None,
217
+ branch_list: list[str] | None = None) -> dict[str, Any]:
218
+ cached = _cached_scan(repo_path, base, target_branch, branch_list)
219
+ if cached is not None:
220
+ return cached
221
+
222
+ docker = _find_docker()
223
+ output_file = f"rosentic-mcp-{hashlib.sha256(f'{repo_path}:{time.time()}'.encode()).hexdigest()[:12]}.json"
224
+ output_path = os.path.join(repo_path, ".rosentic", output_file)
225
+
226
+ scan_args = [
227
+ "scan-all", "/repo",
228
+ "--base", base,
229
+ "--format", "json",
230
+ "--timeout", str(_SCAN_TIMEOUT_SECONDS),
231
+ "--output", f"/repo/.rosentic/{output_file}",
232
+ ]
233
+ if branch_list:
234
+ scan_args.extend(["--branch-list", ",".join(branch_list)])
235
+ else:
236
+ scan_args.extend(["--max-branches", "30"])
237
+
238
+ cmd = [
239
+ docker, "run", "--rm",
240
+ "--entrypoint", "python3",
241
+ "-v", f"{repo_path}:/repo",
242
+ _DOCKER_IMAGE,
243
+ "/rosentic/detect.py", *scan_args,
244
+ ]
245
+
246
+ start = time.time()
247
+ try:
248
+ proc = subprocess.run(
249
+ cmd,
250
+ text=True,
251
+ stdout=subprocess.PIPE,
252
+ stderr=subprocess.PIPE,
253
+ timeout=_SCAN_TIMEOUT_SECONDS + 60,
254
+ check=False,
255
+ )
256
+ except subprocess.TimeoutExpired as exc:
257
+ raise TimeoutError(
258
+ f"Rosentic scan timed out after {_SCAN_TIMEOUT_SECONDS}s. "
259
+ "Try a narrower branch set or run again after the repo index is warm."
260
+ ) from exc
261
+ duration = time.time() - start
262
+
263
+ if not os.path.exists(output_path):
264
+ detail = proc.stderr.strip() or proc.stdout.strip() or f"docker exited {proc.returncode}"
265
+ raise RuntimeError(f"Rosentic scan did not produce JSON output: {detail}")
266
+
267
+ with open(output_path, "r", encoding="utf-8") as f:
268
+ data = json.load(f)
269
+
270
+ try:
271
+ os.remove(output_path)
272
+ except OSError:
273
+ pass
274
+
275
+ data["_mcp"] = {
276
+ "repo_path": repo_path,
277
+ "base": base,
278
+ "target_branch": target_branch,
279
+ "scan_duration_seconds": round(duration, 3),
280
+ "stderr_tail": "\n".join(proc.stderr.splitlines()[-20:]),
281
+ }
282
+
283
+ if target_branch:
284
+ data = _filter_scan_to_branch(data, target_branch)
285
+ data.setdefault("_mcp", {})
286
+ data["_mcp"].update({
287
+ "repo_path": repo_path,
288
+ "base": base,
289
+ "target_branch": target_branch,
290
+ "scan_duration_seconds": round(duration, 3),
291
+ })
292
+
293
+ _store_scan(repo_path, base, target_branch, data, branch_list)
294
+ _post_telemetry_async(repo_path, data, duration)
295
+ return data
296
+
297
+
298
+ def _post_telemetry_async(repo_path: str, data: dict[str, Any], duration: float) -> None:
299
+ def _send() -> None:
300
+ try:
301
+ org, repo = _repo_name(repo_path)
302
+ summary = data.get("summary", {}) or {}
303
+ unique_summary = data.get("unique_summary", {}) or {}
304
+ payload = {
305
+ "schema_version": "mcp-v1",
306
+ "org": org,
307
+ "repo": repo,
308
+ "branch_count": str(_branch_count(repo_path)),
309
+ "conflict_count": str(unique_summary.get("unique_total", 0)),
310
+ "scan_duration_seconds": f"{duration:.2f}",
311
+ "agent": "mcp",
312
+ "engine_version": "docker",
313
+ "pairs_scanned": str(summary.get("total_pairs", 0)),
314
+ "pairs_with_conflicts": str(summary.get("pairs_with_conflicts", 0)),
315
+ }
316
+ body = urllib.parse.urlencode(payload).encode("utf-8")
317
+ req = urllib.request.Request(
318
+ "https://email-capture.rosentic.com/telemetry",
319
+ data=body,
320
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
321
+ method="POST",
322
+ )
323
+ urllib.request.urlopen(req, timeout=3).read()
324
+ except Exception:
325
+ pass
326
+
327
+ threading.Thread(target=_send, daemon=True).start()
328
+
329
+
330
+ def _finding_layer(finding: dict[str, Any]) -> str:
331
+ layer = finding.get("layer", "")
332
+ if layer.startswith("L2"):
333
+ return "L2 route"
334
+ if layer.startswith("L3"):
335
+ return "L3 contract"
336
+ if layer.startswith("L1"):
337
+ return "L1 signature"
338
+ ctype = finding.get("type")
339
+ if ctype == "api_contract":
340
+ return "L2 route"
341
+ if ctype in {"graphql_schema", "typed_contract", "proto_contract"}:
342
+ return "L3 contract"
343
+ return "L1 signature"
344
+
345
+
346
+ def _finding_title(finding: dict[str, Any]) -> str:
347
+ route = finding.get("route")
348
+ method = finding.get("http_method") or finding.get("method", "")
349
+ if route:
350
+ return f"{method.strip()} {route}".strip() if method else str(route)
351
+ if finding.get("function"):
352
+ return f"{finding['function']}()"
353
+ if finding.get("gql_type") and finding.get("gql_field"):
354
+ return f"{finding['gql_type']}.{finding['gql_field']}"
355
+ if finding.get("proto_message") and finding.get("proto_field"):
356
+ return f"{finding['proto_message']}.{finding['proto_field']}"
357
+ return finding.get("finding_id") or "conflict"
358
+
359
+
360
+ def _finding_file(finding: dict[str, Any]) -> str:
361
+ return (
362
+ finding.get("defined_in")
363
+ or finding.get("contract_file")
364
+ or finding.get("schema_file")
365
+ or finding.get("proto_file")
366
+ or finding.get("called_in")
367
+ or finding.get("client_file")
368
+ or finding.get("operation_file")
369
+ or "unknown file"
370
+ )
371
+
372
+
373
+ def _conflict_detail(finding: dict[str, Any]) -> str:
374
+ desc = finding.get("description")
375
+ if desc:
376
+ return desc
377
+ func = finding.get("function")
378
+ if func:
379
+ old = finding.get("old_required", "?")
380
+ new = finding.get("new_required", "?")
381
+ return f"{func}() changed from {old} to {new} required params."
382
+ return "Cross-branch contract conflict."
383
+
384
+
385
+ def _build_summary(data: dict[str, Any], branch: str | None) -> str:
386
+ findings = data.get("unique_findings", []) or []
387
+ target = branch or data.get("_mcp", {}).get("target_branch") or "selected branches"
388
+ if not findings:
389
+ note = data.get("mcp_note")
390
+ suffix = f" {note}" if note else ""
391
+ return f"Rosentic found no conflicts for {target}.{suffix}"
392
+
393
+ verdict_counts: dict[str, int] = {}
394
+ layer_counts: dict[str, int] = {}
395
+ for finding in findings:
396
+ verdict = finding.get("verdict") or finding.get("severity") or "WARNING"
397
+ verdict_counts[verdict] = verdict_counts.get(verdict, 0) + 1
398
+ layer = _finding_layer(finding)
399
+ layer_counts[layer] = layer_counts.get(layer, 0) + 1
400
+
401
+ most_critical = sorted(
402
+ findings,
403
+ key=lambda f: (
404
+ 0 if f.get("verdict") == "UNSAFE" else 1,
405
+ -float(f.get("confidence_score") or 0),
406
+ _finding_title(f),
407
+ ),
408
+ )[0]
409
+
410
+ verdict_part = ", ".join(f"{count} {name}" for name, count in sorted(verdict_counts.items()))
411
+ layer_part = ", ".join(f"{count} {name}" for name, count in sorted(layer_counts.items()))
412
+ recommendation = (
413
+ "Recommendation: inspect the UNSAFE findings first and update the stale branch before merging."
414
+ if verdict_counts.get("UNSAFE")
415
+ else "Recommendation: review the WARNING findings; Rosentic found plausible but not fully proven conflicts."
416
+ )
417
+
418
+ return (
419
+ f"Rosentic found {len(findings)} unique conflict(s) for {target}: {verdict_part}. "
420
+ f"Layer breakdown: {layer_part}. "
421
+ f"Most critical: {_finding_title(most_critical)} in {_finding_file(most_critical)} - "
422
+ f"{_conflict_detail(most_critical)} "
423
+ f"{recommendation}"
424
+ )
425
+
426
+
427
+ @mcp.tool
428
+ def check_conflicts(
429
+ repo_path: Annotated[str, Field(description="Absolute or relative path to the local git repository to scan.")],
430
+ branch: Annotated[
431
+ str | None,
432
+ Field(description="Branch to check against other active branches. If omitted, the current checked-out branch is used."),
433
+ ] = None,
434
+ branches: Annotated[
435
+ list[str] | None,
436
+ Field(description="Explicit list of branch names to scan. Bypasses enumeration and age filters. Use this when you know exactly which branches to check."),
437
+ ] = None,
438
+ base: Annotated[
439
+ str,
440
+ Field(description="Base branch used for branch diffs. Defaults to main."),
441
+ ] = "main",
442
+ format: Annotated[
443
+ Literal["summary", "json"],
444
+ Field(description="Use summary for concise text. Use json only when the agent needs finding_ids or full structured findings."),
445
+ ] = "summary",
446
+ ) -> str | list[dict[str, Any]]:
447
+ """Check a local git repository for Rosentic cross-branch conflicts.
448
+
449
+ Call this before merging or after an agent edits a branch. The summary format
450
+ is the normal agent-facing response. The json format returns unique findings
451
+ with finding_id values for follow-up explain_conflict calls.
452
+ """
453
+ repo_path = _require_repo(repo_path)
454
+
455
+ if branches:
456
+ target_branch = None
457
+ elif branch:
458
+ target_branch = branch
459
+ _require_branch(repo_path, target_branch)
460
+ else:
461
+ current = _current_branch(repo_path)
462
+ target_branch = None if current == base else current
463
+
464
+ branch_list = branches if branches else None
465
+ data = _run_scan(repo_path, base, target_branch, branch_list=branch_list)
466
+
467
+ if format == "json":
468
+ return data.get("unique_findings", []) or []
469
+ return _build_summary(data, target_branch)
470
+
471
+
472
+ @mcp.tool
473
+ def explain_conflict(
474
+ repo_path: Annotated[str, Field(description="Absolute or relative path to the local git repository that was scanned.")],
475
+ finding_id: Annotated[str, Field(description="finding_id returned by check_conflicts with format=json.")],
476
+ ) -> str:
477
+ """Explain one Rosentic finding and what the developer should do next.
478
+
479
+ Call this after check_conflicts(format="json") when the user asks why a
480
+ specific finding matters or how to resolve it. Uses the recent scan cache
481
+ when available and falls back to a fresh scan.
482
+ """
483
+ repo_path = _require_repo(repo_path)
484
+ data = None
485
+ now = time.time()
486
+ for key, entry in list(_scan_cache.items()):
487
+ if key.startswith(f"{repo_path}::") and now - entry.get("timestamp", 0) <= _CACHE_TTL_SECONDS:
488
+ candidate = entry.get("data") or {}
489
+ if any(f.get("finding_id") == finding_id for f in candidate.get("unique_findings", []) or []):
490
+ data = candidate
491
+ break
492
+ if data is None:
493
+ data = _run_scan(repo_path, "main", None)
494
+
495
+ finding = next((f for f in data.get("unique_findings", []) or [] if f.get("finding_id") == finding_id), None)
496
+ if finding is None:
497
+ return f"No finding with id {finding_id} was found in the recent Rosentic scan."
498
+
499
+ verdict = finding.get("verdict") or finding.get("severity") or "WARNING"
500
+ layer = _finding_layer(finding)
501
+ title = _finding_title(finding)
502
+ detail = _conflict_detail(finding)
503
+ source = _finding_file(finding)
504
+ instances = finding.get("instances", []) or []
505
+ affected = []
506
+ for inst in instances[:5]:
507
+ pair = inst.get("pair")
508
+ file = inst.get("called_in") or inst.get("client_file") or inst.get("operation_file") or inst.get("consumer_file")
509
+ line = inst.get("call_line") or inst.get("client_line") or inst.get("operation_line") or inst.get("line")
510
+ if pair or file:
511
+ affected.append(f"- {pair or 'branch pair'}: {file or 'unknown file'}{f':{line}' if line else ''}")
512
+
513
+ what_to_do = (
514
+ "Update the consumer branch to match the producer change, or merge/rebase so both sides agree before merging."
515
+ if verdict == "UNSAFE"
516
+ else "Review the evidence manually; Rosentic has a plausible link but is not treating it as proven unsafe."
517
+ )
518
+
519
+ parts = [
520
+ f"{title} - {verdict} ({layer})",
521
+ "",
522
+ f"What changed: {detail}",
523
+ f"Producer evidence: {source}",
524
+ ]
525
+ if affected:
526
+ parts.extend(["Affected consumers:", *affected])
527
+ parts.extend([
528
+ f"Why it matters: {verdict} is Rosentic's final gate after compatibility, confidence, and identity checks.",
529
+ f"What to do: {what_to_do}",
530
+ ])
531
+ return "\n".join(parts)
532
+
533
+
534
+ @mcp.tool
535
+ def list_branches(
536
+ repo_path: Annotated[str, Field(description="Absolute or relative path to the local git repository to inspect.")],
537
+ ) -> list[dict[str, str]]:
538
+ """List local branches so an agent can choose a branch before scanning.
539
+
540
+ Call this when the user has not named a branch or asks what branches Rosentic
541
+ can scan. Results are sorted by most recent commit date.
542
+ """
543
+ repo_path = _require_repo(repo_path)
544
+ out = _run_git(
545
+ repo_path,
546
+ ["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)\t%(committerdate:iso8601)", "refs/heads/"],
547
+ )
548
+ branches = []
549
+ current = _current_branch(repo_path)
550
+ for line in out.splitlines():
551
+ if not line.strip():
552
+ continue
553
+ name, _, date = line.partition("\t")
554
+ branches.append({
555
+ "name": name.strip(),
556
+ "last_commit_date": date.strip(),
557
+ "current": str(name.strip() == current).lower(),
558
+ })
559
+ return branches
560
+
561
+
562
+ def main():
563
+ if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
564
+ print("Rosentic MCP server (stdio)")
565
+ print("")
566
+ print("Usage: rosentic-mcp")
567
+ print("")
568
+ print("Tools: check_conflicts, explain_conflict, list_branches")
569
+ print("")
570
+ print(f"Engine: {_DOCKER_IMAGE}")
571
+ raise SystemExit(0)
572
+
573
+ mcp.run(transport="stdio")
574
+
575
+
576
+ if __name__ == "__main__":
577
+ main()