easy-mcp-kit 0.2.2__tar.gz → 0.2.4__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.
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/.github/workflows/ci.yml +11 -6
- easy_mcp_kit-0.2.4/CHANGELOG.md +150 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/PKG-INFO +173 -3
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/README.md +166 -2
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/SECURITY.md +20 -2
- easy_mcp_kit-0.2.4/docs/Demo.mp4 +0 -0
- easy_mcp_kit-0.2.4/docs/client.py.png +0 -0
- easy_mcp_kit-0.2.4/docs/run_demo.py.png +0 -0
- easy_mcp_kit-0.2.4/docs/server.py.png +0 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/__init__.py +1 -2
- easy_mcp_kit-0.2.4/easy_mcp/_version.py +8 -0
- easy_mcp_kit-0.2.4/easy_mcp/cli.py +134 -0
- easy_mcp_kit-0.2.4/easy_mcp/connectors/__init__.py +20 -0
- easy_mcp_kit-0.2.4/easy_mcp/connectors/_cli.py +73 -0
- easy_mcp_kit-0.2.4/easy_mcp/connectors/github.py +474 -0
- easy_mcp_kit-0.2.4/easy_mcp/connectors/postgres.py +285 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/decorators.py +47 -4
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/exceptions.py +3 -3
- easy_mcp_kit-0.2.4/easy_mcp/schema.py +531 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/security/auth.py +18 -11
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/security/ratelimit.py +21 -1
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/server.py +100 -14
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/transport/_http.py +5 -2
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/transport/sse.py +24 -5
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/transport/stdio.py +5 -2
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/transport/streamable_http.py +5 -2
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/pyproject.toml +18 -1
- easy_mcp_kit-0.2.4/tests/test_cli.py +153 -0
- easy_mcp_kit-0.2.4/tests/test_connectors.py +351 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_dispatch.py +69 -1
- easy_mcp_kit-0.2.4/tests/test_pydantic.py +220 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_registration.py +57 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_schema.py +156 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_security.py +20 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_stdio.py +2 -1
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_transport.py +14 -0
- easy_mcp_kit-0.2.4/uv.lock +598 -0
- easy_mcp_kit-0.2.2/docs/Demo.mp4 +0 -0
- easy_mcp_kit-0.2.2/docs/client.py.png +0 -0
- easy_mcp_kit-0.2.2/docs/run_demo.py.png +0 -0
- easy_mcp_kit-0.2.2/docs/server.py.png +0 -0
- easy_mcp_kit-0.2.2/easy_mcp/schema.py +0 -246
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/.gitignore +0 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/LICENSE +0 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/logging.py +0 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/protocol.py +0 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/py.typed +0 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/security/__init__.py +0 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/transport/__init__.py +0 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/transport/base.py +0 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/conftest.py +0 -0
- {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_streamable_http.py +0 -0
|
@@ -6,21 +6,26 @@ on:
|
|
|
6
6
|
tags: ["v*"]
|
|
7
7
|
pull_request:
|
|
8
8
|
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
9
12
|
jobs:
|
|
10
13
|
test:
|
|
11
14
|
runs-on: ubuntu-latest
|
|
12
15
|
strategy:
|
|
13
16
|
matrix:
|
|
14
|
-
python-version: ["3.11", "3.12", "3.13"]
|
|
17
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
15
18
|
steps:
|
|
16
|
-
- uses: actions/checkout@
|
|
17
|
-
- uses: actions/setup-python@
|
|
19
|
+
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
20
|
+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
|
18
21
|
with:
|
|
19
22
|
python-version: ${{ matrix.python-version }}
|
|
20
23
|
- name: Install
|
|
21
24
|
run: pip install -e .[dev]
|
|
22
25
|
- name: Lint
|
|
23
26
|
run: ruff check easy_mcp tests
|
|
27
|
+
- name: Type check
|
|
28
|
+
run: mypy easy_mcp
|
|
24
29
|
- name: Test
|
|
25
30
|
run: pytest -q
|
|
26
31
|
|
|
@@ -35,8 +40,8 @@ jobs:
|
|
|
35
40
|
permissions:
|
|
36
41
|
id-token: write
|
|
37
42
|
steps:
|
|
38
|
-
- uses: actions/checkout@
|
|
39
|
-
- uses: actions/setup-python@
|
|
43
|
+
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
44
|
+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
|
40
45
|
with:
|
|
41
46
|
python-version: "3.12"
|
|
42
47
|
- name: Build sdist and wheel
|
|
@@ -44,4 +49,4 @@ jobs:
|
|
|
44
49
|
pip install build
|
|
45
50
|
python -m build
|
|
46
51
|
- name: Publish to PyPI
|
|
47
|
-
uses: pypa/gh-action-pypi-publish@release/v1
|
|
52
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `easy-mcp-kit` are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/); the public API is not frozen until 1.0.
|
|
6
|
+
|
|
7
|
+
## [0.2.4] - 2026-09-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Parameter descriptions from `Annotated[T, "description"]`, alongside the
|
|
12
|
+
docstring's `Args:` section. The annotation sits next to the parameter, so it
|
|
13
|
+
cannot quietly stop applying when the parameter is renamed; where both exist,
|
|
14
|
+
the annotation wins. Nested forms such as `list[Annotated[int, "a row id"]]`
|
|
15
|
+
are described too.
|
|
16
|
+
|
|
17
|
+
- Structured tool results. A tool whose return annotation describes a JSON object
|
|
18
|
+
now publishes an `outputSchema` in `tools/list` and answers with
|
|
19
|
+
`structuredContent` alongside the existing text block, which the spec keeps for
|
|
20
|
+
older clients. `output_schema={...}` declares one by hand and `output_schema={}`
|
|
21
|
+
opts out.
|
|
22
|
+
|
|
23
|
+
Only object-shaped returns qualify, because `structuredContent` is a JSON
|
|
24
|
+
object; `-> str` and `-> list[int]` tools are untouched. A missing or
|
|
25
|
+
unsupported return annotation is not an error -- return types were never
|
|
26
|
+
validated before, so tools that have worked since 0.1 keep working, just
|
|
27
|
+
without a schema.
|
|
28
|
+
|
|
29
|
+
Results are validated against the schema before they are sent, since the spec
|
|
30
|
+
requires a server to honour the shape it advertised. A tool that breaks its own
|
|
31
|
+
contract fails the call with an `error_id`; the offending data goes to the log,
|
|
32
|
+
not to the client.
|
|
33
|
+
|
|
34
|
+
- Optional Pydantic v2 support for complex parameters and results, via the new
|
|
35
|
+
`easy-mcp-kit[pydantic]` extra. A parameter annotated with a model advertises
|
|
36
|
+
the model's own JSON Schema, constraints included, and reaches the tool as a
|
|
37
|
+
validated instance; a model return type becomes the `outputSchema`, and the
|
|
38
|
+
tool may return an instance or any dict the model accepts.
|
|
39
|
+
|
|
40
|
+
Pydantic validates the inside of a model rather than the built-in validator,
|
|
41
|
+
so a client sees every violation Pydantic finds instead of the first one a
|
|
42
|
+
weaker second copy of its rules would hit. Unknown top-level arguments are
|
|
43
|
+
still refused as before.
|
|
44
|
+
|
|
45
|
+
`easy_mcp` never imports Pydantic -- models are recognised by duck typing --
|
|
46
|
+
so projects that do not use it neither pay for the import nor install it.
|
|
47
|
+
|
|
48
|
+
Two cases are refused at registration: a model nested inside another type
|
|
49
|
+
(`list[User]`), and two different models sharing a class name in one tool.
|
|
50
|
+
Both would need `$defs` hoisted out of an ambiguous position.
|
|
51
|
+
|
|
52
|
+
- `easy-mcp run my_tools:server`, a command that imports a module and serves the
|
|
53
|
+
server it defines, so a module of `@server.tool` functions needs no `__main__`
|
|
54
|
+
block to be launchable. The target resolves from the current directory and
|
|
55
|
+
`my_tools.py:server` is accepted too; the attribute defaults to `server` and may
|
|
56
|
+
be a callable returning one. `--transport` picks stdio or HTTP per host, and
|
|
57
|
+
`--host`, `--port` and `--debug` override the server's own constructor arguments
|
|
58
|
+
only when given.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- An `Annotated` parameter's description no longer vanishes from the generated
|
|
63
|
+
schema. `get_type_hints()` strips annotation metadata unless asked not to, so
|
|
64
|
+
the text was silently dropped and clients saw an undocumented parameter.
|
|
65
|
+
|
|
66
|
+
## [0.2.3] - 2026-09-13
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
|
|
70
|
+
- Ready-made connectors, each a normal `MCPServer` built with `@server.tool` and
|
|
71
|
+
launchable with one command:
|
|
72
|
+
- **GitHub** (`easy-mcp-github`): repositories, issues, pull requests and file
|
|
73
|
+
contents through a `GITHUB_TOKEN` read from the environment, using only the
|
|
74
|
+
standard library for HTTP. Read-only by default; `--allow-write` adds
|
|
75
|
+
`create_issue` and `comment_on_issue`, gated by the `github:write` scope.
|
|
76
|
+
- **Postgres** (`easy-mcp-postgres`, extra `easy-mcp-kit[postgres]`): schema and
|
|
77
|
+
table discovery plus `query`, every statement in a `READ ONLY` transaction with
|
|
78
|
+
a statement timeout and a row cap; `DATABASE_URL` read from the environment.
|
|
79
|
+
|
|
80
|
+
### Fixed
|
|
81
|
+
|
|
82
|
+
- `mypy` passes again (`build_tool` typed the tool name loosely) and now runs in CI,
|
|
83
|
+
so type regressions cannot ship unnoticed.
|
|
84
|
+
- The sliding-window rate limiter forgets clients whose history has aged out of the
|
|
85
|
+
window, so a long-running public server no longer grows memory with every distinct
|
|
86
|
+
client it has ever seen.
|
|
87
|
+
- Opening a legacy SSE session (`GET /sse`) now spends the client's rate-limit budget
|
|
88
|
+
and answers `429` with `Retry-After` when it is exhausted. Previously an anonymous
|
|
89
|
+
client could fill `max_sessions` and lock everyone else out with `503`s.
|
|
90
|
+
|
|
91
|
+
### Changed
|
|
92
|
+
|
|
93
|
+
- API keys are compared as SHA-256 digests, so the constant-time check no longer
|
|
94
|
+
reveals a key's length either.
|
|
95
|
+
- Following JSON Schema, a number with no fractional part (`3.0`) is accepted for an
|
|
96
|
+
`int` parameter and reaches the tool as `int`. `validate_arguments` now returns the
|
|
97
|
+
normalized arguments instead of `None`.
|
|
98
|
+
- The package version lives in one place (`easy_mcp/_version.py`); `pyproject.toml`
|
|
99
|
+
reads it at build time and `MCPServer` reports it by default.
|
|
100
|
+
- `MCPServer.check_rate_limit()` is public so custom transports can charge the
|
|
101
|
+
budget for work that happens before a message exists.
|
|
102
|
+
- Transports are typed against `MCPServer` instead of `Any`.
|
|
103
|
+
- CI tests Python 3.14 and uses the current `actions/checkout` and
|
|
104
|
+
`actions/setup-python` releases.
|
|
105
|
+
|
|
106
|
+
## [0.2.2] - 2026-09-10
|
|
107
|
+
|
|
108
|
+
- Refreshed the demo video and code snapshots in `docs/`.
|
|
109
|
+
- Documentation describes MCP clients generically.
|
|
110
|
+
|
|
111
|
+
## [0.2.1] - 2026-09-10
|
|
112
|
+
|
|
113
|
+
### Added
|
|
114
|
+
|
|
115
|
+
- Streamable HTTP transport, the MCP spec's current HTTP transport: one `/mcp`
|
|
116
|
+
endpoint with `MCP-Session-Id` sessions, `DELETE` to end a session, idle-session
|
|
117
|
+
expiry, and an `Origin` allowlist (`allowed_origins`) against DNS rebinding.
|
|
118
|
+
`server.run()` now serves it by default with the legacy `/sse` + `/messages`
|
|
119
|
+
endpoints alongside.
|
|
120
|
+
- Protocol version negotiation for `2024-11-05` through `2025-11-25`, verified
|
|
121
|
+
against the official Python and TypeScript SDK clients.
|
|
122
|
+
|
|
123
|
+
## [0.2.0] - 2026-09-03
|
|
124
|
+
|
|
125
|
+
### Added
|
|
126
|
+
|
|
127
|
+
- stdio transport (`server.run("stdio")`, `StdioTransport`) for desktop MCP hosts
|
|
128
|
+
and local agents, with `EASY_MCP_STDIO_API_KEY` for protected tools and
|
|
129
|
+
`sys.stdout` redirected to stderr while serving so stray prints cannot corrupt the
|
|
130
|
+
protocol stream.
|
|
131
|
+
- Demo video, code snapshots, and status badges in the README.
|
|
132
|
+
|
|
133
|
+
## [0.1.0] - 2026-08-30
|
|
134
|
+
|
|
135
|
+
### Added
|
|
136
|
+
|
|
137
|
+
- Initial release: `@server.tool` registration with JSON Schema generated from type
|
|
138
|
+
hints and docstrings, strict argument validation, `APIKeyAuth` with per-tool
|
|
139
|
+
scopes and hidden protected tools, sliding-window rate limiting, payload caps,
|
|
140
|
+
per-tool timeouts and per-session call caps, sanitized errors with `error_id`
|
|
141
|
+
correlation, structured JSON logs with an audit trail, and the HTTP + SSE
|
|
142
|
+
transport.
|
|
143
|
+
|
|
144
|
+
[Unreleased]: https://github.com/Mark007-R/Easy-MCP/compare/v0.2.4...HEAD
|
|
145
|
+
[0.2.4]: https://github.com/Mark007-R/Easy-MCP/compare/v0.2.3...v0.2.4
|
|
146
|
+
[0.2.3]: https://github.com/Mark007-R/Easy-MCP/compare/v0.2.2...v0.2.3
|
|
147
|
+
[0.2.2]: https://github.com/Mark007-R/Easy-MCP/compare/v0.2.1...v0.2.2
|
|
148
|
+
[0.2.1]: https://github.com/Mark007-R/Easy-MCP/compare/v0.2.0...v0.2.1
|
|
149
|
+
[0.2.0]: https://github.com/Mark007-R/Easy-MCP/compare/v0.1.0...v0.2.0
|
|
150
|
+
[0.1.0]: https://github.com/Mark007-R/Easy-MCP/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: easy-mcp-kit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Build secure MCP (Model Context Protocol) servers from plain Python functions.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Mark007-R/Easy-MCP
|
|
6
6
|
Project-URL: Repository, https://github.com/Mark007-R/Easy-MCP
|
|
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
22
|
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
22
23
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
24
|
Classifier: Typing :: Typed
|
|
@@ -27,9 +28,14 @@ Requires-Dist: uvicorn>=0.30
|
|
|
27
28
|
Provides-Extra: dev
|
|
28
29
|
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
29
30
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
31
|
+
Requires-Dist: pydantic>=2.0; extra == 'dev'
|
|
30
32
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
31
33
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
34
|
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
35
|
+
Provides-Extra: postgres
|
|
36
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
37
|
+
Provides-Extra: pydantic
|
|
38
|
+
Requires-Dist: pydantic>=2.0; extra == 'pydantic'
|
|
33
39
|
Description-Content-Type: text/markdown
|
|
34
40
|
|
|
35
41
|
# easy_mcp
|
|
@@ -83,12 +89,15 @@ Requires Python 3.11+. Only two runtime dependencies: `starlette` and `uvicorn`.
|
|
|
83
89
|
| Concern | What you write | What easy_mcp does |
|
|
84
90
|
|---|---|---|
|
|
85
91
|
| Schemas | Type hints | Generates strict JSON Schema (`additionalProperties: false`) |
|
|
86
|
-
| Descriptions | Docstrings | Parses summary + Google-style `Args:` into tool/param descriptions |
|
|
92
|
+
| Descriptions | Docstrings or `Annotated` | Parses summary + Google-style `Args:` into tool/param descriptions; `Annotated[int, "..."]` documents a parameter in place |
|
|
87
93
|
| Validation | Nothing | Rejects unknown fields, wrong types, missing params — before your code runs |
|
|
94
|
+
| Structured results | A return type | Publishes `outputSchema` and answers with `structuredContent` |
|
|
95
|
+
| Rich schemas | A Pydantic model (optional) | The model's own schema and validation, for parameters and results |
|
|
88
96
|
| Auth | `auth=APIKeyAuth({...})` | Constant-time key checks, per-tool scopes, hidden protected tools |
|
|
89
97
|
| Rate limits | `rate_limit_per_minute=120` | Sliding-window limiter per client |
|
|
90
98
|
| Errors | Just `raise` | Clients get a sanitized message + `error_id`; the log gets the traceback |
|
|
91
99
|
| Crashes | Nothing | One failing tool never takes down the server |
|
|
100
|
+
| Launching | Nothing | `easy-mcp run my_tools:server` serves a module on any transport |
|
|
92
101
|
|
|
93
102
|
## Quickstart tour
|
|
94
103
|
|
|
@@ -105,6 +114,14 @@ def word_count(text: str) -> dict[str, int]:
|
|
|
105
114
|
"""
|
|
106
115
|
return {"words": len(text.split()), "characters": len(text)}
|
|
107
116
|
|
|
117
|
+
# Or document a parameter where it is declared, instead of in the docstring.
|
|
118
|
+
# Annotated is plain typing (from typing import Annotated) and the tool still
|
|
119
|
+
# receives an int; where both exist, the annotation wins.
|
|
120
|
+
@server.tool
|
|
121
|
+
def tail_log(lines: Annotated[int, "How many lines to return, newest first"]) -> list[str]:
|
|
122
|
+
"""Read the end of the log."""
|
|
123
|
+
...
|
|
124
|
+
|
|
108
125
|
# With options:
|
|
109
126
|
@server.tool(name="summarize", tags=("stats",), category="math",
|
|
110
127
|
examples=({"arguments": {"values": [1, 2, 3]}},), timeout=5.0)
|
|
@@ -137,6 +154,85 @@ server.unregister_tool("late_tool")
|
|
|
137
154
|
Anything else is rejected **at registration time** with a clear error — never
|
|
138
155
|
at call time. Validation is strict: booleans are not integers, unknown
|
|
139
156
|
arguments are hard errors, and every violation is reported (not just the first).
|
|
157
|
+
Following JSON Schema, a number with no fractional part (`3.0`) is a valid
|
|
158
|
+
integer; it reaches your function as `int`, so `range(times)` never sees a float.
|
|
159
|
+
|
|
160
|
+
### Structured results
|
|
161
|
+
|
|
162
|
+
A tool whose return annotation describes a JSON object publishes an
|
|
163
|
+
`outputSchema`, and its results carry `structuredContent` so clients get typed
|
|
164
|
+
data instead of a string they have to parse:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
@server.tool
|
|
168
|
+
def weather(city: str) -> dict[str, float]:
|
|
169
|
+
"""Get current weather."""
|
|
170
|
+
return {"temperature": 22.5, "humidity": 65}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```jsonc
|
|
174
|
+
// tools/call result
|
|
175
|
+
{
|
|
176
|
+
"content": [{"type": "text", "text": "{\"humidity\": 65, \"temperature\": 22.5}"}],
|
|
177
|
+
"structuredContent": {"humidity": 65, "temperature": 22.5},
|
|
178
|
+
"isError": false
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The text block stays for clients that predate structured content — the spec
|
|
183
|
+
asks for both, and they always hold the same data.
|
|
184
|
+
|
|
185
|
+
MCP carries structured content as a JSON *object*, so only object-shaped
|
|
186
|
+
returns get a schema; `-> str` and `-> list[int]` tools are unchanged. A
|
|
187
|
+
missing or unsupported return annotation is not an error, it just means no
|
|
188
|
+
schema. Pass `output_schema={...}` to declare one yourself, or
|
|
189
|
+
`output_schema={}` to advertise none.
|
|
190
|
+
|
|
191
|
+
Because the schema is a promise to clients, results are checked against it
|
|
192
|
+
before they are sent. A tool that breaks its own contract fails the call with
|
|
193
|
+
an `error_id` rather than shipping data that does not match.
|
|
194
|
+
|
|
195
|
+
### Pydantic models (optional)
|
|
196
|
+
|
|
197
|
+
For a parameter too complex for a plain type hint, annotate it with a Pydantic
|
|
198
|
+
v2 model. Install it with `pip install "easy-mcp-kit[pydantic]"`; easy_mcp
|
|
199
|
+
never imports Pydantic itself, so projects that skip it pay nothing.
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
class Address(BaseModel):
|
|
203
|
+
city: str
|
|
204
|
+
zip_code: str | None = None
|
|
205
|
+
|
|
206
|
+
class User(BaseModel):
|
|
207
|
+
name: str = Field(min_length=1)
|
|
208
|
+
age: int
|
|
209
|
+
home: Address
|
|
210
|
+
|
|
211
|
+
@server.tool
|
|
212
|
+
def save_user(user: User) -> Saved:
|
|
213
|
+
"""Save a user.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
user: The person to store.
|
|
217
|
+
"""
|
|
218
|
+
return Saved(id=7, label=user.name)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Clients receive the model's own JSON Schema — constraints like `minLength`
|
|
222
|
+
included — and the tool receives a validated `User` instance, not a dict. A
|
|
223
|
+
model return type becomes the `outputSchema`, and the tool may return either an
|
|
224
|
+
instance or any dict the model accepts.
|
|
225
|
+
|
|
226
|
+
Pydantic does the validating inside a model rather than the built-in validator,
|
|
227
|
+
so you get every error it finds instead of the first one a weaker second copy
|
|
228
|
+
of its rules would hit. The outer guarantees are unchanged: unknown top-level
|
|
229
|
+
arguments are still a hard error.
|
|
230
|
+
|
|
231
|
+
Two limits worth knowing. A model must be a whole parameter or return type —
|
|
232
|
+
`list[User]` is refused at registration, because a model brings `$defs` and
|
|
233
|
+
hoisting those out of an arbitrary nesting depth is ambiguous; wrap it in a
|
|
234
|
+
model instead. And two different models that share a class name in one tool are
|
|
235
|
+
refused for the same reason: their definitions would collide.
|
|
140
236
|
|
|
141
237
|
### Transports: Streamable HTTP, SSE, or stdio
|
|
142
238
|
|
|
@@ -197,6 +293,28 @@ Authentication works the same way as over SSE, except the credential is the
|
|
|
197
293
|
`StdioTransport(server, api_key=...)`) instead of a header. An invalid key
|
|
198
294
|
fails at startup rather than silently downgrading to anonymous access.
|
|
199
295
|
|
|
296
|
+
### Launching from the command line
|
|
297
|
+
|
|
298
|
+
A module of `@server.tool` functions does not need a `__main__` block to be
|
|
299
|
+
runnable:
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
easy-mcp run my_tools:server # Streamable HTTP on the server's own host/port
|
|
303
|
+
easy-mcp run my_tools --transport stdio # attribute defaults to "server"
|
|
304
|
+
easy-mcp run my_tools:server --host 0.0.0.0 --port 9000
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The target is `module:attribute`, resolved from the current directory;
|
|
308
|
+
`my_tools.py:server` works too. The attribute may be a server or a callable
|
|
309
|
+
returning one, so a factory that reads configuration at startup is fine.
|
|
310
|
+
|
|
311
|
+
`--host`, `--port` and `--debug` override the server's own constructor
|
|
312
|
+
arguments, and only when given — the transport is the one thing you usually
|
|
313
|
+
want to vary per host, since a desktop MCP client wants `stdio` where
|
|
314
|
+
everything else wants HTTP.
|
|
315
|
+
|
|
316
|
+
Importing a module runs it, so point this only at code you trust.
|
|
317
|
+
|
|
200
318
|
### Authentication and per-tool permissions
|
|
201
319
|
|
|
202
320
|
```python
|
|
@@ -301,6 +419,50 @@ asyncio.run(main())
|
|
|
301
419
|
Stdio servers are started by the MCP host itself, from a config entry like the
|
|
302
420
|
one in [Transports](#transports-streamable-http-sse-or-stdio).
|
|
303
421
|
|
|
422
|
+
## Ready-made connectors
|
|
423
|
+
|
|
424
|
+
Two servers ship with the package. They are built on the same `@server.tool`
|
|
425
|
+
decorator you use, so everything above (validation, scopes, rate limits,
|
|
426
|
+
timeouts, sanitized errors, audit log) applies to them unchanged.
|
|
427
|
+
|
|
428
|
+
| Connector | Command | Credential | Tools |
|
|
429
|
+
|---|---|---|---|
|
|
430
|
+
| GitHub | `easy-mcp-github` | `GITHUB_TOKEN` (optional; public data without it) | `list_repos`, `get_repo`, `list_issues`, `get_issue`, `list_pull_requests`, `get_pull_request`, `get_file`, and with `--allow-write`: `create_issue`, `comment_on_issue` |
|
|
431
|
+
| Postgres | `easy-mcp-postgres` | `DATABASE_URL` | `list_schemas`, `list_tables`, `describe_table`, `query` |
|
|
432
|
+
|
|
433
|
+
```bash
|
|
434
|
+
pip install "easy-mcp-kit[postgres]" # the GitHub connector needs no extra
|
|
435
|
+
|
|
436
|
+
GITHUB_TOKEN=github_pat_... easy-mcp-github --transport stdio
|
|
437
|
+
DATABASE_URL=postgresql://user:pass@host/db easy-mcp-postgres --port 8011
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Both take `--transport {http,sse,stdio}`, `--host`, `--port`, `--rate-limit`
|
|
441
|
+
and `--debug`, and load API keys from `EASY_MCP_API_KEYS` when it is set.
|
|
442
|
+
`python -m easy_mcp.connectors.github` and `... .postgres` work as well, and
|
|
443
|
+
each module's `build_server(...)` returns a normal `MCPServer` for embedding.
|
|
444
|
+
|
|
445
|
+
**GitHub** is read-only by default. `--allow-write` registers `create_issue`
|
|
446
|
+
and `comment_on_issue`, which are gated by the `github:write` scope: only a
|
|
447
|
+
client presenting a key that holds it can see or call them, and starting
|
|
448
|
+
with `--allow-write` but no keys is refused. The token is sent only to
|
|
449
|
+
`GITHUB_API_URL` (default `https://api.github.com`) and never appears in logs
|
|
450
|
+
or errors. Use a fine-grained token scoped to the repositories you need.
|
|
451
|
+
|
|
452
|
+
```bash
|
|
453
|
+
export EASY_MCP_API_KEYS="a-long-random-key:github:write" # key : scope
|
|
454
|
+
easy-mcp-github --allow-write --transport stdio # stdio: EASY_MCP_STDIO_API_KEY=a-long-random-key
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
**Postgres** runs every statement in a `READ ONLY` transaction
|
|
458
|
+
(`default_transaction_read_only=on` is set for the session, so a query cannot
|
|
459
|
+
turn it off) with a statement timeout (`--statement-timeout`, default 10 s)
|
|
460
|
+
and a row cap (`--max-rows`, default 500; `query` returns `truncated: true`
|
|
461
|
+
when it hit the cap). Writes are rejected by the database itself, not by
|
|
462
|
+
parsing SQL. Still connect with a dedicated role holding only `SELECT`
|
|
463
|
+
grants: a read-only transaction does not stop side-effecting functions that
|
|
464
|
+
role is allowed to call.
|
|
465
|
+
|
|
304
466
|
## Architecture
|
|
305
467
|
|
|
306
468
|
```
|
|
@@ -317,6 +479,10 @@ easy_mcp/
|
|
|
317
479
|
│ ├── streamable_http.py Streamable HTTP transport (/mcp, sessions)
|
|
318
480
|
│ ├── sse.py legacy HTTP + SSE transport (Starlette/uvicorn)
|
|
319
481
|
│ └── stdio.py stdin/stdout transport (desktop MCP hosts, local agents)
|
|
482
|
+
├── connectors/
|
|
483
|
+
│ ├── _cli.py shared --transport/--host/--port options
|
|
484
|
+
│ ├── github.py GitHub connector (stdlib HTTP; read-only unless --allow-write)
|
|
485
|
+
│ └── postgres.py Postgres connector (psycopg; READ ONLY, timeout, row cap)
|
|
320
486
|
├── protocol.py supported MCP protocol versions + negotiation
|
|
321
487
|
├── exceptions.py error hierarchy + stable JSON-RPC error codes
|
|
322
488
|
└── logging.py JSON logs + audit trail
|
|
@@ -353,10 +519,14 @@ prefer returning compact structures over huge strings.
|
|
|
353
519
|
|
|
354
520
|
```bash
|
|
355
521
|
pip install -e .[dev]
|
|
356
|
-
pytest #
|
|
522
|
+
pytest # 100+ tests: schema, dispatch, security, Streamable HTTP, SSE, stdio
|
|
357
523
|
ruff check .
|
|
524
|
+
mypy easy_mcp
|
|
358
525
|
```
|
|
359
526
|
|
|
527
|
+
CI runs the same three commands on Python 3.11 through 3.14. Releases are
|
|
528
|
+
listed in [CHANGELOG.md](CHANGELOG.md).
|
|
529
|
+
|
|
360
530
|
## License
|
|
361
531
|
|
|
362
532
|
MIT — see [LICENSE](LICENSE).
|
|
@@ -49,12 +49,15 @@ Requires Python 3.11+. Only two runtime dependencies: `starlette` and `uvicorn`.
|
|
|
49
49
|
| Concern | What you write | What easy_mcp does |
|
|
50
50
|
|---|---|---|
|
|
51
51
|
| Schemas | Type hints | Generates strict JSON Schema (`additionalProperties: false`) |
|
|
52
|
-
| Descriptions | Docstrings | Parses summary + Google-style `Args:` into tool/param descriptions |
|
|
52
|
+
| Descriptions | Docstrings or `Annotated` | Parses summary + Google-style `Args:` into tool/param descriptions; `Annotated[int, "..."]` documents a parameter in place |
|
|
53
53
|
| Validation | Nothing | Rejects unknown fields, wrong types, missing params — before your code runs |
|
|
54
|
+
| Structured results | A return type | Publishes `outputSchema` and answers with `structuredContent` |
|
|
55
|
+
| Rich schemas | A Pydantic model (optional) | The model's own schema and validation, for parameters and results |
|
|
54
56
|
| Auth | `auth=APIKeyAuth({...})` | Constant-time key checks, per-tool scopes, hidden protected tools |
|
|
55
57
|
| Rate limits | `rate_limit_per_minute=120` | Sliding-window limiter per client |
|
|
56
58
|
| Errors | Just `raise` | Clients get a sanitized message + `error_id`; the log gets the traceback |
|
|
57
59
|
| Crashes | Nothing | One failing tool never takes down the server |
|
|
60
|
+
| Launching | Nothing | `easy-mcp run my_tools:server` serves a module on any transport |
|
|
58
61
|
|
|
59
62
|
## Quickstart tour
|
|
60
63
|
|
|
@@ -71,6 +74,14 @@ def word_count(text: str) -> dict[str, int]:
|
|
|
71
74
|
"""
|
|
72
75
|
return {"words": len(text.split()), "characters": len(text)}
|
|
73
76
|
|
|
77
|
+
# Or document a parameter where it is declared, instead of in the docstring.
|
|
78
|
+
# Annotated is plain typing (from typing import Annotated) and the tool still
|
|
79
|
+
# receives an int; where both exist, the annotation wins.
|
|
80
|
+
@server.tool
|
|
81
|
+
def tail_log(lines: Annotated[int, "How many lines to return, newest first"]) -> list[str]:
|
|
82
|
+
"""Read the end of the log."""
|
|
83
|
+
...
|
|
84
|
+
|
|
74
85
|
# With options:
|
|
75
86
|
@server.tool(name="summarize", tags=("stats",), category="math",
|
|
76
87
|
examples=({"arguments": {"values": [1, 2, 3]}},), timeout=5.0)
|
|
@@ -103,6 +114,85 @@ server.unregister_tool("late_tool")
|
|
|
103
114
|
Anything else is rejected **at registration time** with a clear error — never
|
|
104
115
|
at call time. Validation is strict: booleans are not integers, unknown
|
|
105
116
|
arguments are hard errors, and every violation is reported (not just the first).
|
|
117
|
+
Following JSON Schema, a number with no fractional part (`3.0`) is a valid
|
|
118
|
+
integer; it reaches your function as `int`, so `range(times)` never sees a float.
|
|
119
|
+
|
|
120
|
+
### Structured results
|
|
121
|
+
|
|
122
|
+
A tool whose return annotation describes a JSON object publishes an
|
|
123
|
+
`outputSchema`, and its results carry `structuredContent` so clients get typed
|
|
124
|
+
data instead of a string they have to parse:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
@server.tool
|
|
128
|
+
def weather(city: str) -> dict[str, float]:
|
|
129
|
+
"""Get current weather."""
|
|
130
|
+
return {"temperature": 22.5, "humidity": 65}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```jsonc
|
|
134
|
+
// tools/call result
|
|
135
|
+
{
|
|
136
|
+
"content": [{"type": "text", "text": "{\"humidity\": 65, \"temperature\": 22.5}"}],
|
|
137
|
+
"structuredContent": {"humidity": 65, "temperature": 22.5},
|
|
138
|
+
"isError": false
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The text block stays for clients that predate structured content — the spec
|
|
143
|
+
asks for both, and they always hold the same data.
|
|
144
|
+
|
|
145
|
+
MCP carries structured content as a JSON *object*, so only object-shaped
|
|
146
|
+
returns get a schema; `-> str` and `-> list[int]` tools are unchanged. A
|
|
147
|
+
missing or unsupported return annotation is not an error, it just means no
|
|
148
|
+
schema. Pass `output_schema={...}` to declare one yourself, or
|
|
149
|
+
`output_schema={}` to advertise none.
|
|
150
|
+
|
|
151
|
+
Because the schema is a promise to clients, results are checked against it
|
|
152
|
+
before they are sent. A tool that breaks its own contract fails the call with
|
|
153
|
+
an `error_id` rather than shipping data that does not match.
|
|
154
|
+
|
|
155
|
+
### Pydantic models (optional)
|
|
156
|
+
|
|
157
|
+
For a parameter too complex for a plain type hint, annotate it with a Pydantic
|
|
158
|
+
v2 model. Install it with `pip install "easy-mcp-kit[pydantic]"`; easy_mcp
|
|
159
|
+
never imports Pydantic itself, so projects that skip it pay nothing.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
class Address(BaseModel):
|
|
163
|
+
city: str
|
|
164
|
+
zip_code: str | None = None
|
|
165
|
+
|
|
166
|
+
class User(BaseModel):
|
|
167
|
+
name: str = Field(min_length=1)
|
|
168
|
+
age: int
|
|
169
|
+
home: Address
|
|
170
|
+
|
|
171
|
+
@server.tool
|
|
172
|
+
def save_user(user: User) -> Saved:
|
|
173
|
+
"""Save a user.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
user: The person to store.
|
|
177
|
+
"""
|
|
178
|
+
return Saved(id=7, label=user.name)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Clients receive the model's own JSON Schema — constraints like `minLength`
|
|
182
|
+
included — and the tool receives a validated `User` instance, not a dict. A
|
|
183
|
+
model return type becomes the `outputSchema`, and the tool may return either an
|
|
184
|
+
instance or any dict the model accepts.
|
|
185
|
+
|
|
186
|
+
Pydantic does the validating inside a model rather than the built-in validator,
|
|
187
|
+
so you get every error it finds instead of the first one a weaker second copy
|
|
188
|
+
of its rules would hit. The outer guarantees are unchanged: unknown top-level
|
|
189
|
+
arguments are still a hard error.
|
|
190
|
+
|
|
191
|
+
Two limits worth knowing. A model must be a whole parameter or return type —
|
|
192
|
+
`list[User]` is refused at registration, because a model brings `$defs` and
|
|
193
|
+
hoisting those out of an arbitrary nesting depth is ambiguous; wrap it in a
|
|
194
|
+
model instead. And two different models that share a class name in one tool are
|
|
195
|
+
refused for the same reason: their definitions would collide.
|
|
106
196
|
|
|
107
197
|
### Transports: Streamable HTTP, SSE, or stdio
|
|
108
198
|
|
|
@@ -163,6 +253,28 @@ Authentication works the same way as over SSE, except the credential is the
|
|
|
163
253
|
`StdioTransport(server, api_key=...)`) instead of a header. An invalid key
|
|
164
254
|
fails at startup rather than silently downgrading to anonymous access.
|
|
165
255
|
|
|
256
|
+
### Launching from the command line
|
|
257
|
+
|
|
258
|
+
A module of `@server.tool` functions does not need a `__main__` block to be
|
|
259
|
+
runnable:
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
easy-mcp run my_tools:server # Streamable HTTP on the server's own host/port
|
|
263
|
+
easy-mcp run my_tools --transport stdio # attribute defaults to "server"
|
|
264
|
+
easy-mcp run my_tools:server --host 0.0.0.0 --port 9000
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The target is `module:attribute`, resolved from the current directory;
|
|
268
|
+
`my_tools.py:server` works too. The attribute may be a server or a callable
|
|
269
|
+
returning one, so a factory that reads configuration at startup is fine.
|
|
270
|
+
|
|
271
|
+
`--host`, `--port` and `--debug` override the server's own constructor
|
|
272
|
+
arguments, and only when given — the transport is the one thing you usually
|
|
273
|
+
want to vary per host, since a desktop MCP client wants `stdio` where
|
|
274
|
+
everything else wants HTTP.
|
|
275
|
+
|
|
276
|
+
Importing a module runs it, so point this only at code you trust.
|
|
277
|
+
|
|
166
278
|
### Authentication and per-tool permissions
|
|
167
279
|
|
|
168
280
|
```python
|
|
@@ -267,6 +379,50 @@ asyncio.run(main())
|
|
|
267
379
|
Stdio servers are started by the MCP host itself, from a config entry like the
|
|
268
380
|
one in [Transports](#transports-streamable-http-sse-or-stdio).
|
|
269
381
|
|
|
382
|
+
## Ready-made connectors
|
|
383
|
+
|
|
384
|
+
Two servers ship with the package. They are built on the same `@server.tool`
|
|
385
|
+
decorator you use, so everything above (validation, scopes, rate limits,
|
|
386
|
+
timeouts, sanitized errors, audit log) applies to them unchanged.
|
|
387
|
+
|
|
388
|
+
| Connector | Command | Credential | Tools |
|
|
389
|
+
|---|---|---|---|
|
|
390
|
+
| GitHub | `easy-mcp-github` | `GITHUB_TOKEN` (optional; public data without it) | `list_repos`, `get_repo`, `list_issues`, `get_issue`, `list_pull_requests`, `get_pull_request`, `get_file`, and with `--allow-write`: `create_issue`, `comment_on_issue` |
|
|
391
|
+
| Postgres | `easy-mcp-postgres` | `DATABASE_URL` | `list_schemas`, `list_tables`, `describe_table`, `query` |
|
|
392
|
+
|
|
393
|
+
```bash
|
|
394
|
+
pip install "easy-mcp-kit[postgres]" # the GitHub connector needs no extra
|
|
395
|
+
|
|
396
|
+
GITHUB_TOKEN=github_pat_... easy-mcp-github --transport stdio
|
|
397
|
+
DATABASE_URL=postgresql://user:pass@host/db easy-mcp-postgres --port 8011
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Both take `--transport {http,sse,stdio}`, `--host`, `--port`, `--rate-limit`
|
|
401
|
+
and `--debug`, and load API keys from `EASY_MCP_API_KEYS` when it is set.
|
|
402
|
+
`python -m easy_mcp.connectors.github` and `... .postgres` work as well, and
|
|
403
|
+
each module's `build_server(...)` returns a normal `MCPServer` for embedding.
|
|
404
|
+
|
|
405
|
+
**GitHub** is read-only by default. `--allow-write` registers `create_issue`
|
|
406
|
+
and `comment_on_issue`, which are gated by the `github:write` scope: only a
|
|
407
|
+
client presenting a key that holds it can see or call them, and starting
|
|
408
|
+
with `--allow-write` but no keys is refused. The token is sent only to
|
|
409
|
+
`GITHUB_API_URL` (default `https://api.github.com`) and never appears in logs
|
|
410
|
+
or errors. Use a fine-grained token scoped to the repositories you need.
|
|
411
|
+
|
|
412
|
+
```bash
|
|
413
|
+
export EASY_MCP_API_KEYS="a-long-random-key:github:write" # key : scope
|
|
414
|
+
easy-mcp-github --allow-write --transport stdio # stdio: EASY_MCP_STDIO_API_KEY=a-long-random-key
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
**Postgres** runs every statement in a `READ ONLY` transaction
|
|
418
|
+
(`default_transaction_read_only=on` is set for the session, so a query cannot
|
|
419
|
+
turn it off) with a statement timeout (`--statement-timeout`, default 10 s)
|
|
420
|
+
and a row cap (`--max-rows`, default 500; `query` returns `truncated: true`
|
|
421
|
+
when it hit the cap). Writes are rejected by the database itself, not by
|
|
422
|
+
parsing SQL. Still connect with a dedicated role holding only `SELECT`
|
|
423
|
+
grants: a read-only transaction does not stop side-effecting functions that
|
|
424
|
+
role is allowed to call.
|
|
425
|
+
|
|
270
426
|
## Architecture
|
|
271
427
|
|
|
272
428
|
```
|
|
@@ -283,6 +439,10 @@ easy_mcp/
|
|
|
283
439
|
│ ├── streamable_http.py Streamable HTTP transport (/mcp, sessions)
|
|
284
440
|
│ ├── sse.py legacy HTTP + SSE transport (Starlette/uvicorn)
|
|
285
441
|
│ └── stdio.py stdin/stdout transport (desktop MCP hosts, local agents)
|
|
442
|
+
├── connectors/
|
|
443
|
+
│ ├── _cli.py shared --transport/--host/--port options
|
|
444
|
+
│ ├── github.py GitHub connector (stdlib HTTP; read-only unless --allow-write)
|
|
445
|
+
│ └── postgres.py Postgres connector (psycopg; READ ONLY, timeout, row cap)
|
|
286
446
|
├── protocol.py supported MCP protocol versions + negotiation
|
|
287
447
|
├── exceptions.py error hierarchy + stable JSON-RPC error codes
|
|
288
448
|
└── logging.py JSON logs + audit trail
|
|
@@ -319,10 +479,14 @@ prefer returning compact structures over huge strings.
|
|
|
319
479
|
|
|
320
480
|
```bash
|
|
321
481
|
pip install -e .[dev]
|
|
322
|
-
pytest #
|
|
482
|
+
pytest # 100+ tests: schema, dispatch, security, Streamable HTTP, SSE, stdio
|
|
323
483
|
ruff check .
|
|
484
|
+
mypy easy_mcp
|
|
324
485
|
```
|
|
325
486
|
|
|
487
|
+
CI runs the same three commands on Python 3.11 through 3.14. Releases are
|
|
488
|
+
listed in [CHANGELOG.md](CHANGELOG.md).
|
|
489
|
+
|
|
326
490
|
## License
|
|
327
491
|
|
|
328
492
|
MIT — see [LICENSE](LICENSE).
|