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()
|