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.
Files changed (52) hide show
  1. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/.github/workflows/ci.yml +11 -6
  2. easy_mcp_kit-0.2.4/CHANGELOG.md +150 -0
  3. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/PKG-INFO +173 -3
  4. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/README.md +166 -2
  5. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/SECURITY.md +20 -2
  6. easy_mcp_kit-0.2.4/docs/Demo.mp4 +0 -0
  7. easy_mcp_kit-0.2.4/docs/client.py.png +0 -0
  8. easy_mcp_kit-0.2.4/docs/run_demo.py.png +0 -0
  9. easy_mcp_kit-0.2.4/docs/server.py.png +0 -0
  10. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/__init__.py +1 -2
  11. easy_mcp_kit-0.2.4/easy_mcp/_version.py +8 -0
  12. easy_mcp_kit-0.2.4/easy_mcp/cli.py +134 -0
  13. easy_mcp_kit-0.2.4/easy_mcp/connectors/__init__.py +20 -0
  14. easy_mcp_kit-0.2.4/easy_mcp/connectors/_cli.py +73 -0
  15. easy_mcp_kit-0.2.4/easy_mcp/connectors/github.py +474 -0
  16. easy_mcp_kit-0.2.4/easy_mcp/connectors/postgres.py +285 -0
  17. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/decorators.py +47 -4
  18. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/exceptions.py +3 -3
  19. easy_mcp_kit-0.2.4/easy_mcp/schema.py +531 -0
  20. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/security/auth.py +18 -11
  21. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/security/ratelimit.py +21 -1
  22. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/server.py +100 -14
  23. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/transport/_http.py +5 -2
  24. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/transport/sse.py +24 -5
  25. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/transport/stdio.py +5 -2
  26. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/transport/streamable_http.py +5 -2
  27. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/pyproject.toml +18 -1
  28. easy_mcp_kit-0.2.4/tests/test_cli.py +153 -0
  29. easy_mcp_kit-0.2.4/tests/test_connectors.py +351 -0
  30. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_dispatch.py +69 -1
  31. easy_mcp_kit-0.2.4/tests/test_pydantic.py +220 -0
  32. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_registration.py +57 -0
  33. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_schema.py +156 -0
  34. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_security.py +20 -0
  35. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_stdio.py +2 -1
  36. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/test_transport.py +14 -0
  37. easy_mcp_kit-0.2.4/uv.lock +598 -0
  38. easy_mcp_kit-0.2.2/docs/Demo.mp4 +0 -0
  39. easy_mcp_kit-0.2.2/docs/client.py.png +0 -0
  40. easy_mcp_kit-0.2.2/docs/run_demo.py.png +0 -0
  41. easy_mcp_kit-0.2.2/docs/server.py.png +0 -0
  42. easy_mcp_kit-0.2.2/easy_mcp/schema.py +0 -246
  43. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/.gitignore +0 -0
  44. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/LICENSE +0 -0
  45. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/logging.py +0 -0
  46. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/protocol.py +0 -0
  47. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/py.typed +0 -0
  48. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/security/__init__.py +0 -0
  49. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/transport/__init__.py +0 -0
  50. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/easy_mcp/transport/base.py +0 -0
  51. {easy_mcp_kit-0.2.2 → easy_mcp_kit-0.2.4}/tests/conftest.py +0 -0
  52. {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@v4
17
- - uses: actions/setup-python@v5
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@v4
39
- - uses: actions/setup-python@v5
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.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 # 95+ tests: schema, dispatch, security, Streamable HTTP, SSE, stdio
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 # 95+ tests: schema, dispatch, security, Streamable HTTP, SSE, stdio
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).