From 86154c2c6e811d2056e71fab0e2c949485e89eb5 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:58:46 +0100 Subject: [PATCH 1/3] build(ci): add wads uv-CI stub and complete the packaging metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo had a pyproject.toml but no CI at all — nothing tested or published it. Bring it onto the ecosystem standard. CI - Add .github/workflows/ci.yml as the 5-line wads stub calling the reusable workflow i2mint/wads/.github/workflows/uv-ci.yml@master, with the secrets pass-through rendered from [tool.wads.ci.env] (PYPI_PASSWORD only — this package needs no test-time secrets). - [tool.wads.ci] project_name set explicitly (was relying on the fallback); [tool.wads.ci.install] extras = "dev" so CI installs .[dev]; [tool.wads.ci.testing] python_versions = 3.10 / 3.12, coverage on. - [tool.wads.ci.publish] enabled = false. enlace_connector has never been published to PyPI under this name; first publication should be a deliberate decision, not a side effect of merging this PR. Same pattern already used by other unpublished packages in the ecosystem. testpaths - Was ["tests"]. wads CI runs `pytest --doctest-modules` with no path argument, so collection is driven entirely by testpaths — the package's own doctests would have run nowhere while CI still reported green. Now ["enlace_connector", "tests"]. Verified locally: package collection is clean (no hangs, no failures) and the suite is 19 passed. - doctest_optionflags now mirrors what wads' run-tests-uv forces in CI (ELLIPSIS, IGNORE_EXCEPTION_DETAIL) so local and CI agree. Packaging metadata - license: replaced the deprecated [project.license] table with the SPDX string form. Built wheel now carries License-Expression: Apache-2.0. - Added the matching Apache-2.0 LICENSE file (there was none), classifiers, and Repository / Documentation urls. Lint - [tool.ruff] gains the ecosystem-standard exclude list, google pydocstyle convention, per-file-ignores and ignore list. E501 moves to ignore (line length is the formatter's job, and CI runs an unpinned `uvx ruff`, so this is the main source of unrelated CI reds). The stricter select the repo already had (E, F, W, I, D100) is kept as-is. Dependencies were cross-checked with wads-deps and are correct as declared: py2mcp is a real (lazily imported) runtime dep, and uvicorn belongs in the `serve` extra because it is what the generated systemd unit / app.toml command invokes in the deployed connector's own venv. Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- .editorconfig | 17 ++++ .github/workflows/ci.yml | 48 ++++++++++ LICENSE | 201 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 76 +++++++++++++-- 4 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..88bf4d0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,toml,yml,yaml}] +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b58254c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +# wads CI — calls the reusable workflow hosted in i2mint/wads. +# +# All configuration comes from this repo's pyproject.toml [tool.wads.ci.*]. +# To customize the workflow itself (rare), replace this file with the +# full inline template `wads/data/github_ci_uv.yml` from i2mint/wads. +# +# Pinning: `@master` floats with wads. If you need version stability for +# a release-sensitive repo, change `@master` to a wads tag (e.g. `@v0.1.81`). +# CI failure does not block a published release — it blocks the publish +# step itself — so floating master is generally safe. +# +# Permissions: GitHub validates that the caller grants AT LEAST the +# permissions any job in the called workflow requests — at workflow-parse +# time, not at run-time, even if the job would be skipped via `if:`. +# The reusable workflow needs: +# contents: write for the publish job's version-bump push-back +# and for the github-pages job's gh-pages branch push +# pages: write for the github-pages job's REST API Pages config +# Both default to `write` on org-account GITHUB_TOKEN and need to be +# granted explicitly on personal-account callers (where the default is +# read-only). No `id-token: write` needed — the publish-github-pages +# action uses peaceiris/actions-gh-pages (branch-based) + REST API, +# not the OIDC `actions/deploy-pages` flow. +name: Continuous Integration +on: [push, pull_request] +jobs: + ci: + uses: i2mint/wads/.github/workflows/uv-ci.yml@master + permissions: + contents: write + pages: write + # Explicit pass-through (not `secrets: inherit`) because `inherit` does + # not reliably propagate caller-repo secrets to a reusable workflow owned + # by a different account (verified empirically: personal-account caller + + # i2mint-org workflow → `${{ secrets.PYPI_PASSWORD }}` resolved to empty). + # + # This list is the per-repo *transport*: it should contain PYPI_PASSWORD + # (for publishing) plus every secret your tests/CI need. It is generated + # from [tool.wads.ci.env] in pyproject.toml. To add one, run + # wads-secrets add VAR_NAME # updates pyproject + this block + # or just append a line below. *Which* of these become job env vars (and + # which are required) is controlled by [tool.wads.ci.env] — passing a + # secret here does not by itself put it in the environment. + # + # A secret name must also be declared in the reusable workflow's superset + # (wads/ci_secrets.py). `wads-secrets add` warns if it is not. + secrets: + PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/pyproject.toml b/pyproject.toml index 4b60037..5db5e91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,32 +8,96 @@ version = "0.0.1" description = "Deploy Python functions as authenticated MCP connectors on an enlace platform" readme = "README.md" requires-python = ">=3.10" +license = "Apache-2.0" keywords = ["enlace", "mcp", "connector", "claude", "py2mcp", "oauth"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Internet :: WWW/HTTP :: HTTP Servers", +] authors = [{ name = "Thor Whalen" }] dependencies = [ "py2mcp>=0.1.1", ] -[project.license] -text = "Apache-2.0" - [project.urls] Homepage = "https://github.com/i2mint/enlace_connector" +Repository = "https://github.com/i2mint/enlace_connector" +Documentation = "https://i2mint.github.io/enlace_connector" [project.optional-dependencies] serve = ["uvicorn"] dev = ["pytest"] -[tool.pytest.ini_options] -testpaths = ["tests"] +# ============================================================================ +# LINTING / FORMATTING +# ============================================================================ [tool.ruff] line-length = 88 target-version = "py310" +exclude = [ + "**/*.ipynb", + ".git", + ".venv", + "build", + "dist", + "tests", + "examples", + "scrap", +] [tool.ruff.lint] select = ["E", "F", "W", "I", "D100"] -ignore = ["D203"] +# E501 (line too long) is the formatter's business; D203 conflicts with D211. +ignore = ["D203", "E501", "B905"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"**/tests/*" = ["D"] +"**/examples/*" = ["D"] +"**/scrap/*" = ["D"] + +# ============================================================================ +# TESTING +# ============================================================================ + +[tool.pytest.ini_options] +minversion = "6.0" +# The package dir is listed on purpose: wads CI runs `pytest --doctest-modules` +# with no path argument, so collection is driven entirely by testpaths. With +# only ["tests"], the package's own doctests would never run anywhere. +testpaths = ["enlace_connector", "tests"] +# Mirrors what wads' run-tests-uv action forces in CI, so local == CI. +doctest_optionflags = ["ELLIPSIS", "IGNORE_EXCEPTION_DETAIL"] + +# ============================================================================ +# CI (read by the reusable workflow i2mint/wads/.github/workflows/uv-ci.yml) +# ============================================================================ [tool.wads.ci] +project_name = "enlace_connector" installer = "uv" + +[tool.wads.ci.install] +# CI installs `.[dev]` (pytest). `serve` holds the uvicorn that the *deployed* +# connector's own venv needs — not something CI has any use for. +extras = "dev" + +[tool.wads.ci.testing] +python_versions = ["3.10", "3.12"] +coverage_enabled = true + +[tool.wads.ci.publish] +# enlace_connector has never been published to PyPI under this name. First +# publication is a deliberate act, not a side effect of a merge — flip this to +# true when the maintainer decides to release it. +enabled = false From 25221a72e12633be3865d629b7bca15584684a4b Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:00:20 +0100 Subject: [PATCH 2/3] fix(scaffold): carry stateless_http into the generated server.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render_server_py` rebuilt the ConnectorSpec in the generated module from only name/tools/auth/title/route. `make_connector_app` also reads `spec.stateless_http` and passes it to py2mcp's `mk_http_app`, so a spec declared with `stateless_http=False` silently produced a *stateless* connector once deployed — the generated SPEC fell back to the field default. Nothing surfaced the discrepancy: the scaffold tests only checked that the module compiled and mentioned the tools. Emit the field, and document in the docstring which spec fields are serve-time (must round-trip through server.py) versus deploy-time (consumed by the provisioning bundle instead). Added a regression test covering both the non-default and the default value. Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- enlace_connector/scaffold.py | 10 +++++++++- tests/test_scaffold.py | 11 +++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/enlace_connector/scaffold.py b/enlace_connector/scaffold.py index bae509c..411d277 100644 --- a/enlace_connector/scaffold.py +++ b/enlace_connector/scaffold.py @@ -45,7 +45,14 @@ def render_app_toml( def render_server_py(spec: ConnectorSpec) -> str: - """Render the ``server.py`` that exposes ``app`` for enlace's process runner.""" + """Render the ``server.py`` that exposes ``app`` for enlace's process runner. + + The embedded ``SPEC`` carries every field :func:`~enlace_connector.connector. + make_connector_app` reads at serve time — tools, auth, name/title, route and + ``stateless_http``. Deploy-time-only fields (``extras``, ``git_installs``, + ``data``, ``env``, ``post_install``, ``allowed_users``) are deliberately + omitted: they are consumed by the provisioning bundle, not by the process. + """ return ( f'"""Auto-generated enlace connector app for {spec.name!r}.\n\n' "Built from a ConnectorSpec by enlace_connector. The platform origin (the\n" @@ -59,6 +66,7 @@ def render_server_py(spec: ConnectorSpec) -> str: f" auth={spec.auth!r},\n" f" title={spec.title!r},\n" f" route={spec.route!r},\n" + f" stateless_http={spec.stateless_http!r},\n" ")\n\n" f"_issuer = os.environ.get('CONNECTOR_ISSUER', {DFLT_PLATFORM_ORIGIN!r})\n" "app = make_connector_app(SPEC, issuer=_issuer)\n" diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index ca2e146..5836971 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -35,6 +35,17 @@ def test_render_server_py_embeds_spec_and_builds_app(): compile(src, "server.py", "exec") +def test_render_server_py_carries_stateless_http(): + """Every serve-time spec field must survive into the generated server.py. + + ``stateless_http`` used to be dropped, so a spec that deliberately asked for + a stateful transport silently got the (default) stateless one in production. + """ + stateful = ConnectorSpec(name="tp", tools=["m:f"], stateless_http=False) + assert "stateless_http=False" in render_server_py(stateful) + assert "stateless_http=True" in render_server_py(SPEC) # default preserved + + def test_scaffold_app_writes_both_files(tmp_path): out = scaffold_app(SPEC, tmp_path / "trufflepig_mcp", port=8030) assert out["app_toml"].exists() and out["server_py"].exists() From 8b1b645b9c6dd6789d23c091110904514edde4cb Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:05:46 +0100 Subject: [PATCH 3/3] test(deploy): make the exec-bit assertion POSIX-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new CI's Windows job surfaced this: `test_generate_deploy_bundle_ writes_all_artifacts` asserted `st_mode & 0o111` on the generated provision script, which can never hold on Windows — NTFS has no POSIX execute bit and `Path.chmod` there only toggles the read-only flag. The repo had no CI before, so nothing had ever run the suite on Windows. The production code is right as it stands: the provision script is a bash script that runs as root on the Linux server, and `chmod(0o755)` is the correct thing to do wherever the filesystem can express it. It is the *assertion* that is not portable, so it moves into its own test guarded by `skipif(os.name == "nt")` with the reason spelled out. While there, the bundle test now also checks that the returned path map matches the files actually written, instead of leaving `out` unused. Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- tests/test_deploy.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/test_deploy.py b/tests/test_deploy.py index e5020af..09b4003 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -1,5 +1,9 @@ """Tests for the deploy-bundle generators.""" +import os + +import pytest + from enlace_connector import ( ConnectorSpec, generate_deploy_bundle, @@ -83,10 +87,23 @@ def test_generate_deploy_bundle_writes_all_artifacts(tmp_path): "deploy/RUNBOOK.md", ): assert (tmp_path / "acme" / rel).exists(), rel - # provision script is executable - assert out["deploy/provision-acme.sh"].stat().st_mode & 0o111 + assert out[rel] == tmp_path / "acme" / rel, rel # returned map is accurate # server_py override is honored out2 = generate_deploy_bundle( SPEC, tmp_path / "acme2", server_py="# custom\napp = 1\n" ) assert out2["server.py"].read_text() == "# custom\napp = 1\n" + + +@pytest.mark.skipif( + os.name == "nt", + reason=( + "NTFS has no POSIX execute bit — Path.chmod on Windows only toggles the " + "read-only flag, so the mode assertion is meaningless there. The provision " + "script is a bash script that runs on the Linux server regardless of where " + "the bundle was generated." + ), +) +def test_provision_script_is_executable(tmp_path): + out = generate_deploy_bundle(SPEC, tmp_path / "acme") + assert out["deploy/provision-acme.sh"].stat().st_mode & 0o111