romania-mcp-server 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.
- romania_mcp_server-0.1.0/.github/workflows/publish.yml +67 -0
- romania_mcp_server-0.1.0/.github/workflows/tests.yml +21 -0
- romania_mcp_server-0.1.0/.gitignore +9 -0
- romania_mcp_server-0.1.0/CHANGELOG.md +5 -0
- romania_mcp_server-0.1.0/LICENSE +21 -0
- romania_mcp_server-0.1.0/PKG-INFO +137 -0
- romania_mcp_server-0.1.0/README.md +119 -0
- romania_mcp_server-0.1.0/glama.json +4 -0
- romania_mcp_server-0.1.0/pyproject.toml +40 -0
- romania_mcp_server-0.1.0/server.json +22 -0
- romania_mcp_server-0.1.0/src/romania_mcp/__init__.py +1 -0
- romania_mcp_server-0.1.0/src/romania_mcp/__main__.py +3 -0
- romania_mcp_server-0.1.0/src/romania_mcp/anaf.py +219 -0
- romania_mcp_server-0.1.0/src/romania_mcp/bnr.py +79 -0
- romania_mcp_server-0.1.0/src/romania_mcp/cache.py +77 -0
- romania_mcp_server-0.1.0/src/romania_mcp/common.py +39 -0
- romania_mcp_server-0.1.0/src/romania_mcp/courts.py +71 -0
- romania_mcp_server-0.1.0/src/romania_mcp/iban.py +55 -0
- romania_mcp_server-0.1.0/src/romania_mcp/just.py +289 -0
- romania_mcp_server-0.1.0/src/romania_mcp/server.py +263 -0
- romania_mcp_server-0.1.0/src/romania_mcp/shop.py +325 -0
- romania_mcp_server-0.1.0/tests/conftest.py +60 -0
- romania_mcp_server-0.1.0/tests/test_anaf.py +97 -0
- romania_mcp_server-0.1.0/tests/test_bnr.py +78 -0
- romania_mcp_server-0.1.0/tests/test_iban.py +44 -0
- romania_mcp_server-0.1.0/tests/test_just.py +177 -0
- romania_mcp_server-0.1.0/tests/test_server.py +30 -0
- romania_mcp_server-0.1.0/tests/test_shop.py +146 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v7
|
|
12
|
+
- uses: actions/setup-python@v7
|
|
13
|
+
with:
|
|
14
|
+
python-version: "3.12"
|
|
15
|
+
|
|
16
|
+
- name: check tag matches version
|
|
17
|
+
run: |
|
|
18
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
19
|
+
py=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
20
|
+
js=$(python -c "import json; print(json.load(open('server.json'))['version'])")
|
|
21
|
+
echo "tag=$tag pyproject=$py server.json=$js"
|
|
22
|
+
[ "$tag" = "$py" ] && [ "$tag" = "$js" ]
|
|
23
|
+
|
|
24
|
+
- run: pip install -e . pytest build
|
|
25
|
+
- run: pytest
|
|
26
|
+
- run: python -m build
|
|
27
|
+
- uses: actions/upload-artifact@v7
|
|
28
|
+
with:
|
|
29
|
+
name: dist
|
|
30
|
+
path: dist/
|
|
31
|
+
|
|
32
|
+
pypi:
|
|
33
|
+
needs: build
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
environment: pypi
|
|
36
|
+
permissions:
|
|
37
|
+
id-token: write
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/download-artifact@v8
|
|
40
|
+
with:
|
|
41
|
+
name: dist
|
|
42
|
+
path: dist/
|
|
43
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
44
|
+
|
|
45
|
+
mcp-registry:
|
|
46
|
+
needs: pypi
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
permissions:
|
|
49
|
+
id-token: write
|
|
50
|
+
contents: read
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/checkout@v7
|
|
53
|
+
|
|
54
|
+
- name: install mcp-publisher
|
|
55
|
+
run: |
|
|
56
|
+
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
|
|
57
|
+
|
|
58
|
+
- run: ./mcp-publisher login github-oidc
|
|
59
|
+
|
|
60
|
+
# pypi can take a moment before the new version shows up in its api
|
|
61
|
+
- name: publish
|
|
62
|
+
run: |
|
|
63
|
+
for i in 1 2 3 4 5; do
|
|
64
|
+
./mcp-publisher publish && exit 0
|
|
65
|
+
sleep 20
|
|
66
|
+
done
|
|
67
|
+
exit 1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python: ["3.10", "3.12", "3.14"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
- uses: actions/setup-python@v7
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python }}
|
|
20
|
+
- run: pip install -e . pytest
|
|
21
|
+
- run: pytest -v
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Vind-Gardoș
|
|
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,137 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: romania-mcp-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Romanian public data: ANAF company status, financial statements, court cases, BNR exchange rates, online shop checks
|
|
5
|
+
Project-URL: Repository, https://github.com/robyroro/romania-mcp
|
|
6
|
+
Project-URL: Author, https://stratagency.ro/en/robert-vind-gardos
|
|
7
|
+
Author: Robert Vind-Gardoș
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: anaf,bnr,claude,cui,e-factura,mcp,portal-just,romania
|
|
11
|
+
Classifier: Natural Language :: Romanian
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: httpx>=0.27
|
|
16
|
+
Requires-Dist: mcp>=2.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# romania-mcp
|
|
20
|
+
|
|
21
|
+
[](https://github.com/robyroro/romania-mcp/actions/workflows/tests.yml)
|
|
22
|
+
[](https://pypi.org/project/romania-mcp-server/)
|
|
23
|
+
|
|
24
|
+
MCP server for Romanian public data. Lets Claude (or any MCP client) check a company at ANAF, read its financial statements, find its court cases, get BNR exchange rates, validate IBANs, and put all of it together to tell you if an online shop looks like a scam. No API keys, everything comes from official public sources.
|
|
25
|
+
|
|
26
|
+
> **Pe scurt:** verifici o firmă după CUI (activă, radiată, inactivă fiscal, plătitoare de TVA), îi vezi bilanțurile, dosarele din instanță și cursul BNR, direct din Claude. Și îl poți întreba „e țeapă magazinul ăsta?”.
|
|
27
|
+
|
|
28
|
+
## Tools
|
|
29
|
+
|
|
30
|
+
| tool | what it does | source |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `shop_check` | is this online shop legit? reads the CUI off the site, checks the company, its financials, insolvency cases and how old the site is, returns red flags / warnings / good signs | everything below + Wayback Machine |
|
|
33
|
+
| `company_lookup` | status of up to 500 companies by CUI: name, address, Reg. Com. number, CAEN, deregistered, VAT registered, VAT on collection, split VAT, fiscally inactive, e-Factura registry. optionally as of a past date | ANAF |
|
|
34
|
+
| `company_financials` | filed balance sheets year by year: revenue, profit/loss, employees, debts, equity, cash | ANAF |
|
|
35
|
+
| `court_cases` | cases in every Romanian court by party name or case number: stage, parties and roles, latest outcome, next hearing, insolvency as debtor | Portal Just |
|
|
36
|
+
| `exchange_rate` | BNR reference rate for any day since 2005, with conversion | BNR |
|
|
37
|
+
| `iban_check` | is an IBAN well formed, which Romanian bank issued it, is it a Treasury account | offline |
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
Needs Python 3.10+.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
pip install romania-mcp-server
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
or if you use uv you don't need to install anything, just point the client at `uvx` (see below).
|
|
48
|
+
|
|
49
|
+
### Claude Desktop
|
|
50
|
+
|
|
51
|
+
Add this to `claude_desktop_config.json`:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"romania": {
|
|
57
|
+
"command": "uvx",
|
|
58
|
+
"args": ["romania-mcp-server"]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
If you installed with pip, `"command": "romania-mcp-server"` with no args works too.
|
|
65
|
+
|
|
66
|
+
### Claude Code
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
claude mcp add romania -- uvx romania-mcp-server
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Cursor, Windsurf etc. take the same JSON as Claude Desktop.
|
|
73
|
+
|
|
74
|
+
## Example prompts
|
|
75
|
+
|
|
76
|
+
- "e țeapă magazinul ăsta? https://..."
|
|
77
|
+
- "here are my 40 suppliers' CUIs, which ones are fiscally inactive or deregistered?"
|
|
78
|
+
- "was RO18547290 VAT registered on 15.03.2016?"
|
|
79
|
+
- "how did 14399840 do in the last 5 years? revenue, profit, employees"
|
|
80
|
+
- "does Foo Trade SRL have any insolvency cases? when's the next hearing?"
|
|
81
|
+
- "cât fac 2.450 EUR în lei la cursul BNR din 14 martie 2025?"
|
|
82
|
+
- "is this IBAN right? RO49 AAAA 1B31 0075 9384 0000"
|
|
83
|
+
|
|
84
|
+
## How `shop_check` decides
|
|
85
|
+
|
|
86
|
+
It's a list of signals, not a verdict from above:
|
|
87
|
+
|
|
88
|
+
- **red flags** (risk = high): no CUI anywhere on the site, CUI unknown to ANAF, company deregistered or fiscally inactive, company is the debtor in an insolvency case
|
|
89
|
+
- **warnings** (two or more = medium): company or site younger than a few months, never archived by the Wayback Machine, no financial statements, zero revenue or employees, negative equity, no ANPC link, the company name ANAF returns never appears on the site (scam shops sometimes copy a real company's CUI)
|
|
90
|
+
- **good signs**: company and site have been around for years, real revenue and employees, ANPC links present
|
|
91
|
+
|
|
92
|
+
It can't see reviews, delivery times or whether the products exist, so a "low" doesn't mean the shop is good, just that the company behind it checks out.
|
|
93
|
+
|
|
94
|
+
## Caching
|
|
95
|
+
|
|
96
|
+
Responses are cached in sqlite at `~/.cache/romania-mcp/cache.sqlite3` so ANAF and friends don't get asked the same thing twice: company status 6h, financials 24h, court cases 3h, current exchange rates 1h, past years' rates 30 days.
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
ROMANIA_MCP_CACHE=off # disable
|
|
100
|
+
ROMANIA_MCP_CACHE_PATH=/tmp/x.db
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Notes
|
|
104
|
+
|
|
105
|
+
- ANAF only searches by CUI, not by company name. Name search would need the ONRC dataset, maybe later.
|
|
106
|
+
- ANAF asks for at most one request per second. The server spaces requests out, so 500 CUIs take about 5 seconds and 5 years of financials about 5.
|
|
107
|
+
- Financial statements for a year are filed by mid next year, and some companies only have data from around 2020 on.
|
|
108
|
+
- Portal Just's API only answers over plain http (their https is broken), and caps a search at 1000 cases.
|
|
109
|
+
- `.ro` domains have no public RDAP and ROTLD's whois terms forbid automated queries, so for `.ro` shops the site age comes from the Wayback Machine only.
|
|
110
|
+
- `iban_check` knows the main Romanian banks. A valid IBAN doesn't prove who owns the account.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
git clone https://github.com/robyroro/romania-mcp
|
|
116
|
+
cd romania-mcp
|
|
117
|
+
pip install -e . pytest
|
|
118
|
+
pytest
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Tests don't hit the network, HTTP calls are mocked.
|
|
122
|
+
|
|
123
|
+
To poke at it with the MCP inspector:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
npx @modelcontextprotocol/inspector romania-mcp
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Be reasonable
|
|
130
|
+
|
|
131
|
+
All of this is public data, but court records name real people. Use it to vet companies you do business with, not to dig into individuals.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT © [Robert Vind-Gardoș](https://stratagency.ro/en/robert-vind-gardos) ([@robyroro](https://github.com/robyroro))
|
|
136
|
+
|
|
137
|
+
<!-- mcp-name: io.github.robyroro/romania-mcp -->
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# romania-mcp
|
|
2
|
+
|
|
3
|
+
[](https://github.com/robyroro/romania-mcp/actions/workflows/tests.yml)
|
|
4
|
+
[](https://pypi.org/project/romania-mcp-server/)
|
|
5
|
+
|
|
6
|
+
MCP server for Romanian public data. Lets Claude (or any MCP client) check a company at ANAF, read its financial statements, find its court cases, get BNR exchange rates, validate IBANs, and put all of it together to tell you if an online shop looks like a scam. No API keys, everything comes from official public sources.
|
|
7
|
+
|
|
8
|
+
> **Pe scurt:** verifici o firmă după CUI (activă, radiată, inactivă fiscal, plătitoare de TVA), îi vezi bilanțurile, dosarele din instanță și cursul BNR, direct din Claude. Și îl poți întreba „e țeapă magazinul ăsta?”.
|
|
9
|
+
|
|
10
|
+
## Tools
|
|
11
|
+
|
|
12
|
+
| tool | what it does | source |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `shop_check` | is this online shop legit? reads the CUI off the site, checks the company, its financials, insolvency cases and how old the site is, returns red flags / warnings / good signs | everything below + Wayback Machine |
|
|
15
|
+
| `company_lookup` | status of up to 500 companies by CUI: name, address, Reg. Com. number, CAEN, deregistered, VAT registered, VAT on collection, split VAT, fiscally inactive, e-Factura registry. optionally as of a past date | ANAF |
|
|
16
|
+
| `company_financials` | filed balance sheets year by year: revenue, profit/loss, employees, debts, equity, cash | ANAF |
|
|
17
|
+
| `court_cases` | cases in every Romanian court by party name or case number: stage, parties and roles, latest outcome, next hearing, insolvency as debtor | Portal Just |
|
|
18
|
+
| `exchange_rate` | BNR reference rate for any day since 2005, with conversion | BNR |
|
|
19
|
+
| `iban_check` | is an IBAN well formed, which Romanian bank issued it, is it a Treasury account | offline |
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Needs Python 3.10+.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
pip install romania-mcp-server
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
or if you use uv you don't need to install anything, just point the client at `uvx` (see below).
|
|
30
|
+
|
|
31
|
+
### Claude Desktop
|
|
32
|
+
|
|
33
|
+
Add this to `claude_desktop_config.json`:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"romania": {
|
|
39
|
+
"command": "uvx",
|
|
40
|
+
"args": ["romania-mcp-server"]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If you installed with pip, `"command": "romania-mcp-server"` with no args works too.
|
|
47
|
+
|
|
48
|
+
### Claude Code
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
claude mcp add romania -- uvx romania-mcp-server
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Cursor, Windsurf etc. take the same JSON as Claude Desktop.
|
|
55
|
+
|
|
56
|
+
## Example prompts
|
|
57
|
+
|
|
58
|
+
- "e țeapă magazinul ăsta? https://..."
|
|
59
|
+
- "here are my 40 suppliers' CUIs, which ones are fiscally inactive or deregistered?"
|
|
60
|
+
- "was RO18547290 VAT registered on 15.03.2016?"
|
|
61
|
+
- "how did 14399840 do in the last 5 years? revenue, profit, employees"
|
|
62
|
+
- "does Foo Trade SRL have any insolvency cases? when's the next hearing?"
|
|
63
|
+
- "cât fac 2.450 EUR în lei la cursul BNR din 14 martie 2025?"
|
|
64
|
+
- "is this IBAN right? RO49 AAAA 1B31 0075 9384 0000"
|
|
65
|
+
|
|
66
|
+
## How `shop_check` decides
|
|
67
|
+
|
|
68
|
+
It's a list of signals, not a verdict from above:
|
|
69
|
+
|
|
70
|
+
- **red flags** (risk = high): no CUI anywhere on the site, CUI unknown to ANAF, company deregistered or fiscally inactive, company is the debtor in an insolvency case
|
|
71
|
+
- **warnings** (two or more = medium): company or site younger than a few months, never archived by the Wayback Machine, no financial statements, zero revenue or employees, negative equity, no ANPC link, the company name ANAF returns never appears on the site (scam shops sometimes copy a real company's CUI)
|
|
72
|
+
- **good signs**: company and site have been around for years, real revenue and employees, ANPC links present
|
|
73
|
+
|
|
74
|
+
It can't see reviews, delivery times or whether the products exist, so a "low" doesn't mean the shop is good, just that the company behind it checks out.
|
|
75
|
+
|
|
76
|
+
## Caching
|
|
77
|
+
|
|
78
|
+
Responses are cached in sqlite at `~/.cache/romania-mcp/cache.sqlite3` so ANAF and friends don't get asked the same thing twice: company status 6h, financials 24h, court cases 3h, current exchange rates 1h, past years' rates 30 days.
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
ROMANIA_MCP_CACHE=off # disable
|
|
82
|
+
ROMANIA_MCP_CACHE_PATH=/tmp/x.db
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Notes
|
|
86
|
+
|
|
87
|
+
- ANAF only searches by CUI, not by company name. Name search would need the ONRC dataset, maybe later.
|
|
88
|
+
- ANAF asks for at most one request per second. The server spaces requests out, so 500 CUIs take about 5 seconds and 5 years of financials about 5.
|
|
89
|
+
- Financial statements for a year are filed by mid next year, and some companies only have data from around 2020 on.
|
|
90
|
+
- Portal Just's API only answers over plain http (their https is broken), and caps a search at 1000 cases.
|
|
91
|
+
- `.ro` domains have no public RDAP and ROTLD's whois terms forbid automated queries, so for `.ro` shops the site age comes from the Wayback Machine only.
|
|
92
|
+
- `iban_check` knows the main Romanian banks. A valid IBAN doesn't prove who owns the account.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
git clone https://github.com/robyroro/romania-mcp
|
|
98
|
+
cd romania-mcp
|
|
99
|
+
pip install -e . pytest
|
|
100
|
+
pytest
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Tests don't hit the network, HTTP calls are mocked.
|
|
104
|
+
|
|
105
|
+
To poke at it with the MCP inspector:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
npx @modelcontextprotocol/inspector romania-mcp
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Be reasonable
|
|
112
|
+
|
|
113
|
+
All of this is public data, but court records name real people. Use it to vet companies you do business with, not to dig into individuals.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT © [Robert Vind-Gardoș](https://stratagency.ro/en/robert-vind-gardos) ([@robyroro](https://github.com/robyroro))
|
|
118
|
+
|
|
119
|
+
<!-- mcp-name: io.github.robyroro/romania-mcp -->
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "romania-mcp-server"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for Romanian public data: ANAF company status, financial statements, court cases, BNR exchange rates, online shop checks"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Robert Vind-Gardoș" }]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
keywords = ["mcp", "romania", "anaf", "cui", "bnr", "portal-just", "e-factura", "claude"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Natural Language :: Romanian",
|
|
13
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"mcp>=2.0",
|
|
17
|
+
"httpx>=0.27",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Repository = "https://github.com/robyroro/romania-mcp"
|
|
22
|
+
Author = "https://stratagency.ro/en/robert-vind-gardos"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
romania-mcp = "romania_mcp.server:main"
|
|
26
|
+
# so `uvx romania-mcp-server` works without --from
|
|
27
|
+
romania-mcp-server = "romania_mcp.server:main"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/romania_mcp"]
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = ["pytest>=8"]
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["hatchling"]
|
|
37
|
+
build-backend = "hatchling.build"
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.robyroro/romania-mcp",
|
|
4
|
+
"title": "Romania",
|
|
5
|
+
"description": "Romanian public data: ANAF company status, financials, court cases, BNR rates, shop checks",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/robyroro/romania-mcp",
|
|
8
|
+
"source": "github"
|
|
9
|
+
},
|
|
10
|
+
"version": "0.1.0",
|
|
11
|
+
"packages": [
|
|
12
|
+
{
|
|
13
|
+
"registryType": "pypi",
|
|
14
|
+
"identifier": "romania-mcp-server",
|
|
15
|
+
"version": "0.1.0",
|
|
16
|
+
"runtimeHint": "uvx",
|
|
17
|
+
"transport": {
|
|
18
|
+
"type": "stdio"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""ANAF public web services: the VAT payer registry (company status) and filed balance sheets."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
from .cache import cached, get_cache
|
|
8
|
+
from .common import fold
|
|
9
|
+
|
|
10
|
+
TVA_URL = "https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva"
|
|
11
|
+
BILANT_URL = "https://webservicesp.anaf.ro/bilant"
|
|
12
|
+
MAX_BATCH = 100 # ANAF's limit per request
|
|
13
|
+
|
|
14
|
+
# ANAF asks for at most one request per second
|
|
15
|
+
MIN_INTERVAL = 1.0
|
|
16
|
+
_next_slot = 0.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _throttle():
|
|
20
|
+
global _next_slot
|
|
21
|
+
now = time.monotonic()
|
|
22
|
+
wait = _next_slot - now
|
|
23
|
+
_next_slot = max(now, _next_slot) + MIN_INTERVAL
|
|
24
|
+
if wait > 0:
|
|
25
|
+
await asyncio.sleep(wait)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- CUI ---
|
|
29
|
+
|
|
30
|
+
CUI_KEY = "753217532"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cui_checksum_ok(digits):
|
|
34
|
+
body, control = digits[:-1], int(digits[-1])
|
|
35
|
+
total = sum(int(d) * int(k) for d, k in zip(body, CUI_KEY[-len(body):]))
|
|
36
|
+
return total * 10 % 11 % 10 == control
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def clean_cui(value):
|
|
40
|
+
"""'RO 14399840', 'ro14399840', 14399840 -> 14399840. ValueError if the control digit is off."""
|
|
41
|
+
s = str(value).strip().upper().replace(" ", "").removeprefix("RO")
|
|
42
|
+
if not s.isdigit() or not 2 <= len(s) <= 10:
|
|
43
|
+
raise ValueError(f"not a CUI: {value!r}")
|
|
44
|
+
if not cui_checksum_ok(s):
|
|
45
|
+
raise ValueError(f"{value!r} isn't a valid CUI, the control digit doesn't match")
|
|
46
|
+
return int(s)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- company status (VAT registry) ---
|
|
50
|
+
|
|
51
|
+
def _date(value):
|
|
52
|
+
return value or None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_company(item):
|
|
56
|
+
g = item.get("date_generale", {})
|
|
57
|
+
vat = item.get("inregistrare_scop_Tva", {})
|
|
58
|
+
vat_cash = item.get("inregistrare_RTVAI", {})
|
|
59
|
+
inactive = item.get("stare_inactiv", {})
|
|
60
|
+
split = item.get("inregistrare_SplitTVA", {})
|
|
61
|
+
status = g.get("stare_inregistrare") or ""
|
|
62
|
+
|
|
63
|
+
out = {
|
|
64
|
+
"cui": g.get("cui"),
|
|
65
|
+
"name": g.get("denumire"),
|
|
66
|
+
"registration_number": g.get("nrRegCom") or None,
|
|
67
|
+
"registered_on": _date(g.get("data_inregistrare")),
|
|
68
|
+
"status": status,
|
|
69
|
+
"deregistered": status.upper().startswith("RADIERE"),
|
|
70
|
+
"address": g.get("adresa"),
|
|
71
|
+
"county": item.get("adresa_sediu_social", {}).get("sdenumire_Judet") or None,
|
|
72
|
+
"caen": g.get("cod_CAEN") or None,
|
|
73
|
+
"legal_form": g.get("forma_juridica") or None,
|
|
74
|
+
"phone": g.get("telefon") or None,
|
|
75
|
+
"tax_office": g.get("organFiscalCompetent") or None,
|
|
76
|
+
"vat_registered": bool(vat.get("scpTVA")),
|
|
77
|
+
"vat_periods": [
|
|
78
|
+
{
|
|
79
|
+
"from": _date(p.get("data_inceput_ScpTVA")),
|
|
80
|
+
"to": _date(p.get("data_sfarsit_ScpTVA")),
|
|
81
|
+
"note": p.get("mesaj_ScpTVA") or None,
|
|
82
|
+
}
|
|
83
|
+
for p in vat.get("perioade_TVA") or []
|
|
84
|
+
if p.get("data_inceput_ScpTVA")
|
|
85
|
+
],
|
|
86
|
+
"vat_on_collection": bool(vat_cash.get("statusTvaIncasare")),
|
|
87
|
+
"split_vat": bool(split.get("statusSplitTVA")),
|
|
88
|
+
"fiscally_inactive": bool(inactive.get("statusInactivi")),
|
|
89
|
+
"inactive_since": _date(inactive.get("dataInactivare")),
|
|
90
|
+
"reactivated_on": _date(inactive.get("dataReactivare")),
|
|
91
|
+
"e_factura_registry": bool(g.get("statusRO_e_Factura")),
|
|
92
|
+
"as_of": g.get("data"),
|
|
93
|
+
}
|
|
94
|
+
out["warnings"] = company_warnings(out)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def company_warnings(c):
|
|
99
|
+
out = []
|
|
100
|
+
if c["deregistered"]:
|
|
101
|
+
out.append(f"company is deregistered ({c['status']})")
|
|
102
|
+
if c["fiscally_inactive"]:
|
|
103
|
+
out.append(
|
|
104
|
+
f"declared fiscally inactive since {c['inactive_since']}: buyers generally can't "
|
|
105
|
+
"deduct expenses or VAT on its invoices (Cod fiscal art. 11)"
|
|
106
|
+
)
|
|
107
|
+
if "SUSPENDARE" in c["status"].upper():
|
|
108
|
+
out.append(f"activity is suspended ({c['status']})")
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def lookup(client, cuis, on=None):
|
|
113
|
+
"""Status of any number of CUIs (batched by 100). Returns (found, not_found)."""
|
|
114
|
+
on = on or date.today().isoformat()
|
|
115
|
+
cache = get_cache()
|
|
116
|
+
found = {}
|
|
117
|
+
todo = []
|
|
118
|
+
for cui in dict.fromkeys(cuis):
|
|
119
|
+
hit = cache.get(f"anaf:{cui}:{on}")
|
|
120
|
+
if hit is not None:
|
|
121
|
+
found[cui] = hit
|
|
122
|
+
else:
|
|
123
|
+
todo.append(cui)
|
|
124
|
+
|
|
125
|
+
not_found = []
|
|
126
|
+
for i in range(0, len(todo), MAX_BATCH):
|
|
127
|
+
batch = todo[i:i + MAX_BATCH]
|
|
128
|
+
await _throttle()
|
|
129
|
+
r = await client.post(TVA_URL, json=[{"cui": c, "data": on} for c in batch])
|
|
130
|
+
r.raise_for_status()
|
|
131
|
+
body = r.json()
|
|
132
|
+
for item in body.get("found") or []:
|
|
133
|
+
company = parse_company(item)
|
|
134
|
+
found[company["cui"]] = company
|
|
135
|
+
cache.set(f"anaf:{company['cui']}:{on}", company, 6 * 3600)
|
|
136
|
+
not_found += body.get("notFound") or []
|
|
137
|
+
|
|
138
|
+
return [found[c] for c in dict.fromkeys(cuis) if c in found], not_found
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --- balance sheets ---
|
|
142
|
+
|
|
143
|
+
# what the summary calls each line, matched on the folded ANAF label
|
|
144
|
+
LABELS = {
|
|
145
|
+
"cifra de afaceri neta": "revenue",
|
|
146
|
+
"venituri totale": "total_income",
|
|
147
|
+
"cheltuieli totale": "total_expenses",
|
|
148
|
+
"profit brut": "gross_profit",
|
|
149
|
+
"pierdere bruta": "gross_loss",
|
|
150
|
+
"profit net": "net_profit",
|
|
151
|
+
"pierdere neta": "net_loss",
|
|
152
|
+
"numar mediu de salariati": "employees",
|
|
153
|
+
"active imobilizate": "fixed_assets",
|
|
154
|
+
"active circulante": "current_assets",
|
|
155
|
+
"stocuri": "inventory",
|
|
156
|
+
"creante": "receivables",
|
|
157
|
+
"casa si conturi la banci": "cash",
|
|
158
|
+
"cheltuieli in avans": "prepaid_expenses",
|
|
159
|
+
"datorii": "debts",
|
|
160
|
+
"venituri in avans": "deferred_income",
|
|
161
|
+
"provizioane": "provisions",
|
|
162
|
+
"capitaluri": "equity",
|
|
163
|
+
"capital subscris varsat": "share_capital",
|
|
164
|
+
"patrimoniul regiei": "public_patrimony",
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _label_key(label):
|
|
169
|
+
s = fold(label).strip()
|
|
170
|
+
s = s.removesuffix(", din care:").strip()
|
|
171
|
+
s = s.removesuffix("- total").strip()
|
|
172
|
+
return s
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_bilant(data):
|
|
176
|
+
"""One year of filed financials, None if nothing was filed (or the CUI is unknown)."""
|
|
177
|
+
items = data.get("i") or []
|
|
178
|
+
if not items:
|
|
179
|
+
return None
|
|
180
|
+
out = {"year": data.get("an")}
|
|
181
|
+
other = {}
|
|
182
|
+
for i in sorted(items, key=lambda i: int(i["indicator"].lstrip("I") or 0)):
|
|
183
|
+
key = LABELS.get(_label_key(i["val_den_indicator"]))
|
|
184
|
+
if key:
|
|
185
|
+
out[key] = i["val_indicator"]
|
|
186
|
+
else:
|
|
187
|
+
other[i["val_den_indicator"].strip()] = i["val_indicator"]
|
|
188
|
+
if "net_profit" in out or "net_loss" in out:
|
|
189
|
+
out["net_result"] = out.get("net_profit", 0) - out.get("net_loss", 0)
|
|
190
|
+
if other:
|
|
191
|
+
# banks, insurers and NGOs file a different layout, pass it through as is
|
|
192
|
+
out["other"] = other
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def bilant(client, cui, year):
|
|
197
|
+
async def fetch():
|
|
198
|
+
await _throttle()
|
|
199
|
+
r = await client.get(BILANT_URL, params={"an": year, "cui": cui})
|
|
200
|
+
r.raise_for_status()
|
|
201
|
+
data = r.json()
|
|
202
|
+
parsed = parse_bilant(data)
|
|
203
|
+
meta = {"name": data.get("deni") or None, "caen": data.get("caen") or None,
|
|
204
|
+
"caen_description": data.get("den_caen") or None}
|
|
205
|
+
return {"meta": meta, "year": parsed or {"year": year, "filed": False}}
|
|
206
|
+
|
|
207
|
+
# filed statements barely change, a day is plenty
|
|
208
|
+
return await cached(f"bilant:{cui}:{year}", 24 * 3600, fetch)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def financials(client, cui, years):
|
|
212
|
+
meta = {}
|
|
213
|
+
out = []
|
|
214
|
+
for year in years:
|
|
215
|
+
res = await bilant(client, cui, year)
|
|
216
|
+
if res["meta"]["name"] and not meta:
|
|
217
|
+
meta = res["meta"]
|
|
218
|
+
out.append(res["year"])
|
|
219
|
+
return {"cui": cui, **meta, "currency": "RON", "years": out}
|