py2mcp 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ *.ipynb linguist-documentation
@@ -0,0 +1,256 @@
1
+ name: Continuous Integration (uv)
2
+ on: [push, pull_request]
3
+
4
+ # Note: Environment variables (PROJECT_NAME and vars from [tool.wads.ci.env])
5
+ # are set by the read-ci-config action in the setup job and made available
6
+ # to all subsequent jobs via GITHUB_ENV
7
+
8
+ jobs:
9
+ # First job: Read configuration from pyproject.toml
10
+ setup:
11
+ name: Read Configuration
12
+ runs-on: ubuntu-latest
13
+ outputs:
14
+ project-name: ${{ steps.config.outputs.project-name }}
15
+ python-versions: ${{ steps.config.outputs.python-versions }}
16
+ pytest-args: ${{ steps.config.outputs.pytest-args }}
17
+ coverage-enabled: ${{ steps.config.outputs.coverage-enabled }}
18
+ exclude-paths: ${{ steps.config.outputs.exclude-paths }}
19
+ test-on-windows: ${{ steps.config.outputs.test-on-windows }}
20
+ build-sdist: ${{ steps.config.outputs.build-sdist }}
21
+ build-wheel: ${{ steps.config.outputs.build-wheel }}
22
+ metrics-enabled: ${{ steps.config.outputs.metrics-enabled }}
23
+ metrics-config-path: ${{ steps.config.outputs.metrics-config-path }}
24
+ metrics-storage-branch: ${{ steps.config.outputs.metrics-storage-branch }}
25
+ metrics-python-version: ${{ steps.config.outputs.metrics-python-version }}
26
+ metrics-force-run: ${{ steps.config.outputs.metrics-force-run }}
27
+
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+
31
+ - name: Set up uv
32
+ uses: astral-sh/setup-uv@v5
33
+
34
+ - name: Set up Python
35
+ run: uv python install 3.11
36
+
37
+ - name: Read CI Config
38
+ id: config
39
+ uses: i2mint/wads/actions/read-ci-config@master
40
+ with:
41
+ pyproject-path: .
42
+
43
+ # Second job: Validation using the config
44
+ validation:
45
+ name: Validation
46
+ if: "!contains(github.event.head_commit.message, '[skip ci]')"
47
+ needs: setup
48
+ runs-on: ubuntu-latest
49
+ strategy:
50
+ matrix:
51
+ python-version: ${{ fromJson(needs.setup.outputs.python-versions) }}
52
+
53
+ steps:
54
+ - uses: actions/checkout@v4
55
+
56
+ - name: Set up uv
57
+ uses: astral-sh/setup-uv@v5
58
+ with:
59
+ enable-cache: true
60
+
61
+ - name: Set up Python ${{ matrix.python-version }}
62
+ run: |
63
+ uv python install ${{ matrix.python-version }}
64
+ uv venv --python ${{ matrix.python-version }}
65
+
66
+ - name: Install System Dependencies
67
+ uses: i2mint/wads/actions/install-system-deps@master
68
+ with:
69
+ pyproject-path: .
70
+
71
+ - name: Install Dependencies
72
+ run: |
73
+ source .venv/bin/activate
74
+ uv pip install -e ".[dev]"
75
+
76
+ - name: Format Source Code
77
+ run: uvx ruff format .
78
+
79
+ - name: Lint Validation
80
+ run: uvx ruff check --output-format=github ${{ needs.setup.outputs.project-name }}
81
+
82
+ - name: Run Tests
83
+ run: |
84
+ source .venv/bin/activate
85
+ PYTEST_ARGS="${{ needs.setup.outputs.pytest-args }}"
86
+ EXCLUDE="${{ needs.setup.outputs.exclude-paths }}"
87
+ COVERAGE="${{ needs.setup.outputs.coverage-enabled }}"
88
+ ROOT_DIR="${{ needs.setup.outputs.project-name }}"
89
+
90
+ CMD="python -m pytest"
91
+
92
+ # Add coverage flags
93
+ if [ "$COVERAGE" = "true" ]; then
94
+ uv pip install pytest-cov
95
+ CMD="$CMD --cov=$ROOT_DIR --cov-report=term-missing"
96
+ fi
97
+
98
+ # Add doctest flags
99
+ CMD="$CMD --doctest-modules"
100
+ CMD="$CMD -o doctest_optionflags='ELLIPSIS IGNORE_EXCEPTION_DETAIL'"
101
+
102
+ # Add exclude paths
103
+ if [ -n "$EXCLUDE" ]; then
104
+ IFS=',' read -ra PATHS <<< "$EXCLUDE"
105
+ for path in "${PATHS[@]}"; do
106
+ path=$(echo "$path" | xargs)
107
+ CMD="$CMD --ignore=$path"
108
+ done
109
+ fi
110
+
111
+ # Add extra pytest args
112
+ if [ -n "$PYTEST_ARGS" ]; then
113
+ CMD="$CMD $PYTEST_ARGS"
114
+ fi
115
+
116
+ echo "Running: $CMD"
117
+ eval $CMD
118
+
119
+ - name: Track Code Metrics
120
+ if: needs.setup.outputs.metrics-enabled == 'true'
121
+ uses: i2mint/umpyre/actions/track-metrics@master
122
+ continue-on-error: true
123
+ with:
124
+ github-token: ${{ secrets.GITHUB_TOKEN }}
125
+ config-path: ${{ needs.setup.outputs.metrics-config-path }}
126
+ storage-branch: ${{ needs.setup.outputs.metrics-storage-branch }}
127
+ python-version: ${{ needs.setup.outputs.metrics-python-version }}
128
+ force-run: ${{ needs.setup.outputs.metrics-force-run }}
129
+
130
+ # Optional Windows testing (if enabled in config)
131
+ windows-validation:
132
+ name: Windows Tests
133
+ if: "!contains(github.event.head_commit.message, '[skip ci]') && needs.setup.outputs.test-on-windows == 'true'"
134
+ needs: setup
135
+ runs-on: windows-latest
136
+ continue-on-error: true
137
+
138
+ steps:
139
+ - uses: actions/checkout@v4
140
+
141
+ - name: Set up uv
142
+ uses: astral-sh/setup-uv@v5
143
+ with:
144
+ enable-cache: true
145
+
146
+ - name: Set up Python
147
+ run: |
148
+ uv python install ${{ fromJson(needs.setup.outputs.python-versions)[0] }}
149
+ uv venv
150
+
151
+ - name: Install System Dependencies
152
+ uses: i2mint/wads/actions/install-system-deps@master
153
+ with:
154
+ pyproject-path: .
155
+
156
+ - name: Install Dependencies
157
+ run: |
158
+ .venv\Scripts\activate
159
+ uv pip install -e ".[dev]"
160
+
161
+ - name: Run tests
162
+ id: test
163
+ continue-on-error: true
164
+ run: |
165
+ .venv\Scripts\activate
166
+ pytest
167
+
168
+ - name: Report test results
169
+ if: always()
170
+ run: |
171
+ if ("${{ steps.test.outcome }}" -eq "failure") {
172
+ echo "::warning::Windows tests failed but workflow continues"
173
+ echo "## ⚠️ Windows Tests Failed" >> $env:GITHUB_STEP_SUMMARY
174
+ echo "Tests failed on Windows but this is informational only." >> $env:GITHUB_STEP_SUMMARY
175
+ } else {
176
+ echo "## ✅ Windows Tests Passed" >> $env:GITHUB_STEP_SUMMARY
177
+ }
178
+
179
+ # Publishing job
180
+ publish:
181
+ name: Publish
182
+ permissions:
183
+ contents: write
184
+ if: "!contains(github.event.head_commit.message, '[skip ci]') && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')"
185
+ needs: [setup, validation]
186
+ runs-on: ubuntu-latest
187
+
188
+ steps:
189
+ - uses: actions/checkout@v4
190
+ with:
191
+ fetch-depth: 0
192
+ token: ${{ secrets.GITHUB_TOKEN }}
193
+
194
+ - name: Set up uv
195
+ uses: astral-sh/setup-uv@v5
196
+
197
+ - name: Set up Python
198
+ run: uv python install ${{ fromJson(needs.setup.outputs.python-versions)[0] }}
199
+
200
+ - name: Format Source Code
201
+ run: uvx ruff format .
202
+
203
+ - name: Update Version Number
204
+ id: version
205
+ uses: i2mint/isee/actions/bump-version-number@master
206
+
207
+ - name: Build Distribution
208
+ run: |
209
+ BUILD_ARGS=""
210
+ if [ "${{ needs.setup.outputs.build-sdist }}" = "false" ]; then
211
+ BUILD_ARGS="$BUILD_ARGS --no-sdist"
212
+ fi
213
+ if [ "${{ needs.setup.outputs.build-wheel }}" = "false" ]; then
214
+ BUILD_ARGS="$BUILD_ARGS --no-wheel"
215
+ fi
216
+ uv build $BUILD_ARGS
217
+
218
+ - name: Publish to PyPI
219
+ env:
220
+ UV_PUBLISH_TOKEN: ${{ secrets.PYPI_PASSWORD }}
221
+ run: uv publish dist/*
222
+
223
+ - name: Force SSH for git remote
224
+ run: |
225
+ git remote set-url origin git@github.com:${{ github.repository }}.git
226
+
227
+ - name: Commit Changes
228
+ uses: i2mint/wads/actions/git-commit@master
229
+ with:
230
+ commit-message: "**CI** Formatted code + Updated version to ${{ env.VERSION }} [skip ci]"
231
+ ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
232
+ push: true
233
+
234
+ - name: Tag Repository
235
+ uses: i2mint/wads/actions/git-tag@master
236
+ with:
237
+ tag: ${{ env.VERSION }}
238
+ message: "Release version ${{ env.VERSION }}"
239
+ push: true
240
+
241
+ # Optional GitHub Pages
242
+ github-pages:
243
+ name: Publish GitHub Pages
244
+ permissions:
245
+ contents: write
246
+ pages: write
247
+ id-token: write
248
+ if: "!contains(github.event.head_commit.message, '[skip ci]') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)"
249
+ needs: publish
250
+ runs-on: ubuntu-latest
251
+
252
+ steps:
253
+ - uses: i2mint/epythet/actions/publish-github-pages@master
254
+ with:
255
+ github-token: ${{ secrets.GITHUB_TOKEN }}
256
+ ignore: "tests/,scrap/,examples/"
@@ -0,0 +1,121 @@
1
+ wads_configs.json
2
+ data/wads_configs.json
3
+ wads/data/wads_configs.json
4
+
5
+ # Byte-compiled / optimized / DLL files
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+
10
+
11
+ .DS_Store
12
+ # C extensions
13
+ *.so
14
+
15
+ # TLS certificates
16
+ ## Ignore all PEM files anywhere
17
+ *.pem
18
+ ## Also ignore any certs directory
19
+ certs/
20
+
21
+ # Distribution / packaging
22
+ .Python
23
+ build/
24
+ develop-eggs/
25
+ dist/
26
+ downloads/
27
+ eggs/
28
+ .eggs/
29
+ lib/
30
+ lib64/
31
+ parts/
32
+ sdist/
33
+ var/
34
+ wheels/
35
+ *.egg-info/
36
+ .installed.cfg
37
+ *.egg
38
+ MANIFEST
39
+ _build
40
+
41
+ # PyInstaller
42
+ # Usually these files are written by a python script from a template
43
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
44
+ *.manifest
45
+ *.spec
46
+
47
+ # Installer logs
48
+ pip-log.txt
49
+ pip-delete-this-directory.txt
50
+
51
+ # Unit test / coverage reports
52
+ htmlcov/
53
+ .tox/
54
+ .coverage
55
+ .coverage.*
56
+ .cache
57
+ nosetests.xml
58
+ coverage.xml
59
+ *.cover
60
+ .hypothesis/
61
+ .pytest_cache/
62
+
63
+ # Translations
64
+ *.mo
65
+ *.pot
66
+
67
+ # Django stuff:
68
+ *.log
69
+ local_settings.py
70
+ db.sqlite3
71
+
72
+ # Flask stuff:
73
+ instance/
74
+ .webassets-cache
75
+
76
+ # Scrapy stuff:
77
+ .scrapy
78
+
79
+ # Sphinx documentation
80
+ docs/_build/
81
+ docs/*
82
+
83
+ # PyBuilder
84
+ target/
85
+
86
+ # Jupyter Notebook
87
+ .ipynb_checkpoints
88
+
89
+ # pyenv
90
+ .python-version
91
+
92
+ # celery beat schedule file
93
+ celerybeat-schedule
94
+
95
+ # SageMath parsed files
96
+ *.sage.py
97
+
98
+ # Environments
99
+ .env
100
+ .venv
101
+ env/
102
+ venv/
103
+ ENV/
104
+ env.bak/
105
+ venv.bak/
106
+
107
+ # Spyder project settings
108
+ .spyderproject
109
+ .spyproject
110
+
111
+ # Rope project settings
112
+ .ropeproject
113
+
114
+ # mkdocs documentation
115
+ /site
116
+
117
+ # mypy
118
+ .mypy_cache/
119
+
120
+ # PyCharm
121
+ .idea
@@ -0,0 +1,346 @@
1
+ # py2mcp vs qh (py2http) - Pattern Comparison
2
+
3
+ This document shows how `py2mcp` follows the same design patterns as `qh` (the HTTP service builder), making it familiar and intuitive.
4
+
5
+ ## Core Pattern: Functions → Server
6
+
7
+ ### qh (HTTP)
8
+ ```python
9
+ from qh import mk_http_service_app
10
+
11
+ def add(a: int, b: int) -> int:
12
+ return a + b
13
+
14
+ app = mk_http_service_app([add])
15
+ app.run()
16
+ ```
17
+
18
+ ### py2mcp (MCP)
19
+ ```python
20
+ from py2mcp import mk_mcp_server
21
+
22
+ def add(a: int, b: int) -> int:
23
+ return a + b
24
+
25
+ mcp = mk_mcp_server([add])
26
+ mcp.run()
27
+ ```
28
+
29
+ **Identical pattern!** Pass functions, get a server.
30
+
31
+ ## Input Transformations
32
+
33
+ ### qh (HTTP)
34
+ ```python
35
+ from qh import mk_http_service_app
36
+ from qh.trans import mk_json_handler_from_name_mapping
37
+ import numpy as np
38
+
39
+ def add_arrays(a, b):
40
+ return (a + b).tolist()
41
+
42
+ input_trans = mk_json_handler_from_name_mapping({
43
+ 'a': np.array,
44
+ 'b': np.array
45
+ })
46
+
47
+ app = mk_http_service_app([add_arrays], input_trans=input_trans)
48
+ ```
49
+
50
+ ### py2mcp (MCP)
51
+ ```python
52
+ from py2mcp import mk_mcp_server, mk_input_trans
53
+ import numpy as np
54
+
55
+ def add_arrays(a, b):
56
+ return (a + b).tolist()
57
+
58
+ input_trans = mk_input_trans({
59
+ 'a': np.array,
60
+ 'b': np.array
61
+ })
62
+
63
+ mcp = mk_mcp_server([add_arrays], input_trans=input_trans)
64
+ ```
65
+
66
+ **Same transformation pattern!** Map parameter names to conversion functions.
67
+
68
+ ## Store/Mapping Dispatch
69
+
70
+ ### qh (HTTP) - Conceptual
71
+ ```python
72
+ # qh has store dispatch patterns (see scrap/store_dispatch_*.py)
73
+ # where a MutableMapping is exposed via HTTP endpoints
74
+
75
+ from qh.scrap.store_dispatch_1 import StoreAccess
76
+
77
+ store = StoreAccess.from_uri('test_uri')
78
+ # Exposes: list(), read(), write(), delete()
79
+ ```
80
+
81
+ ### py2mcp (MCP)
82
+ ```python
83
+ from py2mcp import mk_mcp_from_store
84
+
85
+ projects = {'proj1': {...}, 'proj2': {...}}
86
+
87
+ mcp = mk_mcp_from_store(projects, name='project')
88
+ # Automatically creates:
89
+ # - list_projects()
90
+ # - get_project(key)
91
+ # - set_project(key, value)
92
+ # - delete_project(key)
93
+ ```
94
+
95
+ **Same CRUD pattern!** Automatically generate operations from stores.
96
+
97
+ ## Architecture Parallels
98
+
99
+ | Aspect | qh (py2http) | py2mcp |
100
+ |--------|--------------|---------|
101
+ | **Foundation** | py2http (bottle/FastAPI) | FastMCP |
102
+ | **Philosophy** | Don't reinvent HTTP | Don't reinvent MCP |
103
+ | **Main Function** | `mk_http_service_app()` | `mk_mcp_server()` |
104
+ | **Transformation** | `mk_json_handler_from_name_mapping()` | `mk_input_trans()` |
105
+ | **Store Support** | Store dispatch examples | `mk_mcp_from_store()` |
106
+ | **Return Type** | HTTP app object | FastMCP object |
107
+ | **Run Method** | `app.run()` | `mcp.run()` |
108
+
109
+ ## Module Structure Comparison
110
+
111
+ ### qh Structure
112
+ ```
113
+ qh/
114
+ ├── __init__.py # Exports: mk_http_service_app
115
+ ├── main.py # Core app creation
116
+ ├── trans.py # Transformations
117
+ ├── util.py # Helpers (flat_callable_for, etc.)
118
+ └── scrap/ # WIP patterns
119
+ ```
120
+
121
+ ### py2mcp Structure
122
+ ```
123
+ py2mcp/
124
+ ├── __init__.py # Exports: mk_mcp_server, mk_input_trans, mk_mcp_from_store
125
+ ├── main.py # Core server creation
126
+ ├── trans.py # Transformations
127
+ ├── util.py # Helpers (store_to_funcs, etc.)
128
+ └── tests/ # Test suite
129
+ ```
130
+
131
+ **Nearly identical organization!**
132
+
133
+ ## Implementation Details
134
+
135
+ ### Function Flattening
136
+
137
+ Both packages handle methods and functions uniformly:
138
+
139
+ #### qh
140
+ ```python
141
+ # from qh.util import flat_callable_for
142
+ def flat_callable_for(func, func_name=None, cls=None):
143
+ """Flatten cls->instance->method call pipeline"""
144
+ containing_cls = get_class_that_defined_method(func)
145
+ if not containing_cls:
146
+ return func # Already flat
147
+ # ... flatten the method
148
+ ```
149
+
150
+ #### py2mcp
151
+ ```python
152
+ # from py2mcp.base import _normalize_to_iterable
153
+ def _normalize_to_iterable(funcs):
154
+ """Normalize input to an iterable of callables"""
155
+ if callable(funcs):
156
+ return [funcs]
157
+ elif isinstance(funcs, Iterable):
158
+ return list(funcs)
159
+ # ... validate
160
+ ```
161
+
162
+ ### Transformation Pipeline
163
+
164
+ Both use the same pattern: **name → function mapping**
165
+
166
+ #### qh
167
+ ```python
168
+ def transform_mapping_vals_with_name_func_map(mapping, name_func_map):
169
+ for name, val in mapping.items():
170
+ if name in name_func_map:
171
+ yield name, name_func_map[name](val)
172
+ else:
173
+ yield name, val
174
+ ```
175
+
176
+ #### py2mcp
177
+ ```python
178
+ def _apply_transformations(kwargs, name_func_map):
179
+ for name, value in kwargs.items():
180
+ if name in name_func_map:
181
+ yield name, name_func_map[name](value)
182
+ else:
183
+ yield name, value
184
+ ```
185
+
186
+ **Identical logic!**
187
+
188
+ ## Key Differences
189
+
190
+ While the patterns are the same, there are protocol differences:
191
+
192
+ | Feature | HTTP (qh) | MCP (py2mcp) |
193
+ |---------|-----------|--------------|
194
+ | **Transport** | HTTP endpoints | Stdio/HTTP/SSE |
195
+ | **Client** | curl, browsers | Claude, Cursor, MCP clients |
196
+ | **Method Types** | GET/POST/PUT/DELETE | Tools/Resources/Prompts |
197
+ | **Request Format** | JSON body, URL params | JSON-RPC |
198
+ | **Response Format** | JSON response | Structured tool results |
199
+
200
+ ## Usage Comparison
201
+
202
+ ### Starting a Server
203
+
204
+ #### qh
205
+ ```python
206
+ # HTTP server on port 8080
207
+ if __name__ == '__main__':
208
+ app.run(port=8080)
209
+ ```
210
+
211
+ Test with:
212
+ ```bash
213
+ curl -X POST http://localhost:8080/add \
214
+ -H "Content-Type: application/json" \
215
+ -d '{"a": 3, "b": 5}'
216
+ ```
217
+
218
+ #### py2mcp
219
+ ```python
220
+ # Stdio server (default) or HTTP
221
+ if __name__ == '__main__':
222
+ mcp.run() # stdio
223
+ # mcp.run(transport='http', port=8000) # http
224
+ ```
225
+
226
+ Test with:
227
+ ```bash
228
+ fastmcp dev server.py # Opens web inspector
229
+ ```
230
+
231
+ ### Function Requirements
232
+
233
+ Both require:
234
+ - Type hints (for schema generation)
235
+ - Docstrings (for documentation)
236
+ - JSON-serializable returns
237
+
238
+ ```python
239
+ # Works in both qh and py2mcp
240
+ def process_data(
241
+ text: str,
242
+ count: int = 10,
243
+ uppercase: bool = False
244
+ ) -> dict:
245
+ """Process text data.
246
+
247
+ Args:
248
+ text: Input text
249
+ count: Max length
250
+ uppercase: Convert to uppercase
251
+
252
+ Returns:
253
+ Processed result
254
+ """
255
+ result = text[:count]
256
+ if uppercase:
257
+ result = result.upper()
258
+ return {"result": result, "length": len(result)}
259
+ ```
260
+
261
+ ## Migration Guide: HTTP → MCP
262
+
263
+ If you have a qh HTTP service and want to make it an MCP server:
264
+
265
+ 1. **Change import**:
266
+ ```python
267
+ # from qh import mk_http_service_app
268
+ from py2mcp import mk_mcp_server
269
+ ```
270
+
271
+ 2. **Change function name**:
272
+ ```python
273
+ # app = mk_http_service_app([...])
274
+ mcp = mk_mcp_server([...])
275
+ ```
276
+
277
+ 3. **Update transformations** (if used):
278
+ ```python
279
+ # from qh.trans import mk_json_handler_from_name_mapping
280
+ from py2mcp import mk_input_trans
281
+
282
+ # input_trans = mk_json_handler_from_name_mapping({...})
283
+ input_trans = mk_input_trans({...})
284
+ ```
285
+
286
+ 4. **Change run call**:
287
+ ```python
288
+ # app.run(port=8080)
289
+ mcp.run() # or mcp.run(transport='http', port=8000)
290
+ ```
291
+
292
+ That's it! Your functions stay exactly the same.
293
+
294
+ ## When to Use Which?
295
+
296
+ ### Use qh (py2http) when:
297
+ - You need HTTP/REST endpoints
298
+ - Building a web API
299
+ - Want browser/curl access
300
+ - Integrating with HTTP clients
301
+
302
+ ### Use py2mcp when:
303
+ - Building AI agent tools
304
+ - Integrating with Claude/Cursor
305
+ - Want LLM-friendly interfaces
306
+ - Following MCP standard
307
+
308
+ ### Use both when:
309
+ - You want both HTTP and MCP access
310
+ - Maximum flexibility
311
+ - Different client types
312
+
313
+ ```python
314
+ from qh import mk_http_service_app
315
+ from py2mcp import mk_mcp_server
316
+
317
+ # Same functions, two servers!
318
+ funcs = [add, multiply, greet]
319
+
320
+ http_app = mk_http_service_app(funcs)
321
+ mcp_server = mk_mcp_server(funcs)
322
+ ```
323
+
324
+ ## Design Philosophy
325
+
326
+ Both packages follow the same principles:
327
+
328
+ 1. **Simplicity**: Minimal API surface
329
+ 2. **Convention over configuration**: Sensible defaults
330
+ 3. **Don't reinvent**: Build on proven frameworks
331
+ 4. **Pythonic**: Feels natural to Python developers
332
+ 5. **Functional**: Prefer functions over classes
333
+ 6. **SSOT**: Single source of truth (the functions)
334
+ 7. **Open/closed**: Easy to extend, works out of the box
335
+
336
+ ## Conclusion
337
+
338
+ `py2mcp` is essentially "qh for MCP" - the same clean, simple pattern applied to a different protocol. If you liked how qh worked, you'll feel right at home with py2mcp.
339
+
340
+ The key insight: **Functions are the universal interface.** Whether exposing them via HTTP or MCP, the pattern is the same:
341
+
342
+ ```
343
+ functions → wrapper → server → run
344
+ ```
345
+
346
+ Simple, clean, Pythonic. ✨