any2mcp
README.md
# any2mcp
**Turn any Python module into an [MCP](https://modelcontextprotocol.io) server — without handing a language model your shell.**
[](https://github.com/SanoberRehman/any2mcp/actions/workflows/ci.yml)
[](https://pypi.org/project/any2mcp/)
[](https://pypi.org/project/any2mcp/)
[](LICENSE)
You have a file full of useful Python functions and you want an agent to call them. Today that means importing an MCP framework and decorating every function — so your business logic now depends on MCP.
`any2mcp` skips that. Point it at a module and **every public function becomes a fully-schematized MCP tool**, with argument types and descriptions derived from the type hints and docstrings you already wrote:
```bash
any2mcp ./tools.py
```
But "expose every function" is a dangerous default, and most MCP tooling stops there. If your module happens to contain a `run_shell()` or a `delete_path()`, that naive approach just gave a language model your machine.
So `any2mcp` reads your code before it exposes it:
```console
$ any2mcp examples/devtools.py --risk-report
any2mcp risk report: examples/devtools.py
policy: guard (allows up to 'moderate')
RISK TOOL CAPABILITIES STATUS
-------- ----------------- ------------ -------
safe word_count - exposed
low read_project_file fs-read exposed
- .read_text (line 36) [heuristic]
low list_directory fs-read exposed
- os.listdir (line 44)
moderate write_note fs-write exposed
- .write_text (line 50) [heuristic]
low parse_json_file fs-read exposed
- open (line 56)
high run_shell subprocess BLOCKED
- subprocess.run (line 66)
! blocked: risk 'high' (subprocess) exceeds policy 'guard' (max 'moderate')
high delete_path fs-delete BLOCKED
- shutil.rmtree (line 85)
- .unlink (line 87) [heuristic]
! blocked: risk 'high' (fs-delete) exceeds policy 'guard' (max 'moderate')
5 exposed, 2 blocked.
```
The two dangerous functions are **not exposed**, and no flags were needed to get that.
---
## The difference
**Before** — one decorator per function, in a file that now depends on MCP:
```python
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("calculator")
@mcp.tool()
def add(a: float, b: float) -> float:
"""Add two numbers."""
return a + b
@mcp.tool()
def divide(a: float, b: float) -> float:
"""Divide a by b."""
return a / b
# ...repeat for every function, forever
if __name__ == "__main__":
mcp.run()
```
**After** — your module stays plain Python and knows nothing about MCP:
```python
# calculator.py — no imports, no decorators
def add(a: float, b: float) -> float:
"""Add two numbers."""
return a + b
def divide(a: float, b: float) -> float:
"""Divide a by b."""
return a / b
```
```bash
any2mcp calculator.py
```
## Install
```bash
pip install any2mcp
# or, with uv:
uv tool install any2mcp
```
> Not yet on PyPI? Install straight from source:
> `uv tool install "git+https://github.com/SanoberRehman/any2mcp"`
## Quickstart
```bash
any2mcp tools.py --risk-report # audit what each function can reach
any2mcp tools.py --list # show the exposed tools + JSON schemas
any2mcp tools.py # serve over stdio
any2mcp tools.py:add # expose only `add`
any2mcp tools.py --exclude '_*' # filter with globs
any2mcp mypackage.tools # a dotted, importable module works too
```
## The safety model
This is the part worth reading carefully, because the guarantees are uneven and it matters which is which.
### What is enforced
**A blocked function is never registered as a tool.** It is absent from `tools/list`, so the model cannot see it, cannot call it, and cannot talk its way past it. There is no confirmation token to forge and no runtime check to race — the tool simply does not exist on the server.
You can watch this happen against a real MCP client:
```console
$ python examples/demo_safety.py
=== any2mcp devtools.py (default policy: guard) ===
tools visible to the model: ['word_count', 'read_project_file', 'list_directory', 'write_note', 'parse_json_file']
run_shell: withheld; calling it anyway -> Unknown tool: run_shell
delete_path: withheld; calling it anyway -> Unknown tool: delete_path
word_count still works -> {"words": 3, "lines": 1, "characters": 18}
```
### What is a heuristic
**Which functions get classified as risky.** `any2mcp` parses your module and looks for calls that reach the filesystem, the network, subprocesses, or the dynamic-execution builtins. It resolves import aliases, so both of these are caught:
```python
import subprocess as sp
def a(cmd): sp.run(cmd) # -> subprocess (high)
from os import system as sh
def b(cmd): sh(cmd) # -> subprocess (high)
```
and it follows calls within your module, so a wrapper inherits its helper's capabilities:
```python
def _upload(data): requests.post("https://...", data=data)
def publish(data): _upload(data) # -> network, "via _upload()"
```
But **static analysis is not sound, and indirection defeats it**:
```python
def sneaky(cmd):
getattr(os, "system")(cmd) # -> reported SAFE. Not detected.
```
So a `safe` classification means *"no known-risky call was found"*, never *"this function is harmless"*. These blind spots are asserted in [`tests/test_risk.py`](tests/test_risk.py) under `TestDocumentedBlindSpots`, so this claim cannot quietly drift into implying more than it should.
### What it is not
**`any2mcp` is not a sandbox.** Tools that *are* exposed run in-process with your full privileges. The policy decides what gets offered to a model; it does not contain what happens next.
Importing a module also executes its top-level code. That is why **`--risk-report` never imports a file target** — it works from source, so you can audit code before running any of it, and even audit a module whose dependencies you don't have installed.
### Policies
| Policy | Allows | Use it when |
|---|---|---|
| `open` | everything | You have read the code and want the 0.1 behaviour. |
| `guard` **(default)** | up to `moderate` — reads, writes, network | General use. Blocks subprocesses, `eval`, recursive deletes, and anything unanalyzable. |
| `readonly` | up to `low` — reads, env | The agent should look but not touch. |
| `strict` | `safe` only | Pure computation, no side effects of any kind. |
Overrides, in order of precedence — naming a specific tool beats a category rule, and an explicit deny always wins:
```bash
any2mcp tools.py --deny-tool 'admin_*' # never expose these
any2mcp tools.py --allow-tool run_shell # expose this one on purpose
any2mcp tools.py --deny-capability network # block a whole capability
```
Capabilities are `fs-read`, `fs-write`, `fs-delete`, `network`, `subprocess`, `dynamic-exec`, and `env`.
### Use it as a CI gate
`--risk-report` exits **3** when anything is blocked and **0** when nothing is, so it can fail a build if someone adds a `subprocess` call to a module you expose to agents:
```yaml
- run: any2mcp src/agent_tools.py --risk-report --format json
```
### Audit log
```bash
any2mcp tools.py --audit-log calls.jsonl
```
Every call is appended as one JSON object. **Argument names and types are recorded; values are not** — tool arguments routinely carry API keys and file contents, and a log that silently copied them to disk would be its own vulnerability. Opt in with `--audit-values` when you've decided that's appropriate for your data.
```jsonc
{"ts": "2026-07-28T05:45:14.604+00:00", "event": "blocked", "tool": "run_shell",
"risk": "high", "reason": "risk 'high' (subprocess) exceeds policy 'guard' (max 'moderate')"}
{"ts": "2026-07-28T05:45:14.612+00:00", "event": "call", "tool": "write_note",
"risk": "moderate", "duration_ms": 0.431, "ok": true,
"args": {"path": "str", "text": "str"}}
{"ts": "2026-07-28T05:45:14.614+00:00", "event": "call", "tool": "word_count",
"risk": "safe", "duration_ms": 0.003, "ok": true, "args": {"text": "str"}}
```
Blocked tools are recorded too, so the log shows what was withheld, not just what ran.
## Rich schemas, for free
`any2mcp` doesn't invent its own type system — it hands each function to the MCP SDK's pydantic machinery, the same code path the official `@tool` decorator uses. So the hard cases just work: `Optional[...]`, unions, `Enum`, `Literal`, `list`/`dict`/`tuple`, pydantic models and dataclasses as parameters, defaults, and `async def`.
```jsonc
// any2mcp calculator.py --list (excerpt for round_to)
{
"name": "round_to",
"input_schema": {
"properties": {
"value": { "type": "number" },
"ndigits": { "type": "integer", "default": 2 },
"mode": { "$ref": "#/$defs/Rounding", "default": "nearest" }
},
"$defs": { "Rounding": { "enum": ["up", "down", "nearest"], "type": "string" } },
"required": ["value"]
}
}
```
A function whose signature genuinely can't be schematized is skipped with a message on stderr — one unsupported function costs one tool, never the whole server.
## Use it with Claude Desktop (or any MCP client)
```jsonc
{
"mcpServers": {
"my-tools": {
"command": "any2mcp",
"args": ["/absolute/path/to/tools.py"]
}
}
}
```
Add `"--policy", "readonly"` to the `args` array to tighten it further.
## Which functions get exposed?
Resolved in this order:
1. A `:selector` (`tools.py:add`) exposes exactly that one function.
2. Otherwise, if the module defines `__all__`, that list is the allow-list.
3. Otherwise, every public function (no leading underscore) **defined in the module** — functions merely imported into it are skipped, so `from os.path import join` won't leak in as a tool.
`--include` / `--exclude` globs apply on top, then the policy decides what survives.
## Roadmap
- [ ] Policy file (`any2mcp.toml`) so per-tool decisions can be reviewed in git
- [ ] Expose class methods and `@staticmethod`s
- [ ] Resources & prompts, not just tools
- [ ] Taint tracking, to tell `read_file("/etc/passwd")` from `read_file(user_path)`
OpenAPI and FastAPI conversion are intentionally **out of scope** — [`fastmcp`](https://github.com/jlowin/fastmcp) already does those well. `any2mcp` focuses on the gap they leave: plain modules, zero decoration, and a defensible answer to "what did I just expose?"
## Development
```bash
uv sync
uv run pytest # 127 tests, incl. real MCP client sessions
uv run ruff check .
```
## License
MIT — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessUnresponsive