From 7f6ea8c8b2a20f60833ec42b0500a2a5a594ec4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:33:16 +0200 Subject: [PATCH 1/4] Document Packagist installation and test the public package as a consumer --- .github/workflows/installed.yml | 8 +- .github/workflows/packagist.yml | 55 ++++++++ CHANGELOG.md | 8 ++ README.md | 161 +++++++++++++++++++--- composer.json | 10 +- docs/IMPLEMENTATION.md | 10 +- docs/RELEASE.md | 45 ++++++- tests/consumer-server.mjs | 93 +++++++++++++ tests/consumer.php | 231 ++++++++++++++++++++++++++++++++ tests/packagist.sh | 109 +++++++++++++++ 10 files changed, 697 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/packagist.yml create mode 100644 tests/consumer-server.mjs create mode 100644 tests/consumer.php create mode 100644 tests/packagist.sh diff --git a/.github/workflows/installed.yml b/.github/workflows/installed.yml index 735a626..2e9f33e 100644 --- a/.github/workflows/installed.yml +++ b/.github/workflows/installed.yml @@ -8,10 +8,10 @@ on: inputs: component_ref: type: string - default: feature/jcb-mcp-runtime + default: main plugin_ref: type: string - default: feature/jcb-mcp-runtime + default: main permissions: contents: read @@ -44,13 +44,13 @@ jobs: - uses: actions/checkout@v7 with: repository: joomengine/mcp_component - ref: ${{ inputs.component_ref || (github.ref_name == 'main' && 'main' || 'feature/jcb-mcp-runtime') }} + ref: ${{ inputs.component_ref || 'main' }} path: component persist-credentials: false - uses: actions/checkout@v7 with: repository: joomengine/mcp_plugin - ref: ${{ inputs.plugin_ref || (github.ref_name == 'main' && 'main' || 'feature/jcb-mcp-runtime') }} + ref: ${{ inputs.plugin_ref || 'main' }} path: plugin persist-credentials: false - uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/packagist.yml b/.github/workflows/packagist.yml new file mode 100644 index 0000000..531fcc0 --- /dev/null +++ b/.github/workflows/packagist.yml @@ -0,0 +1,55 @@ +name: Packagist consumer + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + inputs: + version: + description: 'Packagist constraint: auto selects a stable release, or dev-main before the first release' + default: auto + required: false + type: string + +permissions: + contents: read + +concurrency: + group: packagist-consumer-${{ github.ref }} + cancel-in-progress: true + +jobs: + consumer: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + php: ['8.3', '8.4'] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, json, fileinfo, posix, pcntl + tools: composer:v2 + coverage: none + - name: Install the public Packagist package and test it as an independent consumer + env: + MCP_PACKAGE_VERSION: ${{ inputs.version || 'auto' }} + PACKAGIST_REPORT_PATH: ${{ github.workspace }}/build/packagist/consumer.json + run: | + set -euo pipefail + mkdir -p build/packagist + bash tests/packagist.sh "$MCP_PACKAGE_VERSION" 2>&1 | tee build/packagist/consumer.log + - name: Preserve the resolved package and consumer evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: packagist-consumer-php-${{ matrix.php }} + path: build/packagist/ + if-no-files-found: error + retention-days: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index d32692b..76fec9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased — Packagist distribution + +- Link the registered `joomengine/mcp-client` package, live development-version/download badges, release history and CI from the README. +- Add Composer-first local and global installation, AI stdio launcher setup and a complete PHP SDK discovery example. +- Document indexed `dev-main` availability, semantic-version releases and Packagist synchronization without claiming an unissued stable release. +- Add package discovery/support metadata and PHP 8.3/8.4 consumer checks that download the actual Packagist distribution into a clean project and exercise its generated executable and SDK against local trusted HTTPS. +- Use the component and plugin `main` branches for installed interoperability checks after their original feature branches were merged and deleted. + ## Unreleased - Establish independent ownership of the external PHP MCP client and remote stdio bridge. diff --git a/README.md b/README.md index 489a30e..f3ec1ed 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,183 @@ # JoomEngine MCP Client +[![Packagist development version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpackagist.org%2Fpackages%2Fjoomengine%2Fmcp-client.json&query=%24.package.versions%5B%27dev-main%27%5D.version&label=Packagist&color=orange)](https://packagist.org/packages/joomengine/mcp-client) +[![Total downloads](https://img.shields.io/packagist/dt/joomengine/mcp-client)](https://packagist.org/packages/joomengine/mcp-client/stats) +[![PHP requirement](https://img.shields.io/badge/PHP-%5E8.3-777BB4)](composer.json) +[![License](https://img.shields.io/badge/license-GPL--3.0--or--later-blue)](LICENSE) +[![PHP client contracts](https://github.com/joomengine/mcp_client/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/joomengine/mcp_client/actions/workflows/ci.yml) +[![Packagist consumer](https://github.com/joomengine/mcp_client/actions/workflows/packagist.yml/badge.svg?branch=main)](https://github.com/joomengine/mcp_client/actions/workflows/packagist.yml) + Standalone PHP client and remote stdio bridge for an installed JoomEngine MCP server. -**Composer package:** `joomengine/mcp-client` -**Namespace:** `VDM\Joomla\Mcp\Client` -**Executable:** `joomengine-mcp` +**[Packagist package](https://packagist.org/packages/joomengine/mcp-client)** · **[Releases](https://github.com/joomengine/mcp_client/releases)** · **[Changelog](CHANGELOG.md)** · **[Issues](https://github.com/joomengine/mcp_client/issues)** -Requires PHP 8.3+, cURL and JSON. Persistent named-site configuration also requires POSIX ownership support (`ext-posix`). `ext-pcntl` enables orderly signal handling. No local Joomla installation, component classes or copied Joomla/JCB catalogue are needed. +| Interface | Name | +| --- | --- | +| Composer package | `joomengine/mcp-client` | +| PHP namespace | `VDM\Joomla\Mcp\Client` | +| Executable | `joomengine-mcp` | -## Run with Docker Compose +Requires PHP 8.3+, cURL, JSON and [Composer 2](https://getcomposer.org/download/). Persistent named-site configuration also requires POSIX ownership support (`ext-posix`). `ext-pcntl` enables orderly signal handling. No local Joomla installation is needed. The target HTTPS site must have the [JoomEngine MCP component](https://github.com/joomengine/mcp_component) installed and enabled; use that site's Joomla API token for a user with the necessary permissions. The [console plugin](https://github.com/joomengine/mcp_plugin) is only needed for direct local Joomla console serving. -Docker Engine and Compose v2 provide the complete PHP runtime. Build once from this checkout, then enter the installed site's HTTPS base URL and its Joomla API token: +## Install from Packagist + +The package is registered on Packagist. As of 24 September 2026, the available version is **`dev-main`**; no tagged stable release has been published. Install the indexed development version in your PHP project or an empty working directory: + +```bash +composer require joomengine/mcp-client:dev-main +composer check-platform-reqs +./vendor/bin/joomengine-mcp help +``` + +Packagist is Composer's default repository, so no custom repository configuration or Git checkout is required. The explicit `dev-main` constraint allows this package's development version without lowering your project's overall `minimum-stability`. Keep your application's `composer.lock` to reproduce the resolved commit and dependencies. `composer update joomengine/mcp-client --with-dependencies` updates it deliberately. + +Once a stable version appears on the [package page](https://packagist.org/packages/joomengine/mcp-client) and in [GitHub Releases](https://github.com/joomengine/mcp_client/releases), new installations can use `composer require joomengine/mcp-client` to select a compatible stable release. An existing `dev-main` requirement must be changed to a tagged release constraint when you want to stop tracking development. [Release instructions](docs/RELEASE.md) explain versioning and distribution checks. + +### Connect an AI application + +For a direct stdio connection, supply the site's HTTPS base URL and token separately. This Bash example prompts for both without putting the token in command history: ```bash -docker compose build --pull mcp read -r -p 'Joomla HTTPS base URL: ' JOOMENGINE_MCP_URL read -r -s -p 'Joomla API token: ' JOOMENGINE_MCP_TOKEN printf '\n' export JOOMENGINE_MCP_URL JOOMENGINE_MCP_TOKEN -docker compose run --rm --no-deps -T mcp +./vendor/bin/joomengine-mcp connect ``` -The last command is a stdio MCP connection for an AI application's MCP launcher. Configure that application to execute `docker`, passing `compose`, `--file`, the absolute path to this checkout's `compose.yaml`, `run`, `--rm`, `--no-deps`, `-T`, and `mcp` as separate arguments. Supply `JOOMENGINE_MCP_URL` and `JOOMENGINE_MCP_TOKEN` through the application's environment or secret settings. The connection retains the Joomla user's HTTP permissions and discovers the installed capabilities automatically. +The final command waits for MCP JSON-RPC on stdin and writes protocol replies to stdout. Configure your AI application's MCP launcher as follows: -The container runs without root privileges, with a read-only filesystem, no published ports and no mounted Joomla directory. It contacts the component over HTTPS; it does not require the console plugin. See [Docker operation and private CA configuration](docs/DOCKER.md). +| Setting | Value | +| --- | --- | +| Transport | `stdio` | +| Command | The absolute path to `vendor/bin/joomengine-mcp` in your project | +| Arguments | `connect` | +| Environment | `JOOMENGINE_MCP_URL` and `JOOMENGINE_MCP_TOKEN`, supplied through the application's environment or secret settings | -## Run from a development checkout +Run `realpath vendor/bin/joomengine-mcp` on Linux to obtain the launcher command. On Windows, use Composer's generated `vendor/bin/joomengine-mcp.bat` launcher. The client discovers tools, resources and prompts from the authenticated server; the AI application does not need a copied Joomla/JCB catalogue. An explicit URL argument to `connect` overrides `JOOMENGINE_MCP_URL`. + +To install the executable globally instead of in a project: + +```bash +composer global require joomengine/mcp-client:dev-main +composer global config bin-dir --absolute +``` + +Add the directory printed by the second command to your `PATH`, or give the AI launcher the absolute executable path in that directory. Then `joomengine-mcp connect` uses the same URL/token environment variables. Project-local installations use `./vendor/bin/joomengine-mcp`; a global executable is only available as a bare command when its directory is on `PATH`. + +### Save a named site + +On POSIX systems with `ext-posix`, the installed executable can save credentials in a private configuration file: ```bash -composer install -composer test read -r -p 'Joomla HTTPS base URL: ' JOOMENGINE_MCP_SITE read -r -s -p 'Joomla API token: ' JOOMENGINE_MCP_TOKEN printf '\n' export JOOMENGINE_MCP_TOKEN -php bin/joomengine-mcp configure production "$JOOMENGINE_MCP_SITE" +./vendor/bin/joomengine-mcp configure production "$JOOMENGINE_MCP_SITE" unset JOOMENGINE_MCP_TOKEN -php bin/joomengine-mcp serve production +./vendor/bin/joomengine-mcp serve production ``` -The final command speaks newline-delimited MCP JSON-RPC on stdin/stdout; launch it from your MCP application's stdio configuration. For a Composer-installed executable, use `joomengine-mcp serve production`. The `configure` command saves the site URL and separately supplied token in an owner-only configuration file; it never accepts a token in command-line arguments. `sites` lists names and URLs, and `remove NAME` removes one saved site. Configuration messages and all diagnostics go to stderr. +For an AI launcher, use the same absolute executable path with separate arguments `serve` and `production`. `sites` lists saved names and URLs; `remove NAME` removes a saved site. Configuration messages and diagnostics go to stderr. Tokens are never accepted as command-line arguments. By default named sites are stored beneath `$XDG_CONFIG_HOME/joomengine-mcp`, or `$HOME/.config/joomengine-mcp` when XDG is unset. `JOOMENGINE_MCP_CONFIG_DIR` selects an explicit private directory. Directories must be mode 0700 and credential files mode 0600; unsafe owners, links and shared writable paths are rejected. Writes are locked and atomic. The file is plaintext protected by filesystem permissions, not encrypted storage. -For an ephemeral connection use `joomengine-mcp connect HTTPS_BASE_URL` with `JOOMENGINE_MCP_TOKEN` in the environment, or `joomengine-mcp connect` with both `JOOMENGINE_MCP_URL` and `JOOMENGINE_MCP_TOKEN` set. An explicit URL argument takes precedence. `php examples/discover.php HTTPS_BASE_URL` runs the PHP SDK discovery example. +## Run with Docker Compose + +Docker Engine and Compose v2 provide the complete PHP runtime. Clone this repository using the commands in [Run from a development checkout](#run-from-a-development-checkout), then build once from that directory and enter the installed site's HTTPS base URL and its Joomla API token: + +```bash +docker compose build --pull mcp +read -r -p 'Joomla HTTPS base URL: ' JOOMENGINE_MCP_URL +read -r -s -p 'Joomla API token: ' JOOMENGINE_MCP_TOKEN +printf '\n' +export JOOMENGINE_MCP_URL JOOMENGINE_MCP_TOKEN +docker compose run --rm --no-deps -T mcp +``` + +The last command is a stdio MCP connection for an AI application's MCP launcher. Configure that application to execute `docker`, passing `compose`, `--file`, the absolute path to this checkout's `compose.yaml`, `run`, `--rm`, `--no-deps`, `-T`, and `mcp` as separate arguments. Supply `JOOMENGINE_MCP_URL` and `JOOMENGINE_MCP_TOKEN` through the application's environment or secret settings. The connection retains the Joomla user's HTTP permissions and discovers the installed capabilities automatically. + +The container runs without root privileges, with a read-only filesystem, no published ports and no mounted Joomla directory. It contacts the component over HTTPS; it does not require the console plugin. See [Docker operation and private CA configuration](docs/DOCKER.md). + +## Run from a development checkout + +```bash +git clone https://github.com/joomengine/mcp_client.git +cd mcp_client +composer install +composer test +php bin/joomengine-mcp help +``` + +In a checkout, use `php bin/joomengine-mcp` with the same connection/configuration commands described above. With the URL/token environment variables set, `php examples/discover.php "$JOOMENGINE_MCP_URL"` runs the PHP SDK discovery example. ## PHP API +After installing the package with Composer, save this as `discover.php` in your project's root and run `php discover.php` with `JOOMENGINE_MCP_URL` and `JOOMENGINE_MCP_TOKEN` set as above: + ```php +connect(Connection::fromEnvironment($siteUrl)); +require __DIR__ . '/vendor/autoload.php'; + +$siteUrl = getenv('JOOMENGINE_MCP_URL'); + +if (!is_string($siteUrl) || $siteUrl === '') +{ + fwrite(STDERR, "Set JOOMENGINE_MCP_URL and JOOMENGINE_MCP_TOKEN before running discovery.\n"); + exit(2); +} + +$client = null; +$status = 0; try { - $tools = $client->listTools(); + $client = (new ClientFactory())->connect(Connection::fromEnvironment($siteUrl)); + $cursor = null; + $seen = []; + + do + { + $result = $client->listTools($cursor); + + foreach ($result->tools as $tool) + { + echo $tool->name . PHP_EOL; + } + + $cursor = $result->nextCursor; + + if ($cursor !== null) + { + if (isset($seen[$cursor]) || count($seen) >= 100) + { + throw new RuntimeException('Discovery pagination exceeded its bound.'); + } + + $seen[$cursor] = true; + } + } + while ($cursor !== null); +} +catch (Throwable) +{ + fwrite(STDERR, "MCP discovery failed. Check the URL, token, component installation and Joomla permissions.\n"); + $status = 1; } finally { - $client->disconnect(); + if ($client !== null) + { + $client->disconnect(); + } } + +exit($status); ``` The returned official SDK client supports tools, resources, resource templates and prompts. Preserve `nextCursor` when listing multiple pages and use each discovered input schema when constructing arguments. `Configuration\SiteStore::connection($name)` loads the same `Connection` used by the executable. @@ -79,4 +196,6 @@ Joomla and JCB operations, confirmation grants, durable jobs, cancellation and a `composer test` runs SDK contracts, secure configuration checks, CLI/process framing checks and a real HTTPS fixture. Tests require Node.js and OpenSSL in addition to PHP. `bash tests/container.sh` builds the actual Docker image and tests Compose against the TLS fixture on a Linux Docker engine. Both packaging and PHP contracts run on PHP 8.3/8.4 in CI. The installed interoperability workflow builds and installs the actual component/plugin and tests both this library and this executable over trusted HTTPS. [Implementation evidence](docs/IMPLEMENTATION.md) distinguishes these layers. -See [the server contract](docs/SERVER-CONTRACT.md) and [release instructions](docs/RELEASE.md). This development branch does not itself publish a Composer package or register it on Packagist. +`composer test:packagist` installs the publicly indexed package into a clean Composer project, exercises Composer's generated executable and uses the installed PHP API against a local trusted HTTPS fixture. It verifies distribution, autoloading and protocol behaviour without a Joomla site or real credentials. Use `composer test:packagist -- dev-main` to select the development version explicitly. This consumer test runs independently of checkout tests in the [Packagist consumer workflow](https://github.com/joomengine/mcp_client/actions/workflows/packagist.yml); its logs record the version and source reference actually installed. On a pull request it tests the existing Packagist package, while `composer test` tests the proposed source. It does not certify installed Joomla business operations. + +See [the server contract](docs/SERVER-CONTRACT.md), [release instructions](docs/RELEASE.md) and the [Packagist listing](https://packagist.org/packages/joomengine/mcp-client). diff --git a/composer.json b/composer.json index 342252b..e1fb384 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,15 @@ { "name": "joomengine/mcp-client", "description": "Standalone PHP client for database-driven JoomEngine MCP servers", + "keywords": ["joomla", "joomengine", "mcp", "model-context-protocol", "client", "stdio"], + "homepage": "https://github.com/joomengine/mcp_client", "type": "library", "license": "GPL-3.0-or-later", + "support": { + "issues": "https://github.com/joomengine/mcp_client/issues", + "source": "https://github.com/joomengine/mcp_client", + "docs": "https://github.com/joomengine/mcp_client/blob/main/README.md" + }, "require": { "php": "^8.3", "ext-curl": "*", @@ -22,7 +29,8 @@ "php tests/cli.php", "php tests/bridge.php", "bash tests/http.sh" - ] + ], + "test:packagist": "bash tests/packagist.sh" }, "config": { "allow-plugins": { diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 142fb08..e17e5d7 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1,6 +1,6 @@ # Implementation status — 24 September 2026 -The standalone PHP client and remote stdio bridge are implemented on `feature/standalone-php-client` / [PR #1](https://github.com/joomengine/mcp_client/pull/1). Component and console-plugin business logic remain in their own repositories. The PR records current check results and review status; the [component acceptance checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349) records coordinated Joomla/JCB execution through the installed server and this generic bridge. +The standalone PHP client and remote stdio bridge from [PR #1](https://github.com/joomengine/mcp_client/pull/1) are merged into `main`. Component and console-plugin business logic remain in their own repositories. [Repository workflows](https://github.com/joomengine/mcp_client/actions) record current checks; the [component acceptance checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349) records coordinated Joomla/JCB execution through the installed server and this generic bridge. Implemented: @@ -14,15 +14,19 @@ Implemented: - PHP 8.3/8.4 SDK/configuration/process/TLS CI and installed component interoperability CI. - PHP 8.3/8.4 image builds and real Compose-to-HTTPS protocol checks, including clean EOF/session deletion and failed authentication. - Manually triggered main-only stable/prerelease workflow gated on both test layers; tags are never overwritten. +- Composer-first installation, project-local/global executable setup, complete PHP discovery example and linked Packagist/version/download badges. +- PHP 8.3/8.4 consumer workflow that downloads the indexed Packagist package into a clean project and tests the installed SDK and generated executable against trusted local HTTPS. Evidence is emitted by each test suite and retained by CI. `tests/run.php` uses the actual PHP SDK with recording HTTP substitutes. `tests/cli.php` checks safe executable startup failures and environment/argument precedence. `tests/bridge.php` launches a real bridge process with a deterministic transport. `tests/http.sh` creates a private test CA and real HTTPS server, verifies transport limits/TLS failures and launches the public executable using only URL/token environment configuration. `tests/container.sh` builds the Docker image and exercises the delivered Compose service against that HTTPS fixture; it requires a Linux Docker engine and does not silently skip missing Docker. `tests/live.php` uses actual installed Joomla HTTP and remote stdio, discovers the server catalogue, executes discovered reads and rejects invalid credentials. Missing live test configuration fails instead of reporting a skipped pass. An installed CI success is tied to the exact component, plugin and client revisions recorded in its artifact. Do not infer production approval, JCB write acceptance or publication from isolated client checks. The component repository owns its full write/job/JCB acceptance matrix. -Packagist registration is a one-time distribution setup after the reviewed package metadata reaches main. No repository code can truthfully assert that an external Packagist account has registered this package. No merge, release or publication is performed by the implementation PR. +Packagist registration is verified through the public [`joomengine/mcp-client` metadata](https://packagist.org/packages/joomengine/mcp-client.json). On 24 September 2026 it indexed `dev-main` from this repository at `8691e3556254e314f3a106fb933756cf502e4780`; no tagged version was present. Registration is distinct from a stable release and does not prove automatic webhook synchronization. [Release documentation](RELEASE.md) covers synchronization checks and first-release verification. + +`composer test:packagist` tests the public distribution and reports its installed version and source reference; CI retains the report and log under `build/packagist/`. It uses Composer's generated binary proxy and the dependency's autoloader, with no local path override. A local HTTPS fixture supplies disposable credentials and protocol responses, so these checks need no Joomla site. On a PR this test covers the indexed package, while source contracts cover the PR commit. Package tests do not claim installed Joomla/JCB execution or external account configuration. Verified runtime source: `1ebb989f6ae92eacfc3251c82f69f7c5ffd5118c`. PHP 8.3/8.4 contract and Docker Compose CI passed on both [push run 35983558847](https://github.com/joomengine/mcp_client/actions/runs/35983558847) and [PR run 35983565554](https://github.com/joomengine/mcp_client/actions/runs/35983565554). Each matrix includes 25 process-level bridge checks, including complete-frame/EOF byte limits for whitespace and JSON, and nine actual built-image Compose/TLS checks. The latter verify non-root/read-only runtime dependencies, required environment configuration, remote discovery, rejected credentials and orderly session deletion. The Compose connection command remains explicit when a private CA entrypoint is configured. [Installed interoperability run 35983558934](https://github.com/joomengine/mcp_client/actions/runs/35983558934) passed on PHP 8.3 and 8.4 using that client source, component `75d9685268332241de846935aad2edc2e92c8459` and plugin `3526cae818803a02971374c044a2e2184f1c2c61`. Each installed client suite executed 14 live assertions against 24 discovered tools through the PHP SDK and external stdio executable. Source revisions and full fixture results are retained in the run's artifacts. -These results certify the recorded revisions and test scopes. The linked PR reports checks for its latest head, and the coordinated component acceptance checklist records the separate Joomla/JCB write/job matrix. Review/merge and deliberate release publication follow separately; this evidence does not claim a published image, Composer release or Packagist registration. +These historical results certify the recorded revisions and test scopes. Current workflows report checks for newer revisions, and the coordinated component acceptance checklist records the separate Joomla/JCB write/job matrix. Deliberate tagged releases follow separately; this runtime evidence does not claim a published image or stable Composer release. The public Packagist registration described above was verified independently. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 79a1714..506bacf 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,12 +1,43 @@ # Composer distribution and release automation -Composer package `joomengine/mcp-client` is versioned by Git tags, with no hard-coded version property. The package registers `bin/joomengine-mcp`; library and executable versions are independent of component/plugin versions. +Composer package [`joomengine/mcp-client`](https://packagist.org/packages/joomengine/mcp-client) is registered against [`joomengine/mcp_client`](https://github.com/joomengine/mcp_client). Semantic versions come from Git tags, with no hard-coded version property in `composer.json`. The package registers `bin/joomengine-mcp`; library and executable versions are independent of component/plugin versions. -## One-time Packagist setup +## Current distribution and installation -After the reviewed `composer.json` is on main, register `joomengine/mcp_client` on Packagist and configure Packagist's GitHub integration or authenticated push webhook. Verify the package name and an indexed test prerelease through Composer. This development PR does not configure an external Packagist account and does not publish a version. +Packagist's public metadata was checked on 24 September 2026: `dev-main` points to this repository, and no tagged release is indexed. Registration makes the development branch installable; it does not create a stable release. The README's live version badge therefore displays Packagist's `dev-main` version. -References: https://packagist.org/about and https://getcomposer.org/doc/04-schema.md. Packagist indexes Git tags; releases do not require uploading an archive or committing credentials. +```bash +composer show --all joomengine/mcp-client +composer require joomengine/mcp-client:dev-main +composer check-platform-reqs +./vendor/bin/joomengine-mcp help +``` + +Use the explicit development constraint until a tested stable tag is indexed. For a new project after a stable release, `composer require joomengine/mcp-client` selects a compatible stable version. Existing development users must replace their `dev-main` requirement with the chosen release constraint. Composer installs the executable proxy under the consuming project's `vendor/bin`; this is distinct from `bin/joomengine-mcp` in a source checkout. + +## Keep Packagist synchronized + +Packagist registration is already complete. A package maintainer should check [Packagist's package list](https://packagist.org/profile/) for an automatic-sync warning and use [Packagist's GitHub integration instructions](https://packagist.org/about#how-to-update-packages) if synchronization needs configuring. The integration must have access to the `joomengine` organization and this repository. Alternatively, configure the authenticated GitHub push webhook documented by Packagist, keeping the API token in the webhook secret setting. + +The public listing and a successful consumer install verify published metadata, not the account's webhook configuration. After a main-branch push or a new tag, check the package's indexed source reference and version. A logged-in maintainer can trigger an update from the package page if necessary. No Packagist credentials belong in source files or workflow logs; the public consumer test requires none. + +References: [Packagist versioning and update schedule](https://packagist.org/about#managing-package-versions), [Composer package schema](https://getcomposer.org/doc/04-schema.md) and [Composer versions and constraints](https://getcomposer.org/doc/articles/versions.md). Packagist indexes Git tags; releases do not require uploading a separate archive. + +## Verify the package as a consumer + +From a development checkout on Linux with Bash, PHP 8.3+, Composer 2, cURL, Node.js, OpenSSL and GNU `timeout` installed: + +```bash +composer install +composer test +composer test:packagist +``` + +The consumer test resolves the public package in a new Composer project without a local path or VCS repository override. It checks package metadata and autoloading, runs Composer's generated executable, and exercises the installed SDK and bridge against a local HTTPS fixture with a private test CA. Its output records the selected package version and source reference; CI saves the report and log under `build/packagist/`. Set `PACKAGIST_REPORT_PATH` to save the JSON report locally. These checks need no Joomla installation or real site token. Missing requirements, an unavailable package or a protocol failure fail the test. + +`composer test:packagist -- dev-main` selects a specific version constraint. With no argument, the runner selects the latest compatible stable version when available, falling back to `dev-main` when no stable release is indexed. The [Packagist consumer workflow](https://github.com/joomengine/mcp_client/actions/workflows/packagist.yml) runs on PHP 8.3 and 8.4 for pull requests and main-branch pushes, and supports a manual version input. CI retains the installation and fixture evidence as artifacts. + +On a PR, the registry install tests the version already indexed by Packagist, not unpublished PR code. The separate PHP contract and Docker checks exercise the proposed source. Neither layer substitutes for installed Joomla acceptance. ## Tested release workflow @@ -16,4 +47,10 @@ Before creating a tag, it runs the full PHP 8.3/8.4 contract/TLS suite, the Dock After testing, the workflow creates the tag and matching GitHub release, setting prerelease status when appropriate. Packagist's configured integration should index the tag; verify that externally rather than assuming it occurred. If release creation fails after tagging, inspect the existing tag and complete its release without moving the tag. +After Packagist indexes the tag: + +1. Confirm the exact version and source commit in `composer show --all joomengine/mcp-client` and on the [package page](https://packagist.org/packages/joomengine/mcp-client). +2. Run **Packagist consumer** manually with that exact version. Both PHP versions must pass against the downloaded distribution. +3. For the first stable release, update the README's primary installation command and development-status text. Replace its development-version badge with the standard [Packagist release badge](https://img.shields.io/packagist/v/joomengine/mcp-client), retaining the link to the package page. This badge then tracks subsequent stable releases automatically. + For local installed acceptance, supply `JOOMENGINE_MCP_URL` and `JOOMENGINE_MCP_TOKEN` and run `php tests/live.php`; a private test CA can be configured through PHP's `curl.cainfo`. Missing configuration fails. Full component/JCB write and job acceptance remains owned by the installed component's fixture, rather than a duplicated business-operation catalogue in this package. diff --git a/tests/consumer-server.mjs b/tests/consumer-server.mjs new file mode 100644 index 0000000..5d81e9f --- /dev/null +++ b/tests/consumer-server.mjs @@ -0,0 +1,93 @@ +/** HTTPS protocol fixture for an independently installed Composer consumer. */ +import https from 'node:https'; +import { appendFileSync, readFileSync } from 'node:fs'; + +const sessions = new Map(); +const endpoint = '/nested/api/index.php/v1/joomengine-mcp'; +let nextSession = 0; +const server = https.createServer({ + key: readFileSync(process.argv[2]), cert: readFileSync(process.argv[3]), +}, (request, response) => { + let body = ''; + request.on('data', chunk => { + body += chunk; + if (body.length > 1048576) request.destroy(); + }); + request.on('end', () => { + let payload; + try { payload = body === '' ? {} : JSON.parse(body); } + catch { response.writeHead(400).end(); return; } + const session = request.headers['mcp-session-id']; + const authorized = request.headers['x-joomla-token'] === 'consumer-fixture-token'; + appendFileSync(process.argv[4], JSON.stringify({ + http: request.method, path: request.url, method: payload.method, + session: session ?? null, protocol: request.headers['mcp-protocol-version'] ?? null, + authorized, name: payload.params?.name ?? null, + }) + '\n'); + if (request.url !== endpoint) { response.writeHead(404).end(); return; } + if (request.headers['x-joomla-token'] === 'consumer-redirect-token') { + response.writeHead(302, { Location: `https://127.0.0.1:${server.address().port}/capture` }).end(); + return; + } + if (!authorized) { + response.writeHead(401, { 'Content-Type': 'text/plain' }).end('private-fixture-diagnostic'); + return; + } + const reply = (result, sessionId = session) => { + response.writeHead(200, { 'Content-Type': 'application/json', 'Mcp-Session-Id': sessionId }); + response.end(JSON.stringify({ jsonrpc: '2.0', id: payload.id, result })); + }; + if (payload.method === 'initialize') { + const id = `consumer-session-${++nextSession}`; + sessions.set(id, payload.params.protocolVersion); + reply({ protocolVersion: payload.params.protocolVersion, + capabilities: { tools: {}, resources: {}, prompts: {} }, + serverInfo: { name: 'packagist-consumer-fixture', version: '1.0.0' }, + instructions: 'Fixture capabilities are discovered from the remote server.', + }, id); + return; + } + if (!sessions.has(session)) { response.writeHead(404).end(); return; } + if (request.method === 'DELETE') { + sessions.delete(session); + response.writeHead(204).end(); + return; + } + if (request.headers['mcp-protocol-version'] !== sessions.get(session)) { + response.writeHead(400).end(); return; + } + if (request.method !== 'POST') { response.writeHead(405).end(); return; } + if (!Object.hasOwn(payload, 'id')) { response.writeHead(202).end(); return; } + const secondPage = payload.params?.cursor === 'opaque-second-page'; + const page = (key, first, second) => secondPage + ? { [key]: [second] } : { [key]: [first], nextCursor: 'opaque-second-page' }; + switch (payload.method) { + case 'ping': reply({}); return; + case 'tools/list': reply(page('tools', + { name: 'extension.dynamic_echo', inputSchema: { type: 'object' } }, + { name: 'extension.dynamic_error', inputSchema: { type: 'object' } })); return; + case 'tools/call': + reply({ content: [{ type: 'text', text: JSON.stringify(payload.params) }], + structuredContent: { name: payload.params.name, arguments: payload.params.arguments }, + isError: payload.params.name === 'extension.dynamic_error' }); return; + case 'resources/list': reply(page('resources', + { name: 'First', uri: 'fixture://data/first', mimeType: 'application/json' }, + { name: 'Second', uri: 'fixture://data/second', mimeType: 'application/json' })); return; + case 'resources/templates/list': + reply({ resourceTemplates: [{ name: 'Dynamic', uriTemplate: 'fixture://data/{id}' }] }); return; + case 'resources/read': reply({ contents: [{ uri: payload.params.uri, + mimeType: 'application/json', text: JSON.stringify({ from: 'remote', value: 7 }) }] }); return; + case 'prompts/list': reply(page('prompts', + { name: 'dynamic-first' }, { name: 'dynamic-second' })); return; + case 'prompts/get': reply({ messages: [{ role: 'user', content: { + type: 'text', text: `Remote instructions for ${payload.params.arguments?.topic}.`, + } }] }); return; + default: + response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ + jsonrpc: '2.0', id: payload.id, error: { code: -32601, message: 'Unknown fixture method.' }, + })); + } + }); +}); +server.on('tlsClientError', () => {}); +server.listen(0, '127.0.0.1', () => process.stdout.write(String(server.address().port) + '\n')); diff --git a/tests/consumer.php b/tests/consumer.php new file mode 100644 index 0000000..fe39925 --- /dev/null +++ b/tests/consumer.php @@ -0,0 +1,231 @@ +getFileName()); + $check(is_string($file) && str_starts_with($file, $installation . '/src/'), $class . ' autoloads from the installed package'); +} +$proxy = $consumer . '/vendor/bin/joomengine-mcp'; +$check(is_file($proxy) && is_executable($proxy) && !is_link($proxy) + && str_contains(file_get_contents($proxy), '_composer_autoload_path'), 'Composer generated the installed executable proxy'); +$check(is_file($installation . '/LICENSE'), 'installed distribution includes its license'); + +$client = (new ClientFactory())->connect(new Connection($site, 'consumer-fixture-token', 5)); +try +{ + $check($client->isConnected() && $client->getServerInfo()->name === 'packagist-consumer-fixture', 'SDK initializes over certificate-verified HTTPS'); + $check($client->getInstructions() === 'Fixture capabilities are discovered from the remote server.', 'SDK exposes server instructions'); + $tools = $client->listTools(); + $check($tools->tools[0]->name === 'extension.dynamic_echo' && $tools->nextCursor === 'opaque-second-page', 'SDK discovers arbitrary tools and opaque pagination'); + $second = $client->listTools($tools->nextCursor); + $check($second->tools[0]->name === 'extension.dynamic_error' && $second->nextCursor === null, 'SDK reaches the final tool page'); + $result = $client->callTool($tools->tools[0]->name, ['nested' => ['value' => 7]]); + $check(!$result->isError && $result->structuredContent['arguments']['nested']['value'] === 7, 'SDK forwards arguments and structured tool results'); + $check($client->callTool('extension.dynamic_error')->isError, 'SDK preserves remote tool error results'); + $resources = $client->listResources(); + $check($resources->resources[0]->uri === 'fixture://data/first' && $resources->nextCursor === 'opaque-second-page', 'SDK discovers resources and their cursor'); + $check($client->listResources($resources->nextCursor)->resources[0]->uri === 'fixture://data/second', 'SDK requests the second resource page'); + $check($client->listResourceTemplates()->resourceTemplates[0]->uriTemplate === 'fixture://data/{id}', 'SDK discovers resource templates'); + $content = $client->readResource('fixture://data/second')->contents[0]; + $check($content->uri === 'fixture://data/second' && json_decode($content->text, true)['value'] === 7, 'SDK reads the requested resource'); + $prompts = $client->listPrompts(); + $check($prompts->prompts[0]->name === 'dynamic-first' && $prompts->nextCursor === 'opaque-second-page', 'SDK discovers prompts and their cursor'); + $check($client->listPrompts($prompts->nextCursor)->prompts[0]->name === 'dynamic-second', 'SDK requests the second prompt page'); + $check($client->getPrompt('dynamic-second', ['topic' => 'consumer'])->messages[0]->content->text === 'Remote instructions for consumer.', 'SDK retrieves prompt arguments and content'); + $client->ping(); + $check(true, 'SDK ping completes'); +} +finally +{ + $client->disconnect(); +} +$check(!$client->isConnected(), 'SDK disconnect ends the session'); + +foreach (['consumer-invalid-token' => 'authentication failure', 'consumer-redirect-token' => 'redirect'] as $token => $name) +{ + $failure = null; + try + { + $unexpected = (new ClientFactory())->connect(new Connection($site, $token, 5)); + $unexpected->disconnect(); + } + catch (Throwable $error) + { + $failure = $error; + } + $check($failure !== null && !str_contains($failure->getMessage(), $token) + && !str_contains($failure->getMessage(), 'private-fixture-diagnostic'), 'SDK rejects ' . $name . ' with a safe diagnostic'); +} + +// Execute Composer's proxy, never the repository or installed-package binary directly. +$environment = getenv(); +$environment['JOOMENGINE_MCP_URL'] = $site; +$environment['JOOMENGINE_MCP_TOKEN'] = 'consumer-fixture-token'; +$environment['JOOMENGINE_MCP_CONFIG_DIR'] = $consumer . '/private-config'; +$process = proc_open([PHP_BINARY, '-d', 'curl.cainfo=' . ini_get('curl.cainfo'), $proxy, 'connect'], + [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes, $consumer, $environment); +$check(is_resource($process), 'installed Composer executable starts from the consumer directory'); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); +$buffer = ''; +$read = static function () use ($pipes, &$buffer): array +{ + $deadline = microtime(true) + 8; + while (($end = strpos($buffer, "\n")) === false) + { + $ready = [$pipes[1]]; + $write = $except = []; + if (microtime(true) >= $deadline || stream_select($ready, $write, $except, 0, 100000) === false) + { + throw new RuntimeException('Installed executable response timed out.'); + } + if ($ready !== []) + { + $chunk = fread($pipes[1], 8192); + if ($chunk === false || ($chunk === '' && feof($pipes[1]))) + { + throw new RuntimeException('Installed executable ended before its reply.'); + } + $buffer .= $chunk; + } + } + $line = substr($buffer, 0, $end); + $buffer = substr($buffer, $end + 1); + return json_decode($line, true, 64, JSON_THROW_ON_ERROR); +}; +$send = static function (array $message) use ($pipes): void +{ + $data = json_encode($message, JSON_THROW_ON_ERROR) . "\n"; + if (fwrite($pipes[0], $data) !== strlen($data) || !fflush($pipes[0])) + { + throw new RuntimeException('Unable to send a complete request to the installed executable.'); + } +}; +$request = static function (int $id, string $method, array $params = []) use ($send, $read, $check): array +{ + $message = ['jsonrpc' => '2.0', 'id' => $id, 'method' => $method]; + if ($params !== []) + { + $message['params'] = $params; + } + $send($message); + $reply = $read(); + $check(($reply['jsonrpc'] ?? '') === '2.0' && ($reply['id'] ?? null) === $id, 'stdio correlates ' . $method . ' reply'); + return $reply; +}; +try +{ + $check(($request(1, 'tools/list')['error']['code'] ?? null) === -32002, 'stdio requires initialization before discovery'); + $initialize = $request(2, 'initialize', ['protocolVersion' => '2025-06-18', + 'capabilities' => (object) [], 'clientInfo' => ['name' => 'packagist-consumer', 'version' => '1.0.0']]); + $check(($initialize['result']['serverInfo']['name'] ?? '') === 'packagist-consumer-fixture' + && ($initialize['result']['protocolVersion'] ?? '') === '2025-06-18', 'stdio preserves initialization and protocol negotiation'); + $send(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']); + $tools = $request(3, 'tools/list')['result']; + $check($tools['tools'][0]['name'] === 'extension.dynamic_echo' && $tools['nextCursor'] === 'opaque-second-page', 'stdio discovers remote tools and pagination'); + $check($request(4, 'tools/list', ['cursor' => $tools['nextCursor']])['result']['tools'][0]['name'] === 'extension.dynamic_error', 'stdio forwards an opaque cursor'); + $result = $request(5, 'tools/call', ['name' => $tools['tools'][0]['name'], 'arguments' => ['nested' => ['value' => 9]]]); + $check($result['result']['structuredContent']['arguments']['nested']['value'] === 9, 'stdio forwards tool arguments and structured content'); + $check($request(6, 'resources/list')['result']['resources'][0]['uri'] === 'fixture://data/first', 'stdio forwards resource discovery'); + $check($request(7, 'resources/templates/list')['result']['resourceTemplates'][0]['uriTemplate'] === 'fixture://data/{id}', 'stdio forwards resource templates'); + $check($request(8, 'resources/read', ['uri' => 'fixture://data/first'])['result']['contents'][0]['uri'] === 'fixture://data/first', 'stdio forwards resource reads'); + $check($request(9, 'prompts/list')['result']['prompts'][0]['name'] === 'dynamic-first', 'stdio forwards prompt discovery'); + $check($request(10, 'prompts/get', ['name' => 'dynamic-first', 'arguments' => ['topic' => 'stdio']])['result']['messages'][0]['content']['text'] === 'Remote instructions for stdio.', 'stdio forwards prompt arguments and content'); + fclose($pipes[0]); + stream_set_blocking($pipes[1], true); + stream_set_timeout($pipes[1], 8); + $remaining = $buffer . stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); + $process = null; + $check($status === 0 && $remaining === '' && $errors === '', 'installed executable exits cleanly with protocol-only stdout'); +} +finally +{ + if (is_resource($process)) + { + proc_terminate($process); + foreach ($pipes as $pipe) + { + if (is_resource($pipe)) + { + fclose($pipe); + } + } + proc_close($process); + } +} + +$requests = array_map(static fn (string $line): array => json_decode($line, true, 64, JSON_THROW_ON_ERROR), file($audit, FILE_IGNORE_NEW_LINES)); +$check(count(array_filter($requests, static fn (array $request): bool => $request['path'] !== '/nested/api/index.php/v1/joomengine-mcp')) === 0, 'all requests retain the installation subdirectory and never follow redirects'); +$check(count(array_filter($requests, static fn (array $request): bool => !$request['authorized'])) === 2, 'authentication and redirect failures are attempted once without retry'); +$sessions = array_values(array_filter($requests, static fn (array $request): bool => $request['http'] === 'DELETE')); +$check(count($sessions) === 2 && count(array_unique(array_column($sessions, 'session'))) === 2, 'SDK and executable each delete their own isolated session'); +$check(count(array_filter($requests, static fn (array $request): bool => $request['authorized'] + && ($request['method'] ?? null) !== 'initialize' && $request['session'] === null)) === 0, 'every authenticated request after initialization carries its session'); +$check(count(array_filter($requests, static fn (array $request): bool => $request['authorized'] + && $request['http'] === 'POST' && ($request['method'] ?? null) !== 'initialize' && $request['protocol'] === null)) === 0, 'authenticated protocol requests carry their negotiated revision'); + +$evidence = ['package' => 'joomengine/mcp-client', 'version' => $version, 'reference' => $reference, + 'php' => PHP_VERSION, 'checks' => $passed, 'fixture' => 'loopback HTTPS protocol fixture; no installed Joomla site']; +$report = json_encode($evidence, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL; +echo $report; +$reportPath = getenv('PACKAGIST_REPORT_PATH'); +if (is_string($reportPath) && $reportPath !== '') +{ + $directory = dirname($reportPath); + if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) + { + throw new RuntimeException('Cannot create the consumer evidence directory.'); + } + if (file_put_contents($reportPath, $report) === false) + { + throw new RuntimeException('Cannot save the consumer evidence report.'); + } +} diff --git a/tests/packagist.sh b/tests/packagist.sh new file mode 100644 index 0000000..3e85122 --- /dev/null +++ b/tests/packagist.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Install the real registry package in an isolated consumer, then test its public API. +set -Eeuo pipefail + +test_directory=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +export PACKAGIST_REPORT_PATH=${PACKAGIST_REPORT_PATH:-"$test_directory/../build/packagist/consumer.json"} +constraint=${1:-auto} +if (( $# > 1 )) || [[ -z "$constraint" ]]; then + printf 'Usage: bash tests/packagist.sh [auto|COMPOSER_VERSION_CONSTRAINT]\n' >&2 + exit 2 +fi +for executable in php composer curl node openssl timeout; do + if ! command -v "$executable" >/dev/null 2>&1; then + printf 'Packagist consumer tests require %s.\n' "$executable" >&2 + exit 1 + fi +done +php -r 'if (PHP_VERSION_ID < 80300 || !extension_loaded("curl")) { fwrite(STDERR, "PHP 8.3+ with ext-curl is required.\n"); exit(1); }' + +consumer_directory=$(mktemp -d) +fixture_pid='' +cleanup() { + if [[ -n "$fixture_pid" ]]; then + kill "$fixture_pid" 2>/dev/null || true + wait "$fixture_pid" 2>/dev/null || true + fi + rm -rf -- "$consumer_directory" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# A fresh Composer home/cache prevents local repositories, credentials and cached +# packages from disguising a broken public installation. No checkout autoloader, +# VCS/path repository override is supplied by this harness. +unset COMPOSER_AUTH COMPOSER_VENDOR_DIR COMPOSER_BIN_DIR COMPOSER_REPO_PACKAGIST COMPOSER_ROOT_VERSION +unset COMPOSER_DISABLE_NETWORK COMPOSER_IGNORE_PLATFORM_REQS COMPOSER_IGNORE_PLATFORM_REQ +unset COMPOSER_PREFER_STABLE COMPOSER_PREFER_LOWEST COMPOSER_MINIMAL_CHANGES +export COMPOSER="$consumer_directory/composer.json" +export COMPOSER_HOME="$consumer_directory/composer-home" +export COMPOSER_CACHE_DIR="$consumer_directory/composer-cache" +export COMPOSER_NO_INTERACTION=1 +cat > "$COMPOSER" <<'JSON' +{ + "name": "joomengine/packagist-consumer-test", + "description": "Temporary public registry consumer integration test", + "license": "proprietary", + "require": {}, + "config": { "allow-plugins": false, "secure-http": true } +} +JSON + +if [[ "$constraint" == auto ]]; then + curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ + --connect-timeout 15 --max-time 60 \ + 'https://repo.packagist.org/p2/joomengine/mcp-client.json' \ + --output "$consumer_directory/packagist.json" + constraint=$(php -r ' + $data = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + $packages = $data["packages"]["joomengine/mcp-client"] ?? null; + if (!is_array($packages)) { throw new RuntimeException("Invalid Packagist package metadata."); } + foreach ($packages as $package) { + if (preg_match("/\\Av?\\d+(?:\\.\\d+){0,3}(?:\\+[0-9A-Za-z.-]+)?\\z/D", $package["version"] ?? "")) { + echo "*"; exit; + } + } + echo "dev-main"; + ' "$consumer_directory/packagist.json") +fi +printf 'Installing public Packagist package joomengine/mcp-client (%s).\n' "$constraint" +if [[ "$constraint" == dev-main ]]; then + printf 'Development branch selected explicitly; this is not a tagged stable release.\n' +fi +composer --working-dir="$consumer_directory" require --no-interaction --no-progress \ + --prefer-dist --no-plugins --no-scripts --update-no-dev -- "joomengine/mcp-client:$constraint" +composer --working-dir="$consumer_directory" check-platform-reqs --no-dev +if [[ -n "${PACKAGIST_REPORT_PATH:-}" ]]; then + evidence_directory=$(dirname -- "$PACKAGIST_REPORT_PATH") + mkdir -p -- "$evidence_directory" + cp -- "$consumer_directory/composer.json" "$evidence_directory/consumer-composer.json" + cp -- "$consumer_directory/composer.lock" "$evidence_directory/consumer-composer.lock" +fi + +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -subj '/CN=JoomEngine consumer fixture' -addext 'subjectAltName=IP:127.0.0.1' \ + -keyout "$consumer_directory/key.pem" -out "$consumer_directory/cert.pem" \ + >"$consumer_directory/certificate.log" 2>&1 +node "$test_directory/consumer-server.mjs" "$consumer_directory/key.pem" \ + "$consumer_directory/cert.pem" "$consumer_directory/audit.jsonl" \ + >"$consumer_directory/port" 2>"$consumer_directory/server.log" & +fixture_pid=$! +for ((attempt = 0; attempt < 100; attempt++)); do + [[ -s "$consumer_directory/port" ]] && break + if ! kill -0 "$fixture_pid" 2>/dev/null; then + cat "$consumer_directory/server.log" >&2 + exit 1 + fi + sleep 0.05 +done +fixture_port=$(<"$consumer_directory/port") +if [[ ! "$fixture_port" =~ ^[0-9]+$ ]]; then + printf 'Consumer TLS fixture failed to start.\n' >&2 + exit 1 +fi + +timeout 120 php -d "curl.cainfo=$consumer_directory/cert.pem" \ + "$test_directory/consumer.php" "$consumer_directory" \ + "https://127.0.0.1:$fixture_port/nested" "$consumer_directory/audit.jsonl" +printf 'Public package consumer checks passed; fixture protocol only, no installed Joomla site.\n' From 7c636e543576f4f2852f48c573ffa4801fe61e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:37:24 +0200 Subject: [PATCH 2/4] Preserve negotiated HTTP protocol headers and test published compatibility --- CHANGELOG.md | 2 ++ docs/IMPLEMENTATION.md | 3 +++ src/ClientFactory.php | 6 ++++- src/Http/EndpointClient.php | 25 +++++++++++++++++++-- tests/consumer-server.mjs | 6 ++++- tests/consumer.php | 12 ++++++++-- tests/run.php | 44 +++++++++++++++++++++++++++++++++++-- 7 files changed, 90 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fec9c..aea3879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - Document indexed `dev-main` availability, semantic-version releases and Packagist synchronization without claiming an unissued stable release. - Add package discovery/support metadata and PHP 8.3/8.4 consumer checks that download the actual Packagist distribution into a clean project and exercise its generated executable and SDK against local trusted HTTPS. - Use the component and plugin `main` branches for installed interoperability checks after their original feature branches were merged and deleted. +- Send the SDK's negotiated `MCP-Protocol-Version` on subsequent HTTP requests, notifications and session deletion, including when the server selects a different supported protocol revision. +- Report missing SDK protocol headers in consumer evidence for the existing published development version while retaining the component's backward-compatible handling; require the stdio bridge's negotiated header and reject incorrect revisions. ## Unreleased diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index e17e5d7..5923025 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -16,6 +16,7 @@ Implemented: - Manually triggered main-only stable/prerelease workflow gated on both test layers; tags are never overwritten. - Composer-first installation, project-local/global executable setup, complete PHP discovery example and linked Packagist/version/download badges. - PHP 8.3/8.4 consumer workflow that downloads the indexed Packagist package into a clean project and tests the installed SDK and generated executable against trusted local HTTPS. +- SDK HTTP binding that sends the publicly negotiated protocol revision on subsequent requests, notifications and session deletion, with source checks for both the requested revision and a supported server-selected alternative. Evidence is emitted by each test suite and retained by CI. `tests/run.php` uses the actual PHP SDK with recording HTTP substitutes. `tests/cli.php` checks safe executable startup failures and environment/argument precedence. `tests/bridge.php` launches a real bridge process with a deterministic transport. `tests/http.sh` creates a private test CA and real HTTPS server, verifies transport limits/TLS failures and launches the public executable using only URL/token environment configuration. `tests/container.sh` builds the Docker image and exercises the delivered Compose service against that HTTPS fixture; it requires a Linux Docker engine and does not silently skip missing Docker. `tests/live.php` uses actual installed Joomla HTTP and remote stdio, discovers the server catalogue, executes discovered reads and rejects invalid credentials. Missing live test configuration fails instead of reporting a skipped pass. @@ -25,6 +26,8 @@ Packagist registration is verified through the public [`joomengine/mcp-client` m `composer test:packagist` tests the public distribution and reports its installed version and source reference; CI retains the report and log under `build/packagist/`. It uses Composer's generated binary proxy and the dependency's autoloader, with no local path override. A local HTTPS fixture supplies disposable credentials and protocol responses, so these checks need no Joomla site. On a PR this test covers the indexed package, while source contracts cover the PR commit. Package tests do not claim installed Joomla/JCB execution or external account configuration. +The new consumer checks exposed a compatibility issue in the already indexed development source: the upstream SDK's HTTP handshake can omit `MCP-Protocol-Version` after initialization. The component accepts that omission for SDK compatibility. The consumer fixture mirrors this existing behaviour for the SDK, records missing-header counts in its evidence and rejects an incorrect supplied revision; the stdio bridge must still send its negotiated header. These registry checks therefore do not certify strict SDK header conformance for the old indexed source. The updated client binds the SDK's public negotiated-version getter to its endpoint transport, and source regression checks require the correct header after initialization and on session deletion, including when the server selects another supported revision. Once this source is merged and indexed, consumer evidence can verify the downloaded SDK path sends those headers too. + Verified runtime source: `1ebb989f6ae92eacfc3251c82f69f7c5ffd5118c`. PHP 8.3/8.4 contract and Docker Compose CI passed on both [push run 35983558847](https://github.com/joomengine/mcp_client/actions/runs/35983558847) and [PR run 35983565554](https://github.com/joomengine/mcp_client/actions/runs/35983565554). Each matrix includes 25 process-level bridge checks, including complete-frame/EOF byte limits for whitespace and JSON, and nine actual built-image Compose/TLS checks. The latter verify non-root/read-only runtime dependencies, required environment configuration, remote discovery, rejected credentials and orderly session deletion. The Compose connection command remains explicit when a private CA entrypoint is configured. [Installed interoperability run 35983558934](https://github.com/joomengine/mcp_client/actions/runs/35983558934) passed on PHP 8.3 and 8.4 using that client source, component `75d9685268332241de846935aad2edc2e92c8459` and plugin `3526cae818803a02971374c044a2e2184f1c2c61`. Each installed client suite executed 14 live assertions against 24 discovered tools through the PHP SDK and external stdio executable. Source revisions and full fixture results are retained in the run's artifacts. diff --git a/src/ClientFactory.php b/src/ClientFactory.php index b11aa36..cdb33bf 100644 --- a/src/ClientFactory.php +++ b/src/ClientFactory.php @@ -16,6 +16,7 @@ use Psr\Http\Client\ClientInterface; use VDM\Joomla\Mcp\Client\Http\CurlClient; use VDM\Joomla\Mcp\Client\Http\EndpointClient; +use WeakReference; /** @@ -55,7 +56,10 @@ public function connect(Connection $connection): Client ->setRequestTimeout($connection->timeout()) ->setMaxRetries(0) ->build(); - $client->connect(new HttpTransport($connection->endpoint(), [], new EndpointClient($connection, $http), + $reference = WeakReference::create($client); + $endpoint = new EndpointClient($connection, $http, + static fn (): ?string => $reference->get()?->getProtocolVersion()?->value); + $client->connect(new HttpTransport($connection->endpoint(), [], $endpoint, $factory, $factory, maxSseBufferBytes: $connection->maximum())); return $client; diff --git a/src/Http/EndpointClient.php b/src/Http/EndpointClient.php index 55e2483..9a596c2 100644 --- a/src/Http/EndpointClient.php +++ b/src/Http/EndpointClient.php @@ -9,6 +9,7 @@ namespace VDM\Joomla\Mcp\Client\Http; +use Closure; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -30,12 +31,20 @@ final class EndpointClient implements ClientInterface private Connection $connection; /** @var ClientInterface Bounded non-redirecting transport. @since 0.1.0 */ private ClientInterface $http; + /** @var (Closure(): ?string)|null Current SDK-negotiated protocol revision. @since 0.1.0 */ + private ?Closure $protocolVersion; - /** @param Connection $connection Endpoint. @param ClientInterface $http Trusted transport. @since 0.1.0 */ - public function __construct(Connection $connection, ClientInterface $http) + /** + * @param Connection $connection Endpoint. + * @param ClientInterface $http Trusted transport. + * @param (callable(): ?string)|null $protocolVersion Current negotiated revision, null before initialization. + * @since 0.1.0 + */ + public function __construct(Connection $connection, ClientInterface $http, ?callable $protocolVersion = null) { $this->connection = $connection; $this->http = $http; + $this->protocolVersion = $protocolVersion === null ? null : Closure::fromCallable($protocolVersion); } /** @@ -61,6 +70,18 @@ public function sendRequest(RequestInterface $request): ResponseInterface $request = $request->withHeader($name, $value); } + // SDK handshake transports omit this header, including on DELETE. Read + // negotiated SDK state at send time and retain any upstream per-request header. + if (!$request->hasHeader('MCP-Protocol-Version') && $this->protocolVersion !== null) + { + $version = ($this->protocolVersion)(); + + if ($version !== null) + { + $request = $request->withHeader('MCP-Protocol-Version', $version); + } + } + $response = $this->http->sendRequest($request); $status = $response->getStatusCode(); diff --git a/tests/consumer-server.mjs b/tests/consumer-server.mjs index 5d81e9f..e76af5d 100644 --- a/tests/consumer-server.mjs +++ b/tests/consumer-server.mjs @@ -22,6 +22,7 @@ const server = https.createServer({ appendFileSync(process.argv[4], JSON.stringify({ http: request.method, path: request.url, method: payload.method, session: session ?? null, protocol: request.headers['mcp-protocol-version'] ?? null, + negotiated: sessions.get(session) ?? null, authorized, name: payload.params?.name ?? null, }) + '\n'); if (request.url !== endpoint) { response.writeHead(404).end(); return; } @@ -53,7 +54,10 @@ const server = https.createServer({ response.writeHead(204).end(); return; } - if (request.headers['mcp-protocol-version'] !== sessions.get(session)) { + // Match the component's SDK middleware: legacy missing headers are accepted. + // A supplied revision must still agree with this fixture's negotiated session. + if (request.headers['mcp-protocol-version'] !== undefined + && request.headers['mcp-protocol-version'] !== sessions.get(session)) { response.writeHead(400).end(); return; } if (request.method !== 'POST') { response.writeHead(405).end(); return; } diff --git a/tests/consumer.php b/tests/consumer.php index fe39925..65a10cf 100644 --- a/tests/consumer.php +++ b/tests/consumer.php @@ -210,10 +210,18 @@ $check(count(array_filter($requests, static fn (array $request): bool => $request['authorized'] && ($request['method'] ?? null) !== 'initialize' && $request['session'] === null)) === 0, 'every authenticated request after initialization carries its session'); $check(count(array_filter($requests, static fn (array $request): bool => $request['authorized'] - && $request['http'] === 'POST' && ($request['method'] ?? null) !== 'initialize' && $request['protocol'] === null)) === 0, 'authenticated protocol requests carry their negotiated revision'); + && $request['http'] === 'POST' && ($request['method'] ?? null) !== 'initialize' + && $request['protocol'] !== null && $request['protocol'] !== $request['negotiated'])) === 0, 'supplied protocol headers agree with the negotiated session'); +$stdioSession = $sessions[1]['session']; +$check(count(array_filter($requests, static fn (array $request): bool => $request['session'] === $stdioSession + && $request['http'] === 'POST' && $request['protocol'] !== '2025-06-18')) === 0, 'stdio requests always carry the negotiated revision'); +$legacyRequests = count(array_filter($requests, static fn (array $request): bool => $request['session'] !== null + && $request['session'] !== $stdioSession && $request['protocol'] === null)); +echo 'Published SDK requests using component-compatible missing-header handling: ' . $legacyRequests . PHP_EOL; $evidence = ['package' => 'joomengine/mcp-client', 'version' => $version, 'reference' => $reference, - 'php' => PHP_VERSION, 'checks' => $passed, 'fixture' => 'loopback HTTPS protocol fixture; no installed Joomla site']; + 'php' => PHP_VERSION, 'checks' => $passed, 'legacyProtocolHeaderRequests' => $legacyRequests, + 'fixture' => 'loopback HTTPS protocol fixture with component-compatible legacy header handling; no installed Joomla site']; $report = json_encode($evidence, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL; echo $report; $reportPath = getenv('PACKAGIST_REPORT_PATH'); diff --git a/tests/run.php b/tests/run.php index 2157dea..20abcca 100644 --- a/tests/run.php +++ b/tests/run.php @@ -106,6 +106,10 @@ public string $session = 'fixture-session'; /** @var string Name supplied by the server, never by the client catalogue. */ public string $tool = 'third_party.dynamic_feature'; + /** @var ?string Optional protocol counter-offer from the server. */ + public ?string $protocol = null; + /** @var ?string Protocol agreed during the current handshake. */ + public ?string $negotiated = null; /** @return ResponseInterface Reply using only the requested SDK method. */ public function sendRequest(RequestInterface $request): ResponseInterface @@ -119,11 +123,20 @@ public function sendRequest(RequestInterface $request): ResponseInterface if ($request->getMethod() === 'DELETE') { - return new Response(204); + return new Response($request->getHeaderLine('MCP-Protocol-Version') === $this->negotiated ? 204 : 400); } $data = json_decode((string) $request->getBody(), true, 64, JSON_THROW_ON_ERROR); + if ($data['method'] === 'initialize') + { + $this->negotiated = $this->protocol ?? $data['params']['protocolVersion']; + } + elseif ($request->getHeaderLine('MCP-Protocol-Version') !== $this->negotiated) + { + return new Response(400); + } + if (!array_key_exists('id', $data)) { return new Response(202); @@ -131,7 +144,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface $result = match ($data['method']) { - 'initialize' => ['protocolVersion' => $data['params']['protocolVersion'], + 'initialize' => ['protocolVersion' => $this->negotiated, 'capabilities' => ['tools' => (object) [], 'resources' => (object) [], 'prompts' => (object) []], 'serverInfo' => ['name' => 'fixture', 'version' => '1.0.0']], 'ping' => (object) [], @@ -174,6 +187,33 @@ public function sendRequest(RequestInterface $request): ResponseInterface $check($request->getHeaderLine('X-Joomla-Token') === 'private-fixture-token', 'site token propagated'); } +$check(!$http->requests[0]->hasHeader('MCP-Protocol-Version'), 'initial handshake does not invent a negotiated revision'); +foreach (array_slice($http->requests, 1) as $request) +{ + $check($request->getHeaderLine('MCP-Protocol-Version') === $http->negotiated, + 'negotiated revision accompanies every SDK notification, request and session deletion'); +} + +$counterOffer = clone $http; +$counterOffer->requests = []; +$counterOffer->protocol = '2025-06-18'; +$counterOffer->negotiated = null; +$client = (new ClientFactory($counterOffer))->connect($connection); +$check($client->getProtocolVersion()?->value === '2025-06-18', 'SDK accepts the supported server protocol counter-offer'); +$check($client->listTools()->tools[0]->name === $counterOffer->tool, 'discovery succeeds using the counter-offered revision'); +$client->disconnect(); +foreach (array_slice($counterOffer->requests, 1) as $request) +{ + $check($request->getHeaderLine('MCP-Protocol-Version') === '2025-06-18', + 'counter-offered revision reaches initialized notification, discovery and DELETE'); +} + +$upstream = new EndpointClient($connection, $http, static fn (): ?string => '2025-06-18'); +$upstream->sendRequest(new Request('POST', $connection->endpoint(), + ['MCP-Protocol-Version' => $http->negotiated], '{"jsonrpc":"2.0","id":99,"method":"ping"}')); +$check(end($http->requests)->getHeaderLine('MCP-Protocol-Version') === $http->negotiated, + 'upstream per-request protocol headers retain precedence'); + $bound = new EndpointClient($connection, $http); $reject(static fn () => $bound->sendRequest(new Request('POST', 'https://other.test')), 'cross-origin SDK request denied before transport'); From 88ce2f4cfef4125373ba1d37b9fb973696af8f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:09:13 +0200 Subject: [PATCH 3/4] Prepare the first stable client release as version 1.0.0 --- .github/workflows/release.yml | 2 +- CHANGELOG.md | 43 ++++++++++++++++------------------- README.md | 37 +++++++++++++++++++++++------- docs/IMPLEMENTATION.md | 8 +++++-- docs/RELEASE.md | 42 +++++++++++++++++++++++++++++----- tests/consumer.php | 6 +++++ 6 files changed, 98 insertions(+), 40 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b3166d..06ca6b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: 'Version without v, for example 0.1.0 or 0.1.0-rc.1' + description: 'Version without v, for example 1.0.0 or 1.1.0-rc.1' required: true type: string component_ref: diff --git a/CHANGELOG.md b/CHANGELOG.md index aea3879..8e8dd12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,32 +1,29 @@ # Changelog -## Unreleased — Packagist distribution +## 1.0.0 -- Link the registered `joomengine/mcp-client` package, live development-version/download badges, release history and CI from the README. -- Add Composer-first local and global installation, AI stdio launcher setup and a complete PHP SDK discovery example. -- Document indexed `dev-main` availability, semantic-version releases and Packagist synchronization without claiming an unissued stable release. -- Add package discovery/support metadata and PHP 8.3/8.4 consumer checks that download the actual Packagist distribution into a clean project and exercise its generated executable and SDK against local trusted HTTPS. -- Use the component and plugin `main` branches for installed interoperability checks after their original feature branches were merged and deleted. -- Send the SDK's negotiated `MCP-Protocol-Version` on subsequent HTTP requests, notifications and session deletion, including when the server selects a different supported protocol revision. -- Report missing SDK protocol headers in consumer evidence for the existing published development version while retaining the component's backward-compatible handling; require the stdio bridge's negotiated header and reject incorrect revisions. +Changes for the first stable client release. Publication is recorded by the [`v1.0.0` GitHub release](https://github.com/joomengine/mcp_client/releases/tag/v1.0.0) and [Packagist version history](https://packagist.org/packages/joomengine/mcp-client); this entry alone does not establish availability. Client versions are independent of component/plugin versions; the selected interoperability targets are component 0.1.1 and console plugin 0.1.0. -## Unreleased +### Added -- Establish independent ownership of the external PHP MCP client and remote stdio bridge. -- Add HTTPS installation URL/token configuration, endpoint-bound official SDK connection and bounded transport derived from the component infrastructure. -- Add dynamic tool/resource/prompt discovery and call contract tests, developer discovery example, PHP 8.3/8.4 CI and tested prerelease automation. -- Preserve the server-first acceptance dependency, full JCB discovery objective and transport provenance. No stable release, available stdio executable or live Joomla/JCB certification is claimed. +- Standalone external PHP MCP client and remote stdio bridge, with generic tool/resource/template/prompt discovery through the official SDK. +- HTTPS installation URL/token configuration, subdirectory support, bounded transport and endpoint-bound credentials. +- `joomengine-mcp` executable with ephemeral connections and private named-site configuration. +- Concurrent remote forwarding of HTTP identity, JSON-RPC/session metadata, pagination, finite SSE events, cancellation notifications and structured job results. +- Complete non-root Docker image and read-only Compose service, using a site URL and API token through environment variables. +- PHP 8.3/8.4 source contracts, TLS and process tests, real Compose checks and packaged Joomla interoperability acceptance. +- Main-only stable/prerelease automation gated on client, Docker and installed Joomla checks, with immutable first-release component/plugin inputs documented. +- Packagist metadata, stable-version/download badges, release links, Composer-first local/global installation, AI launcher setup and a complete PHP discovery example. +- Stable `^1.0` installation instructions with an explicit `dev-main` fallback before publication and for development testing. +- PHP 8.3/8.4 consumer checks that download the actual Packagist distribution into a clean project and exercise its generated executable and SDK against trusted local HTTPS. -## Unreleased — remote connection completion +### Fixed -- Add the generic remote `joomengine-mcp` stdio executable and private named-site configuration. -- Preserve HTTP identity, protocol/session metadata, pagination, finite SSE events, cancellation notifications and structured job results. -- Add concurrent bounded HTTPS transport with TLS failure and process-level acceptance coverage. -- Gate stable/prerelease automation on client contracts and packaged Joomla interoperability. +- Use component/plugin `main` for installed interoperability after the original feature branches were merged and deleted. +- Send the SDK's negotiated `MCP-Protocol-Version` on subsequent HTTP requests, notifications and session deletion, including when the server selects a different supported revision. -## Unreleased — Docker Compose client +### Distribution verification -- Add a complete non-root Docker image and read-only Compose service for connecting an AI application's stdio MCP transport using a Joomla HTTPS URL and API token. -- Allow `connect` to obtain its URL from `JOOMENGINE_MCP_URL` while preserving explicit URL arguments and credential isolation. -- Gate client releases on real Compose/TLS packaging checks for PHP 8.3 and 8.4, alongside existing PHP and installed Joomla acceptance. -- Document AI launcher setup, private CA bundles, Docker operation and the distinction between remote HTTP authority and trusted local-console serving. +- Consumer evidence identifies the downloaded version and commit and records SDK requests missing their negotiated protocol header. This compatibility diagnostic supports the older development package; stable versions from 1.0.0 must report zero missing SDK protocol headers. +- The stdio bridge must send the negotiated protocol header, and the fixture rejects incorrect supplied revisions for both SDK and stdio clients. +- Packagist synchronization and exact-version consumer verification are documented separately from source tests and installed Joomla acceptance. diff --git a/README.md b/README.md index f3ec1ed..792129f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # JoomEngine MCP Client -[![Packagist development version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpackagist.org%2Fpackages%2Fjoomengine%2Fmcp-client.json&query=%24.package.versions%5B%27dev-main%27%5D.version&label=Packagist&color=orange)](https://packagist.org/packages/joomengine/mcp-client) +[![Latest stable Packagist version](https://img.shields.io/packagist/v/joomengine/mcp-client)](https://packagist.org/packages/joomengine/mcp-client) [![Total downloads](https://img.shields.io/packagist/dt/joomengine/mcp-client)](https://packagist.org/packages/joomengine/mcp-client/stats) [![PHP requirement](https://img.shields.io/badge/PHP-%5E8.3-777BB4)](composer.json) [![License](https://img.shields.io/badge/license-GPL--3.0--or--later-blue)](LICENSE) @@ -21,17 +21,38 @@ Requires PHP 8.3+, cURL, JSON and [Composer 2](https://getcomposer.org/download/ ## Install from Packagist -The package is registered on Packagist. As of 24 September 2026, the available version is **`dev-main`**; no tagged stable release has been published. Install the indexed development version in your PHP project or an empty working directory: +The stable **1.x** series starts at **1.0.0**. Check the live version badge, [GitHub Releases](https://github.com/joomengine/mcp_client/releases) and the [package page](https://packagist.org/packages/joomengine/mcp-client) for published versions. Once a stable 1.x tag is indexed, install it in your PHP project or an empty working directory: ```bash -composer require joomengine/mcp-client:dev-main +composer require 'joomengine/mcp-client:^1.0' composer check-platform-reqs ./vendor/bin/joomengine-mcp help ``` -Packagist is Composer's default repository, so no custom repository configuration or Git checkout is required. The explicit `dev-main` constraint allows this package's development version without lowering your project's overall `minimum-stability`. Keep your application's `composer.lock` to reproduce the resolved commit and dependencies. `composer update joomengine/mcp-client --with-dependencies` updates it deliberately. +Packagist is Composer's default repository, so no custom repository configuration or Git checkout is required. `^1.0` accepts compatible stable 1.x updates while excluding 2.0. Keep your application's `composer.lock` to reproduce the resolved version and dependencies. `composer update joomengine/mcp-client --with-dependencies` updates it deliberately. The version badge reflects indexed stable releases and will report no release until the first tag is indexed. + +### Development version + +Until the first stable release is indexed, or when intentionally testing current development, use: + +```bash +composer require joomengine/mcp-client:dev-main +``` + +The explicit constraint allows this package's development version without lowering your project's overall `minimum-stability`. Development installs follow `main` when updated. After the stable release, existing development users can run `composer require 'joomengine/mcp-client:^1.0' --with-dependencies` to move to the stable series. [Release instructions](docs/RELEASE.md) explain publication and distribution checks. + +### Version compatibility + +The client, installed component and console plugin have independent version numbers. Client 1.0.0 does not require component or plugin 1.0.0. The selected first-release interoperability targets are: + +| Part | Version / requirement | Release-test source | +| --- | --- | --- | +| MCP client | `1.0.0` baseline; PHP 8.3+ | Client commit tested by the release workflow | +| Installed MCP component | `0.1.1` | [`14c715c`](https://github.com/joomengine/mcp_component/commit/14c715c50c2cc29fd3c8cc3c4780442efb507398) | +| Console plugin | `0.1.0`; needed for local console serving | [`9935228`](https://github.com/joomengine/mcp_plugin/commit/993522852770e2f8968ab066deedef00f174d8c7) | +| Joomla site | Joomla 6.1+ | Packaged Joomla installation in the interoperability fixture | -Once a stable version appears on the [package page](https://packagist.org/packages/joomengine/mcp-client) and in [GitHub Releases](https://github.com/joomengine/mcp_client/releases), new installations can use `composer require joomengine/mcp-client` to select a compatible stable release. An existing `dev-main` requirement must be changed to a tagged release constraint when you want to stop tracking development. [Release instructions](docs/RELEASE.md) explain versioning and distribution checks. +The release workflow must pass against these exact component/plugin revisions before publishing the client tag. [Implementation evidence](docs/IMPLEMENTATION.md) records test scope; [release instructions](docs/RELEASE.md) provide the immutable workflow inputs. ### Connect an AI application @@ -56,14 +77,14 @@ The final command waits for MCP JSON-RPC on stdin and writes protocol replies to Run `realpath vendor/bin/joomengine-mcp` on Linux to obtain the launcher command. On Windows, use Composer's generated `vendor/bin/joomengine-mcp.bat` launcher. The client discovers tools, resources and prompts from the authenticated server; the AI application does not need a copied Joomla/JCB catalogue. An explicit URL argument to `connect` overrides `JOOMENGINE_MCP_URL`. -To install the executable globally instead of in a project: +To install the stable executable globally instead of in a project, once 1.0.0 is indexed: ```bash -composer global require joomengine/mcp-client:dev-main +composer global require 'joomengine/mcp-client:^1.0' composer global config bin-dir --absolute ``` -Add the directory printed by the second command to your `PATH`, or give the AI launcher the absolute executable path in that directory. Then `joomengine-mcp connect` uses the same URL/token environment variables. Project-local installations use `./vendor/bin/joomengine-mcp`; a global executable is only available as a bare command when its directory is on `PATH`. +For the development fallback, use `composer global require joomengine/mcp-client:dev-main`. Add the directory printed by the second command to your `PATH`, or give the AI launcher the absolute executable path in that directory. Then `joomengine-mcp connect` uses the same URL/token environment variables. Project-local installations use `./vendor/bin/joomengine-mcp`; a global executable is only available as a bare command when its directory is on `PATH`. ### Save a named site diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 5923025..d555fca 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -2,6 +2,10 @@ The standalone PHP client and remote stdio bridge from [PR #1](https://github.com/joomengine/mcp_client/pull/1) are merged into `main`. Component and console-plugin business logic remain in their own repositories. [Repository workflows](https://github.com/joomengine/mcp_client/actions) record current checks; the [component acceptance checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349) records coordinated Joomla/JCB execution through the installed server and this generic bridge. +**First stable release preparation, 24 September 2026:** [PR #2](https://github.com/joomengine/mcp_client/pull/2) prepares client **1.0.0**, independently versioned from the installed component (**0.1.1**) and console plugin (**0.1.0**). The selected release inputs pin component `14c715c50c2cc29fd3c8cc3c4780442efb507398` and plugin `993522852770e2f8968ab066deedef00f174d8c7` for the Joomla 6.1+ installed fixture. [Release instructions](RELEASE.md#first-stable-release-inputs) contain the exact workflow inputs. Each release run must record and pass against its selected sources before publishing. Current publication status is shown by [GitHub Releases](https://github.com/joomengine/mcp_client/releases) and [Packagist](https://packagist.org/packages/joomengine/mcp-client), independently of this preparation record. + +[Installed interoperability run 35988304612](https://github.com/joomengine/mcp_client/actions/runs/35988304612) passed on PHP 8.3 and 8.4 against those exact component/plugin revisions. This is existing compatibility evidence; the release workflow still retests its own client commit before tagging. + Implemented: - HTTPS installation/subdirectory normalization with separately supplied tokens and bounded TLS transport. @@ -14,7 +18,7 @@ Implemented: - PHP 8.3/8.4 SDK/configuration/process/TLS CI and installed component interoperability CI. - PHP 8.3/8.4 image builds and real Compose-to-HTTPS protocol checks, including clean EOF/session deletion and failed authentication. - Manually triggered main-only stable/prerelease workflow gated on both test layers; tags are never overwritten. -- Composer-first installation, project-local/global executable setup, complete PHP discovery example and linked Packagist/version/download badges. +- Composer-first stable 1.x installation and explicit development fallback, project-local/global executable setup, complete PHP discovery example and linked Packagist/version/download badges. - PHP 8.3/8.4 consumer workflow that downloads the indexed Packagist package into a clean project and tests the installed SDK and generated executable against trusted local HTTPS. - SDK HTTP binding that sends the publicly negotiated protocol revision on subsequent requests, notifications and session deletion, with source checks for both the requested revision and a supported server-selected alternative. @@ -26,7 +30,7 @@ Packagist registration is verified through the public [`joomengine/mcp-client` m `composer test:packagist` tests the public distribution and reports its installed version and source reference; CI retains the report and log under `build/packagist/`. It uses Composer's generated binary proxy and the dependency's autoloader, with no local path override. A local HTTPS fixture supplies disposable credentials and protocol responses, so these checks need no Joomla site. On a PR this test covers the indexed package, while source contracts cover the PR commit. Package tests do not claim installed Joomla/JCB execution or external account configuration. -The new consumer checks exposed a compatibility issue in the already indexed development source: the upstream SDK's HTTP handshake can omit `MCP-Protocol-Version` after initialization. The component accepts that omission for SDK compatibility. The consumer fixture mirrors this existing behaviour for the SDK, records missing-header counts in its evidence and rejects an incorrect supplied revision; the stdio bridge must still send its negotiated header. These registry checks therefore do not certify strict SDK header conformance for the old indexed source. The updated client binds the SDK's public negotiated-version getter to its endpoint transport, and source regression checks require the correct header after initialization and on session deletion, including when the server selects another supported revision. Once this source is merged and indexed, consumer evidence can verify the downloaded SDK path sends those headers too. +The new consumer checks exposed a compatibility issue in the already indexed development source: the upstream SDK's HTTP handshake can omit `MCP-Protocol-Version` after initialization. The component accepts that omission for SDK compatibility. The consumer fixture mirrors this existing behaviour for the SDK, records missing-header counts in its evidence and rejects an incorrect supplied revision; the stdio bridge must still send its negotiated header. These compatibility checks do not certify strict SDK header conformance for the old indexed source. The updated client binds the SDK's public negotiated-version getter to its endpoint transport, and source regression checks require the correct header after initialization and on session deletion, including when the server selects another supported revision. For a downloaded stable version **1.0.0 or later**, the consumer suite additionally requires `legacyProtocolHeaderRequests` to equal **0**. The old development package can retain a visible compatibility diagnostic; a stable release cannot pass with omitted SDK protocol headers. Verified runtime source: `1ebb989f6ae92eacfc3251c82f69f7c5ffd5118c`. PHP 8.3/8.4 contract and Docker Compose CI passed on both [push run 35983558847](https://github.com/joomengine/mcp_client/actions/runs/35983558847) and [PR run 35983565554](https://github.com/joomengine/mcp_client/actions/runs/35983565554). Each matrix includes 25 process-level bridge checks, including complete-frame/EOF byte limits for whitespace and JSON, and nine actual built-image Compose/TLS checks. The latter verify non-root/read-only runtime dependencies, required environment configuration, remote discovery, rejected credentials and orderly session deletion. The Compose connection command remains explicit when a private CA entrypoint is configured. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 506bacf..ad244bf 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -4,16 +4,44 @@ Composer package [`joomengine/mcp-client`](https://packagist.org/packages/joomen ## Current distribution and installation -Packagist's public metadata was checked on 24 September 2026: `dev-main` points to this repository, and no tagged release is indexed. Registration makes the development branch installable; it does not create a stable release. The README's live version badge therefore displays Packagist's `dev-main` version. +The first stable client release target is **1.0.0**. During release preparation on 24 September 2026, Packagist's public metadata indexed only `dev-main` from this repository. Registration makes the development branch installable; it does not create a stable release. Check the live [package page](https://packagist.org/packages/joomengine/mcp-client) and [GitHub Releases](https://github.com/joomengine/mcp_client/releases) for current publication status. The README's standard Packagist badge tracks the latest indexed stable version. + +After the tested `v1.0.0` tag is published and indexed: ```bash composer show --all joomengine/mcp-client -composer require joomengine/mcp-client:dev-main +composer require 'joomengine/mcp-client:^1.0' composer check-platform-reqs ./vendor/bin/joomengine-mcp help ``` -Use the explicit development constraint until a tested stable tag is indexed. For a new project after a stable release, `composer require joomengine/mcp-client` selects a compatible stable version. Existing development users must replace their `dev-main` requirement with the chosen release constraint. Composer installs the executable proxy under the consuming project's `vendor/bin`; this is distinct from `bin/joomengine-mcp` in a source checkout. +Until indexing completes, `composer require joomengine/mcp-client:dev-main` remains the explicit development fallback. Existing development users can switch to stable with `composer require 'joomengine/mcp-client:^1.0' --with-dependencies`. Composer installs the executable proxy under the consuming project's `vendor/bin`; this is distinct from `bin/joomengine-mcp` in a source checkout. + +## First stable release inputs + +The client follows its own semantic versioning: **1.0.0** identifies the first stable external client API and executable. The installed component currently declares **0.1.1**, and the console plugin declares **0.1.0**. These package versions do not need to match. Interoperability is tested against concrete source revisions and the installed MCP protocol. + +Run [**Tested client release**](https://github.com/joomengine/mcp_client/actions/workflows/release.yml) from `main` containing the release preparation, with: + +| Workflow field | Exact value | +| --- | --- | +| Use workflow from | `main` | +| `version` | `1.0.0` | +| `component_ref` | `14c715c50c2cc29fd3c8cc3c4780442efb507398` | +| `plugin_ref` | `993522852770e2f8968ab066deedef00f174d8c7` | + +These immutable component/plugin refs are the release-test targets. The workflow records the actual client revision and installs the component/plugin in its Joomla 6.1+ fixture on PHP 8.3 and 8.4. Preparing these inputs is not evidence that the release workflow has run or that `v1.0.0` exists. + +From an authenticated GitHub CLI, the equivalent dispatch is: + +```bash +gh workflow run release.yml \ + --repo joomengine/mcp_client \ + --ref main \ + --field version=1.0.0 \ + --field component_ref=14c715c50c2cc29fd3c8cc3c4780442efb507398 \ + --field plugin_ref=993522852770e2f8968ab066deedef00f174d8c7 +``` ## Keep Packagist synchronized @@ -33,9 +61,11 @@ composer test composer test:packagist ``` -The consumer test resolves the public package in a new Composer project without a local path or VCS repository override. It checks package metadata and autoloading, runs Composer's generated executable, and exercises the installed SDK and bridge against a local HTTPS fixture with a private test CA. Its output records the selected package version and source reference; CI saves the report and log under `build/packagist/`. Set `PACKAGIST_REPORT_PATH` to save the JSON report locally. These checks need no Joomla installation or real site token. Missing requirements, an unavailable package or a protocol failure fail the test. +The consumer test resolves the public package in a new Composer project without a local path or VCS repository override. It checks package metadata and autoloading, runs Composer's generated executable, and exercises the installed SDK and bridge against a local HTTPS fixture with a private test CA. Its output records the selected package version and source reference and saves the JSON report to `build/packagist/consumer.json` by default; `PACKAGIST_REPORT_PATH` overrides that destination. CI retains the report and log under `build/packagist/`. These checks need no Joomla installation or real site token. Missing requirements, an unavailable package or a protocol failure fail the test. + +For stable package versions 1.0.0 or later, consumer evidence must report `legacyProtocolHeaderRequests: 0`: every post-initialization SDK request must carry its negotiated `MCP-Protocol-Version`. The fixture retains the component's missing-header compatibility for the older development package and records it explicitly; that compatibility cannot hide a missing-header regression in a stable release. -`composer test:packagist -- dev-main` selects a specific version constraint. With no argument, the runner selects the latest compatible stable version when available, falling back to `dev-main` when no stable release is indexed. The [Packagist consumer workflow](https://github.com/joomengine/mcp_client/actions/workflows/packagist.yml) runs on PHP 8.3 and 8.4 for pull requests and main-branch pushes, and supports a manual version input. CI retains the installation and fixture evidence as artifacts. +`composer test:packagist -- 1.0.0` checks the exact first stable release after indexing; `composer test:packagist -- dev-main` explicitly selects development. With no argument, the runner selects the latest compatible stable version when available, falling back to `dev-main` when no stable release is indexed. The [Packagist consumer workflow](https://github.com/joomengine/mcp_client/actions/workflows/packagist.yml) runs on PHP 8.3 and 8.4 for pull requests and main-branch pushes, and supports a manual version input. CI retains the installation and fixture evidence as artifacts. On a PR, the registry install tests the version already indexed by Packagist, not unpublished PR code. The separate PHP contract and Docker checks exercise the proposed source. Neither layer substitutes for installed Joomla acceptance. @@ -51,6 +81,6 @@ After Packagist indexes the tag: 1. Confirm the exact version and source commit in `composer show --all joomengine/mcp-client` and on the [package page](https://packagist.org/packages/joomengine/mcp-client). 2. Run **Packagist consumer** manually with that exact version. Both PHP versions must pass against the downloaded distribution. -3. For the first stable release, update the README's primary installation command and development-status text. Replace its development-version badge with the standard [Packagist release badge](https://img.shields.io/packagist/v/joomengine/mcp-client), retaining the link to the package page. This badge then tracks subsequent stable releases automatically. +3. Confirm the standard [Packagist release badge](https://img.shields.io/packagist/v/joomengine/mcp-client) shows the indexed stable version and that the exact-version consumer report records the tag's source commit with no missing SDK protocol headers. Retain the release and consumer workflow evidence. The `^1.0` installation command supports subsequent compatible stable 1.x releases. For local installed acceptance, supply `JOOMENGINE_MCP_URL` and `JOOMENGINE_MCP_TOKEN` and run `php tests/live.php`; a private test CA can be configured through PHP's `curl.cainfo`. Missing configuration fails. Full component/JCB write and job acceptance remains owned by the installed component's fixture, rather than a duplicated business-operation catalogue in this package. diff --git a/tests/consumer.php b/tests/consumer.php index 65a10cf..43d9228 100644 --- a/tests/consumer.php +++ b/tests/consumer.php @@ -219,6 +219,12 @@ && $request['session'] !== $stdioSession && $request['protocol'] === null)); echo 'Published SDK requests using component-compatible missing-header handling: ' . $legacyRequests . PHP_EOL; +if (preg_match('/\Av?\d+\.\d+\.\d+\z/D', $version ?? '') === 1 + && version_compare(ltrim($version, 'v'), '1.0.0', '>=')) +{ + $check($legacyRequests === 0, 'stable SDK sends the negotiated protocol header on every session request'); +} + $evidence = ['package' => 'joomengine/mcp-client', 'version' => $version, 'reference' => $reference, 'php' => PHP_VERSION, 'checks' => $passed, 'legacyProtocolHeaderRequests' => $legacyRequests, 'fixture' => 'loopback HTTPS protocol fixture with component-compatible legacy header handling; no installed Joomla site']; From aa960899aa6de8e197ad8c8531538bb356df252d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:23:23 +0200 Subject: [PATCH 4/4] Automate tested stable releases and exact Packagist verification --- .github/workflows/packagist.yml | 30 ++++++- .github/workflows/release.yml | 97 +++++++++++++------- CHANGELOG.md | 4 +- README.md | 2 +- docs/IMPLEMENTATION.md | 7 +- docs/RELEASE.md | 39 ++++---- release.json | 5 ++ tests/consumer.php | 64 +++++++++++++ tests/packagist.sh | 53 ++++++++++- tests/release.sh | 153 ++++++++++++++++++++++++++++++++ tools/release-plan.sh | 123 +++++++++++++++++++++++++ 11 files changed, 518 insertions(+), 59 deletions(-) create mode 100644 release.json create mode 100644 tests/release.sh create mode 100644 tools/release-plan.sh diff --git a/.github/workflows/packagist.yml b/.github/workflows/packagist.yml index 531fcc0..0d0f306 100644 --- a/.github/workflows/packagist.yml +++ b/.github/workflows/packagist.yml @@ -11,12 +11,38 @@ on: default: auto required: false type: string + expected_reference: + description: 'Optional full commit SHA that the installed public package must contain' + default: '' + required: false + type: string + wait_for_index: + description: 'Wait up to five minutes for the exact release version to appear in Packagist' + default: false + required: false + type: boolean + workflow_call: + inputs: + version: + description: 'Exact Packagist release version to install, for example 1.0.0' + required: true + type: string + expected_reference: + description: 'Optional full release commit SHA to verify against the installed package' + default: '' + required: false + type: string + wait_for_index: + description: 'Wait up to five minutes for the exact release version to appear in Packagist' + default: false + required: false + type: boolean permissions: contents: read concurrency: - group: packagist-consumer-${{ github.ref }} + group: packagist-consumer-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -40,6 +66,8 @@ jobs: - name: Install the public Packagist package and test it as an independent consumer env: MCP_PACKAGE_VERSION: ${{ inputs.version || 'auto' }} + PACKAGIST_EXPECTED_REFERENCE: ${{ inputs.expected_reference || '' }} + PACKAGIST_WAIT_FOR_INDEX: ${{ inputs.wait_for_index && 'true' || 'false' }} PACKAGIST_REPORT_PATH: ${{ github.workspace }}/build/packagist/consumer.json run: | set -euo pipefail diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06ca6b7..4d7ae85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,78 +1,109 @@ name: Tested client release on: + pull_request: + push: + branches: [main] workflow_dispatch: inputs: version: - description: 'Version without v, for example 1.0.0 or 1.1.0-rc.1' - required: true + description: 'Optional version override, without v; otherwise use release.json' + required: false type: string component_ref: - description: Tested installed component tag or commit - required: true - default: main + description: Optional installed component tag or commit override + required: false type: string plugin_ref: - description: Tested console plugin tag or commit - required: true - default: main + description: Optional console plugin tag or commit override + required: false type: string permissions: contents: read concurrency: - group: client-release + group: client-release-${{ github.event_name == 'pull_request' && github.ref || 'publish' }} cancel-in-progress: false jobs: - validate: + plan: runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + publish: ${{ steps.plan.outputs.publish }} + verify: ${{ steps.plan.outputs.verify }} + version: ${{ steps.plan.outputs.version }} + component_ref: ${{ steps.plan.outputs.component_ref }} + plugin_ref: ${{ steps.plan.outputs.plugin_ref }} + client_ref: ${{ steps.plan.outputs.client_ref }} steps: - - name: Require a main-branch semantic version + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Validate the declaration and release-state behavior without publishing + run: | + bash tools/release-plan.sh validate + bash tests/release.sh + - name: Plan the immutable release from the reviewed main commit + id: plan + if: github.event_name != 'pull_request' env: - VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_COMPONENT_REF: ${{ inputs.component_ref }} + RELEASE_PLUGIN_REF: ${{ inputs.plugin_ref }} run: | set -euo pipefail - test "$GITHUB_REF" = refs/heads/main - [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(alpha|beta|rc)\.(0|[1-9][0-9]*))?$ ]] + bash tools/release-plan.sh plan | tee "$GITHUB_OUTPUT" tests: - needs: validate + needs: plan + if: needs.plan.outputs.publish == 'true' uses: ./.github/workflows/ci.yml permissions: contents: read installed: - needs: validate + needs: plan + if: needs.plan.outputs.publish == 'true' uses: ./.github/workflows/installed.yml with: - component_ref: ${{ inputs.component_ref }} - plugin_ref: ${{ inputs.plugin_ref }} + component_ref: ${{ needs.plan.outputs.component_ref }} + plugin_ref: ${{ needs.plan.outputs.plugin_ref }} permissions: contents: read release: - needs: [validate, tests, installed] + needs: [plan, tests, installed] + if: github.ref == 'refs/heads/main' && needs.plan.outputs.publish == 'true' runs-on: ubuntu-latest + timeout-minutes: 5 permissions: contents: write + outputs: + version: ${{ steps.publish.outputs.version }} + client_ref: ${{ steps.publish.outputs.client_ref }} steps: - uses: actions/checkout@v7 with: - ref: ${{ github.sha }} + ref: ${{ needs.plan.outputs.client_ref }} persist-credentials: true - - name: Push a new tested version tag and create its GitHub release + - name: Publish or recover the tested version without moving an existing tag + id: publish env: GH_TOKEN: ${{ github.token }} - VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ needs.plan.outputs.version }} + RELEASE_COMPONENT_REF: ${{ needs.plan.outputs.component_ref }} + RELEASE_PLUGIN_REF: ${{ needs.plan.outputs.plugin_ref }} run: | set -euo pipefail - TAG="v$VERSION" - if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" >/dev/null 2>&1; then - echo 'This version tag already exists; it will not be overwritten.' >&2 - exit 1 - fi - git tag "$TAG" "$GITHUB_SHA" - git push origin "refs/tags/$TAG" - release_flags=() - if [[ "$VERSION" == *-* ]]; then release_flags+=(--prerelease); fi - gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \ - "${release_flags[@]}" --generate-notes --title "JoomEngine MCP Client $VERSION" + bash tools/release-plan.sh publish | tee "$GITHUB_OUTPUT" + verify: + needs: [plan, release] + if: always() && needs.plan.result == 'success' && needs.plan.outputs.verify == 'true' && (needs.plan.outputs.publish == 'false' || needs.release.result == 'success') + uses: ./.github/workflows/packagist.yml + with: + version: ${{ needs.plan.outputs.version }} + expected_reference: ${{ needs.plan.outputs.client_ref }} + wait_for_index: true + permissions: + contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e8dd12..fd00724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ Changes for the first stable client release. Publication is recorded by the [`v1 - Concurrent remote forwarding of HTTP identity, JSON-RPC/session metadata, pagination, finite SSE events, cancellation notifications and structured job results. - Complete non-root Docker image and read-only Compose service, using a site URL and API token through environment variables. - PHP 8.3/8.4 source contracts, TLS and process tests, real Compose checks and packaged Joomla interoperability acceptance. -- Main-only stable/prerelease automation gated on client, Docker and installed Joomla checks, with immutable first-release component/plugin inputs documented. +- Reviewed `release.json` manifest with an independent client version and immutable component/plugin revisions; pull requests validate the release plan without publishing. +- Automatic publication after merge to `main`, gated on client, Docker and installed Joomla checks; existing versions are never overwritten and manual dispatch remains available. - Packagist metadata, stable-version/download badges, release links, Composer-first local/global installation, AI launcher setup and a complete PHP discovery example. - Stable `^1.0` installation instructions with an explicit `dev-main` fallback before publication and for development testing. - PHP 8.3/8.4 consumer checks that download the actual Packagist distribution into a clean project and exercise its generated executable and SDK against trusted local HTTPS. @@ -27,3 +28,4 @@ Changes for the first stable client release. Publication is recorded by the [`v1 - Consumer evidence identifies the downloaded version and commit and records SDK requests missing their negotiated protocol header. This compatibility diagnostic supports the older development package; stable versions from 1.0.0 must report zero missing SDK protocol headers. - The stdio bridge must send the negotiated protocol header, and the fixture rejects incorrect supplied revisions for both SDK and stdio clients. - Packagist synchronization and exact-version consumer verification are documented separately from source tests and installed Joomla acceptance. +- Release automation waits a bounded time for Packagist to index the new version, then verifies that exact version and source commit with the consumer suite on PHP 8.3 and 8.4. Installation and protocol failures fail the workflow without retries. diff --git a/README.md b/README.md index 792129f..020e5a4 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The client, installed component and console plugin have independent version numb | Console plugin | `0.1.0`; needed for local console serving | [`9935228`](https://github.com/joomengine/mcp_plugin/commit/993522852770e2f8968ab066deedef00f174d8c7) | | Joomla site | Joomla 6.1+ | Packaged Joomla installation in the interoperability fixture | -The release workflow must pass against these exact component/plugin revisions before publishing the client tag. [Implementation evidence](docs/IMPLEMENTATION.md) records test scope; [release instructions](docs/RELEASE.md) provide the immutable workflow inputs. +The reviewed [release manifest](release.json) declares the version and exact component/plugin revisions. After its pull request is merged into `main`, the release workflow tests that client commit and publishes a new version only when all release checks pass. It then verifies the exact version and source commit installed from Packagist on PHP 8.3 and 8.4. The manifest declares release intent; the live badge and package listing show actual publication. [Implementation evidence](docs/IMPLEMENTATION.md) records test scope, and [release instructions](docs/RELEASE.md) explain subsequent version updates. ### Connect an AI application diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index d555fca..116ab32 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -2,7 +2,7 @@ The standalone PHP client and remote stdio bridge from [PR #1](https://github.com/joomengine/mcp_client/pull/1) are merged into `main`. Component and console-plugin business logic remain in their own repositories. [Repository workflows](https://github.com/joomengine/mcp_client/actions) record current checks; the [component acceptance checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349) records coordinated Joomla/JCB execution through the installed server and this generic bridge. -**First stable release preparation, 24 September 2026:** [PR #2](https://github.com/joomengine/mcp_client/pull/2) prepares client **1.0.0**, independently versioned from the installed component (**0.1.1**) and console plugin (**0.1.0**). The selected release inputs pin component `14c715c50c2cc29fd3c8cc3c4780442efb507398` and plugin `993522852770e2f8968ab066deedef00f174d8c7` for the Joomla 6.1+ installed fixture. [Release instructions](RELEASE.md#first-stable-release-inputs) contain the exact workflow inputs. Each release run must record and pass against its selected sources before publishing. Current publication status is shown by [GitHub Releases](https://github.com/joomengine/mcp_client/releases) and [Packagist](https://packagist.org/packages/joomengine/mcp-client), independently of this preparation record. +**First stable release preparation, 24 September 2026:** [PR #2](https://github.com/joomengine/mcp_client/pull/2) prepares client **1.0.0**, independently versioned from the installed component (**0.1.1**) and console plugin (**0.1.0**). The [release manifest](../release.json) pins component `14c715c50c2cc29fd3c8cc3c4780442efb507398` and plugin `993522852770e2f8968ab066deedef00f174d8c7` for the Joomla 6.1+ installed fixture. [Release instructions](RELEASE.md#first-stable-release-manifest) record these inputs and the review/merge process. Each release run must record and pass against its selected sources before publishing. Current publication status is shown by [GitHub Releases](https://github.com/joomengine/mcp_client/releases) and [Packagist](https://packagist.org/packages/joomengine/mcp-client), independently of this preparation record. [Installed interoperability run 35988304612](https://github.com/joomengine/mcp_client/actions/runs/35988304612) passed on PHP 8.3 and 8.4 against those exact component/plugin revisions. This is existing compatibility evidence; the release workflow still retests its own client commit before tagging. @@ -17,7 +17,8 @@ Implemented: - Safe error framing, bounded input/output/concurrency, stdout isolation and no automatic retries. - PHP 8.3/8.4 SDK/configuration/process/TLS CI and installed component interoperability CI. - PHP 8.3/8.4 image builds and real Compose-to-HTTPS protocol checks, including clean EOF/session deletion and failed authentication. -- Manually triggered main-only stable/prerelease workflow gated on both test layers; tags are never overwritten. +- Reviewed release manifest with read-only PR validation and automatic main-branch publication gated on source, Docker and installed Joomla tests; existing tags are never overwritten and manual dispatch remains supported. +- Post-release Packagist verification that waits a bounded time for indexing, then tests the exact released version and source commit on PHP 8.3 and 8.4 without retrying installation or protocol failures. - Composer-first stable 1.x installation and explicit development fallback, project-local/global executable setup, complete PHP discovery example and linked Packagist/version/download badges. - PHP 8.3/8.4 consumer workflow that downloads the indexed Packagist package into a clean project and tests the installed SDK and generated executable against trusted local HTTPS. - SDK HTTP binding that sends the publicly negotiated protocol revision on subsequent requests, notifications and session deletion, with source checks for both the requested revision and a supported server-selected alternative. @@ -36,4 +37,4 @@ Verified runtime source: `1ebb989f6ae92eacfc3251c82f69f7c5ffd5118c`. PHP 8.3/8.4 [Installed interoperability run 35983558934](https://github.com/joomengine/mcp_client/actions/runs/35983558934) passed on PHP 8.3 and 8.4 using that client source, component `75d9685268332241de846935aad2edc2e92c8459` and plugin `3526cae818803a02971374c044a2e2184f1c2c61`. Each installed client suite executed 14 live assertions against 24 discovered tools through the PHP SDK and external stdio executable. Source revisions and full fixture results are retained in the run's artifacts. -These historical results certify the recorded revisions and test scopes. Current workflows report checks for newer revisions, and the coordinated component acceptance checklist records the separate Joomla/JCB write/job matrix. Deliberate tagged releases follow separately; this runtime evidence does not claim a published image or stable Composer release. The public Packagist registration described above was verified independently. +These historical results certify the recorded revisions and test scopes. Current workflows report checks for newer revisions, and the coordinated component acceptance checklist records the separate Joomla/JCB write/job matrix. Merging a reviewed manifest for a new version starts the tested publication workflow; the manifest and runtime evidence alone do not establish publication. The public Packagist registration described above was verified independently. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index ad244bf..3b125b8 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -17,31 +17,30 @@ composer check-platform-reqs Until indexing completes, `composer require joomengine/mcp-client:dev-main` remains the explicit development fallback. Existing development users can switch to stable with `composer require 'joomengine/mcp-client:^1.0' --with-dependencies`. Composer installs the executable proxy under the consuming project's `vendor/bin`; this is distinct from `bin/joomengine-mcp` in a source checkout. -## First stable release inputs +## First stable release manifest The client follows its own semantic versioning: **1.0.0** identifies the first stable external client API and executable. The installed component currently declares **0.1.1**, and the console plugin declares **0.1.0**. These package versions do not need to match. Interoperability is tested against concrete source revisions and the installed MCP protocol. -Run [**Tested client release**](https://github.com/joomengine/mcp_client/actions/workflows/release.yml) from `main` containing the release preparation, with: +The reviewed [`release.json`](../release.json) declares these first-release inputs: -| Workflow field | Exact value | +| Manifest field | Exact value | | --- | --- | -| Use workflow from | `main` | | `version` | `1.0.0` | | `component_ref` | `14c715c50c2cc29fd3c8cc3c4780442efb507398` | | `plugin_ref` | `993522852770e2f8968ab066deedef00f174d8c7` | -These immutable component/plugin refs are the release-test targets. The workflow records the actual client revision and installs the component/plugin in its Joomla 6.1+ fixture on PHP 8.3 and 8.4. Preparing these inputs is not evidence that the release workflow has run or that `v1.0.0` exists. +These immutable component/plugin refs are the release-test targets. [**Tested client release**](https://github.com/joomengine/mcp_client/actions/workflows/release.yml) records the actual client revision and installs the component/plugin in its Joomla 6.1+ fixture on PHP 8.3 and 8.4. The manifest declares intent; it is not evidence that `v1.0.0` has been published or indexed. -From an authenticated GitHub CLI, the equivalent dispatch is: +## Publish through review and merge -```bash -gh workflow run release.yml \ - --repo joomengine/mcp_client \ - --ref main \ - --field version=1.0.0 \ - --field component_ref=14c715c50c2cc29fd3c8cc3c4780442efb507398 \ - --field plugin_ref=993522852770e2f8968ab066deedef00f174d8c7 -``` +1. Update `release.json` with the new client semantic version and the full component/plugin commit IDs to test. Add that version's changes to `CHANGELOG.md`; `composer.json` continues to omit a version property. +2. Open a pull request. The release workflow validates the proposed manifest with read-only permissions. Pull requests cannot create tags or releases. Source, Docker and installed interoperability checks validate the proposed client changes separately. +3. Review and merge the PR into `main`. The main-branch release workflow reads the manifest. A new version triggers the full release tests before any tag is created; an existing completed version on a later main-branch push is skipped without moving or overwriting its tag. +4. After the tests pass, the workflow tags the tested client commit and creates its GitHub release. It then waits a bounded time for Packagist indexing and invokes consumer verification against that exact version and source commit on both supported PHP versions. + +Future releases use the same process: bump the manifest and changelog in a reviewed PR, then merge. Merging ordinary changes with an already published manifest version does not republish it. Stable `X.Y.Z` versions and `alpha.N`, `beta.N` or `rc.N` prereleases are supported; prerelease tags produce GitHub prereleases. + +Manual `workflow_dispatch` remains available on `main` for explicit release operations. Inputs default to the manifest and may be overridden; component/plugin refs are resolved to immutable commit IDs before testing. It uses the same validation, test gates, immutable-tag protection and consumer verification; it is not required for normal publication after merging a new manifest version. ## Keep Packagist synchronized @@ -65,22 +64,24 @@ The consumer test resolves the public package in a new Composer project without For stable package versions 1.0.0 or later, consumer evidence must report `legacyProtocolHeaderRequests: 0`: every post-initialization SDK request must carry its negotiated `MCP-Protocol-Version`. The fixture retains the component's missing-header compatibility for the older development package and records it explicitly; that compatibility cannot hide a missing-header regression in a stable release. -`composer test:packagist -- 1.0.0` checks the exact first stable release after indexing; `composer test:packagist -- dev-main` explicitly selects development. With no argument, the runner selects the latest compatible stable version when available, falling back to `dev-main` when no stable release is indexed. The [Packagist consumer workflow](https://github.com/joomengine/mcp_client/actions/workflows/packagist.yml) runs on PHP 8.3 and 8.4 for pull requests and main-branch pushes, and supports a manual version input. CI retains the installation and fixture evidence as artifacts. +`composer test:packagist -- 1.0.0` checks the exact first stable release after indexing; `composer test:packagist -- dev-main` explicitly selects development. With no argument, the runner selects the latest compatible stable version when available, falling back to `dev-main` when no stable release is indexed. The [Packagist consumer workflow](https://github.com/joomengine/mcp_client/actions/workflows/packagist.yml) runs on PHP 8.3 and 8.4 for pull requests and main-branch pushes, supports a manual version input, and is reusable by the release workflow. CI retains the installation and fixture evidence as artifacts. + +For a release, the caller supplies the exact version and expected source commit. Only Packagist's indexing delay is retried within the configured time bound. Once metadata identifies the expected release, Composer installation and protocol tests run once: download, dependency, certificate or behaviour failures fail the workflow. If indexing times out, the workflow fails visibly and preserves its evidence; it does not substitute `dev-main` or another version. On a PR, the registry install tests the version already indexed by Packagist, not unpublished PR code. The separate PHP contract and Docker checks exercise the proposed source. Neither layer substitutes for installed Joomla acceptance. ## Tested release workflow -Run **Tested client release** on main with a semantic version without `v`, plus the component and console-plugin refs to test. Prefer immutable tags or full commit IDs for the dependencies. The workflow accepts stable versions and `alpha.N`, `beta.N` or `rc.N` prereleases. - Before creating a tag, it runs the full PHP 8.3/8.4 contract/TLS suite, the Docker Compose build/HTTPS suites on both PHP versions, and the installed Joomla interoperability workflow using that same client commit. The installed workflow records all three actual commit IDs. Any failed job prevents tagging. Releases cannot run from pull requests, and existing tags are never moved or overwritten. -After testing, the workflow creates the tag and matching GitHub release, setting prerelease status when appropriate. Packagist's configured integration should index the tag; verify that externally rather than assuming it occurred. If release creation fails after tagging, inspect the existing tag and complete its release without moving the tag. +After testing, the workflow creates the tag and matching GitHub release, setting prerelease status when appropriate. Packagist's configured integration should index the tag; the subsequent consumer job verifies this externally. A failed post-publication check leaves the immutable tag and release intact for diagnosis. Retrying the original workflow at the same client commit can repeat registry verification for an already published version. + +If tag creation succeeded but GitHub release creation failed, the workflow can recover the missing release only when the tag resolves to the exact tested client commit. An orphan tag at another commit, an existing draft release, or an API authentication, rate-limit or network failure stops publication instead of being treated as an absent release. Existing tags are never moved to repair a failed run. After Packagist indexes the tag: 1. Confirm the exact version and source commit in `composer show --all joomengine/mcp-client` and on the [package page](https://packagist.org/packages/joomengine/mcp-client). -2. Run **Packagist consumer** manually with that exact version. Both PHP versions must pass against the downloaded distribution. +2. Confirm that the release workflow's **Packagist consumer** checks passed for the exact version and source commit on both PHP versions. The standalone manual consumer workflow remains available for later verification. 3. Confirm the standard [Packagist release badge](https://img.shields.io/packagist/v/joomengine/mcp-client) shows the indexed stable version and that the exact-version consumer report records the tag's source commit with no missing SDK protocol headers. Retain the release and consumer workflow evidence. The `^1.0` installation command supports subsequent compatible stable 1.x releases. For local installed acceptance, supply `JOOMENGINE_MCP_URL` and `JOOMENGINE_MCP_TOKEN` and run `php tests/live.php`; a private test CA can be configured through PHP's `curl.cainfo`. Missing configuration fails. Full component/JCB write and job acceptance remains owned by the installed component's fixture, rather than a duplicated business-operation catalogue in this package. diff --git a/release.json b/release.json new file mode 100644 index 0000000..fac28ec --- /dev/null +++ b/release.json @@ -0,0 +1,5 @@ +{ + "version": "1.0.0", + "component_ref": "14c715c50c2cc29fd3c8cc3c4780442efb507398", + "plugin_ref": "993522852770e2f8968ab066deedef00f174d8c7" +} diff --git a/tests/consumer.php b/tests/consumer.php index 43d9228..a95b9c6 100644 --- a/tests/consumer.php +++ b/tests/consumer.php @@ -5,6 +5,63 @@ use VDM\Joomla\Mcp\Client\Connection; +// Run before loading any package: only a public Packagist p2 response is needed +// to determine whether a newly published, exact release is ready to install. +if (($argv[1] ?? '') === '--index-ready') +{ + $metadata = json_decode(file_get_contents($argv[2]), true, 512, JSON_THROW_ON_ERROR); + $target = ltrim($argv[3] ?? '', 'v'); + $expected = $argv[4] ?? ''; + $versions = $metadata['packages']['joomengine/mcp-client'] ?? null; + if (!is_array($versions) || !array_is_list($versions) + || (isset($metadata['minified']) && $metadata['minified'] !== 'composer/2.0')) + { + throw new RuntimeException('Invalid public Packagist version metadata.'); + } + $expanded = []; + foreach ($versions as $version) + { + if (!is_array($version)) + { + throw new RuntimeException('Invalid public Packagist version entry.'); + } + if (($metadata['minified'] ?? '') === 'composer/2.0') + { + // Composer p2 entries inherit unchanged top-level fields from their + // predecessor; the literal __unset marker removes an inherited key. + foreach ($version as $key => $value) + { + if ($value === '__unset') + { + unset($expanded[$key]); + } + else + { + $expanded[$key] = $value; + } + } + } + else + { + $expanded = $version; + } + if (ltrim($expanded['version'] ?? '', 'v') !== $target) + { + continue; + } + $indexedReference = $expanded['source']['reference'] ?? ''; + if (($expanded['source']['url'] ?? '') !== 'https://github.com/joomengine/mcp_client.git' + || preg_match('/\A[0-9a-f]{40}\z/D', $indexedReference) !== 1 + || ($expected !== '' && $indexedReference !== $expected)) + { + throw new RuntimeException('The indexed release does not match the expected repository and release commit.'); + } + echo 'Packagist indexed joomengine/mcp-client ' . $expanded['version'] . ' at ' . $indexedReference . PHP_EOL; + exit(0); + } + exit(3); +} + $consumer = realpath($argv[1] ?? ''); $site = $argv[2] ?? ''; $audit = $argv[3] ?? ''; @@ -42,6 +99,12 @@ $installedPackages = array_column($installed['packages'] ?? $installed, null, 'name'); $check(($installedPackages['joomengine/mcp-client']['installation-source'] ?? null) === 'dist', 'Composer installed the actual package distribution archive'); echo 'Installed joomengine/mcp-client ' . $version . ' at ' . $reference . PHP_EOL; +$expectedReference = getenv('PACKAGIST_EXPECTED_REFERENCE'); +if (is_string($expectedReference) && $expectedReference !== '') +{ + $check(preg_match('/\A[0-9a-f]{40}\z/D', $expectedReference) === 1 && $reference === $expectedReference, + 'installed public package contains the exact expected release commit'); +} foreach ([Connection::class, ClientFactory::class, VDM\Joomla\Mcp\Client\Bridge\StdioBridge::class, VDM\Joomla\Mcp\Client\Http\CurlClient::class, VDM\Joomla\Mcp\Client\Http\MultiClient::class] as $class) @@ -226,6 +289,7 @@ } $evidence = ['package' => 'joomengine/mcp-client', 'version' => $version, 'reference' => $reference, + 'expectedReference' => is_string($expectedReference) && $expectedReference !== '' ? $expectedReference : null, 'php' => PHP_VERSION, 'checks' => $passed, 'legacyProtocolHeaderRequests' => $legacyRequests, 'fixture' => 'loopback HTTPS protocol fixture with component-compatible legacy header handling; no installed Joomla site']; $report = json_encode($evidence, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL; diff --git a/tests/packagist.sh b/tests/packagist.sh index 3e85122..8d41797 100644 --- a/tests/packagist.sh +++ b/tests/packagist.sh @@ -5,10 +5,27 @@ set -Eeuo pipefail test_directory=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) export PACKAGIST_REPORT_PATH=${PACKAGIST_REPORT_PATH:-"$test_directory/../build/packagist/consumer.json"} constraint=${1:-auto} +expected_reference=${PACKAGIST_EXPECTED_REFERENCE:-} +wait_for_index=${PACKAGIST_WAIT_FOR_INDEX:-false} if (( $# > 1 )) || [[ -z "$constraint" ]]; then printf 'Usage: bash tests/packagist.sh [auto|COMPOSER_VERSION_CONSTRAINT]\n' >&2 exit 2 fi +if [[ "$wait_for_index" != true && "$wait_for_index" != false ]]; then + printf 'PACKAGIST_WAIT_FOR_INDEX must be true or false.\n' >&2 + exit 2 +fi +if [[ -n "$expected_reference" && ! "$expected_reference" =~ ^[0-9a-f]{40}$ ]]; then + printf 'PACKAGIST_EXPECTED_REFERENCE must be a full lowercase Git commit SHA.\n' >&2 + exit 2 +fi +if [[ "$wait_for_index" == true || -n "$expected_reference" ]]; then + if [[ ! "$constraint" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(alpha|beta|rc)\.(0|[1-9][0-9]*))?$ ]]; then + printf 'Release verification requires an exact MAJOR.MINOR.PATCH version, optionally with -alpha.N, -beta.N or -rc.N.\n' >&2 + exit 2 + fi +fi +export PACKAGIST_EXPECTED_REFERENCE="$expected_reference" for executable in php composer curl node openssl timeout; do if ! command -v "$executable" >/dev/null 2>&1; then printf 'Packagist consumer tests require %s.\n' "$executable" >&2 @@ -50,8 +67,42 @@ cat > "$COMPOSER" <<'JSON' } JSON +if [[ "$wait_for_index" == true ]]; then + printf 'Waiting up to 300 seconds for Packagist to index joomengine/mcp-client %s.\n' "$constraint" + index_deadline=$((SECONDS + 300)) + while true; do + remaining=$((index_deadline - SECONDS)) + if (( remaining <= 0 )); then + printf 'Packagist did not index %s within 300 seconds; no package was substituted.\n' "$constraint" >&2 + exit 1 + fi + request_timeout=$((remaining < 30 ? remaining : 30)) + # Read public Composer metadata only. HTTP failures are fatal; only an + # otherwise valid index missing the requested tag is polled again. + curl --disable --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ + --connect-timeout 15 --max-time "$request_timeout" \ + 'https://repo.packagist.org/p2/joomengine/mcp-client.json' \ + --output "$consumer_directory/packagist.json" + if php "$test_directory/consumer.php" --index-ready "$consumer_directory/packagist.json" \ + "$constraint" "$expected_reference"; then + break + else + index_status=$? + if (( index_status != 3 )); then + exit "$index_status" + fi + fi + remaining=$((index_deadline - SECONDS)) + if (( remaining > 0 )); then + printf 'Release %s is not indexed yet; checking again in %s seconds.\n' \ + "$constraint" "$((remaining < 15 ? remaining : 15))" + sleep "$((remaining < 15 ? remaining : 15))" + fi + done +fi + if [[ "$constraint" == auto ]]; then - curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ + curl --disable --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ --connect-timeout 15 --max-time 60 \ 'https://repo.packagist.org/p2/joomengine/mcp-client.json' \ --output "$consumer_directory/packagist.json" diff --git a/tests/release.sh b/tests/release.sh new file mode 100644 index 0000000..a9f3415 --- /dev/null +++ b/tests/release.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Offline tests: no repository credentials, network calls, tags or releases. +set -Eeuo pipefail +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +temporary=$(mktemp -d) +trap 'rm -rf -- "$temporary"' EXIT +mkdir "$temporary/bin" +cat >"$temporary/release.json" <<'JSON' +{"version":"1.0.0","component_ref":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","plugin_ref":"cccccccccccccccccccccccccccccccccccccccc"} +JSON +export GITHUB_REF=refs/heads/main GITHUB_EVENT_NAME=push GITHUB_REPOSITORY=joomengine/mcp_client +export GITHUB_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +export RELEASE_TEST_LOG="$temporary/mutations" +unset RELEASE_VERSION RELEASE_COMPONENT_REF RELEASE_PLUGIN_REF +cat >"$temporary/bin/gh" <<'MOCK' +#!/usr/bin/env bash +set -eu +if [[ "$1" == release ]]; then + printf '%s\n' "$*" >>"$RELEASE_TEST_LOG" + [[ "${RELEASE_TEST_CASE:-}" != create_failure ]] + printf 'https://github.com/joomengine/mcp_client/releases/tag/test-version\n' + exit $? +fi +endpoint=${!#} +status=200 +body='{}' +case "$endpoint" in + */commits/*) + ref=${endpoint##*/} + if [[ ! "$ref" =~ ^[0-9a-f]{40}$ ]]; then ref=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb; fi + body="{\"sha\":\"$ref\"}" + ;; + */git/ref/tags/*) + case "${RELEASE_TEST_CASE:-new}" in + auth) status=403 ;; + rate_limit) status=429 ;; + network) exit 1 ;; + malformed) printf 'unexpected data\n'; exit 0 ;; + new|prerelease|create_failure|orphan_release) status=404 ;; + annotated) body='{"object":{"type":"tag","sha":"cccccccccccccccccccccccccccccccccccccccc"}}' ;; + mismatch|published_old) body='{"object":{"type":"commit","sha":"dddddddddddddddddddddddddddddddddddddddd"}}' ;; + *) body="{\"object\":{\"type\":\"commit\",\"sha\":\"$GITHUB_SHA\"}}" ;; + esac + ;; + */git/tags/*) body="{\"object\":{\"type\":\"commit\",\"sha\":\"$GITHUB_SHA\"}}" ;; + */releases/tags/*) + case "${RELEASE_TEST_CASE:-new}" in + published|published_old|orphan_release) body='{"tag_name":"v1.0.0","draft":false}' ;; + draft) body='{"tag_name":"v1.0.0","draft":true}' ;; + release_auth) status=401 ;; + *) status=404 ;; + esac + ;; + *) printf 'Unexpected API request: %s\n' "$endpoint" >&2; exit 1 ;; +esac +printf 'HTTP/2.0 %s Mock\r\nContent-Type: application/json\r\n\r\n%s\n' "$status" "$body" +[[ "$status" == 200 ]] +MOCK +cat >"$temporary/bin/git" <<'MOCK' +#!/usr/bin/env bash +set -eu +if [[ "$1" == rev-parse ]]; then + printf '%s\n' "$GITHUB_SHA" +else + printf 'git %s\n' "$*" >>"$RELEASE_TEST_LOG" +fi +MOCK +chmod +x "$temporary/bin/gh" "$temporary/bin/git" +export PATH="$temporary/bin:$PATH" +checks=0 +pass() { checks=$((checks + 1)); } +run() { bash "$root/tools/release-plan.sh" "$1" "${2:-$temporary/release.json}" >"$temporary/output" 2>"$temporary/error"; } +contains() { grep -Fxq -- "$1" "$temporary/output" || { cat "$temporary/output"; exit 1; }; pass; } +reject() { + if run "$1" "${2:-$temporary/release.json}"; then + printf 'Expected rejection for case %s.\n' "${RELEASE_TEST_CASE:-manifest}" >&2 + exit 1 + fi + pass +} + +run validate +contains 'version=1.0.0' +contains 'publish=false' +for invalid in '{"version":"01.0.0","component_ref":"main","plugin_ref":"main"}' \ + '{"version":"1.0.0","component_ref":"main","plugin_ref":"main"}' \ + '{"version":"1.0.0","component_ref":null,"plugin_ref":true}'; do + printf '%s\n' "$invalid" >"$temporary/invalid.json" + reject validate "$temporary/invalid.json" +done + +export RELEASE_TEST_CASE=new +run plan +contains 'publish=true' +contains 'verify=true' +contains 'create_tag=true' +contains 'component_ref=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +[[ ! -e "$RELEASE_TEST_LOG" ]] || exit 1 +pass +run publish +if grep -vE '^[a-z_]+=[A-Za-z0-9_.-]+$' "$temporary/output"; then + printf 'Publication emitted invalid GitHub Actions output.\n' >&2 + exit 1 +fi +pass +grep -Fxq "git tag v1.0.0 $GITHUB_SHA" "$RELEASE_TEST_LOG" +grep -Fxq 'git push origin refs/tags/v1.0.0' "$RELEASE_TEST_LOG" +grep -Fq 'release create v1.0.0' "$RELEASE_TEST_LOG" +pass + +for RELEASE_TEST_CASE in recovery annotated; do + export RELEASE_TEST_CASE + : >"$RELEASE_TEST_LOG" + run plan + contains 'create_tag=false' + run publish + [[ "$(wc -l <"$RELEASE_TEST_LOG")" == 1 ]] + grep -Fq 'release create v1.0.0' "$RELEASE_TEST_LOG" + pass +done +for RELEASE_TEST_CASE in published published_old; do + export RELEASE_TEST_CASE + : >"$RELEASE_TEST_LOG" + run publish + contains 'publish=false' + if [[ "$RELEASE_TEST_CASE" == published ]]; then contains 'verify=true'; else contains 'verify=false'; fi + [[ ! -s "$RELEASE_TEST_LOG" ]] + pass +done +for RELEASE_TEST_CASE in mismatch orphan_release draft auth rate_limit network malformed release_auth; do + export RELEASE_TEST_CASE + : >"$RELEASE_TEST_LOG" + reject publish + [[ ! -s "$RELEASE_TEST_LOG" ]] || exit 1 +done +export RELEASE_TEST_CASE=create_failure +reject publish +export RELEASE_TEST_CASE=new GITHUB_EVENT_NAME=pull_request +reject publish +export GITHUB_EVENT_NAME=push GITHUB_REF=refs/heads/feature +reject publish +export GITHUB_REF=refs/heads/main GITHUB_EVENT_NAME=workflow_dispatch RELEASE_VERSION=1.1.0-rc.1 +export RELEASE_COMPONENT_REF=v0.1.1 RELEASE_PLUGIN_REF=main RELEASE_TEST_CASE=prerelease +: >"$RELEASE_TEST_LOG" +run publish +contains 'version=1.1.0-rc.1' +grep -Fq -- '--prerelease' "$RELEASE_TEST_LOG" +pass +export RELEASE_VERSION=1.0.0 RELEASE_TEST_CASE=published_old +reject publish +export RELEASE_VERSION='1.0.0;false' +reject publish +printf 'Release declaration and publication planning: %s checks passed.\n' "$checks" diff --git a/tools/release-plan.sh b/tools/release-plan.sh new file mode 100644 index 0000000..cb20d73 --- /dev/null +++ b/tools/release-plan.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Validate the reviewed release declaration, plan publication, or publish tested code. +set -Eeuo pipefail + +mode=${1:-validate} +manifest=${2:-release.json} +[[ $# -le 2 && "$mode" =~ ^(validate|plan|publish)$ ]] || { + printf 'Usage: bash tools/release-plan.sh [validate|plan|publish] [release.json]\n' >&2 + exit 2 +} +fail() { printf '%s\n' "$*" >&2; exit 1; } +semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(alpha|beta|rc)\.(0|[1-9][0-9]*))?$' +jq -e 'type == "object" and (keys == ["component_ref", "plugin_ref", "version"]) + and (.version | type == "string") and (.component_ref | type == "string") + and (.plugin_ref | type == "string")' "$manifest" >/dev/null || fail 'Invalid release manifest.' +version=$(jq -r .version "$manifest") +component_ref=$(jq -r .component_ref "$manifest") +plugin_ref=$(jq -r .plugin_ref "$manifest") +[[ "$version" =~ $semver ]] || fail 'The manifest must declare a semantic release version.' +[[ "$component_ref" =~ ^[0-9a-f]{40}$ && "$plugin_ref" =~ ^[0-9a-f]{40}$ ]] \ + || fail 'Manifest component and plugin references must be immutable commit IDs.' + +if [[ "$mode" == validate ]]; then + printf 'version=%s\ncomponent_ref=%s\nplugin_ref=%s\npublish=false\n' "$version" "$component_ref" "$plugin_ref" + exit 0 +fi + +[[ "${GITHUB_REF:-}" == refs/heads/main ]] || fail 'Releases can only run from main.' +[[ "${GITHUB_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || fail 'Missing immutable client commit.' +[[ "${GITHUB_REPOSITORY:-}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail 'Invalid GitHub repository.' +[[ "${GITHUB_EVENT_NAME:-}" =~ ^(push|workflow_dispatch)$ ]] || fail 'This event cannot publish releases.' + +temporary=$(mktemp -d) +trap 'rm -rf -- "$temporary"' EXIT + +# Only an actual HTTP 404 means absent. Authentication, rate limits, transport +# failures and unexpected responses must never turn into permission to create a tag. +api_get() { + local endpoint=$1 status result=0 + gh api --include --method GET "$endpoint" >"$temporary/response" 2>"$temporary/error" || result=$? + status=$(sed -n '1s/^HTTP\/[0-9.]* \([0-9][0-9][0-9]\).*$/\1/p' "$temporary/response") + awk 'body { print } /^\r?$/ { body = 1 }' "$temporary/response" >"$temporary/body" + if [[ "$status" == 404 && "$result" != 0 ]]; then return 4; fi + if [[ "$status" != 200 || "$result" != 0 ]]; then + cat "$temporary/error" >&2 + fail "Cannot verify GitHub release state (HTTP ${status:-unavailable})." + fi + jq -e 'type == "object"' "$temporary/body" >/dev/null || fail 'Invalid GitHub API response.' + cat "$temporary/body" +} + +resolve_ref() { + local repository=$1 ref=$2 response resolved + [[ "$ref" =~ ^[A-Za-z0-9._/-]+$ && "$ref" != -* ]] || fail 'Invalid server reference.' + response=$(api_get "repos/$repository/commits/$ref") || fail "Cannot resolve $repository reference." + resolved=$(jq -er '.sha | select(test("^[0-9a-f]{40}$"))' <<<"$response") || fail 'Invalid resolved server commit.' + if [[ "$ref" =~ ^[0-9a-f]{40}$ && "$resolved" != "$ref" ]]; then + fail 'GitHub resolved an immutable server reference to a different commit.' + fi + printf '%s\n' "$resolved" +} + +if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + version=${RELEASE_VERSION:-$version} + component_ref=${RELEASE_COMPONENT_REF:-$component_ref} + plugin_ref=${RELEASE_PLUGIN_REF:-$plugin_ref} +fi +[[ "$version" =~ $semver ]] || fail 'Invalid requested semantic release version.' +component_ref=$(resolve_ref joomengine/mcp_component "$component_ref") +plugin_ref=$(resolve_ref joomengine/mcp_plugin "$plugin_ref") +tag="v$version" +publish=true +verify=true +create_tag=true +tag_response='' +if tag_response=$(api_get "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag"); then + create_tag=false + tag_type=$(jq -er .object.type <<<"$tag_response") + tag_commit=$(jq -er '.object.sha | select(test("^[0-9a-f]{40}$"))' <<<"$tag_response") + for ((depth = 0; depth < 8; depth++)); do + [[ "$tag_type" == tag ]] || break + tag_response=$(api_get "repos/$GITHUB_REPOSITORY/git/tags/$tag_commit") || fail 'Cannot resolve annotated tag.' + tag_type=$(jq -er .object.type <<<"$tag_response") + tag_commit=$(jq -er '.object.sha | select(test("^[0-9a-f]{40}$"))' <<<"$tag_response") + done + [[ "$tag_type" == commit ]] || fail 'The version tag does not resolve to a commit.' +else + status=$? + [[ "$status" == 4 ]] || exit "$status" +fi + +if release_response=$(api_get "repos/$GITHUB_REPOSITORY/releases/tags/$tag"); then + [[ "$create_tag" == false ]] || fail 'Published release has no corresponding tag.' + jq -e --arg tag "$tag" '.tag_name == $tag and .draft == false' <<<"$release_response" >/dev/null \ + || fail 'An existing draft or inconsistent release needs maintainer review.' + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch && "$tag_commit" != "$GITHUB_SHA" ]]; then + fail 'The requested version already belongs to another commit.' + fi + publish=false + if [[ "$tag_commit" != "$GITHUB_SHA" ]]; then verify=false; fi + printf 'Version %s is already published; leaving its immutable tag unchanged.\n' "$version" >&2 +else + status=$? + [[ "$status" == 4 ]] || exit "$status" + if [[ "$create_tag" == false && "$tag_commit" != "$GITHUB_SHA" ]]; then + fail 'An unreleased version tag belongs to another commit; it will not be moved.' + fi +fi + +if [[ "$mode" == publish && "$publish" == true ]]; then + [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] || fail 'Checkout differs from the tested client commit.' + if [[ "$create_tag" == true ]]; then + git tag "$tag" "$GITHUB_SHA" >&2 + git push origin "refs/tags/$tag" >&2 + fi + release_flags=() + if [[ "$version" == *-* ]]; then release_flags+=(--prerelease); fi + gh release create "$tag" --repo "$GITHUB_REPOSITORY" --verify-tag \ + "${release_flags[@]}" --generate-notes --title "JoomEngine MCP Client $version" >&2 +fi + +printf 'version=%s\ntag=%s\ncomponent_ref=%s\nplugin_ref=%s\nclient_ref=%s\npublish=%s\nverify=%s\ncreate_tag=%s\n' \ + "$version" "$tag" "$component_ref" "$plugin_ref" "$GITHUB_SHA" "$publish" "$verify" "$create_tag"