From 5631731a28e94ac4b55e1a356dcc42b74e1f27ef Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 24 Sep 2026 18:40:11 -0400 Subject: [PATCH 1/8] Add OAuth-protected MCP endpoint scaffolding (#111, phase 2) First step of the remote MCP server: mcp/server.php serves /mcp over Streamable HTTP via the official MCP PHP SDK (mcp/sdk, pinned to exactly 0.8.1 since it breaks BC in nearly every minor release), with Authelia as the OAuth authorization server. No tools yet: this phase only stands up authentication, RFC 9728 Protected Resource Metadata, and routing. - Composer is introduced for the MCP endpoint ONLY: nothing else loads vendor/autoload.php, so a failed composer install at deploy time can only take /mcp down. vendor/ is gitignored and excluded from phpcs; composer.lock is resolved for PHP 8.2 so it installs on 8.2 and 8.4. - Tokens are validated against Authelia's JWKS (cached) for signature, issuer, audience == MCP_RESOURCE_URL (a beta token is rejected by prod and vice versa), and expiry. scopeClaim is 'scp' because Authelia puts scopes in an array claim of that name, not the standard 'scope'. - The request URI's scheme/host are pinned to MCP_RESOURCE_URL before the SDK sees it: TLS ends at the proxy, so PHP sees http and a client-controlled Host, and the SDK builds the 401 challenge's resource_metadata URL from that. preserveHost keeps the real Host header for the DNS-rebinding check, which only allows the canonical hostname (so direct-by-IP access is refused too). - Identity will come only from the token; this entrypoint never reads Remote-* headers or $_SESSION, since /mcp is exempt from forward-auth at the proxy and those headers are client-controlled there. Verified locally with PHP's built-in server: unauthenticated POST gets 401 + WWW-Authenticate with the https resource_metadata URL and scopes; metadata served at both /.well-known/oauth-protected-resource/mcp and the root form; malformed token -> 401 invalid_token; foreign Host and direct-IP Host -> 403; a forged Remote-User header without a token is still 401. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- .gitignore | 4 +- .phpcs.xml | 1 + composer.json | 33 + composer.lock | 2421 +++++++++++++++++++++++++++++++++++++++++++++ config/config.php | 8 + mcp/server.php | 126 +++ 6 files changed, 2592 insertions(+), 1 deletion(-) create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 mcp/server.php diff --git a/.gitignore b/.gitignore index 3ca00a5..ee4c9de 100755 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ settings.local.json # Upload files (keep folder structure, ignore actual uploads) uploads/* !uploads/.gitkeep -!uploads/.htaccess \ No newline at end of file +!uploads/.htaccess +# Composer dependencies (used only by the MCP endpoint; installed at deploy time) +vendor/ diff --git a/.phpcs.xml b/.phpcs.xml index 58389b9..74d6774 100644 --- a/.phpcs.xml +++ b/.phpcs.xml @@ -6,6 +6,7 @@ */uploads/* */migrations/* */.gitea/* + */vendor/* diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..59dcc71 --- /dev/null +++ b/composer.json @@ -0,0 +1,33 @@ +{ + "name": "lotusguild/tinker-tickets", + "description": "Tinker Tickets. Composer is used ONLY by the MCP endpoint (mcp/); nothing else may require vendor/autoload.php.", + "type": "project", + "license": "proprietary", + "require": { + "php": ">=8.2", + "ext-openssl": "*", + "firebase/php-jwt": "^7.0", + "laminas/laminas-httphandlerrunner": "^2.12", + "mcp/sdk": "0.8.1", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "psr/simple-cache": "^3.0", + "symfony/cache": "^7.3", + "symfony/http-client": "^7.3" + }, + "autoload": { + "psr-4": { + "TinkerTickets\\Mcp\\": "mcp/src/" + } + }, + "config": { + "platform": { + "php": "8.2.0" + }, + "sort-packages": true, + "optimize-autoloader": true, + "allow-plugins": { + "php-http/discovery": false + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..5815cf3 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2421 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "dd83586e9ecd3408852359dc0e84a4be", + "packages": [ + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "firebase/php-jwt", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "f502cdbf279cd7532060b041f7c22a05208c0b93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/f502cdbf279cd7532060b041f7c22a05208c0b93", + "reference": "f502cdbf279cd7532060b041f7c22a05208c0b93", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4||^8.0", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "extra": { + "component": { + "id": "jwt", + "path": "Jwt", + "entry": "README.md", + "target": "googleapis/php-jwt.git" + } + }, + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.2.0" + }, + "time": "2026-09-21T19:29:58+00:00" + }, + { + "name": "laminas/laminas-httphandlerrunner", + "version": "2.14.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-httphandlerrunner.git", + "reference": "14d5182a4ba180c998dfbba9d19f9a70337c02b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-httphandlerrunner/zipball/14d5182a4ba180c998dfbba9d19f9a70337c02b4", + "reference": "14d5182a4ba180c998dfbba9d19f9a70337c02b4", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-message-implementation": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "require-dev": { + "laminas/laminas-coding-standard": "~3.1.0", + "laminas/laminas-diactoros": "^3.6.0", + "phpunit/phpunit": "^10.5.46", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.10.3" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Laminas\\HttpHandlerRunner\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Laminas\\HttpHandlerRunner\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Execute PSR-15 RequestHandlerInterface instances and emit responses they generate.", + "homepage": "https://laminas.dev", + "keywords": [ + "components", + "laminas", + "mezzio", + "psr-15", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-httphandlerrunner/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-httphandlerrunner/issues", + "rss": "https://github.com/laminas/laminas-httphandlerrunner/releases.atom", + "source": "https://github.com/laminas/laminas-httphandlerrunner" + }, + "funding": [ + { + "url": "https://crowdfunding.linuxfoundation.org/initiatives/laminas-project", + "type": "custom" + } + ], + "time": "2026-09-21T10:14:52+00:00" + }, + { + "name": "mcp/sdk", + "version": "v0.8.1", + "source": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/php-sdk.git", + "reference": "c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e", + "reference": "c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "opis/json-schema": "^2.4", + "php": "^8.1", + "php-http/discovery": "^1.20", + "phpdocumentor/reflection-docblock": "^5.6 || ^6.0", + "psr/clock": "^1.0", + "psr/container": "^1.0 || ^2.0", + "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "require-dev": { + "composer/semver": "^3.0", + "ext-openssl": "*", + "firebase/php-jwt": "^6.10 || ^7.0", + "laminas/laminas-httphandlerrunner": "^2.12", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "phar-io/composer-distributor": "^1.0.2", + "php-cs-fixer/shim": "^3.91", + "phpdocumentor/shim": "^3", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", + "psr/simple-cache": "^2.0 || ^3.0", + "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "suggest": { + "symfony/finder": "Required for file-based discovery." + }, + "type": "library", + "autoload": { + "psr-4": { + "Mcp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "Model Context Protocol SDK for Client and Server applications in PHP", + "support": { + "issues": "https://github.com/modelcontextprotocol/php-sdk/issues", + "source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.8.1" + }, + "time": "2026-08-29T23:10:56+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "nyholm/psr7-server", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7-server.git", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "nyholm/nsa": "^1.1", + "nyholm/psr7": "^1.3", + "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Nyholm\\Psr7Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "Helper classes to handle PSR-7 server requests", + "homepage": "http://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7-server/issues", + "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2023-11-08T09:30:43+00:00" + }, + { + "name": "opis/json-schema", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.1", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + } + ], + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", + "keywords": [ + "json", + "json-schema", + "schema", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.6.0" + }, + "time": "2025-10-17T12:46:48+00:00" + }, + { + "name": "opis/string", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/string.git", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.1.0" + }, + "time": "2025-10-17T12:38:41+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.5", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" + }, + "time": "2026-08-31T16:05:28+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "symfony/cache", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "e037fd41e9f8ec9ac270e2a4c76a1992d18d808c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/e037fd41e9f8ec9ac270e2a4c76a1992d18d808c", + "reference": "e037fd41e9f8ec9ac270e2a4c76a1992d18d808c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "^1.0.3", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v7.4.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-08T13:28:43+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/http-client", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/3a523f38dc399337ec45b2ab9628e8c37ceb23c7", + "reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-08T13:28:43+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-04T08:41:16+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-11T07:38:58+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/d6a87acbe48cbc707b9aba7f1a6c2215aab02603", + "reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.2", + "ext-openssl": "*" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.2.0" + }, + "plugin-api-version": "2.9.0" +} diff --git a/config/config.php b/config/config.php index 99454b9..1bc3543 100644 --- a/config/config.php +++ b/config/config.php @@ -102,6 +102,14 @@ $GLOBALS['config'] = [ explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1') )), + // MCP endpoint (mcp/server.php). MCP_RESOURCE_URL is this server's + // canonical URL: access tokens must carry it as their audience, so a + // beta token can't be replayed against prod. MCP_OAUTH_ISSUER is the + // Authelia issuer that signs those tokens. + 'MCP_RESOURCE_URL' => $envVars['MCP_RESOURCE_URL'] + ?? (!empty($envVars['APP_DOMAIN']) ? 'https://' . $envVars['APP_DOMAIN'] . '/mcp' : null), + 'MCP_OAUTH_ISSUER' => $envVars['MCP_OAUTH_ISSUER'] ?? 'https://auth.lotusguild.org', + // Session settings 'SESSION_TIMEOUT' => 18000, // 5 hours in seconds 'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes diff --git a/mcp/server.php b/mcp/server.php new file mode 100644 index 0000000..bc97d74 --- /dev/null +++ b/mcp/server.php @@ -0,0 +1,126 @@ + 'MCP endpoint is not configured (MCP_RESOURCE_URL / MCP_OAUTH_ISSUER)']); + exit; +} + +$psr17 = new Psr17Factory(); +$request = (new ServerRequestCreator($psr17, $psr17, $psr17, $psr17))->fromGlobals(); + +// TLS terminates at the reverse proxy, so PHP sees plain http and a Host header +// the client controls. The SDK derives the resource_metadata URL in its 401 +// challenge from the request URI, so pin scheme/host/port to the configured +// canonical URL instead of anything the request claims. preserveHost keeps +// the client's real Host header for the DNS-rebinding check below; without +// it withUri() would overwrite Host and make that check a no-op. +$canonical = parse_url($resourceUrl); +$request = $request->withUri( + $request->getUri() + ->withScheme($canonical['scheme']) + ->withHost($canonical['host']) + ->withPort($canonical['port'] ?? null), + true +); + +// Cache OIDC discovery + JWKS so every MCP call isn't two extra round trips to +// Authelia. Outside the webroot on purpose. +$cache = new Psr16Cache(new FilesystemAdapter('tinker_mcp', 3600, sys_get_temp_dir() . '/tinker_mcp_cache')); + +$validator = new JwtTokenValidator( + issuer: $issuer, + audience: $resourceUrl, + jwksProvider: new JwksProvider(new OidcDiscovery(cache: $cache), cache: $cache), + // Authelia puts scopes in an `scp` array, not the standard `scope` string + // (verified in #111 phase 1). With the default, every scope check fails. + scopeClaim: 'scp', +); + +$resourcePath = $canonical['path'] ?? ''; +$metadata = new ProtectedResourceMetadata( + authorizationServers: [$issuer], + scopesSupported: ['tickets:read', 'tickets:write'], + resource: $resourceUrl, + resourceName: 'Tinker Tickets', + // RFC 9728 path-suffixed form first (used in the WWW-Authenticate + // challenge), plus the root form some clients probe. + metadataPaths: array_values(array_unique([ + '/.well-known/oauth-protected-resource' . $resourcePath, + '/.well-known/oauth-protected-resource', + ])), +); + +$server = Server::builder() + ->setServerInfo('Tinker Tickets', '1.0.0') + // Handshake-era clients (pre-2026-07-28) still use protocol sessions. + ->setSession(new FileSessionStore(sys_get_temp_dir() . '/tinker_mcp_sessions')) + ->build(); + +$transport = new StreamableHttpTransport( + $request, + middleware: [ + // CORS: SDK default (no Access-Control-Allow-Origin, so cross-origin + // browser calls are refused). Host allowlist: only the canonical + // hostname, which also refuses direct-by-IP access. + new CorsMiddleware(), + new DnsRebindingProtectionMiddleware([$canonical['host']]), + new ProtectedResourceMetadataMiddleware($metadata), + new AuthorizationMiddleware($validator, $metadata), + new OAuthRequestMetaMiddleware(), + ], +); + +try { + $response = $server->run($transport); +} catch (\Throwable $e) { + error_log('mcp/server.php: ' . $e::class . ': ' . $e->getMessage()); + $response = $psr17->createResponse(500) + ->withHeader('Content-Type', 'application/json') + ->withBody($psr17->createStream(json_encode([ + 'jsonrpc' => '2.0', + 'id' => null, + 'error' => ['code' => -32603, 'message' => 'Internal error'], + ]))); +} + +(new SapiEmitter())->emit($response); From 13660b4398894c2f91eddb4109d60470280e18dd Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 24 Sep 2026 18:46:52 -0400 Subject: [PATCH 2/8] Fix duplicated Host header rejecting all MCP requests under PHP-FPM (#111) nyholm's ServerRequestCreator adds Host both from the URI it builds and from the request headers, so under PHP-FPM getHeaderLine('Host') returns "beta.t.lotusguild.org, beta.t.lotusguild.org". The SDK's DNS-rebinding check compares that joined string against the allowlist and refused every request with 403 "Invalid Host header", even for the correct hostname. It only passed locally because PHP's built-in server exposes headers differently. Collapse Host to the single value the client sent before the middleware runs. The rebinding check still sees the client's real Host, so foreign hosts and direct-by-IP access stay refused. Verified on the beta host by running the patched entrypoint against beta's real config and vendor/: the correct host gets the Protected Resource Metadata, and a foreign host still gets 403. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- mcp/server.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/mcp/server.php b/mcp/server.php index bc97d74..e9e2dca 100644 --- a/mcp/server.php +++ b/mcp/server.php @@ -48,6 +48,15 @@ if (empty($resourceUrl) || empty($issuer)) { $psr17 = new Psr17Factory(); $request = (new ServerRequestCreator($psr17, $psr17, $psr17, $psr17))->fromGlobals(); +// ServerRequestCreator adds Host both from the URI and from the request +// headers, so getHeaderLine('Host') comes back as "h, h" under PHP-FPM, which +// the DNS-rebinding check below then rejects. Collapse to the single value the +// client actually sent. +$clientHost = $request->getHeader('Host')[0] ?? ''; +if ($clientHost !== '') { + $request = $request->withHeader('Host', $clientHost); +} + // TLS terminates at the reverse proxy, so PHP sees plain http and a Host header // the client controls. The SDK derives the resource_metadata URL in its 401 // challenge from the request URI, so pin scheme/host/port to the configured From 65deedc29529e28388854cea7f0fe96a6f2d1049 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 24 Sep 2026 18:58:30 -0400 Subject: [PATCH 3/8] Add MCP identity mapping and read tools (#111, phase 4) MCP requests now run as the signed-in Tinker Tickets user, under exactly the same rules as the web login, and expose the first two tools: search_tickets and get_ticket. Identity: - IdentityMiddleware maps the validated token to a user with the same checks as AuthMiddleware: the admin/employee group rule, now extracted into helpers/AccessPolicy.php so both entry points share one copy, then UserModel::syncUserFromAuthelia(), which creates/updates the row and derives is_admin from groups. - Claims are read from the validated token's server-side PSR-7 request attributes, not from JSON-RPC _meta. The SDK's OAuthRequestMetaMiddleware is deliberately not used: it array_merges into client-writable _meta, so only the keys the validator happens to set are overwritten and a client could inject others. Scopes (ToolScopeMiddleware), enforced before dispatch: - lifecycle messages need only a valid token; write tools (listed in ToolCatalog, the single registry) need tickets:write; everything else needs tickets:read, which tickets:write implies. - Denials are the spec's step-up challenge: 403 + WWW-Authenticate: Bearer error="insufficient_scope", scope=..., resource_metadata=... Tools (read-only, annotated readOnlyHint): - search_tickets: text/status/priority/category/assignee ("me", "unassigned", or a username), paginated, via TicketModel::getAllTickets with the user's visibility filter. Defaults to every non-Closed status. - get_ticket: details + comments, gated by canUserAccessTicket. A missing ticket and a non-visible one return the same "not found". Verified locally against a real MariaDB fixture (public, confidential, internal+group, and closed tickets across two users), driving the real pipeline (ToolCatalog, both middlewares, SDK transport) with only JWT validation stubbed: 20/20 checks pass, including visibility parity per user, confidential tickets hidden from non-owners, the group check rejecting a user without admin/employee, a missing preferred_username rejected, 403 insufficient_scope for a token without tickets:*, and write implying read. Also exercised the stateless 2026-07-28 era (no session, _meta + MCP-Protocol-Version/Mcp-Method/Mcp-Name headers), which returns the same visibility-filtered results. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- helpers/AccessPolicy.php | 31 +++++ mcp/server.php | 33 ++++- mcp/src/Auth/IdentityMiddleware.php | 78 ++++++++++++ mcp/src/Auth/McpIdentity.php | 59 +++++++++ mcp/src/Auth/ToolScopeMiddleware.php | 91 +++++++++++++ mcp/src/ToolCatalog.php | 33 +++++ mcp/src/Tools/TicketReadTools.php | 183 +++++++++++++++++++++++++++ middleware/AuthMiddleware.php | 17 +-- 8 files changed, 503 insertions(+), 22 deletions(-) create mode 100644 helpers/AccessPolicy.php create mode 100644 mcp/src/Auth/IdentityMiddleware.php create mode 100644 mcp/src/Auth/McpIdentity.php create mode 100644 mcp/src/Auth/ToolScopeMiddleware.php create mode 100644 mcp/src/ToolCatalog.php create mode 100644 mcp/src/Tools/TicketReadTools.php diff --git a/helpers/AccessPolicy.php b/helpers/AccessPolicy.php new file mode 100644 index 0000000..071000b --- /dev/null +++ b/helpers/AccessPolicy.php @@ -0,0 +1,31 @@ + preg_match('/^[a-z0-9_\-]+$/', $g) + ); + + return !empty(array_intersect($userGroups, self::REQUIRED_GROUPS)); + } +} diff --git a/mcp/server.php b/mcp/server.php index e9e2dca..1c65b5d 100644 --- a/mcp/server.php +++ b/mcp/server.php @@ -17,6 +17,12 @@ RateLimitMiddleware::apply('api', false); require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/vendor/autoload.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; +require_once dirname(__DIR__) . '/helpers/AccessPolicy.php'; +require_once dirname(__DIR__) . '/helpers/UrlHelper.php'; +require_once dirname(__DIR__) . '/models/UserModel.php'; +require_once dirname(__DIR__) . '/models/TicketModel.php'; +require_once dirname(__DIR__) . '/models/CommentModel.php'; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; @@ -24,7 +30,6 @@ use Mcp\Server\Session\FileSessionStore; use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware; use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; -use Mcp\Server\Transport\Http\Middleware\OAuthRequestMetaMiddleware; use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware; use Mcp\Server\Transport\Http\OAuth\JwksProvider; use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; @@ -35,6 +40,9 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7Server\ServerRequestCreator; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Psr16Cache; +use TinkerTickets\Mcp\Auth\IdentityMiddleware; +use TinkerTickets\Mcp\Auth\ToolScopeMiddleware; +use TinkerTickets\Mcp\ToolCatalog; $resourceUrl = $GLOBALS['config']['MCP_RESOURCE_URL'] ?? null; $issuer = $GLOBALS['config']['MCP_OAUTH_ISSUER'] ?? null; @@ -99,11 +107,15 @@ $metadata = new ProtectedResourceMetadata( ])), ); -$server = Server::builder() - ->setServerInfo('Tinker Tickets', '1.0.0') - // Handshake-era clients (pre-2026-07-28) still use protocol sessions. - ->setSession(new FileSessionStore(sys_get_temp_dir() . '/tinker_mcp_sessions')) - ->build(); +$conn = Database::getConnection(); + +$server = ToolCatalog::register( + Server::builder() + ->setServerInfo('Tinker Tickets', '1.0.0') + // Handshake-era clients (pre-2026-07-28) still use protocol sessions. + ->setSession(new FileSessionStore(sys_get_temp_dir() . '/tinker_mcp_sessions')), + $conn +)->build(); $transport = new StreamableHttpTransport( $request, @@ -115,7 +127,14 @@ $transport = new StreamableHttpTransport( new DnsRebindingProtectionMiddleware([$canonical['host']]), new ProtectedResourceMetadataMiddleware($metadata), new AuthorizationMiddleware($validator, $metadata), - new OAuthRequestMetaMiddleware(), + // Identity comes from the validated token's server-side request + // attributes, never from client-writable JSON-RPC `_meta` (so the + // SDK's OAuthRequestMetaMiddleware is intentionally not used). + new IdentityMiddleware($conn, $psr17, $psr17), + new ToolScopeMiddleware( + $psr17, + $canonical['scheme'] . '://' . $canonical['host'] . $metadata->getPrimaryMetadataPath() + ), ], ); diff --git a/mcp/src/Auth/IdentityMiddleware.php b/mcp/src/Auth/IdentityMiddleware.php new file mode 100644 index 0000000..c130cdb --- /dev/null +++ b/mcp/src/Auth/IdentityMiddleware.php @@ -0,0 +1,78 @@ +getAttribute('oauth.claims'); + $scopes = $request->getAttribute('oauth.scopes') ?? []; + if (!is_array($claims)) { + // Only reachable if the stack is misordered; never serve anonymously. + return $this->deny(500, 'Identity unavailable'); + } + + $username = is_string($claims['preferred_username'] ?? null) ? trim($claims['preferred_username']) : ''; + if ($username === '') { + return $this->deny(403, 'Access token has no preferred_username claim'); + } + + $groupsClaim = $claims['groups'] ?? []; + $groups = implode(',', array_filter( + is_array($groupsClaim) ? $groupsClaim : [$groupsClaim], + 'is_string' + )); + + if (!\AccessPolicy::hasAppAccess($groups)) { + return $this->deny(403, 'Your account is not permitted to use Tinker Tickets'); + } + + $user = (new \UserModel($this->conn))->syncUserFromAuthelia( + $username, + is_string($claims['name'] ?? null) ? $claims['name'] : '', + is_string($claims['email'] ?? null) ? $claims['email'] : '', + $groups + ); + + McpIdentity::set($user, array_values(array_filter($scopes, 'is_string'))); + + return $handler->handle($request); + } + + private function deny(int $status, string $message): ResponseInterface + { + return $this->responseFactory->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream(json_encode([ + 'jsonrpc' => '2.0', + 'id' => null, + 'error' => ['code' => -32001, 'message' => $message], + ]))); + } +} diff --git a/mcp/src/Auth/McpIdentity.php b/mcp/src/Auth/McpIdentity.php new file mode 100644 index 0000000..fbc30ed --- /dev/null +++ b/mcp/src/Auth/McpIdentity.php @@ -0,0 +1,59 @@ + */ + private static array $scopes = []; + + /** + * @param array $user + * @param list $scopes + */ + public static function set(array $user, array $scopes): void + { + self::$user = $user; + self::$scopes = $scopes; + } + + /** + * @return array + */ + public static function user(): array + { + if (self::$user === null) { + // Unreachable if the middleware stack is intact; fail closed. + throw new \LogicException('MCP identity requested before IdentityMiddleware ran'); + } + return self::$user; + } + + /** tickets:write implies tickets:read (scope hierarchy, MCP spec §Scope Challenge Handling). */ + public static function canRead(array $scopes): bool + { + return in_array(self::SCOPE_READ, $scopes, true) || in_array(self::SCOPE_WRITE, $scopes, true); + } + + public static function canWrite(array $scopes): bool + { + return in_array(self::SCOPE_WRITE, $scopes, true); + } +} diff --git a/mcp/src/Auth/ToolScopeMiddleware.php b/mcp/src/Auth/ToolScopeMiddleware.php new file mode 100644 index 0000000..395a984 --- /dev/null +++ b/mcp/src/Auth/ToolScopeMiddleware.php @@ -0,0 +1,91 @@ +getMethod() !== 'POST') { + return $handler->handle($request); + } + + $payload = json_decode((string)$request->getBody(), true); + $request->getBody()->rewind(); + if (!is_array($payload)) { + return $handler->handle($request); // the SDK answers malformed JSON-RPC itself + } + + $messages = array_is_list($payload) ? $payload : [$payload]; + $needWrite = false; + $needRead = false; + foreach ($messages as $message) { + if (!is_array($message) || !is_string($message['method'] ?? null)) { + continue; // responses to server->client requests carry no method + } + $method = $message['method']; + if (in_array($method, self::LIFECYCLE_METHODS, true) || str_starts_with($method, 'notifications/')) { + continue; + } + if ($method === 'tools/call' && ToolCatalog::isWriteTool((string)($message['params']['name'] ?? ''))) { + $needWrite = true; + } else { + $needRead = true; + } + } + + $scopes = $request->getAttribute('oauth.scopes') ?? []; + if ($needWrite && !McpIdentity::canWrite($scopes)) { + return $this->insufficient(McpIdentity::SCOPE_WRITE, 'This operation requires the tickets:write scope.'); + } + if ($needRead && !McpIdentity::canRead($scopes)) { + return $this->insufficient( + $needWrite ? McpIdentity::SCOPE_WRITE : McpIdentity::SCOPE_READ, + 'This operation requires the tickets:read scope.' + ); + } + + return $handler->handle($request); + } + + private function insufficient(string $scope, string $description): ResponseInterface + { + return $this->responseFactory->createResponse(403)->withHeader( + 'WWW-Authenticate', + sprintf( + 'Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="%s"', + $scope, + $this->resourceMetadataUrl, + $description + ) + ); + } +} diff --git a/mcp/src/ToolCatalog.php b/mcp/src/ToolCatalog.php new file mode 100644 index 0000000..b09825d --- /dev/null +++ b/mcp/src/ToolCatalog.php @@ -0,0 +1,33 @@ +addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly) + ->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly); + } +} diff --git a/mcp/src/Tools/TicketReadTools.php b/mcp/src/Tools/TicketReadTools.php new file mode 100644 index 0000000..2a9f5cf --- /dev/null +++ b/mcp/src/Tools/TicketReadTools.php @@ -0,0 +1,183 @@ + + */ + public function searchTickets( + ?string $query = null, + ?string $status = null, + ?int $priority = null, + ?string $category = null, + ?string $assignee = null, + int $page = 1, + int $limit = 20, + ): array { + $user = McpIdentity::user(); + + $allStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed']; + if ($status === null || trim($status) === '') { + $statusFilter = implode(',', array_filter($allStatuses, fn($s) => $s !== 'Closed')); + } elseif (strtolower(trim($status)) === 'all') { + $statusFilter = null; + } else { + $requested = array_map('trim', explode(',', $status)); + $unknown = array_diff($requested, $allStatuses); + if ($unknown) { + throw new ToolCallException('Unknown status: ' . implode(', ', $unknown) + . '. Valid statuses: ' . implode(', ', $allStatuses)); + } + $statusFilter = implode(',', $requested); + } + + $filters = []; + if ($priority !== null) { + if ($priority < 1 || $priority > 5) { + throw new ToolCallException('priority must be between 1 and 5'); + } + $filters['priority_min'] = $priority; + $filters['priority_max'] = $priority; + } + if ($assignee !== null && trim($assignee) !== '') { + $filters['assigned_to'] = $this->resolveAssigneeFilter(trim($assignee), $user); + } + + $page = max(1, $page); + $limit = min(50, max(1, $limit)); + + $result = (new \TicketModel($this->conn))->getAllTickets( + $page, + $limit, + $statusFilter, + 'updated_at', + 'desc', + ($category !== null && trim($category) !== '') ? trim($category) : null, + null, + ($query !== null && trim($query) !== '') ? trim($query) : null, + $filters, + $user + ); + + return [ + 'tickets' => array_map([$this, 'summarize'], $result['tickets']), + 'page' => $result['current_page'], + 'pages' => $result['pages'], + 'total' => $result['total'], + ]; + } + + /** + * Get one ticket's full details and its comments. + * + * @param string $ticket_id The ticket ID (digits only, e.g. "123456789"). + * @param bool $include_comments Include the ticket's comments, newest first. + * + * @return array + */ + public function getTicket(string $ticket_id, bool $include_comments = true): array + { + $user = McpIdentity::user(); + $ticketModel = new \TicketModel($this->conn); + + $ticketId = trim($ticket_id); + $ticket = preg_match('/^\d+$/', $ticketId) ? $ticketModel->getTicketById($ticketId) : null; + // Same "not found" for missing and not-visible, so a restricted + // ticket's existence isn't revealed. + if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $user)) { + throw new ToolCallException("Ticket {$ticketId} not found"); + } + + $details = $this->summarize($ticket) + [ + 'description' => $ticket['description'] ?? '', + 'visibility_groups' => $ticket['visibility_groups'] ?? null, + 'closed_at' => $ticket['closed_at'] ?? null, + 'updated_by' => $ticket['updater_display_name'] ?? $ticket['updater_username'] ?? null, + ]; + + if ($include_comments) { + $comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false); + $details['comment_count'] = count($comments); + $details['comments'] = array_map(fn(array $c) => [ + 'comment_id' => (int)$c['comment_id'], + 'author' => $c['display_name'] ?? $c['username'] ?? $c['user_name'] ?? null, + 'created_at' => $c['created_at'] ?? null, + 'reply_to' => isset($c['parent_comment_id']) ? (int)$c['parent_comment_id'] : null, + 'text' => $c['comment_text'] ?? '', + ], array_slice($comments, 0, self::MAX_COMMENTS)); + if (count($comments) > self::MAX_COMMENTS) { + $details['comments_truncated'] = true; + } + } + + return $details; + } + + /** + * @param array $user + */ + private function resolveAssigneeFilter(string $assignee, array $user): int|string + { + $lower = strtolower($assignee); + if ($lower === 'me') { + return (int)$user['user_id']; + } + if ($lower === 'unassigned') { + return 'unassigned'; + } + $match = (new \UserModel($this->conn))->getUserByUsername($assignee); + if (!$match) { + throw new ToolCallException("Unknown user: {$assignee}"); + } + return (int)$match['user_id']; + } + + /** + * @param array $t + * + * @return array + */ + private function summarize(array $t): array + { + return [ + 'ticket_id' => (string)$t['ticket_id'], + 'title' => $t['title'] ?? '', + 'status' => $t['status'] ?? null, + 'priority' => isset($t['priority']) ? (int)$t['priority'] : null, + 'category' => $t['category'] ?? null, + 'type' => $t['type'] ?? null, + 'visibility' => $t['visibility'] ?? 'public', + 'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'] ?? null, + 'created_by' => $t['creator_display_name'] ?? $t['creator_username'] ?? null, + 'created_at' => $t['created_at'] ?? null, + 'updated_at' => $t['updated_at'] ?? null, + 'url' => \UrlHelper::ticketUrl((string)$t['ticket_id']), + ]; + } +} diff --git a/middleware/AuthMiddleware.php b/middleware/AuthMiddleware.php index 27686fb..9d68ab8 100644 --- a/middleware/AuthMiddleware.php +++ b/middleware/AuthMiddleware.php @@ -263,21 +263,8 @@ class AuthMiddleware */ private function checkGroupAccess($groups) { - if (empty($groups)) { - return false; - } - - // Check for admin or employee group membership - // Filter to safe characters only to prevent header injection attacks - $userGroups = array_filter( - array_map('trim', explode(',', strtolower($groups))), - function ($g) { - return preg_match('/^[a-z0-9_\-]+$/', $g); - } - ); - $requiredGroups = ['admin', 'employee']; - - return !empty(array_intersect($userGroups, $requiredGroups)); + require_once dirname(__DIR__) . '/helpers/AccessPolicy.php'; + return AccessPolicy::hasAppAccess((string)($groups ?? '')); } /** From 6ce3380d6a9623e12e8388f8fde5119d060811b2 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 24 Sep 2026 19:13:31 -0400 Subject: [PATCH 4/8] Move ApiTicketController out of update_ticket.php into its own file (#111) The partial-update controller (status transitions incl. requires_comment with the comment in the same transaction, field edits, visibility, audit delta, status-change notifications) was defined inline inside api/update_ticket.php, so nothing else could reuse it. Moved it verbatim to controllers/ApiTicketController.php so the MCP update_status tool can run the exact same code path as the web UI; update_ticket.php now just require_once's it. The class body is byte-identical to the original (diffed against HEAD, modulo the 4-space dedent). Verified the web endpoint over real HTTP with a real session + CSRF: Open -> In Progress succeeds, In Progress -> Closed without a comment is refused with requires_comment (400), and with a comment closes the ticket and persists the reason. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- api/update_ticket.php | 257 +------------------------- controllers/ApiTicketController.php | 275 ++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+), 256 deletions(-) create mode 100644 controllers/ApiTicketController.php diff --git a/api/update_ticket.php b/api/update_ticket.php index 08c4c22..c8825b0 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -62,262 +62,7 @@ try { $userId = $currentUser['user_id']; $isAdmin = $currentUser['is_admin'] ?? false; - // Updated controller class that handles partial updates - class ApiTicketController - { - private $conn; - private $ticketModel; - private $commentModel; - private $auditLog; - private $workflowModel; - private $userId; - private $isAdmin; - private $currentUser; - - public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) - { - $this->conn = $conn; - $this->ticketModel = new TicketModel($conn); - $this->commentModel = new CommentModel($conn); - $this->auditLog = new AuditLogModel($conn); - $this->workflowModel = new WorkflowModel($conn); - $this->userId = $userId; - $this->isAdmin = $isAdmin; - $this->currentUser = $currentUser; - } - - public function update($id, $data) - { - // First, get the current ticket data to fill in missing fields - $currentTicket = $this->ticketModel->getTicketById($id); - if (!$currentTicket) { - return [ - 'success' => false, - 'error' => 'Ticket not found' - ]; - } - - // Visibility check: return 404 for tickets the user cannot access - if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) { - return [ - 'success' => false, - 'error' => 'Ticket not found', - 'http_status' => 404 - ]; - } - - // Any authenticated team member can update tickets. - // Admin-only operations (delete, bulk actions) are enforced separately. - - // Merge current data with updates, keeping existing values for missing fields - $updateData = [ - 'ticket_id' => $id, - 'title' => $data['title'] ?? $currentTicket['title'], - 'description' => $data['description'] ?? $currentTicket['description'], - 'category' => $data['category'] ?? $currentTicket['category'], - 'type' => $data['type'] ?? $currentTicket['type'], - 'status' => $data['status'] ?? $currentTicket['status'], - 'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority'] - ]; - - // Validate required fields - if (empty($updateData['title'])) { - return [ - 'success' => false, - 'error' => 'Title cannot be empty', - 'http_status' => 400 - ]; - } - - // Validate priority range - if ($updateData['priority'] < 1 || $updateData['priority'] > 5) { - return [ - 'success' => false, - 'error' => 'Priority must be between 1 and 5', - 'http_status' => 400 - ]; - } - - // Validate visibility BEFORE any DB write so a bad payload can't leave the - // ticket half-updated (core fields committed but request reported as failed). - $visibilityGroups = null; - if (isset($data['visibility'])) { - $visibilityGroups = $data['visibility_groups'] ?? null; - // Convert array to comma-separated string if needed - if (is_array($visibilityGroups)) { - $visibilityGroups = implode(',', array_map('trim', $visibilityGroups)); - } - - // Authorization: only an admin or the ticket's creator may change - // visibility. Enforce only when the requested visibility actually - // differs so ordinary edits that re-send the same value aren't blocked. - $currentVisibility = $currentTicket['visibility'] ?? 'public'; - $currentGroups = $currentTicket['visibility_groups'] ?? null; - $groupsProvided = array_key_exists('visibility_groups', $data); - $visibilityChanged = ($data['visibility'] !== $currentVisibility) - || ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups); - if ($visibilityChanged) { - $isCreator = $this->userId !== null - && (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId; - if (!$this->isAdmin && !$isCreator) { - return [ - 'success' => false, - 'error' => 'You do not have permission to change ticket visibility', - 'http_status' => 403 - ]; - } - } - - // Internal visibility requires at least one group - if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) { - return [ - 'success' => false, - 'error' => 'Internal visibility requires at least one group to be specified', - 'http_status' => 400 - ]; - } - } - - // Validate status transition using workflow model - if ($currentTicket['status'] !== $updateData['status']) { - $allowed = $this->workflowModel->isTransitionAllowed( - $currentTicket['status'], - $updateData['status'], - $this->isAdmin - ); - - if (!$allowed) { - return [ - 'success' => false, - 'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status'] - ]; - } - - // Enforce requires_comment transitions server-side. - if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) { - $statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? '')); - if ($statusChangeComment === '') { - return [ - 'success' => false, - 'error' => 'A comment is required for this status change', - 'requires_comment' => true, - 'http_status' => 400 - ]; - } - } - } - - // A comment accompanying a status change (required or optional) is - // persisted in the SAME transaction as the status update below, so - // a failure partway through can't leave an orphaned "reason" - // comment attached with no matching status change — the two - // previously ran as separate, non-transactional HTTP calls from - // the client (add_comment.php then update_ticket.php). - $statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? '')); - - $result = null; - $this->conn->begin_transaction(); - try { - if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) { - $commentResult = $this->commentModel->addComment($id, [ - 'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User', - 'comment_text' => $statusChangeComment, - 'markdown_enabled' => !empty($data['markdown_enabled']), - ], $this->userId); - if (empty($commentResult['success'])) { - throw new Exception($commentResult['error'] ?? 'Failed to add comment'); - } - } - - // Update ticket with user tracking and optional optimistic locking - $expectedUpdatedAt = $data['expected_updated_at'] ?? null; - $result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt); - if (!$result['success']) { - throw new Exception($result['error'] ?? 'Failed to update ticket in database'); - } - - // Handle visibility update if provided (already validated above) - if (isset($data['visibility'])) { - $visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId); - if (!$visResult) { - throw new Exception('Failed to update ticket visibility'); - } - } - - $this->conn->commit(); - } catch (Exception $e) { - $this->conn->rollback(); - $response = ['success' => false, 'error' => $e->getMessage()]; - if (is_array($result) && !empty($result['conflict'])) { - $response['conflict'] = true; - $response['current_updated_at'] = $result['current_updated_at'] ?? null; - } - return $response; - } - - if (isset($data['visibility']) && $this->userId) { - $this->auditLog->log( - $this->userId, - 'update', - 'ticket', - (string)$id, - [ - 'field' => 'visibility', - 'from' => $currentTicket['visibility'] ?? 'public', - 'to' => $data['visibility'], - 'groups' => $visibilityGroups - ] - ); - } - - // Log ticket update to audit log — only the changed fields (delta) - if ($this->userId) { - $trackFields = ['title', 'priority', 'status', 'description', 'category', 'type']; - $delta = []; - foreach ($trackFields as $field) { - $oldVal = (string)($currentTicket[$field] ?? ''); - $newVal = (string)($updateData[$field] ?? ''); - if ($oldVal !== $newVal) { - $delta[$field] = ['from' => $oldVal, 'to' => $newVal]; - } - } - if (!empty($delta)) { - $this->auditLog->logTicketUpdate($this->userId, $id, $delta); - } - } - - // Notify on status change (global notify list + watchers) - if ($currentTicket['status'] !== $updateData['status']) { - $changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null; - NotificationHelper::sendStatusChangeNotification( - $id, - $currentTicket['status'], - $updateData['status'], - $updateData['title'], - $changedBy, - $currentTicket['visibility'] ?? 'public' - ); - NotificationHelper::notifyWatchers( - $this->conn, - $id, - $updateData['title'], - 'status_changed', - ['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy], - (int)$this->userId, - $currentTicket['visibility'] ?? 'public' - ); - } - - return [ - 'success' => true, - 'status' => $updateData['status'], - 'priority' => $updateData['priority'], - 'updated_at' => date('Y-m-d H:i:s'), - 'message' => 'Ticket updated successfully', - 'csrf_token' => $GLOBALS['newCsrfToken'] ?? null - ]; - } - } + require_once dirname(__DIR__) . '/controllers/ApiTicketController.php'; // Use centralized database connection $conn = Database::getConnection(); diff --git a/controllers/ApiTicketController.php b/controllers/ApiTicketController.php new file mode 100644 index 0000000..9a47bf6 --- /dev/null +++ b/controllers/ApiTicketController.php @@ -0,0 +1,275 @@ +conn = $conn; + $this->ticketModel = new TicketModel($conn); + $this->commentModel = new CommentModel($conn); + $this->auditLog = new AuditLogModel($conn); + $this->workflowModel = new WorkflowModel($conn); + $this->userId = $userId; + $this->isAdmin = $isAdmin; + $this->currentUser = $currentUser; + } + + public function update($id, $data) + { + // First, get the current ticket data to fill in missing fields + $currentTicket = $this->ticketModel->getTicketById($id); + if (!$currentTicket) { + return [ + 'success' => false, + 'error' => 'Ticket not found' + ]; + } + + // Visibility check: return 404 for tickets the user cannot access + if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) { + return [ + 'success' => false, + 'error' => 'Ticket not found', + 'http_status' => 404 + ]; + } + + // Any authenticated team member can update tickets. + // Admin-only operations (delete, bulk actions) are enforced separately. + + // Merge current data with updates, keeping existing values for missing fields + $updateData = [ + 'ticket_id' => $id, + 'title' => $data['title'] ?? $currentTicket['title'], + 'description' => $data['description'] ?? $currentTicket['description'], + 'category' => $data['category'] ?? $currentTicket['category'], + 'type' => $data['type'] ?? $currentTicket['type'], + 'status' => $data['status'] ?? $currentTicket['status'], + 'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority'] + ]; + + // Validate required fields + if (empty($updateData['title'])) { + return [ + 'success' => false, + 'error' => 'Title cannot be empty', + 'http_status' => 400 + ]; + } + + // Validate priority range + if ($updateData['priority'] < 1 || $updateData['priority'] > 5) { + return [ + 'success' => false, + 'error' => 'Priority must be between 1 and 5', + 'http_status' => 400 + ]; + } + + // Validate visibility BEFORE any DB write so a bad payload can't leave the + // ticket half-updated (core fields committed but request reported as failed). + $visibilityGroups = null; + if (isset($data['visibility'])) { + $visibilityGroups = $data['visibility_groups'] ?? null; + // Convert array to comma-separated string if needed + if (is_array($visibilityGroups)) { + $visibilityGroups = implode(',', array_map('trim', $visibilityGroups)); + } + + // Authorization: only an admin or the ticket's creator may change + // visibility. Enforce only when the requested visibility actually + // differs so ordinary edits that re-send the same value aren't blocked. + $currentVisibility = $currentTicket['visibility'] ?? 'public'; + $currentGroups = $currentTicket['visibility_groups'] ?? null; + $groupsProvided = array_key_exists('visibility_groups', $data); + $visibilityChanged = ($data['visibility'] !== $currentVisibility) + || ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups); + if ($visibilityChanged) { + $isCreator = $this->userId !== null + && (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId; + if (!$this->isAdmin && !$isCreator) { + return [ + 'success' => false, + 'error' => 'You do not have permission to change ticket visibility', + 'http_status' => 403 + ]; + } + } + + // Internal visibility requires at least one group + if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) { + return [ + 'success' => false, + 'error' => 'Internal visibility requires at least one group to be specified', + 'http_status' => 400 + ]; + } + } + + // Validate status transition using workflow model + if ($currentTicket['status'] !== $updateData['status']) { + $allowed = $this->workflowModel->isTransitionAllowed( + $currentTicket['status'], + $updateData['status'], + $this->isAdmin + ); + + if (!$allowed) { + return [ + 'success' => false, + 'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status'] + ]; + } + + // Enforce requires_comment transitions server-side. + if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) { + $statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? '')); + if ($statusChangeComment === '') { + return [ + 'success' => false, + 'error' => 'A comment is required for this status change', + 'requires_comment' => true, + 'http_status' => 400 + ]; + } + } + } + + // A comment accompanying a status change (required or optional) is + // persisted in the SAME transaction as the status update below, so + // a failure partway through can't leave an orphaned "reason" + // comment attached with no matching status change — the two + // previously ran as separate, non-transactional HTTP calls from + // the client (add_comment.php then update_ticket.php). + $statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? '')); + + $result = null; + $this->conn->begin_transaction(); + try { + if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) { + $commentResult = $this->commentModel->addComment($id, [ + 'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User', + 'comment_text' => $statusChangeComment, + 'markdown_enabled' => !empty($data['markdown_enabled']), + ], $this->userId); + if (empty($commentResult['success'])) { + throw new Exception($commentResult['error'] ?? 'Failed to add comment'); + } + } + + // Update ticket with user tracking and optional optimistic locking + $expectedUpdatedAt = $data['expected_updated_at'] ?? null; + $result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt); + if (!$result['success']) { + throw new Exception($result['error'] ?? 'Failed to update ticket in database'); + } + + // Handle visibility update if provided (already validated above) + if (isset($data['visibility'])) { + $visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId); + if (!$visResult) { + throw new Exception('Failed to update ticket visibility'); + } + } + + $this->conn->commit(); + } catch (Exception $e) { + $this->conn->rollback(); + $response = ['success' => false, 'error' => $e->getMessage()]; + if (is_array($result) && !empty($result['conflict'])) { + $response['conflict'] = true; + $response['current_updated_at'] = $result['current_updated_at'] ?? null; + } + return $response; + } + + if (isset($data['visibility']) && $this->userId) { + $this->auditLog->log( + $this->userId, + 'update', + 'ticket', + (string)$id, + [ + 'field' => 'visibility', + 'from' => $currentTicket['visibility'] ?? 'public', + 'to' => $data['visibility'], + 'groups' => $visibilityGroups + ] + ); + } + + // Log ticket update to audit log — only the changed fields (delta) + if ($this->userId) { + $trackFields = ['title', 'priority', 'status', 'description', 'category', 'type']; + $delta = []; + foreach ($trackFields as $field) { + $oldVal = (string)($currentTicket[$field] ?? ''); + $newVal = (string)($updateData[$field] ?? ''); + if ($oldVal !== $newVal) { + $delta[$field] = ['from' => $oldVal, 'to' => $newVal]; + } + } + if (!empty($delta)) { + $this->auditLog->logTicketUpdate($this->userId, $id, $delta); + } + } + + // Notify on status change (global notify list + watchers) + if ($currentTicket['status'] !== $updateData['status']) { + $changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null; + NotificationHelper::sendStatusChangeNotification( + $id, + $currentTicket['status'], + $updateData['status'], + $updateData['title'], + $changedBy, + $currentTicket['visibility'] ?? 'public' + ); + NotificationHelper::notifyWatchers( + $this->conn, + $id, + $updateData['title'], + 'status_changed', + ['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy], + (int)$this->userId, + $currentTicket['visibility'] ?? 'public' + ); + } + + return [ + 'success' => true, + 'status' => $updateData['status'], + 'priority' => $updateData['priority'], + 'updated_at' => date('Y-m-d H:i:s'), + 'message' => 'Ticket updated successfully', + 'csrf_token' => $GLOBALS['newCsrfToken'] ?? null + ]; + } +} From d5832fb58a75558837437c4dbac676a641565341 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 24 Sep 2026 19:13:31 -0400 Subject: [PATCH 5/8] Extract comment creation from add_comment.php into CommentService (#111) Validation, the ticket access check, reply-parent validation, @mention extraction (audit-logged, notified only to mentioned users who can see the ticket), and comment/watcher notifications move into services/CommentService.php, so the MCP add_comment tool runs one code path with the web UI. add_comment.php keeps session, CSRF, JSON parsing and response codes. Error messages and status codes are unchanged; the extracted body diffs against the original only where each 'emit error and exit' became a 'return [..., http_status]'. Verified the web endpoint over real HTTP: a comment is trimmed, saved with its @mention and the rotated CSRF token returned; a confidential ticket the user can't see still gets 403 'Access denied'; empty text still gets 400. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- api/add_comment.php | 155 +++------------------------------- services/CommentService.php | 160 ++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 144 deletions(-) create mode 100644 services/CommentService.php diff --git a/api/add_comment.php b/api/add_comment.php index 0a17e8b..9e14e65 100644 --- a/api/add_comment.php +++ b/api/add_comment.php @@ -80,155 +80,22 @@ try { exit; } - $ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : ''; - if (!ctype_digit($ticketId) || (int)$ticketId <= 0) { - http_response_code(400); + // Validation, access check, mentions, audit log and notifications live in + // CommentService so the MCP add_comment tool runs the same code path. + require_once dirname(__DIR__) . '/services/CommentService.php'; + $result = CommentService::addComment($conn, $currentUser, $data); + + if (!empty($result['http_status'])) { + http_response_code($result['http_status']); + unset($result['http_status']); ob_end_clean(); header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']); + echo json_encode($result); exit; } - // Reject empty/whitespace-only comments - $commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : ''; - if ($commentTextRaw === '') { - http_response_code(400); - ob_end_clean(); - header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']); - exit; - } - - // Persist the trimmed text (not the raw client value) — matches update_comment.php - // and keeps stored comment_text free of leading whitespace that could shift a - // markdown-enabled comment's first line out of column 0 on reload. - $data['comment_text'] = $commentTextRaw; - - // Never trust a client-supplied display name — always attribute the comment to - // the authenticated session user. - $data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User'; - - // Verify user can access the ticket before allowing a comment - $ticketModel = new TicketModel($conn); - $ticket = $ticketModel->getTicketById($ticketId); - if (!$ticket) { - http_response_code(404); - ob_end_clean(); - header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Ticket not found']); - exit; - } - if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) { - http_response_code(403); - ob_end_clean(); - header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Access denied']); - exit; - } - - // Initialize models - $commentModel = new CommentModel($conn); - $auditLog = new AuditLogModel($conn); - - // If replying, the parent comment must belong to this same (accessible) ticket. - if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') { - $parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']); - if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) { - http_response_code(400); - ob_end_clean(); - header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Invalid parent comment']); - exit; - } - } - - // Extract @mentions from comment text - $mentions = $commentModel->extractMentions($data['comment_text'] ?? ''); - $mentionedUsers = []; - if (!empty($mentions)) { - $mentionedUsers = $commentModel->getMentionedUsers($mentions); - } - - // Add comment with user tracking - $result = $commentModel->addComment($ticketId, $data, $userId); - - // Log comment creation to audit log - if ($result['success'] && isset($result['comment_id'])) { - $auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId); - - // Log mentions to audit log - foreach ($mentionedUsers as $mentionedUser) { - $auditLog->log( - $userId, - 'mention', - 'user', - (string)$mentionedUser['user_id'], - [ - 'ticket_id' => $ticketId, - 'comment_id' => $result['comment_id'], - 'mentioned_username' => $mentionedUser['username'] - ] - ); - } - - // Matrix notifications - $authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null; - $commentText = $data['comment_text'] ?? ''; - $ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}"; - $ticketVisibility = $ticket['visibility'] ?? 'public'; - - // @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API. - // Only notify mentioned users who actually have access to this ticket; - // otherwise a mention would DM them the ticket's title and comment text - // even though canUserAccessTicket() would deny them the ticket itself. - $accessibleMentionedUsers = array_filter( - $mentionedUsers, - fn($u) => $ticketModel->canUserAccessTicket($ticket, $u) - ); - if (!empty($accessibleMentionedUsers)) { - $mentionedUsernames = array_column($accessibleMentionedUsers, 'username'); - $mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames); - if (!empty($mentionedMatrixIds)) { - NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds); - } - } - - // General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS) - if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) { - NotificationHelper::sendCommentNotification( - $ticketId, - $ticketTitle, - $commentText, - $authorDisplay, - $ticketVisibility !== 'public', - $ticketVisibility - ); - } - - // Notify watchers of the new comment - NotificationHelper::notifyWatchers( - $conn, - $ticketId, - $ticketTitle, - 'comment_added', - ['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')], - (int)$userId, - $ticketVisibility - ); - - // Add mentioned users to result for frontend - $result['mentions'] = array_map(function ($u) { - return $u['username']; - }, $mentionedUsers); - } - - // Add user info to result for frontend avatar rendering - if ($result['success']) { - $result['user_name'] = $currentUser['display_name'] ?? $currentUser['username']; - $result['user_id'] = $userId; - if (isset($newCsrfToken)) { - $result['csrf_token'] = $newCsrfToken; - } + if ($result['success'] && isset($newCsrfToken)) { + $result['csrf_token'] = $newCsrfToken; } // Discard any unexpected output diff --git a/services/CommentService.php b/services/CommentService.php new file mode 100644 index 0000000..a837ccd --- /dev/null +++ b/services/CommentService.php @@ -0,0 +1,160 @@ + false, 'error' => 'Invalid ticket ID', 'http_status' => 400]; + } + + // Reject empty/whitespace-only comments + $commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : ''; + if ($commentTextRaw === '') { + return ['success' => false, 'error' => 'Comment text cannot be empty', 'http_status' => 400]; + } + + // Persist the trimmed text (not the raw client value) — matches update_comment.php + // and keeps stored comment_text free of leading whitespace that could shift a + // markdown-enabled comment's first line out of column 0 on reload. + $data['comment_text'] = $commentTextRaw; + + // Never trust a client-supplied display name — always attribute the comment to + // the authenticated user. + $data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User'; + + // Verify user can access the ticket before allowing a comment + $ticketModel = new TicketModel($conn); + $ticket = $ticketModel->getTicketById($ticketId); + if (!$ticket) { + return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404]; + } + if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) { + return ['success' => false, 'error' => 'Access denied', 'http_status' => 403]; + } + + // Initialize models + $commentModel = new CommentModel($conn); + $auditLog = new AuditLogModel($conn); + + // If replying, the parent comment must belong to this same (accessible) ticket. + if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') { + $parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']); + if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) { + return ['success' => false, 'error' => 'Invalid parent comment', 'http_status' => 400]; + } + } + + // Extract @mentions from comment text + $mentions = $commentModel->extractMentions($data['comment_text'] ?? ''); + $mentionedUsers = []; + if (!empty($mentions)) { + $mentionedUsers = $commentModel->getMentionedUsers($mentions); + } + + // Add comment with user tracking + $result = $commentModel->addComment($ticketId, $data, $userId); + + // Log comment creation to audit log + if ($result['success'] && isset($result['comment_id'])) { + $auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId); + + // Log mentions to audit log + foreach ($mentionedUsers as $mentionedUser) { + $auditLog->log( + $userId, + 'mention', + 'user', + (string)$mentionedUser['user_id'], + [ + 'ticket_id' => $ticketId, + 'comment_id' => $result['comment_id'], + 'mentioned_username' => $mentionedUser['username'] + ] + ); + } + + // Matrix notifications + $authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null; + $commentText = $data['comment_text'] ?? ''; + $ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}"; + $ticketVisibility = $ticket['visibility'] ?? 'public'; + + // @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API. + // Only notify mentioned users who actually have access to this ticket; + // otherwise a mention would DM them the ticket's title and comment text + // even though canUserAccessTicket() would deny them the ticket itself. + $accessibleMentionedUsers = array_filter( + $mentionedUsers, + fn($u) => $ticketModel->canUserAccessTicket($ticket, $u) + ); + if (!empty($accessibleMentionedUsers)) { + $mentionedUsernames = array_column($accessibleMentionedUsers, 'username'); + $mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames); + if (!empty($mentionedMatrixIds)) { + NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds); + } + } + + // General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS) + if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) { + NotificationHelper::sendCommentNotification( + $ticketId, + $ticketTitle, + $commentText, + $authorDisplay, + $ticketVisibility !== 'public', + $ticketVisibility + ); + } + + // Notify watchers of the new comment + NotificationHelper::notifyWatchers( + $conn, + $ticketId, + $ticketTitle, + 'comment_added', + ['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')], + (int)$userId, + $ticketVisibility + ); + + // Add mentioned users to result for frontend + $result['mentions'] = array_map(function ($u) { + return $u['username']; + }, $mentionedUsers); + } + + // Add user info to result for frontend avatar rendering + if ($result['success']) { + $result['user_name'] = $currentUser['display_name'] ?? $currentUser['username']; + $result['user_id'] = $userId; + } + + return $result; + } +} From 9e462f7f006eaee0ce415a6b0e3e8bf8ce385560 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 24 Sep 2026 19:13:31 -0400 Subject: [PATCH 6/8] Extract ticket assignment from assign_ticket.php into AssignmentService (#111) The access check, the admin/creator/current-assignee permission rule, unassign/assign, the audit log, the optional Matrix assignment notification and the stats-cache invalidation move into services/AssignmentService.php for reuse by the MCP assign_ticket tool. Error messages and status codes are unchanged. One deliberate difference: assign_ticket.php's early error responses (400/403/404) used a bare echo and so omitted the CSRF token that bootstrap had just rotated. Every response now goes through apiRespond(), which includes it. The front end already resyncs its token from any response body, so this is compatible, and a failed assign can no longer leave the page holding a stale token. Verified over real HTTP: the assignee can reassign (200); a user who can see the ticket but isn't admin/creator/assignee gets 403 'Permission denied'. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- api/assign_ticket.php | 88 ++++-------------------------- services/AssignmentService.php | 98 ++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 79 deletions(-) create mode 100644 services/AssignmentService.php diff --git a/api/assign_ticket.php b/api/assign_ticket.php index 1046909..28ced6c 100644 --- a/api/assign_ticket.php +++ b/api/assign_ticket.php @@ -1,11 +1,7 @@ false, 'error' => 'Ticket ID required']); - exit; -} -$ticketId = $ticketIdRaw; - -$ticketModel = new TicketModel($conn); -$auditLogModel = new AuditLogModel($conn); -$userModel = new UserModel($conn); - -// Verify ticket exists and user can access it -$ticket = $ticketModel->getTicketById($ticketId); -if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { - http_response_code(404); - echo json_encode(['success' => false, 'error' => 'Ticket not found']); - exit; -} - -// Authorization: only admins or the ticket creator/assignee can reassign -if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) { - http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Permission denied']); - exit; -} - -if ($assignedTo === null || $assignedTo === '') { - // Unassign ticket - $success = $ticketModel->unassignTicket($ticketId, $userId); - if ($success) { - $auditLogModel->log($userId, 'unassign', 'ticket', $ticketId); - } -} else { - // Validate assigned_to is a valid user ID - $assignedTo = (int)$assignedTo; - $targetUser = $userModel->getUserById($assignedTo); - if (!$targetUser) { - http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Invalid user ID']); - exit; - } - - // Assign ticket - $success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId); - if ($success) { - $auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]); - - if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) { - $changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null; - $assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null; - $assigneeMatrix = isset($targetUser['username']) - ? SynapseHelper::resolveUsername($targetUser['username']) - : null; - NotificationHelper::sendAssignmentNotification( - $ticketId, - $ticket['title'] ?? "Ticket #{$ticketId}", - $assigneeName, - $assigneeMatrix, - $changedByDisplay, - $ticket['visibility'] ?? 'public' - ); - } - } -} - -if (!$success) { - http_response_code(500); - apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']); -} else { - require_once dirname(__DIR__) . '/models/StatsModel.php'; - (new StatsModel($conn))->invalidateCache(); - apiRespond(['success' => true]); +if (!empty($result['http_status'])) { + http_response_code($result['http_status']); + unset($result['http_status']); } +apiRespond($result); diff --git a/services/AssignmentService.php b/services/AssignmentService.php new file mode 100644 index 0000000..706fcba --- /dev/null +++ b/services/AssignmentService.php @@ -0,0 +1,98 @@ + false, 'error' => 'Ticket ID required', 'http_status' => 400]; + } + $ticketId = $ticketIdRaw; + + $ticketModel = new TicketModel($conn); + $auditLogModel = new AuditLogModel($conn); + $userModel = new UserModel($conn); + + // Verify ticket exists and user can access it + $ticket = $ticketModel->getTicketById($ticketId); + if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { + return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404]; + } + + // Authorization: only admins or the ticket creator/assignee can reassign + if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) { + return ['success' => false, 'error' => 'Permission denied', 'http_status' => 403]; + } + + if ($assignedTo === null || $assignedTo === '') { + // Unassign ticket + $success = $ticketModel->unassignTicket($ticketId, $userId); + if ($success) { + $auditLogModel->log($userId, 'unassign', 'ticket', $ticketId); + } + } else { + // Validate assigned_to is a valid user ID + $assignedTo = (int)$assignedTo; + $targetUser = $userModel->getUserById($assignedTo); + if (!$targetUser) { + return ['success' => false, 'error' => 'Invalid user ID', 'http_status' => 400]; + } + + // Assign ticket + $success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId); + if ($success) { + $auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]); + + if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) { + $changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null; + $assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null; + $assigneeMatrix = isset($targetUser['username']) + ? SynapseHelper::resolveUsername($targetUser['username']) + : null; + NotificationHelper::sendAssignmentNotification( + $ticketId, + $ticket['title'] ?? "Ticket #{$ticketId}", + $assigneeName, + $assigneeMatrix, + $changedByDisplay, + $ticket['visibility'] ?? 'public' + ); + } + } + } + + if (!$success) { + return ['success' => false, 'error' => 'Failed to update ticket assignment', 'http_status' => 500]; + } + + (new StatsModel($conn))->invalidateCache(); + return ['success' => true]; + } +} From f206bb5889bac1f8bb1bd52d16e16627e66f834c Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 24 Sep 2026 19:13:31 -0400 Subject: [PATCH 7/8] Extract ticket creation from TicketController into TicketCreationService (#111) Required-field and required-custom-field validation, createTicket (which enforces visibility rules), the audit log, stats-cache invalidation, custom field values, the optional 'duplicates' link and the new-ticket notification move into services/TicketCreationService.php with the same order and error messages, so the MCP create_ticket tool runs one code path with the web form. TicketController::create keeps CSRF, the redirect, and re-rendering the form on error (the view never read the removed locals). The service also accepts visibility_groups as a string for API callers; the controller still passes only the form's array, so web behaviour is unchanged. Verified the real web form over HTTP (logged in via Remote-User, real CSRF token): a valid submit redirects to the new ticket, created and audit-logged as the user; a blank description re-renders the form with 'Description is required' and creates nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- controllers/TicketController.php | 116 +++---------------------- services/TicketCreationService.php | 134 +++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 102 deletions(-) create mode 100644 services/TicketCreationService.php diff --git a/controllers/TicketController.php b/controllers/TicketController.php index 2825d29..fd4dc51 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -101,115 +101,27 @@ class TicketController return; } - // Handle visibility groups (comes as array from checkboxes) - $visibilityGroups = null; - if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) { - $visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups'])); - } - - // Honor the posted status, validated against the app's canonical list - $validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed']; - $status = $_POST['status'] ?? 'Open'; - if (!in_array($status, $validStatuses, true)) { - $status = 'Open'; - } - - $ticketData = [ - 'title' => trim($_POST['title'] ?? ''), + // Validation (incl. required custom fields), creation, audit log, + // stats cache, custom field values, duplicate link and notification + // live in TicketCreationService so the MCP create_ticket tool runs + // the same code path. + require_once dirname(__DIR__) . '/services/TicketCreationService.php'; + $result = TicketCreationService::create($this->conn, $currentUser ?? [], [ + 'title' => $_POST['title'] ?? '', 'description' => $_POST['description'] ?? '', 'priority' => $_POST['priority'] ?? '4', 'category' => $_POST['category'] ?? 'General', 'type' => $_POST['type'] ?? 'Issue', - 'status' => $status, + 'status' => $_POST['status'] ?? 'Open', 'visibility' => $_POST['visibility'] ?? 'public', - 'visibility_groups' => $visibilityGroups, - 'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null - ]; - - // Validate input (server-side; form is novalidate) - if ($ticketData['title'] === '') { - $error = "Title is required"; - $templates = $this->templateModel->getAllTemplates(); - $allUsers = $this->userModel->getAllUsers(); - $conn = $this->conn; // Make $conn available to view - include dirname(__DIR__) . '/views/CreateTicketView.php'; - return; - } - - if (trim($ticketData['description']) === '') { - $error = "Description is required"; - $templates = $this->templateModel->getAllTemplates(); - $allUsers = $this->userModel->getAllUsers(); - $conn = $this->conn; // Make $conn available to view - include dirname(__DIR__) . '/views/CreateTicketView.php'; - return; - } - - // Custom fields applicable to the submitted category — validate - // is_required server-side (the form is novalidate, and a field - // hidden by the client-side category toggle must not silently - // bypass a requirement that applies to the category actually - // submitted). - $submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : []; - $applicableFieldDefs = array_filter( - $allCustomFieldDefs, - fn($def) => $def['category'] === null || $def['category'] === $ticketData['category'] - ); - $customFieldsToSave = []; - foreach ($applicableFieldDefs as $def) { - $fieldId = (int)$def['field_id']; - $raw = $submittedCustomFields[$fieldId] ?? null; - $normalized = $def['field_type'] === 'checkbox' - ? (!empty($raw) ? '1' : '0') - : (is_scalar($raw) ? trim((string)$raw) : ''); - - if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') { - $error = $def['field_label'] . ' is required'; - $templates = $this->templateModel->getAllTemplates(); - $allUsers = $this->userModel->getAllUsers(); - $conn = $this->conn; - include dirname(__DIR__) . '/views/CreateTicketView.php'; - return; - } - - if ($normalized !== '') { - $customFieldsToSave[$fieldId] = $normalized; - } - } - - // Create ticket with user tracking - $result = $this->ticketModel->createTicket($ticketData, $userId); + 'visibility_groups' => (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) + ? $_POST['visibility_groups'] : null, + 'assigned_to' => $_POST['assigned_to'] ?? null, + 'custom_fields' => $_POST['custom_fields'] ?? [], + 'link_duplicate_of' => $_POST['link_duplicate_of'] ?? '', + ]); if ($result['success']) { - // Log ticket creation to audit log - if (isset($GLOBALS['auditLog']) && $userId) { - $GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData); - } - - // Ticket counts changed — invalidate the cached dashboard stats - require_once dirname(__DIR__) . '/models/StatsModel.php'; - (new StatsModel($this->conn))->invalidateCache(); - - // Persist custom field values for the fields applicable to - // this ticket's category - if (!empty($customFieldsToSave)) { - $this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave); - } - - // Auto-link as duplicate if requested from create form - $linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? ''); - if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) { - $depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by) - VALUES (?, ?, 'duplicates', ?)"; - $depStmt = $this->conn->prepare($depSql); - $depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId); - $depStmt->execute(); - $depStmt->close(); - } - - // Send Matrix notification for new ticket - NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual'); - // Redirect to the new ticket header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']); exit; diff --git a/services/TicketCreationService.php b/services/TicketCreationService.php new file mode 100644 index 0000000..f60138b --- /dev/null +++ b/services/TicketCreationService.php @@ -0,0 +1,134 @@ + value), link_duplicate_of + * + * @return array ['success' => true, 'ticket_id' => ...] or ['success' => false, 'error' => ...] + */ + public static function create(mysqli $conn, array $currentUser, array $input): array + { + $userId = $currentUser['user_id'] ?? null; + + // Handle visibility groups (a list from the web form's checkboxes, or a string) + $visibilityGroups = null; + if (isset($input['visibility_groups']) && is_array($input['visibility_groups'])) { + $visibilityGroups = implode(',', array_map('trim', $input['visibility_groups'])); + } elseif (isset($input['visibility_groups']) && is_string($input['visibility_groups']) && trim($input['visibility_groups']) !== '') { + $visibilityGroups = trim($input['visibility_groups']); + } + + // Honor the posted status, validated against the app's canonical list + $validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed']; + $status = $input['status'] ?? 'Open'; + if (!in_array($status, $validStatuses, true)) { + $status = 'Open'; + } + + $ticketData = [ + 'title' => trim($input['title'] ?? ''), + 'description' => $input['description'] ?? '', + 'priority' => $input['priority'] ?? '4', + 'category' => $input['category'] ?? 'General', + 'type' => $input['type'] ?? 'Issue', + 'status' => $status, + 'visibility' => $input['visibility'] ?? 'public', + 'visibility_groups' => $visibilityGroups, + 'assigned_to' => !empty($input['assigned_to']) ? $input['assigned_to'] : null + ]; + + if ($ticketData['title'] === '') { + return ['success' => false, 'error' => 'Title is required']; + } + + if (trim($ticketData['description']) === '') { + return ['success' => false, 'error' => 'Description is required']; + } + + // Custom fields applicable to the submitted category — validate + // is_required server-side (the form is novalidate, and a field + // hidden by the client-side category toggle must not silently + // bypass a requirement that applies to the category actually + // submitted). + $customFieldModel = new CustomFieldModel($conn); + $allCustomFieldDefs = $customFieldModel->getAllDefinitions(null, true); + $submittedCustomFields = is_array($input['custom_fields'] ?? null) ? $input['custom_fields'] : []; + $applicableFieldDefs = array_filter( + $allCustomFieldDefs, + fn($def) => $def['category'] === null || $def['category'] === $ticketData['category'] + ); + $customFieldsToSave = []; + foreach ($applicableFieldDefs as $def) { + $fieldId = (int)$def['field_id']; + $raw = $submittedCustomFields[$fieldId] ?? null; + $normalized = $def['field_type'] === 'checkbox' + ? (!empty($raw) ? '1' : '0') + : (is_scalar($raw) ? trim((string)$raw) : ''); + + if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') { + return ['success' => false, 'error' => $def['field_label'] . ' is required']; + } + + if ($normalized !== '') { + $customFieldsToSave[$fieldId] = $normalized; + } + } + + // Create ticket with user tracking + $result = (new TicketModel($conn))->createTicket($ticketData, $userId); + if (!$result['success']) { + return ['success' => false, 'error' => $result['error']]; + } + + // Log ticket creation to audit log + if ($userId) { + (new AuditLogModel($conn))->logTicketCreate($userId, $result['ticket_id'], $ticketData); + } + + // Ticket counts changed — invalidate the cached dashboard stats + (new StatsModel($conn))->invalidateCache(); + + // Persist custom field values for the fields applicable to this ticket's category + if (!empty($customFieldsToSave)) { + $customFieldModel->setValues($result['ticket_id'], $customFieldsToSave); + } + + // Auto-link as duplicate if requested + $linkDupOfRaw = trim((string)($input['link_duplicate_of'] ?? '')); + if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) { + $depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by) + VALUES (?, ?, 'duplicates', ?)"; + $depStmt = $conn->prepare($depSql); + $depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId); + $depStmt->execute(); + $depStmt->close(); + } + + // Send Matrix notification for new ticket + NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual'); + + return ['success' => true, 'ticket_id' => $result['ticket_id']]; + } +} From 17be55bf8f622bfd6b8905c3d4a637fd10303232 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 24 Sep 2026 19:13:41 -0400 Subject: [PATCH 8/8] Add MCP write tools: create_ticket, add_comment, update_status, assign_ticket (#111, phase 5) Each tool is a thin adapter over the same code path the web UI uses (TicketCreationService, CommentService, ApiTicketController, AssignmentService), run as the signed-in user, so permissions, Workflow Designer rules, audit entries, notifications and stats-cache invalidation are identical to doing the same thing in the browser. - Registered in ToolCatalog and listed in its WRITE_TOOLS, so ToolScopeMiddleware requires tickets:write for them. - Annotated readOnlyHint=false / destructiveHint=false (nothing deletes). - Input the web form constrains with dropdowns (priority 1-5, visibility, status) is validated in the tools. Assignees are "me", a username or "unassigned". - A ticket the user can't see reads as "not found" (never "access denied"), consistent with get_ticket. - update_status turns requires_comment into an actionable error and invalidates the stats cache like api/update_ticket.php does. Verified locally through the real pipeline (only JWT validation stubbed) against MariaDB with seeded workflow transitions: 30/30 checks, including a read-only token getting 403 insufficient_scope on create_ticket with nothing written; create/comment/status/assign attributed and audit-logged as the user; @mentions; internal visibility needing groups and staying hidden from non-members; an invisible confidential ticket not found for comment/status; requires_comment enforced, and closing with a reason persisted in one transaction; transitions outside the workflow refused; the admin/creator/assignee rule for assigning. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- mcp/server.php | 5 + mcp/src/ToolCatalog.php | 13 +- mcp/src/Tools/TicketWriteTools.php | 216 +++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 mcp/src/Tools/TicketWriteTools.php diff --git a/mcp/server.php b/mcp/server.php index 1c65b5d..78254c4 100644 --- a/mcp/server.php +++ b/mcp/server.php @@ -23,6 +23,11 @@ require_once dirname(__DIR__) . '/helpers/UrlHelper.php'; require_once dirname(__DIR__) . '/models/UserModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/CommentModel.php'; +require_once dirname(__DIR__) . '/models/StatsModel.php'; +require_once dirname(__DIR__) . '/controllers/ApiTicketController.php'; +require_once dirname(__DIR__) . '/services/TicketCreationService.php'; +require_once dirname(__DIR__) . '/services/CommentService.php'; +require_once dirname(__DIR__) . '/services/AssignmentService.php'; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; diff --git a/mcp/src/ToolCatalog.php b/mcp/src/ToolCatalog.php index b09825d..41d57e0 100644 --- a/mcp/src/ToolCatalog.php +++ b/mcp/src/ToolCatalog.php @@ -5,6 +5,7 @@ namespace TinkerTickets\Mcp; use Mcp\Schema\ToolAnnotations; use Mcp\Server\Builder; use TinkerTickets\Mcp\Tools\TicketReadTools; +use TinkerTickets\Mcp\Tools\TicketWriteTools; /** * The single list of MCP tools: registration, plus which ones need the @@ -14,7 +15,7 @@ use TinkerTickets\Mcp\Tools\TicketReadTools; final class ToolCatalog { /** Tool names that mutate data and require tickets:write. */ - private const WRITE_TOOLS = []; + private const WRITE_TOOLS = ['create_ticket', 'add_comment', 'update_status', 'assign_ticket']; public static function isWriteTool(string $name): bool { @@ -24,10 +25,18 @@ final class ToolCatalog public static function register(Builder $builder, \mysqli $conn): Builder { $read = new TicketReadTools($conn); + $write = new TicketWriteTools($conn); $readOnly = new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false); + // Writes change tickets (and notify people) but never delete anything. + $additive = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false); + $update = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false); return $builder ->addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly) - ->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly); + ->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly) + ->addTool([$write, 'createTicket'], 'create_ticket', 'Create ticket', null, $additive) + ->addTool([$write, 'addComment'], 'add_comment', 'Add comment', null, $additive) + ->addTool([$write, 'updateStatus'], 'update_status', 'Update ticket status', null, $update) + ->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update); } } diff --git a/mcp/src/Tools/TicketWriteTools.php b/mcp/src/Tools/TicketWriteTools.php new file mode 100644 index 0000000..2b28012 --- /dev/null +++ b/mcp/src/Tools/TicketWriteTools.php @@ -0,0 +1,216 @@ + + */ + public function createTicket( + string $title, + string $description, + int $priority = 4, + string $category = 'General', + string $type = 'Issue', + string $visibility = 'public', + ?string $visibility_groups = null, + ?string $assignee = null, + ): array { + $user = McpIdentity::user(); + + // The web form constrains these with dropdowns; an API caller can send + // anything, so validate them here before handing off. + if ($priority < 1 || $priority > 5) { + throw new ToolCallException('priority must be between 1 and 5'); + } + if (!in_array($visibility, self::VISIBILITIES, true)) { + throw new ToolCallException('visibility must be one of: ' . implode(', ', self::VISIBILITIES)); + } + + $assignedTo = null; + if ($assignee !== null && trim($assignee) !== '') { + $assignedTo = $this->resolveUserId(trim($assignee), $user); + } + + $result = \TicketCreationService::create($this->conn, $user, [ + 'title' => $title, + 'description' => $description, + 'priority' => (string)$priority, + 'category' => $category, + 'type' => $type, + 'status' => 'Open', + 'visibility' => $visibility, + 'visibility_groups' => $visibility_groups, + 'assigned_to' => $assignedTo, + ]); + if (!$result['success']) { + throw new ToolCallException($result['error']); + } + + return [ + 'ticket_id' => (string)$result['ticket_id'], + 'url' => \UrlHelper::ticketUrl((string)$result['ticket_id']), + ]; + } + + /** + * Post a comment on a ticket as you. @username mentions notify that user. + * + * @param string $ticket_id The ticket ID. + * @param string $text The comment text. + * @param bool $markdown Render the comment as markdown. + * @param int|null $reply_to comment_id of the comment you are replying to, if any. + * + * @return array + */ + public function addComment(string $ticket_id, string $text, bool $markdown = true, ?int $reply_to = null): array + { + $user = McpIdentity::user(); + $result = \CommentService::addComment($this->conn, $user, [ + 'ticket_id' => $ticket_id, + 'comment_text' => $text, + 'markdown_enabled' => $markdown, + 'parent_comment_id' => $reply_to, + ]); + if (empty($result['success'])) { + throw new ToolCallException($this->errorMessage($result, $ticket_id)); + } + + return [ + 'comment_id' => (int)$result['comment_id'], + 'ticket_id' => trim($ticket_id), + 'mentions' => $result['mentions'] ?? [], + ]; + } + + /** + * Change a ticket's status. Transitions follow the Workflow Designer rules; + * some transitions (e.g. closing) require a comment, which is posted as the reason. + * + * @param string $ticket_id The ticket ID. + * @param string $status New status, e.g. "Open", "Pending", "In Progress", "Closed". + * @param string|null $comment Reason for the change; required for transitions that need one. + * + * @return array + */ + public function updateStatus(string $ticket_id, string $status, ?string $comment = null): array + { + $user = McpIdentity::user(); + $validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed']; + if (!in_array($status, $validStatuses, true)) { + throw new ToolCallException('Unknown status. Valid statuses: ' . implode(', ', $validStatuses)); + } + + $ticketId = trim($ticket_id); + if (!ctype_digit($ticketId)) { + throw new ToolCallException("Ticket {$ticketId} not found"); + } + + $controller = new \ApiTicketController($this->conn, (int)$user['user_id'], !empty($user['is_admin']), $user); + $data = ['status' => $status]; + if ($comment !== null && trim($comment) !== '') { + $data['comment'] = $comment; + $data['markdown_enabled'] = true; + } + $result = $controller->update($ticketId, $data); + if (empty($result['success'])) { + if (!empty($result['requires_comment'])) { + throw new ToolCallException('This status change requires a comment explaining why. Call update_status again with a comment.'); + } + throw new ToolCallException($this->errorMessage($result, $ticketId)); + } + + // api/update_ticket.php invalidates the dashboard stats cache after a + // successful update (outside the controller); do the same here. + (new \StatsModel($this->conn))->invalidateCache(); + + return ['ticket_id' => $ticketId, 'status' => $result['status']]; + } + + /** + * Assign a ticket to someone, or unassign it. Only admins, the ticket's + * creator, or its current assignee may do this. + * + * @param string $ticket_id The ticket ID. + * @param string $assignee Username to assign to, "me", or "unassigned". + * + * @return array + */ + public function assignTicket(string $ticket_id, string $assignee): array + { + $user = McpIdentity::user(); + $target = trim($assignee); + $assignedTo = strtolower($target) === 'unassigned' || $target === '' + ? null + : $this->resolveUserId($target, $user); + + $result = \AssignmentService::assign($this->conn, $user, [ + 'ticket_id' => $ticket_id, + 'assigned_to' => $assignedTo, + ]); + if (empty($result['success'])) { + throw new ToolCallException($this->errorMessage($result, $ticket_id)); + } + + return ['ticket_id' => trim($ticket_id), 'assigned_to' => $assignedTo === null ? null : $target]; + } + + /** + * @param array $user + */ + private function resolveUserId(string $username, array $user): int + { + if (strtolower($username) === 'me') { + return (int)$user['user_id']; + } + $match = (new \UserModel($this->conn))->getUserByUsername($username); + if (!$match) { + throw new ToolCallException("Unknown user: {$username}"); + } + return (int)$match['user_id']; + } + + /** + * Map a shared-service failure to a tool error. A ticket the user can't + * see reads as "not found" (like get_ticket), never "access denied", so a + * restricted ticket's existence isn't revealed. + * + * @param array $result + */ + private function errorMessage(array $result, string $ticketId): string + { + $error = (string)($result['error'] ?? 'Request failed'); + if (in_array($error, ['Access denied', 'Ticket not found', 'Invalid ticket ID', 'Ticket ID required'], true)) { + return 'Ticket ' . trim($ticketId) . ' not found'; + } + return $error; + } +}