{"app_name":"Debian Janitor","binary_packages":["libmcp-perl"],"branch_url":"git+ssh://git@salsa.debian.org/perl-team/modules/packages/libmcp-perl.git","build_distribution":"sid","build_log_highlight_lines":[],"build_log_line_count":2411,"build_log_name":"build.log","campaign":"control","change_set":"e58403b7-06ba-44e7-95b0-9cf87ec1f08f","codebase":"libmcp-perl","codemod_log_name":"codemod.log","command":"brz up '--revision=git-v1:66f2fee702eaa19a544056e3c13acbc2211c1d4a'","current_year":"2026","description":"","is_admin":false,"lintian_result":{"groups":[{"group_id":"libmcp-perl_0.08-1~jan+control1","input_files":[{"hints":[{"experimental":false,"note":"","pointer":{"item":{"index":"libmcp-perl_0.08-1~jan+control1.dsc (patched)","name":"debian/changelog"},"line_position":1},"tag":"no-nmu-in-changelog","visibility":"warning"},{"experimental":false,"note":"0.08-1~jan+control1","pointer":{"item":{"index":"libmcp-perl_0.08-1~jan+control1.dsc (patched)","name":"debian/changelog"},"line_position":1},"tag":"source-nmu-has-incorrect-version-number","visibility":"warning"}],"path":"libmcp-perl_0.08-1~jan+control1.dsc"},{"hints":[{"experimental":false,"note":"control != sid","pointer":{"item":{"index":"libmcp-perl_0.08-1~jan+control1_all.deb (installed)","name":"usr/share/doc/libmcp-perl/changelog.Debian.gz"},"line_position":1},"tag":"changelog-distribution-does-not-match-changes-file","visibility":"warning"}],"path":"libmcp-perl_0.08-1~jan+control1_all.deb"},{"hints":[],"path":"libmcp-perl_0.08-1~jan+control1_amd64.buildinfo"},{"hints":[{"experimental":false,"note":"sid control","tag":"distribution-and-changes-mismatch","visibility":"warning"}],"path":"libmcp-perl_0.08-1~jan+control1_amd64.changes"}],"source_name":"libmcp-perl","source_version":"0.08-1~jan+control1"}],"lintian_version":"2.143.0"},"logs":{"build":{"description":"build","name":"build.log","url":"/control/c/libmcp-perl/e58403b7-06ba-44e7-95b0-9cf87ec1f08f/logs/build.log"},"codemod":{"description":"codemod","name":"codemod.log","url":"/control/c/libmcp-perl/e58403b7-06ba-44e7-95b0-9cf87ec1f08f/logs/codemod.log"},"worker":{"content":"[2026-11-08T04:34:55Z INFO  janitor_worker] Opening branch at https://salsa.debian.org/perl-team/modules/packages/libmcp-perl.git\n[2026-11-08T04:34:58Z INFO  janitor_worker] Cached branch URL http://janitor.debian.net/git/libmcp-perl missing: http://janitor.debian.net/git/libmcp-perl/\n[2026-11-08T04:34:58Z DEBUG janitor_worker] Workspace path: /srv/worker/output/1/janitor-worker-rvH9A4/libmcp-perl\n[2026-11-08T04:34:58Z DEBUG silver_platter::workspace] Creating sprout from git+ssh://git@salsa.debian.org/perl-team/modules/packages/libmcp-perl.git\n3% (44/132)\n  80% (106/132)\nTotal 796 (delta 96), reused 148 (delta 67), pack-reused 590 (from 1)\n[2026-11-08T04:35:01Z DEBUG silver_platter::utils] Colocated branch upstream not found, skipping\n[2026-11-08T04:35:01Z DEBUG silver_platter::utils] Colocated branch vendor not found, skipping\nTotal 12 (delta 2), reused 12 (delta 2), pack-reused 0 (from 0)\n[2026-11-08T04:35:04Z DEBUG silver_platter::utils] Colocated branch experimental not found, skipping\nNothing to delete.\n[2026-11-08T04:35:04Z DEBUG silver_platter::workspace] Fetching colocated branches: {\"pristine-tar\": \"pristine-tar\", \"vendor\": \"vendor\", \"experimental\": \"experimental\", \"upstream\": \"upstream\"}\nTraceback (most recent call last):\n  File \"/usr/lib/python3.14/threading.py\", line 940, in __init__\n    self._os_thread_handle = _ThreadHandle()\nRuntimeError: breezyshim::branch::py_tag_selector::PyTagSelector is unsendable, but is being dropped on another thread\nTotal 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)\n[2026-11-08T04:35:07Z DEBUG silver_platter::workspace] Successfully fetched colocated branch pristine-tar -> pristine-tar\n[2026-11-08T04:35:07Z INFO  janitor_worker] Workspace ready - starting.\n[2026-11-08T04:35:07Z INFO  janitor_worker::debian] Running [\"brz\", \"up\", \"--revision=git-v1:66f2fee702eaa19a544056e3c13acbc2211c1d4a\"]\n[2026-11-08T04:35:07Z DEBUG debian_analyzer::detect_gbp_dch] Branch history analysis: changelog_only: 7, other_only: 7, mixed: 2, dch_references: 7, unreleased_references: 4\n[2026-11-08T04:35:07Z INFO  silver_platter::debian::codemod] Assuming changelog does not need to be updated, since there are Gbp-Dch stanzas in commit messages\nfound 7 deltas to reuse\n[2026-11-08T04:35:09Z INFO  ognibuild::debian::fix_build] Using fixers: [PgBuildExtOutOfDateControlFixer, MissingConfigureFixer, MissingAutomakeInputFixer, MissingConfigStatusInputFixer, MissingPerlFileFixer, DebcargoUnacceptablePredicateFixer, DebcargoUnacceptableComparatorFixer, RetryAptFetchFailure, PackageDependencyFixer]\n[2026-11-08T04:35:09Z DEBUG ognibuild::debian::build] Adding dummy changelog entry 0.08-1~jan+control1 for build\n[2026-11-08T04:35:09Z DEBUG ognibuild::debian::build] Writing build log to /srv/worker/output/1/janitor-worker-rvH9A4/build.log\n[2026-11-08T04:35:09Z INFO  ognibuild::debian::build] Building debian packages, running sbuild --chroot-mode=unshare --no-clean-source -A -s -v -d sid.\n[2026-11-08T04:41:14Z INFO  ognibuild::debian::build] Build succeeded.\n[2026-11-08T04:41:14Z INFO  janitor_worker::debian::build] Built [\"/srv/worker/output/1/janitor-worker-rvH9A4/libmcp-perl_0.08-1~jan+control1_amd64.buildinfo\", \"/srv/worker/output/1/janitor-worker-rvH9A4/libmcp-perl_0.08-1~jan+control1_all.deb\", \"/srv/worker/output/1/janitor-worker-rvH9A4/libmcp-perl_0.08-1~jan+control1_amd64.changes\"].\n[2026-11-08T04:41:14Z DEBUG janitor_worker::debian::lintian] Running lintian: cd \"/srv/worker/output/1/janitor-worker-rvH9A4\" && \"lintian\" \"--exp-output=format=json\" \"--allow-root\" \"/srv/worker/output/1/janitor-worker-rvH9A4/libmcp-perl_0.08-1~jan+control1_amd64.buildinfo\" \"/srv/worker/output/1/janitor-worker-rvH9A4/libmcp-perl_0.08-1~jan+control1_all.deb\" \"/srv/worker/output/1/janitor-worker-rvH9A4/libmcp-perl_0.08-1~jan+control1_amd64.changes\"\n[2026-11-08T04:41:17Z INFO  janitor_worker] Pushing result branch to http://janitor.debian.net/git/libmcp-perl\nfound 0 deltas to reuse\n[2026-11-08T04:41:17Z INFO  janitor_worker::vcs] Pushing packaging branch cache to http://janitor.debian.net/git/libmcp-perl,branch=debian%2Flatest\nfound 377 deltas to reuse\n[2026-11-08T04:41:18Z INFO  janitor_worker::vcs] Pushed packaging branch cache to http://janitor.debian.net/git/libmcp-perl,branch=debian%2Flatest\n","description":"worker","name":"worker.log","url":"/control/c/libmcp-perl/e58403b7-06ba-44e7-95b0-9cf87ec1f08f/logs/worker.log"}},"my_review":null,"other_reviews":[],"primary_log":"worker","publish_history":[],"queue_position":1,"result_code":"success","resume_from":null,"reviews":[],"revision":"git-v1:66f2fee702eaa19a544056e3c13acbc2211c1d4a","run":{"branch_url":"git+ssh://git@salsa.debian.org/perl-team/modules/packages/libmcp-perl.git","build_version":null,"change_set":"e58403b7-06ba-44e7-95b0-9cf87ec1f08f","codebase":"libmcp-perl","command":"brz up '--revision=git-v1:66f2fee702eaa19a544056e3c13acbc2211c1d4a'","context":null,"debian_build_distribution":"sid","debian_build_version":null,"description":"","duration_seconds":385,"failure_stage":null,"failure_transient":null,"finish_time":"2026-11-08T04:41:18.954774Z","id":"e58403b7-06ba-44e7-95b0-9cf87ec1f08f","logfilenames":["worker.log","codemod.log","build.log"],"main_branch_revision":"git-v1:d4d332645592f46fd2d599d306db53e2d168fc2e","publish_status":"unknown","result_branches":[{"absorbed":false,"base_revision":"git-v1:d4d332645592f46fd2d599d306db53e2d168fc2e","diff":"diff --git a/Changes b/Changes\nindex 87bd21b..e2fc27a 100644\n--- a/Changes\n+++ b/Changes\n@@ -1,89 +1,4 @@\n \n-0.15  2026-07-30\n-  - Updated to MCP protocol revision 2026-07-28, which makes the protocol stateless. This is a breaking change for\n-    every user of this module.\n-  - Removed the initialize handshake, protocol sessions, and the Mcp-Session-Id header. Every request now declares\n-    the protocol version and client capabilities it was made with in _meta.\n-  - Removed MCP::Server::Session, and the sessions and session_timeout attributes from MCP::Server::Transport::HTTP.\n-    GET and DELETE requests are now answered with status 405.\n-  - Removed initialize_session, delete_session, ping, and session_id from MCP::Client.\n-  - Removed the ping method and the RESOURCE_NOT_FOUND constant. Prompts, resources, and tools that were not found\n-    now use INVALID_PARAMS, and unknown methods are answered with status 404.\n-  - Added MCP::Server::Legacy, a temporary fallback that answers the initialize handshake of protocol revisions\n-    older than 2026-07-28, so clients that have not been updated yet can still list and call tools, prompts, and\n-    resources. It also adds a legacy attribute to MCP::Server::Context, and will be removed again in a future\n-    release.\n-  - Added the server/discover method, which replaces initialize, and an instructions attribute to MCP::Server.\n-  - Added a discover method to MCP::Client.\n-  - Added subscriptions/listen, which replaces the GET stream and resources/subscribe, to both transports.\n-  - Added MCP::Server::Subscription class, and a subscriptions attribute to both transports.\n-  - Added a listen method and a notification event to MCP::Client.\n-  - Added support for multi-round tool requests, with an input_required method on MCP::Primitive that seals its\n-    request state with HMAC-SHA256, and matching input_responses and request_state options for call_tool, get_prompt,\n-    and read_resource in MCP::Client.\n-  - Added state_secret and state_timeout attributes to MCP::Server.\n-  - Added input_responses, principal, raw_request_state, state_binding, state_secret, and state_timeout attributes,\n-    and request_state and seal_state methods, to MCP::Server::Context.\n-  - Added routing header validation to MCP::Server::Transport::HTTP, covering MCP-Protocol-Version, Mcp-Method,\n-    Mcp-Name, and the Mcp-Param-* headers declared with x-mcp-header in a tool input schema.\n-  - Added an origins attribute to MCP::Server::Transport::HTTP, to protect against DNS rebinding attacks.\n-  - Added cache hints to discovery, list, and read results, with cache_scope and cache_ttl attributes on MCP::Server\n-    and MCP::Primitive.\n-  - Added a resultType field and server info to every result.\n-  - Added a log attribute to MCP::Server, and the INTERNAL_ERROR constant.\n-  - Fixed a bug where an exception thrown by a prompt, resource, or tool, or a rejected promise returned by one,\n-    was not turned into a JSON-RPC error. Over stdio it terminated the server, and over HTTP it produced a response\n-    without a body and leaked the notification stream. The exception is now logged and answered with\n-    INTERNAL_ERROR.\n-  - Added JSON Schema 2020-12 support to MCP::Tool, which is now the default dialect, together with a\n-    max_schema_depth attribute and validation of structuredContent against output_schema. This requires\n-    JSON::Schema::Tiny 0.034.\n-  - Added client_capabilities, client_info, log_level, and protocol_version attributes, and a notify_log method, to\n-    MCP::Server::Context.\n-  - Added a cancelled event, and cancel and is_cancelled methods, to MCP::Server::Context, so long running tools can\n-    stop as soon as a client goes away. MCP::Server::Context is now a Mojo::EventEmitter.\n-  - Added HEADER_MISMATCH, META_CLIENT_CAPABILITIES, META_CLIENT_INFO, META_LOG_LEVEL, META_PROTOCOL_VERSION,\n-    META_SERVER_INFO, META_SUBSCRIPTION_ID, MISSING_CLIENT_CAPABILITY, SUPPORTED_VERSIONS, and\n-    UNSUPPORTED_PROTOCOL_VERSION constants to MCP::Constants.\n-  - Added capabilities and protocol_version attributes to MCP::Client, which renegotiates once when a server rejects\n-    its protocol version.\n-  - Improved progress and log notifications to be delivered on the response stream of the request they belong to, so\n-    they no longer require streaming to be enabled and work with pre-forking web servers.\n-  - Added a tutorial to the MCP documentation, introducing tools, prompts, resources, and everything around them.\n-  - Added examples/perldoc_stdio.pl, the server built in the tutorial.\n-\n-0.13  2026-06-22\n-  - Switched from JSON::Valdiator to JSON::Schema::Tiny for better portability.\n-\n-0.12  2026-06-19\n-  - Fixed bug in MCP::Server::Transport::Stdio where the server could disconnect when reading from Windows pipes,\n-    such as when spawned by Claude Desktop.\n-\n-0.11  2026-06-19\n-  - Added OAuth scope support, so MCP servers can act as OAuth 2.0 resource servers.\n-  - Added scopes attribute to MCP::Primitive, and therefore to MCP::Tool, MCP::Prompt, and MCP::Resource.\n-  - Added scopes and insufficient_scope attributes, and a has_scope method, to MCP::Server::Context.\n-  - Added auth and metadata_url attributes to MCP::Server::Transport::HTTP.\n-  - Added headers attribute to MCP::Client.\n-  - Added oauth_metadata method to MCP::Server.\n-  - Added INSUFFICIENT_SCOPE constant to MCP::Constants.\n-\n-0.10  2026-05-06\n-  - Added opt-in server-to-client streaming and session termination to the HTTP transport. Not compatible with\n-    pre-forking web servers.\n-  - Added support for list_changed notifications.\n-  - Added support for progress notifications.\n-  - Added MCP::Primitive class.\n-  - Added MCP::Server::Context class.\n-  - Added MCP::Server::Session class.\n-  - Added heartbeat, session_timeout, sessions, and streaming attributes, and a notify method, to\n-    MCP::Server::Transport::HTTP.\n-  - Added notify method to MCP::Server::Transport::Stdio.\n-  - Added notifications method to MCP::Server::Transport.\n-  - Added notify_all method to MCP::Server::Transport::HTTP and MCP::Server::Transport::Stdio.\n-  - Added notify_list_changed method to MCP::Server.\n-  - Added delete_session method to MCP::Client.\n-\n 0.08  2026-02-17\n   - Added support for tool annotations. (d3flex)\n \ndiff --git a/MANIFEST b/MANIFEST\nindex fbd9635..20b7218 100644\n--- a/MANIFEST\n+++ b/MANIFEST\n@@ -2,18 +2,12 @@\n Changes\n examples/echo_http.pl\n examples/echo_stdio.pl\n-examples/perldoc_stdio.pl\n-examples/streaming_http.pl\n lib/MCP.pm\n lib/MCP/Client.pm\n lib/MCP/Constants.pm\n-lib/MCP/Primitive.pm\n lib/MCP/Prompt.pm\n lib/MCP/Resource.pm\n lib/MCP/Server.pm\n-lib/MCP/Server/Context.pm\n-lib/MCP/Server/Legacy.pm\n-lib/MCP/Server/Subscription.pm\n lib/MCP/Server/Transport.pm\n lib/MCP/Server/Transport/HTTP.pm\n lib/MCP/Server/Transport/Stdio.pm\n@@ -26,21 +20,11 @@ t/apps/empty.wav\n t/apps/lite_app.pl\n t/apps/mojolicious.png\n t/apps/stdio.pl\n-t/auth_scopes_app.t\n-t/cancel.t\n-t/context_specific_app.t\n-t/exceptions.t\n-t/headers.t\n-t/legacy.t\n t/lib/MCPStdioTest.pm\n t/lite_app.t\n-t/mrtr.t\n-t/notifications.t\n t/pod.t\n t/pod_coverage.t\n-t/schema.t\n+t/session_specific_app.t\n t/stdio.t\n-t/streaming.t\n-t/tool_validation.t\n META.yml                                 Module YAML meta-data (added by MakeMaker)\n META.json                                Module JSON meta-data (added by MakeMaker)\ndiff --git a/META.json b/META.json\nindex e808283..890f5a8 100644\n--- a/META.json\n+++ b/META.json\n@@ -4,7 +4,7 @@\n       \"Sebastian Riedel <sri@cpan.org>\"\n    ],\n    \"dynamic_config\" : 0,\n-   \"generated_by\" : \"ExtUtils::MakeMaker version 7.78, CPAN::Meta::Converter version 2.150013\",\n+   \"generated_by\" : \"ExtUtils::MakeMaker version 7.76, CPAN::Meta::Converter version 2.150010\",\n    \"license\" : [\n       \"mit\"\n    ],\n@@ -36,7 +36,7 @@\n          \"requires\" : {\n             \"CryptX\" : \"0.087\",\n             \"IPC::Run\" : \"20231003.0\",\n-            \"JSON::Schema::Tiny\" : \"0.034\",\n+            \"JSON::Validator\" : \"5.15\",\n             \"Mojolicious\" : \"9.41\",\n             \"perl\" : \"5.020\"\n          }\n@@ -61,6 +61,6 @@\n          \"web\" : \"https://web.libera.chat/#mojo\"\n       }\n    },\n-   \"version\" : \"0.15\",\n+   \"version\" : \"0.08\",\n    \"x_serialization_backend\" : \"JSON::PP version 4.16\"\n }\ndiff --git a/META.yml b/META.yml\nindex 39d8580..f76d110 100644\n--- a/META.yml\n+++ b/META.yml\n@@ -7,7 +7,7 @@ build_requires:\n configure_requires:\n   ExtUtils::MakeMaker: '0'\n dynamic_config: 0\n-generated_by: 'ExtUtils::MakeMaker version 7.78, CPAN::Meta::Converter version 2.150013'\n+generated_by: 'ExtUtils::MakeMaker version 7.76, CPAN::Meta::Converter version 2.150010'\n license: mit\n meta-spec:\n   url: http://module-build.sourceforge.net/META-spec-v1.4.html\n@@ -22,7 +22,7 @@ no_index:\n requires:\n   CryptX: '0.087'\n   IPC::Run: '20231003.0'\n-  JSON::Schema::Tiny: '0.034'\n+  JSON::Validator: '5.15'\n   Mojolicious: '9.41'\n   perl: '5.020'\n resources:\n@@ -33,5 +33,5 @@ resources:\n   homepage: https://mojolicious.org\n   license: http://www.opensource.org/licenses/mit\n   repository: https://github.com/mojolicious/mojo-mcp.git\n-version: '0.15'\n+version: '0.08'\n x_serialization_backend: 'CPAN::Meta::YAML version 0.020'\ndiff --git a/Makefile.PL b/Makefile.PL\nindex 8787889..2d98b0d 100644\n--- a/Makefile.PL\n+++ b/Makefile.PL\n@@ -29,7 +29,6 @@ WriteMakefile(\n       x_IRC => {url => 'irc://irc.libera.chat/#mojo', web => 'https://web.libera.chat/#mojo'}\n     },\n   },\n-  PREREQ_PM =>\n-    {'CryptX' => '0.087', 'IPC::Run' => '20231003.0', 'JSON::Schema::Tiny' => '0.034', Mojolicious => '9.41'},\n-  test => {TESTS => 't/*.t t/*/*.t'}\n+  PREREQ_PM => {'CryptX' => '0.087', 'IPC::Run' => '20231003.0', 'JSON::Validator' => '5.15', Mojolicious => '9.41'},\n+  test      => {TESTS    => 't/*.t t/*/*.t'}\n );\ndiff --git a/README.md b/README.md\nindex 88a9d5c..a577a73 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,7 +1,7 @@\n \n # MCP Perl SDK\n \n- [![](https://github.com/mojolicious/mojo-mcp/workflows/linux/badge.svg)](https://github.com/mojolicious/mojo-mcp/actions) [![](https://github.com/mojolicious/mojo-mcp/workflows/macos/badge.svg)](https://github.com/mojolicious/mojo-mcp/actions) [![](https://github.com/mojolicious/mojo-mcp/workflows/windows/badge.svg)](https://github.com/mojolicious/mojo-mcp/actions)\n+ [![](https://github.com/mojolicious/mojo-mcp/workflows/linux/badge.svg)](https://github.com/mojolicious/mojo-mcp/actions) [![](https://github.com/mojolicious/mojo-mcp/workflows/macos/badge.svg)](https://github.com/mojolicious/mojo-mcp/actions)\n \n   [Model Context Protocol](https://modelcontextprotocol.io/) support for [Perl](https://perl.org) and the\n   [Mojolicious](https://mojolicious.org) real-time web framework.\n@@ -9,28 +9,14 @@\n ### Features\n \n Please be aware that this module is still in development and will be changing rapidly. Additionally the MCP\n-specification is getting regular updates which we will implement. Breaking changes are very likely. The protocol\n-revision currently implemented is `2026-07-28`.\n+specification is getting regular updates which we will implement. Breaking changes are very likely.\n \n   * Tool calling, prompts and resources\n   * Streamable HTTP and Stdio transports\n-  * Stateless protocol, with no handshake and no session to keep alive\n-  * Routing header validation, so gateways and servers can never disagree about what was called\n-  * JSON Schema 2020-12 validation of tool arguments\n-  * Progress and log notifications on the response stream of the request they belong to\n-  * Cancellation as a first-class signal, so long-running tools can stop early\n-  * Multi-round tool requests with integrity-protected request state\n-  * Cache hints for discovery, list and read results\n-  * Notifications for list changes (tools, prompts, resources)\n-  * OAuth scopes for tools, prompts and resources\n   * Scalable with pre-forking web server and async tools using promises\n   * HTTP client for testing\n   * Can be embedded in Mojolicious web apps\n \n-Not supported yet: pagination, resource templates, completion, tasks, MCP Apps, and resource subscriptions. The\n-roots, sampling and logging client features are deprecated in this revision and are not implemented. New OAuth\n-clients should use client ID metadata documents, since dynamic client registration is deprecated as well.\n-\n ## Installation\n \n   All you need is Perl 5.20 or newer. Just install from [CPAN](https://metacpan.org/pod/MCP).\n@@ -39,9 +25,6 @@ clients should use client ID metadata documents, since dynamic client registrati\n \n   We recommend the use of a [Perlbrew](http://perlbrew.pl) environment.\n \n-  Then follow the [tutorial](https://metacpan.org/pod/MCP#TUTORIAL), which builds a real server one feature at a\n-  time.\n-\n ## Streamable HTTP Transport\n \n Use the `to_action` method to add an MCP endpoint to any Mojolicious application.\n@@ -66,43 +49,7 @@ any '/mcp' => $server->to_action;\n app->start;\n ```\n \n-Authentication can be added by the web application, just like for any other route. OAuth scopes can be enforced per\n-tool, prompt and resource.\n-\n-## Notifications\n-\n-Notifications that belong to a request, such as progress reports, are delivered on the response stream of that very\n-request, which is upgraded from a plain JSON response to SSE whenever there is something to deliver. No extra\n-configuration is required, and it works under a pre-forking web server.\n-\n-```perl\n-use Mojolicious::Lite -signatures;\n-\n-use MCP::Server;\n-\n-my $server = MCP::Server->new;\n-$server->tool(\n-  name         => 'echo',\n-  description  => 'Echo the input text',\n-  input_schema => {type => 'object', properties => {msg => {type => 'string'}}, required => ['msg']},\n-  code         => sub ($tool, $args) {\n-    $tool->context->notify_progress(1, 2, \"Echoing: $args->{msg}\");\n-    return \"Echo: $args->{msg}\";\n-  }\n-);\n-\n-any '/mcp' => $server->to_action;\n-\n-app->start;\n-```\n-\n-Notifications that do not belong to a request, such as `list_changed`, need a long-lived stream, which clients open\n-with a `subscriptions/listen` request. That does require per-process state and is not compatible with pre-forking\n-web servers, so it is opt-in with `streaming`.\n-\n-```perl\n-any '/mcp' => $server->to_action({streaming => 1});\n-```\n+Authentication can be added by the web application, just like for any other route.\n \n ## Stdio Transport\n \n@@ -126,11 +73,10 @@ $server->tool(\n $server->to_stdio;\n ```\n \n-Just run the script and type requests on the command line. Every request has to declare the protocol version it is\n-made with and the capabilities of the client making it.\n+Just run the script and type requests on the command line.\n \n ```\n $ perl examples/echo_stdio.pl\n-{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"tools/list\",\"params\":{\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientCapabilities\":{}}}}\n-{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"msg\":\"hello perl\"},\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientCapabilities\":{}}}}\n+{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"tools/list\"}\n+{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"msg\":\"hello perl\"}}}\n ```\ndiff --git a/debian/changelog b/debian/changelog\nindex fe680f6..5ec5b5a 100644\n--- a/debian/changelog\n+++ b/debian/changelog\n@@ -1,24 +1,3 @@\n-libmcp-perl (0.15-1) unstable; urgency=medium\n-\n-  * Team upload.\n-  * Import upstream version 0.15.\n-  * Update dependencies:\n-    libjson-validator-perl → libjson-schema-tiny-perl.\n-  * Convert debian/watch to version 5.\n-\n- -- gregor herrmann <gregoa@debian.org>  Wed, 28 Oct 2026 18:40:54 +0100\n-\n-libmcp-perl (0.10-1) unstable; urgency=medium\n-\n-  * Team upload.\n-  * Import upstream version 0.10.\n-  * Declare compliance with Debian Policy 4.7.4.\n-  * Remove «Rules-Requires-Root: no», which is the current default.\n-  * Remove «Priority: optional», which is the current default.\n-  * Make short description a noun phrase.\n-\n- -- gregor herrmann <gregoa@debian.org>  Sat, 09 May 2026 20:13:57 +0200\n-\n libmcp-perl (0.08-1) unstable; urgency=medium\n \n   * Initial release. (closes: 1134937)\ndiff --git a/debian/control b/debian/control\nindex 59a3c35..25cf0af 100644\n--- a/debian/control\n+++ b/debian/control\n@@ -1,32 +1,30 @@\n Source: libmcp-perl\n-Standards-Version: 4.7.4\n Maintainer: Debian Perl Group <pkg-perl-maintainers@lists.alioth.debian.org>\n-Uploaders:\n- Philip Hands <phil@hands.com>,\n+Uploaders: Philip Hands <phil@hands.com>\n Section: perl\n Testsuite: autopkgtest-pkg-perl\n-Build-Depends:\n- debhelper-compat (= 13),\n-Build-Depends-Indep:\n- libcryptx-perl (>= 0.087) <!nocheck>,\n- libipc-run-perl (>= 20231003.0) <!nocheck>,\n- libjson-schema-tiny-perl <!nocheck>,\n- libmojolicious-perl (>= 9.41) <!nocheck>,\n- perl,\n+Priority: optional\n+Build-Depends: debhelper-compat (= 13)\n+Build-Depends-Indep: libcryptx-perl (>= 0.087) <!nocheck>,\n+                     libipc-run-perl (>= 20231003.0) <!nocheck>,\n+                     libjson-validator-perl (>= 5.15) <!nocheck>,\n+                     libmojolicious-perl (>= 9.41) <!nocheck>,\n+                     perl\n+Standards-Version: 4.7.2\n Vcs-Browser: https://salsa.debian.org/perl-team/modules/packages/libmcp-perl\n Vcs-Git: https://salsa.debian.org/perl-team/modules/packages/libmcp-perl.git\n Homepage: https://metacpan.org/release/MCP\n+Rules-Requires-Root: no\n \n Package: libmcp-perl\n Architecture: all\n-Depends:\n- ${misc:Depends},\n- ${perl:Depends},\n- libcryptx-perl (>= 0.087),\n- libipc-run-perl (>= 20231003.0),\n- libjson-schema-tiny-perl,\n- libmojolicious-perl (>= 9.41),\n-Description: module to connect Perl with LLMs using MCP (Model Context Protocol)\n+Depends: ${misc:Depends},\n+         ${perl:Depends},\n+         libcryptx-perl (>= 0.087),\n+         libipc-run-perl (>= 20231003.0),\n+         libjson-validator-perl (>= 5.15),\n+         libmojolicious-perl (>= 9.41)\n+Description: Connect Perl with LLMs using MCP (Model Context Protocol)\n  The Model Context Protocol (MCP - https://modelcontextprotocol.io/)\n  is a standard that allows Large Language Models (LLMs) to interact\n  with web services.\ndiff --git a/debian/watch b/debian/watch\nindex 64a8dbd..6d45907 100644\n--- a/debian/watch\n+++ b/debian/watch\n@@ -1,4 +1,2 @@\n-Version: 5\n-\n-Template: Metacpan\n-Dist: MCP\n+version=4\n+https://metacpan.org/release/MCP   .*/MCP-v?@ANY_VERSION@@ARCHIVE_EXT@$\ndiff --git a/examples/perldoc_stdio.pl b/examples/perldoc_stdio.pl\ndeleted file mode 100644\nindex 2fadfd1..0000000\n--- a/examples/perldoc_stdio.pl\n+++ /dev/null\n@@ -1,55 +0,0 @@\n-#\n-# This example is the server built in the MCP tutorial, giving a model access to your local Perl documentation\n-#\n-# mcp.json:\n-# {\n-#   \"mcpServers\": {\n-#     \"perldoc\": {\n-#       \"command\": \"/home/kraih/mojo-mcp/examples/perldoc_stdio.pl\"\n-#     }\n-#   }\n-# }\n-#\n-use Mojo::Base -strict, -signatures;\n-\n-use Config;\n-use MCP::Server;\n-\n-my $server = MCP::Server->new(name => 'PerldocServer');\n-$server->tool(\n-  name         => 'perldoc',\n-  description  => 'Look up the documentation of a Perl module',\n-  input_schema => {\n-    type       => 'object',\n-    properties => {module => {type => 'string', description => 'Module name, such as Mojo::UserAgent'}},\n-    required   => ['module']\n-  },\n-  code => sub ($tool, $args) {\n-    my $module = $args->{module};\n-    return $tool->text_result(\"Not a module name: $module\", 1) unless $module =~ /^\\w+(?:::\\w+)*\\z/;\n-    open my $doc, '-|', 'perldoc', '-o', 'text', '-T', $module or return $tool->text_result('Found no perldoc', 1);\n-    my $text = do { local $/; <$doc> };\n-    return length($text // '') ? $text : $tool->text_result(\"Found no documentation for $module\", 1);\n-  }\n-);\n-$server->prompt(\n-  name        => 'explain',\n-  description => 'Explain what a Perl module is for',\n-  arguments   => [{name => 'module', description => 'Module name', required => 1}],\n-  code        => sub ($prompt, $args) {\n-    return \"Read the documentation of $args->{module} with the perldoc tool, then explain in three sentences \"\n-      . 'what problem it solves and when to reach for it.';\n-  }\n-);\n-$server->resource(\n-  uri         => 'file:///perl/config',\n-  name        => 'perl_config',\n-  description => 'Configuration of the Perl interpreter running this server',\n-  mime_type   => 'text/plain',\n-  cache_ttl   => 3_600_000,\n-  code        => sub ($resource) {\n-    return join \"\\n\", map {\"$_=$Config{$_}\"} qw(archname osname perlpath version);\n-  }\n-);\n-\n-$server->to_stdio;\ndiff --git a/examples/streaming_http.pl b/examples/streaming_http.pl\ndeleted file mode 100644\nindex 35ce35e..0000000\n--- a/examples/streaming_http.pl\n+++ /dev/null\n@@ -1,46 +0,0 @@\n-#\n-# This example demonstrates progress notifications and cancellation for a long-running MCP tool\n-#\n-# mcp.json:\n-# {\n-#   \"mcpServers\": {\n-#     \"mojo\": {\n-#       \"url\": \"http://127.0.0.1:3000/mcp\"\n-#     }\n-#   }\n-# }\n-#\n-use Mojolicious::Lite -signatures;\n-\n-use MCP::Server;\n-use Mojo::IOLoop;\n-use Mojo::Promise;\n-\n-my $server = MCP::Server->new;\n-$server->tool(\n-  name         => 'process_items',\n-  description  => 'Process a number of items and report progress along the way',\n-  input_schema => {type => 'object', properties => {items => {type => 'integer'}}},\n-  code         => sub ($tool, $args) {\n-    my $context = $tool->context;\n-    my $total   = $args->{items} || 5;\n-    my $promise = Mojo::Promise->new;\n-    my $done    = 0;\n-    my $id;\n-    $id = Mojo::IOLoop->recurring(\n-      0.5 => sub {\n-        $done++;\n-        $context->notify_progress($done, $total, \"Processed item $done of $total\");\n-        return if $done < $total;\n-        Mojo::IOLoop->remove($id);\n-        $promise->resolve(\"Processed $total items\");\n-      }\n-    );\n-    $context->on(cancelled => sub ($context) { Mojo::IOLoop->remove($id) });\n-    return $promise;\n-  }\n-);\n-\n-any '/mcp' => $server->to_action;\n-\n-app->start;\ndiff --git a/lib/MCP.pm b/lib/MCP.pm\nindex 5822d01..32fcb9a 100644\n--- a/lib/MCP.pm\n+++ b/lib/MCP.pm\n@@ -1,7 +1,7 @@\n package MCP;\n use Mojo::Base -base, -signatures;\n \n-our $VERSION = '0.15';\n+our $VERSION = '0.08';\n \n 1;\n \n@@ -19,9 +19,9 @@ MCP - Model Context Protocol Perl SDK\n \n   my $server = MCP::Server->new;\n   $server->tool(\n-    name        => 'time',\n-    description => 'Get the current local time',\n-    code        => sub ($tool, $args) {\n+    name         => 'time',\n+    description  => 'Get the current local time',\n+    code         => sub ($tool, $args) {\n       return localtime(time);\n     }\n   );\n@@ -32,503 +32,62 @@ MCP - Model Context Protocol Perl SDK\n \n =head1 DESCRIPTION\n \n-Connect Perl with AI using the Model Context Protocol (MCP). An MCP server hands a model three kinds of things:\n-L<tools|MCP::Tool> it can call, L<prompts|MCP::Prompt> it can start from, and L<resources|MCP::Resource> it can read.\n-At its core MCP is all about text processing, which makes it a great fit for Perl.\n-\n-The protocol revision implemented is C<2026-07-28>, and it is stateless. There is no handshake and no session, every\n-request stands on its own, so an MCP endpoint is just another route in your L<Mojolicious> application and scales\n-the same way.\n-\n-Read on for a tour, or go straight to L<MCP::Server> for the reference documentation.\n-\n-=head1 TUTORIAL\n-\n-A walk through building MCP applications with Perl, from a single tool to a deployed web service.\n-\n-=head2 Concepts\n-\n-A server exposes three kinds of primitives, and you can mix them freely.\n-\n-  # Called by the model, whenever it decides to\n-  $server->tool(name => 'deploy', code => sub ($tool, $args) {...});\n-\n-  # Picked by the user, usually from a menu\n-  $server->prompt(name => 'review', code => sub ($prompt, $args) {...});\n+Connect Perl with AI using the Model Context Protocol (MCP). Currently this module is focused on tool calling and\n+prompts, but it will be extended to support other MCP features in the future. At its core, MCP is all about text\n+processing, making it a great fit for Perl.\n \n-  # Read by the client, to put into context\n-  $server->resource(uri => 'file:///readme', code => sub ($resource) {...});\n+=head3 Streamable HTTP Transport\n \n-L<Tools|MCP::Tool> are functions the model calls on its own, and are what most servers are made of.\n-L<Prompts|MCP::Prompt> are templates a user invokes deliberately. L<Resources|MCP::Resource> are documents a client\n-attaches to a conversation. Everything else in this tutorial builds on those three.\n+Use the L<MCP::Server/\"to_action\"> method to add an MCP endpoint to any L<Mojolicious> application. The tool name and\n+description are used for discovery, and the L<JSON schema|https://json-schema.org> is used to validate the input.\n \n-=head2 Your first server\n-\n-This is a complete MCP server that gives a model access to your local Perl documentation.\n-\n-  use Mojo::Base -strict, -signatures;\n+  use Mojolicious::Lite -signatures;\n \n   use MCP::Server;\n \n-  my $server = MCP::Server->new(name => 'PerldocServer');\n-\n+  my $server = MCP::Server->new;\n   $server->tool(\n-    name         => 'perldoc',\n-    description  => 'Look up the documentation of a Perl module',\n-    input_schema => {\n-      type       => 'object',\n-      properties => {module => {type => 'string', description => 'Module name, such as Mojo::UserAgent'}},\n-      required   => ['module']\n-    },\n-    code => sub ($tool, $args) {\n-      my $module = $args->{module};\n-      return $tool->text_result(\"Not a module name: $module\", 1) unless $module =~ /^\\w+(?:::\\w+)*\\z/;\n-      open my $doc, '-|', 'perldoc', '-o', 'text', '-T', $module or return $tool->text_result('Found no perldoc', 1);\n-      my $text = do { local $/; <$doc> };\n-      return length($text // '') ? $text : $tool->text_result(\"Found no documentation for $module\", 1);\n+    name         => 'echo',\n+    description  => 'Echo the input text',\n+    input_schema => {type => 'object', properties => {msg => {type => 'string'}}, required => ['msg']},\n+    code         => sub ($tool, $args) {\n+      return \"Echo: $args->{msg}\";\n     }\n   );\n \n-  $server->to_stdio;\n-\n-The name and description are how a model decides to call the tool, so write them for the model rather than for a\n-human reader. The L<input schema|MCP::Tool/\"input_schema\"> is enforced before your code runs, so C<$args> is always\n-valid by the time you see it. Returning a plain string is shorthand for a text result.\n-\n-=head2 Talking to your server\n-\n-L<MCP::Server/\"to_stdio\"> speaks JSON-RPC on standard input and output, so you can drive the server by hand. Every\n-request declares the protocol version it was made with and the capabilities of the client making it.\n-\n-  $ perl perldoc_stdio.pl\n-  {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientCapabilities\":{}}}}\n-  {\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"perldoc\",\"arguments\":{\"module\":\"Mojo::JSON\"},\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientCapabilities\":{}}}}\n-\n-One JSON object per line, in and out. That is the whole protocol, and it is worth doing once before reaching for a\n-client.\n-\n-=head2 Connecting a client\n-\n-Most MCP hosts launch stdio servers themselves and are configured with a JSON file, C<claude_desktop_config.json>\n-for Claude Desktop and C<mcp.json> for many others.\n-\n-  {\n-    \"mcpServers\": {\n-      \"perldoc\": {\n-        \"command\": \"perl\",\n-        \"args\": [\"/absolute/path/to/perldoc_stdio.pl\"]\n-      }\n-    }\n-  }\n-\n-Use absolute paths, since the host does not run the command from your working directory, and restart the host to\n-pick up changes. When a server does not show up, its C<STDERR> is where to look, see L</\"Logging\">.\n-\n-=head2 Web applications\n-\n-The same server becomes an HTTP endpoint with L<MCP::Server/\"to_action\">, which returns a plain L<Mojolicious>\n-action.\n-\n-  use Mojolicious::Lite -signatures;\n-\n-  use MCP::Server;\n-\n-  my $server = MCP::Server->new(name => 'PerldocServer');\n-  $server->tool(...);\n-\n   any '/mcp' => $server->to_action;\n \n   app->start;\n \n-There is nothing special about that route. It can sit under an C<under>, carry placeholders, or share authentication\n-with the rest of your application. In a full L<Mojolicious> application it goes into C<startup> like any other.\n+Authentication can be added by the web application, just like for any other route. To allow for MCP applications to\n+scale with prefork web servers, server to client streaming is currentlly avoided when possible.\n \n-  sub startup ($self) {\n-    my $server = MCP::Server->new(name => 'PerldocServer');\n-    $server->tool(...);\n-    $self->routes->any('/mcp' => $server->to_action);\n-  }\n+=head3 Stdio Transport\n \n-Clients reach it with the Streamable HTTP transport, so the same JSON travels in a C<POST> body.\n-\n-  {\n-    \"mcpServers\": {\n-      \"perldoc\": {\n-        \"url\": \"http://127.0.0.1:3000/mcp\"\n-      }\n-    }\n-  }\n-\n-=head2 Testing\n-\n-L<MCP::Client> is conformant on the wire, so pointing it at a L<Test::Mojo> application tests a server the way a real\n-client sees it.\n+Build local command line applications and use the stdio transport for testing with the L<MCP::Server/\"to_stdio\">\n+method.\n \n   use Mojo::Base -strict, -signatures;\n \n-  use Test::More;\n-  use Test::Mojo;\n-  use MCP::Client;\n-\n-  my $t      = Test::Mojo->new('MyApp');\n-  my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp'));\n-\n-  my $result = $client->call_tool('perldoc', {module => 'Mojo::JSON'});\n-  like $result->{content}[0]{text}, qr/Minimalistic JSON/, 'documentation found';\n-\n-  done_testing;\n-\n-Sharing the user agent with L<Test::Mojo> keeps requests inside the process, so no port is ever bound.\n-\n-=head2 Validating arguments\n-\n-Input schemas are JSON Schema 2020-12 unless they name another dialect with C<$schema>, and are checked before your\n-code is reached, so a call with bad arguments never becomes your problem.\n-\n-  use Mojo::JSON qw(false);\n-\n-  input_schema => {\n-    type       => 'object',\n-    properties => {\n-      module => {type => 'string', description => 'Module name, such as Mojo::UserAgent'},\n-      lines  => {type => 'integer', description => 'Maximum number of lines', minimum => 1, maximum => 500}\n-    },\n-    required             => ['module'],\n-    additionalProperties => false\n-  }\n-\n-Descriptions are part of the interface, since the model reads them to decide what to pass, and so are the\n-constraints. A schema that says up front what is acceptable produces fewer failed calls than a tool that says so\n-afterwards.\n-\n-=head2 Tool results\n-\n-Returning a string is shorthand for L<MCP::Tool/\"text_result\">, and there are constructors for the other content\n-types.\n-\n-  # Text\n-  return $tool->text_result('Hello!');\n-\n-  # Error the model can see and react to\n-  return $tool->text_result(\"Found no documentation for $module\", 1);\n-\n-  # Image or audio bytes, base64 encoded for you\n-  return $tool->image_result($png, {mime_type => 'image/png'});\n-  return $tool->audio_result($wav);\n-\n-  # Pointer to a resource, instead of its content\n-  return $tool->resource_link_result('file:///perl/config', {name => 'perl_config'});\n-\n-Anything the model could react to belongs in the result with the error flag set, such as a name that does not exist\n-or an argument that makes no sense together with another. A tool that dies is answered with an C<InternalError>\n-instead, and the exception goes to L<MCP::Server/\"log\"> rather than to the caller, since it can easily contain file\n-system paths or connection strings. That is a safe fallback and a poor error message, so prefer returning an error\n-result for anything you can predict.\n-\n-=head2 Structured results\n-\n-A tool with an L<output schema|MCP::Tool/\"output_schema\"> returns data instead of prose, and the client gets a\n-guarantee about its shape.\n-\n-  $server->tool(\n-    name          => 'perl_version',\n-    description   => 'Version of the Perl interpreter running this server',\n-    output_schema => {\n-      type       => 'object',\n-      properties => {version => {type => 'string'}, release => {type => 'number'}},\n-      required   => ['version', 'release']\n-    },\n-    code => sub ($tool, $args) {\n-      return $tool->structured_result({version => \"$^V\", release => $] + 0});\n-    }\n-  );\n-\n-Structured content is validated against the output schema before it is sent, and data that does not match becomes an\n-error result, so a tool cannot quietly break its own contract.\n-\n-=head2 Prompts\n-\n-Prompts are templates a user picks deliberately, usually from a menu in the client.\n-\n-  $server->prompt(\n-    name        => 'explain',\n-    description => 'Explain what a Perl module is for',\n-    arguments   => [{name => 'module', description => 'Module name', required => 1}],\n-    code        => sub ($prompt, $args) {\n-      return \"Read the documentation of $args->{module} with the perldoc tool, then explain in three sentences \"\n-        . 'what problem it solves and when to reach for it.';\n-    }\n-  );\n-\n-A plain string becomes a user message, and L<MCP::Prompt/\"text_prompt\"> gives you control over the role.\n-\n-  return $prompt->text_prompt('You are a Perl mentor.', 'assistant');\n-\n-=head2 Resources\n-\n-Resources are documents a client can read and attach to a conversation, identified by URI.\n-\n-  use Config;\n-\n-  $server->resource(\n-    uri         => 'file:///perl/config',\n-    name        => 'perl_config',\n-    description => 'Configuration of the Perl interpreter running this server',\n-    mime_type   => 'text/plain',\n-    code        => sub ($resource) {\n-      return join \"\\n\", map {\"$_=$Config{$_}\"} qw(archname osname perlpath version);\n-    }\n-  );\n-\n-Resources take no arguments, only the URI they were registered with. For binary content use\n-L<MCP::Resource/\"binary_resource\">, which base64 encodes it for you.\n-\n-=head2 Non-blocking tools\n-\n-A tool that returns a L<Mojo::Promise> leaves the server free to handle other requests while it waits, which starts\n-to matter as soon as it talks to the network.\n-\n-  use Mojo::URL;\n-  use Mojo::UserAgent;\n-\n-  my $ua = Mojo::UserAgent->new;\n-\n-  $server->tool(\n-    name         => 'metacpan',\n-    description  => 'Look up the latest release of a distribution on MetaCPAN',\n-    input_schema => {type => 'object', properties => {dist => {type => 'string'}}, required => ['dist']},\n-    code         => sub ($tool, $args) {\n-      my $url = Mojo::URL->new('https://fastapi.metacpan.org/v1/release');\n-      push @{$url->path->parts}, $args->{dist};\n-\n-      return $ua->get_p($url)->then(sub ($tx) {\n-        my $release = $tx->result->json;\n-        return \"$release->{name} was released on $release->{date} by $release->{author}\";\n-      })->catch(sub ($err) {\n-        return $tool->text_result(\"MetaCPAN is unavailable: $err\", 1);\n-      });\n-    }\n-  );\n-\n-The promise resolves to whatever a synchronous tool would have returned. Both transports serve other requests while\n-it is pending.\n-\n-Two details are worth copying. The argument becomes a path part instead of being interpolated into the URL, so a\n-value containing a slash or a question mark cannot change which endpoint is called. And the chain ends in a\n-C<catch>, which turns a failure into an error result; a promise that is rejected instead never produces a response\n-at all.\n-\n-=head2 Progress reports\n-\n-Long running tools report progress through the L<context|MCP::Server::Context>, the per-request object your code\n-shares with the transport.\n-\n-  code => sub ($tool, $args) {\n-    my $context = $tool->context;\n-\n-    my $total = @{$args->{modules}};\n-    my $done  = 0;\n-    for my $module (@{$args->{modules}}) {\n-      $context->notify_progress(++$done, $total, \"Indexed $module\");\n-      ...\n-    }\n-\n-    return \"Indexed $total modules\";\n-  }\n-\n-Notifications travel on the response stream of the request they belong to, which is upgraded from JSON to SSE the\n-moment there is something to send. That needs no configuration and works behind a pre-forking web server.\n-\n-Capture the context in a variable before an async boundary, since L<MCP::Primitive/\"context\"> is only valid for the\n-duration of the call itself.\n-\n-  code => sub ($tool, $args) {\n-    my $context = $tool->context;\n-    my $promise = Mojo::Promise->new;\n-    Mojo::IOLoop->timer(1 => sub {\n-      $context->notify_progress(1, 1, 'Finished');\n-      $promise->resolve('Done');\n-    });\n-    return $promise;\n-  }\n-\n-Progress is only sent when the client asked for it with a progress token, so C<notify_progress> is always safe to\n-call.\n-\n-=head2 Logging\n-\n-L<MCP::Server::Context/\"notify_log\"> sends log messages to the client, gated by the level it asked for.\n-\n-  $context->notify_log(info    => \"Looking up $module\");\n-  $context->notify_log(warning => {module => $module, truncated => 1});\n-\n-Nothing is sent unless the client requested logging for the current request, and messages below the level it asked\n-for are dropped, so this too is always safe to call.\n-\n-Your own logs belong on C<STDERR>, which is where L<Mojo::Log> writes and where an MCP host collects them. Never\n-write to C<STDOUT> from a stdio server, because that is the JSON-RPC channel and a stray C<print> corrupts it.\n-\n-  # Bad, breaks the stdio transport\n-  print \"Looking up $module\\n\";\n-\n-  # Good\n-  my $log = Mojo::Log->new;\n-  $log->info(\"Looking up $module\");\n-\n-=head2 Cancellation\n-\n-When a client goes away the context emits L<MCP::Server::Context/\"cancelled\">, so work can stop instead of running\n-to completion for nobody.\n-\n-  code => sub ($tool, $args) {\n-    my $context = $tool->context;\n-    my $promise = Mojo::Promise->new;\n-    my $id      = Mojo::IOLoop->recurring(1 => sub {...});\n-    $context->on(cancelled => sub ($context) { Mojo::IOLoop->remove($id) });\n-    return $promise;\n-  }\n-\n-Code that cannot subscribe to an event polls L<MCP::Server::Context/\"is_cancelled\"> instead. Both transports feed the\n-same signal, a closed response stream over HTTP and a C<notifications/cancelled> notification over stdio, so a tool\n-only has to handle it once.\n-\n-=head2 Asking the client for input\n-\n-A tool that needs a decision from the user returns an C<input_required> result instead of a final one, and the client\n-calls it again with the answer.\n+  use MCP::Server;\n \n+  my $server = MCP::Server->new;\n   $server->tool(\n-    name         => 'delete_release',\n-    input_schema => {type => 'object', properties => {name => {type => 'string'}}, required => ['name']},\n+    name         => 'echo',\n+    description  => 'Echo the input text',\n+    input_schema => {type => 'object', properties => {msg => {type => 'string'}}, required => ['msg']},\n     code         => sub ($tool, $args) {\n-      my $context = $tool->context;\n-      my $state   = $context->request_state;\n-      my $confirm = ($context->input_responses // {})->{confirm} // {};\n-      return $tool->text_result(\"Deleted $state->{name}\") if $state && ($confirm->{action} // '') eq 'accept';\n-\n-      return $tool->input_required({\n-        confirm => {\n-          method => 'elicitation/create',\n-          params => {\n-            message         => \"Really delete $args->{name}?\",\n-            requestedSchema => {type => 'object', properties => {ok => {type => 'boolean'}}}\n-          }\n-        }\n-      }, {name => $args->{name}});\n+      return \"Echo: $args->{msg}\";\n     }\n   );\n \n-The second argument is state carried across the round trip, and the server remembers nothing. It is sealed with\n-HMAC-SHA256, bound to the caller and to this very tool, and given a short lifetime, so a client cannot edit it on the\n-way back. L<MCP::Server::Context/\"request_state\"> returns C<undef> for state that fails any of those checks, which\n-your code should treat exactly like a first call and simply ask again.\n-\n-Note that the retry acts on C<< $state->{name} >> and not on C<< $args->{name} >>. Only the state is sealed; the\n-arguments of the second call are as fresh and as untrusted as those of the first, so a client could ask about one\n-release and then present that confirmation with another one named. Whatever the user actually agreed to has to come\n-back out of the state.\n-\n-Only ask for capabilities the client declared in L<MCP::Server::Context/\"client_capabilities\">, and set\n-L<MCP::Server/\"state_secret\"> if the server runs in more than one process, see L</\"Deployment\">.\n-\n-=head2 Authentication and scopes\n-\n-An MCP endpoint is authenticated like any other route, and for OAuth 2.0 bearer tokens the HTTP transport has an\n-L<auth hook|MCP::Server::Transport::HTTP/\"auth\"> that runs before dispatch.\n-\n-  any '/mcp' => $server->to_action({\n-    auth => sub ($c) {\n-      return undef unless ($c->req->headers->authorization // '') =~ /^Bearer\\s+(\\S+)$/;\n-      return undef unless my $token = validate_token($1);\n-      $c->stash(role => $token->{role});\n-      return {principal => $token->{sub}, scopes => $token->{scopes}};\n-    },\n-    metadata_url => 'https://example.com/.well-known/oauth-protected-resource'\n-  });\n-\n-Returning a false value rejects the request with a C<401> and a C<WWW-Authenticate> challenge pointing at the\n-metadata document. Validating the token is yours to do, since only your application knows the authorization server.\n-\n-Primitives can then require scopes, which are checked on every call and hide the primitive from callers who are not\n-entitled to see it.\n-\n-  $server->tool(name => 'delete_release', scopes => ['mcp:write'], code => sub ($tool, $args) {...});\n-\n-Scopes are enforced against what the hook granted, so they only protect anything on a server that has one. Without\n-it no request carries scopes, and a scoped primitive is as reachable as an unscoped one; over stdio, where the\n-caller is whoever started the process, that is the intended behaviour.\n-\n-Serve the metadata document with L<MCP::Server/\"oauth_metadata\">, which fills in the scopes your server actually\n-uses.\n-\n-  get '/.well-known/oauth-protected-resource' => sub ($c) {\n-    $c->render(json => $server->oauth_metadata(\n-      resource              => 'https://example.com/mcp',\n-      authorization_servers => ['https://auth.example.com']\n-    ));\n-  };\n-\n-=head2 Per-caller primitives\n-\n-The lists of tools, prompts and resources are assembled per request, and L<MCP::Server> emits an event for each one,\n-so a single server can show different things to different callers.\n-\n-  $server->on(tools => sub ($server, $tools, $context) {\n-    my $c = $context->controller;\n-    return if $c && ($c->stash('role') // '') eq 'admin';\n-    @$tools = grep { $_->name ne 'delete_release' } @$tools;\n-  });\n-\n-The array reference is yours to modify in place, and removing a primitive also makes it uncallable, not merely\n-invisible. Everything known about the caller is on the L<context|MCP::Server::Context>, including the\n-L<Mojolicious::Controller> for HTTP requests, which is only there for the HTTP transport. Decide what to hide from\n-what the context actually says, never from what it fails to say, so an unauthenticated request ends up with the\n-smallest list rather than the largest.\n-\n-=head2 Caching\n-\n-Results that are expensive to produce and rarely change may be cached by clients and gateways, for as long as the\n-primitive says.\n-\n-  $server->resource(uri => 'file:///perl/config', cache_ttl => 3_600_000, code => sub ($resource) {...});\n-\n-Times are in milliseconds, and the default of C<0> means every result has to be revalidated. Lists derive their\n-hints from the primitives in them, and are marked C<private> as soon as they could differ per caller, which is the\n-case for every server using scopes or the events above, so caching never leaks one caller's view to another.\n-\n-=head2 Notification streams\n-\n-Notifications that belong to no request, such as a tool list that changed while a client was connected, need a\n-stream of their own. Clients open one with C<subscriptions/listen>, which has to be enabled explicitly.\n-\n-  any '/mcp' => $server->to_action({streaming => 1});\n-\n-  # Somewhere else in the application\n-  $server->notify_list_changed('tools');\n-\n-Every subscription only receives the notification types it asked for. This is the one part of the protocol that\n-keeps per-process state, so it is off by default and not compatible with pre-forking web servers. Progress and log\n-notifications do not depend on it.\n-\n-=head2 Deployment\n-\n-An MCP endpoint deploys exactly like the rest of your L<Mojolicious> application, see\n-L<Mojolicious::Guides::Cookbook/\"DEPLOYMENT\">. The protocol is stateless, so a pre-forking web server and a load\n-balancer need no special handling.\n-\n-  $ ./myapp.pl prefork\n-\n-Serve a remote endpoint over HTTPS, which the protocol requires and which is usually the reverse proxy's job. Two\n-settings are worth having as well. L<MCP::Server/\"state_secret\"> has to be shared by every process that could serve\n-a retry, or state minted by one worker is rejected by the next, and L<MCP::Server::Transport::HTTP/\"origins\">\n-protects a server bound to localhost from being driven by a web page.\n-\n-  my $server = MCP::Server->new(state_secret => $ENV{MCP_STATE_SECRET});\n+  $server->to_stdio;\n \n-  any '/mcp' => $server->to_action({origins => ['https://example.com']});\n+Just run the script and type requests on the command line.\n \n-Everything except L</\"Notification streams\"> works under C<prefork>.\n+  $ perl examples/echo_stdio.pl\n+  {\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"tools/list\"}\n+  {\"jsonrpc\":\"2.0\",\"id\":\"2\",\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"msg\":\"hello perl\"}}}\n \n =head1 COPYRIGHT AND LICENSE\n \n@@ -538,6 +97,6 @@ This program is free software, you can redistribute it and/or modify it under th\n \n =head1 SEE ALSO\n \n-L<MCP::Server>, L<MCP::Client>, L<Mojolicious>, L<https://mojolicious.org>, L<https://modelcontextprotocol.io>.\n+L<Mojolicious>, L<https://mojolicious.org>, L<https://modelcontextprotocol.io>.\n \n =cut\ndiff --git a/lib/MCP/Client.pm b/lib/MCP/Client.pm\nindex a95d588..a4eb12c 100644\n--- a/lib/MCP/Client.pm\n+++ b/lib/MCP/Client.pm\n@@ -1,135 +1,75 @@\n package MCP::Client;\n-use Mojo::Base 'Mojo::EventEmitter', -signatures;\n+use Mojo::Base -base, -signatures;\n \n use Carp           qw(croak);\n-use List::Util     qw(first);\n-use MCP::Constants qw(META_CLIENT_CAPABILITIES META_CLIENT_INFO META_PROTOCOL_VERSION PROTOCOL_VERSION),\n-  qw(SUPPORTED_VERSIONS UNSUPPORTED_PROTOCOL_VERSION);\n-use Mojo::JSON qw(from_json);\n-use Mojo::Util qw(b64_encode encode);\n+use MCP::Constants qw(PROTOCOL_VERSION);\n+use Mojo::JSON     qw(from_json);\n use Mojo::UserAgent;\n+use Scalar::Util qw(weaken);\n \n-use constant NAMES => {'prompts/get' => 'name', 'resources/read' => 'uri', 'tools/call' => 'name'};\n+has name => 'PerlClient';\n+has 'session_id';\n+has ua      => sub { Mojo::UserAgent->new };\n+has url     => sub {'http://localhost:3000/mcp'};\n+has version => '1.0.0';\n \n-has capabilities     => sub { {} };\n-has headers          => sub { {} };\n-has name             => 'PerlClient';\n-has protocol_version => PROTOCOL_VERSION;\n-has ua               => sub { Mojo::UserAgent->new };\n-has url              => sub {'http://localhost:3000/mcp'};\n-has version          => '1.0.0';\n+sub build_request ($self, $method, $params = {}) {\n+  my $request = $self->build_notification($method, $params);\n+  $request->{id} = $self->{id} = $self->{id} ? $self->{id} + 1 : 1;\n+  return $request;\n+}\n \n sub build_notification ($self, $method, $params = {}) {\n   return {jsonrpc => '2.0', method => $method, params => $params};\n }\n \n-sub build_request ($self, $method, $params = {}) {\n-  my $meta = {%{$params->{_meta} // {}}};\n-  $meta->{+META_CLIENT_CAPABILITIES} = $self->capabilities;\n-  $meta->{+META_CLIENT_INFO}         = {name => $self->name, version => $self->version};\n-  $meta->{+META_PROTOCOL_VERSION}    = $self->protocol_version;\n-  my $request = $self->build_notification($method, {%$params, _meta => $meta});\n-  $request->{id} = $self->{id} = $self->{id} ? $self->{id} + 1 : 1;\n-  return $request;\n+sub call_tool ($self, $name, $args = {}) {\n+  my $request = $self->build_request('tools/call', {name => $name, arguments => $args});\n+  return _result($self->send_request($request));\n }\n \n-sub call_tool ($self, $name, $args = {}, $options = {}) {\n-  my $request = $self->build_request('tools/call', {name => $name, arguments => $args, %{_retry($options)}});\n+sub get_prompt ($self, $name, $args = {}) {\n+  my $request = $self->build_request('prompts/get', {name => $name, arguments => $args});\n   return _result($self->send_request($request));\n }\n \n-sub discover ($self) { _result($self->send_request($self->build_request('server/discover'))) }\n-\n-sub get_prompt ($self, $name, $args = {}, $options = {}) {\n-  my $request = $self->build_request('prompts/get', {name => $name, arguments => $args, %{_retry($options)}});\n-  return _result($self->send_request($request));\n+sub initialize_session ($self) {\n+  my $request = $self->build_request(\n+    initialize => {\n+      protocolVersion => PROTOCOL_VERSION,\n+      capabilities    => {},\n+      clientInfo      => {name => $self->name, version => $self->version,},\n+    }\n+  );\n+  my $result = _result($self->send_request($request));\n+  $self->send_request($self->build_notification('notifications/initialized'));\n+  return $result;\n }\n \n sub list_prompts   ($self) { _result($self->send_request($self->build_request('prompts/list'))) }\n sub list_resources ($self) { _result($self->send_request($self->build_request('resources/list'))) }\n sub list_tools     ($self) { _result($self->send_request($self->build_request('tools/list'))) }\n+sub ping           ($self) { _result($self->send_request($self->build_request('ping'))) }\n \n-sub listen ($self, $filter, $cb) {\n-  my $request = $self->build_request('subscriptions/listen', {notifications => $filter});\n-  my $ua      = $self->ua;\n-  my $tx      = $ua->build_tx(POST => $self->url => $self->_headers($request) => json => $request);\n-\n-  $tx->res->content->on(\n-    sse => sub ($content, $event = undef) {\n-      return unless $event && $event->{text} && (my $message = eval { from_json($event->{text}) });\n-      $tx->res->error({message => 'Interrupted'}) unless $cb->($message);\n-    }\n-  );\n-  $ua->start($tx);\n-\n-  return 1;\n-}\n-\n-sub read_resource ($self, $uri, $options = {}) {\n-  my $request = $self->build_request('resources/read', {uri => $uri, %{_retry($options)}});\n+sub read_resource ($self, $uri) {\n+  my $request = $self->build_request('resources/read', {uri => $uri});\n   return _result($self->send_request($request));\n }\n \n sub send_request ($self, $request) {\n-  my $res = $self->_send($request);\n-  return $res unless my $version = _renegotiate($res);\n-  return $res if $version eq $self->protocol_version;\n-\n-  $self->protocol_version($version);\n-  $request->{params}{_meta}{+META_PROTOCOL_VERSION} = $version;\n-  return $self->_send($request);\n-}\n-\n-sub _encode_header ($value) {\n-  return $value if $value =~ /^[\\x20-\\x7e]*\\z/ && $value !~ /^=\\?base64\\?.*\\?=$/;\n-  return '=?base64?' . b64_encode(encode('UTF-8', $value), '') . '?=';\n-}\n-\n-sub _headers ($self, $request) {\n-  my $headers = {\n-    %{$self->headers},\n-    Accept                 => 'application/json, text/event-stream',\n-    'Content-Type'         => 'application/json',\n-    'MCP-Protocol-Version' => $request->{params}{_meta}{+META_PROTOCOL_VERSION} // $self->protocol_version,\n-    'Mcp-Method'           => $request->{method}\n-  };\n-  if (my $key = NAMES->{$request->{method}}) {\n-    $headers->{'Mcp-Name'} = _encode_header($request->{params}{$key} // '');\n-  }\n-  return $headers;\n-}\n-\n-sub _renegotiate ($res) {\n-  return undef unless ref $res eq 'HASH' && ref(my $error = $res->{error}) eq 'HASH';\n-  return undef unless ($error->{code} // 0) == UNSUPPORTED_PROTOCOL_VERSION;\n-  my %supported = map { $_ => 1 } @{($error->{data} // {})->{supported} // []};\n-  return first { $supported{$_} } @{(SUPPORTED_VERSIONS)};\n-}\n-\n-sub _result ($res) {\n-  croak 'No response' unless $res;\n-  if (my $err = $res->{error}) { croak \"Error $err->{code}: $err->{message}\" }\n-  return $res->{result};\n-}\n-\n-sub _retry ($options) {\n-  my $params = {};\n-  $params->{inputResponses} = $options->{input_responses} if $options->{input_responses};\n-  $params->{requestState}   = $options->{request_state}   if defined $options->{request_state};\n-  return $params;\n-}\n-\n-sub _send ($self, $request) {\n+  my $headers = {Accept => 'application/json, text/event-stream', 'Content-Type' => 'application/json'};\n+  if (my $session_id = $self->session_id) { $headers->{'Mcp-Session-Id'} = $session_id }\n   my $ua = $self->ua;\n-  my $tx = $ua->build_tx(POST => $self->url => $self->_headers($request) => json => $request);\n+  my $tx = $ua->build_tx(POST => $self->url => $headers => json => $request);\n \n   # SSE handling\n   my $id = $request->{id};\n   my $response;\n   $tx->res->content->on(\n-    sse => sub ($content, $event = undef) {\n-      return unless $event && $event->{text} && (my $res = eval { from_json($event->{text}) });\n-      return $self->emit(notification => $res) unless defined($res->{id}) && defined($id) && $res->{id} eq $id;\n+    sse => sub {\n+      my ($content, $event) = @_;\n+      return unless $event->{text} && (my $res = eval { from_json($event->{text}) });\n+      return unless defined($res->{id}) && defined($id) && $res->{id} eq $id;\n       $response = $res;\n       $tx->res->error({message => 'Interrupted'});\n     }\n@@ -137,12 +77,13 @@ sub _send ($self, $request) {\n \n   $tx = $ua->start($tx);\n \n+  if (my $session_id = $tx->res->headers->header('Mcp-Session-Id')) { $self->session_id($session_id) }\n+\n   # Request or notification accepted without a response\n   return undef if $tx->res->code eq '202';\n \n   if (my $err = $tx->error) {\n     return $response                               if $err->{message} eq 'Interrupted';\n-    return $tx->res->json                          if $err->{code} && $tx->res->json;\n     croak \"$err->{code} response: $err->{message}\" if $err->{code};\n     croak \"Connection error: $err->{message}\";\n   }\n@@ -150,59 +91,35 @@ sub _send ($self, $request) {\n   return $tx->res->json;\n }\n \n+sub _result ($res) {\n+  croak 'No response' unless $res;\n+  if (my $err = $res->{error}) { croak \"Error $err->{code}: $err->{message}\" }\n+  return $res->{result};\n+}\n+\n 1;\n \n =encoding utf8\n \n =head1 NAME\n \n-MCP::Client - MCP client\n+MCP::Server::Transport::HTTP - HTTP transport for MCP servers\n \n =head1 SYNOPSIS\n \n   use MCP::Client;\n \n   my $client = MCP::Client->new(url => 'http://localhost:3000/mcp');\n-  my $tools  = $client->list_tools;\n+  $client->initialize_session;\n+  my $tools = $client->list_tools;\n \n =head1 DESCRIPTION\n \n L<MCP::Client> is a client for MCP (Model Context Protocol) that communicates with MCP servers over HTTP.\n \n-It is exactly conformant on the wire, sending the C<_meta> fields and routing headers every request has to carry,\n-and it is deliberately nothing more than that. TTL caching, retry loops for input requests, and the OAuth\n-authorization flow are all left to the application, so this is a good client to test a server with and a starting\n-point rather than a finished agent.\n-\n-=head1 EVENTS\n-\n-L<MCP::Client> inherits all events from L<Mojo::EventEmitter> and emits the following new ones.\n-\n-=head2 notification\n-\n-  $client->on(notification => sub ($client, $notification) { ... });\n-\n-Emitted for every JSON-RPC notification the server sends on the response stream of a request, such as a progress\n-report for a long running tool call.\n-\n =head1 ATTRIBUTES\n \n-L<MCP::Client> inherits all attributes from L<Mojo::EventEmitter> and implements the following new ones.\n-\n-=head2 capabilities\n-\n-  my $capabilities = $client->capabilities;\n-  $client          = $client->capabilities({elicitation => {}});\n-\n-Capabilities to declare with every request, defaults to an empty hash reference.\n-\n-=head2 headers\n-\n-  my $headers = $client->headers;\n-  $client     = $client->headers({Authorization => 'Bearer abc123'});\n-\n-Extra HTTP headers to send with every request, as a hash reference. Useful for passing an C<Authorization> header to\n-an MCP server that requires OAuth bearer authentication. Defaults to an empty hash reference.\n+L<MCP::Client> inherits all attributes from L<Mojo::Base> and implements the following new ones.\n \n =head2 name\n \n@@ -211,14 +128,12 @@ an MCP server that requires OAuth bearer authentication. Defaults to an empty ha\n \n The name of the client, defaults to C<PerlClient>.\n \n-=head2 protocol_version\n+=head2 session_id\n \n-  my $version = $client->protocol_version;\n-  $client     = $client->protocol_version('2026-07-28');\n+  my $session_id = $client->session_id;\n+  $client        = $client->session_id('12345');\n \n-The protocol version to make requests with, defaults to L<MCP::Constants/\"PROTOCOL_VERSION\">. A server rejecting it\n-with an C<UnsupportedProtocolVersionError> gets one more chance with the newest version both sides support, and that\n-version is remembered for subsequent requests.\n+The session ID for the client, used to maintain state across requests.\n \n =head2 ua\n \n@@ -243,64 +158,39 @@ The version of the client, defaults to C<1.0.0>.\n \n =head1 METHODS\n \n-L<MCP::Client> inherits all methods from L<Mojo::EventEmitter> and implements the following new ones.\n+L<MCP::Client> inherits all methods from L<Mojo::Base> and implements the following new ones.\n \n-=head2 build_notification\n+=head2 build_request\n \n-  my $notification = $client->build_notification('method_name', {param1 => 'value1'});\n+  my $request = $client->build_request('method_name', {param1 => 'value1'});\n \n-Builds a JSON-RPC notification with the given method name and parameters.\n+Builds a JSON-RPC request with the given method name and parameters.\n \n-=head2 build_request\n+=head2 build_notification\n \n-  my $request = $client->build_request('method_name', {param1 => 'value1'});\n+  my $notification = $client->build_notification('method_name', {param1 => 'value1'});\n \n-Builds a JSON-RPC request with the given method name and parameters, adding the C<_meta> fields every request has to\n-carry.\n+Builds a JSON-RPC notification with the given method name and parameters.\n \n =head2 call_tool\n \n   my $result = $client->call_tool('tool_name');\n   my $result = $client->call_tool('tool_name', {arg1 => 'value1'});\n-  my $result = $client->call_tool('tool_name', {arg1 => 'value1'}, {request_state => $state});\n \n Calls a tool on the MCP server with the specified name and arguments, returning the result.\n \n-A result with C<resultType> set to C<input_required> is returned as-is; gathering the requested input and deciding\n-whether to call again is up to you.\n-\n-These options are currently available:\n-\n-=over 2\n-\n-=item input_responses\n-\n-  input_responses => {confirm => {action => 'accept', content => {ok => \\1}}}\n-\n-Responses to the input requests of an earlier C<input_required> result, keyed by the same names.\n-\n-=item request_state\n-\n-  request_state => 'eyJ...'\n-\n-The C<requestState> of an earlier C<input_required> result, passed back verbatim.\n-\n-=back\n-\n-=head2 discover\n-\n-  my $result = $client->discover;\n-\n-Discover the protocol versions, capabilities, and instructions of the MCP server.\n-\n =head2 get_prompt\n \n   my $result = $client->get_prompt('prompt_name');\n   my $result = $client->get_prompt('prompt_name', {arg1 => 'value1'});\n-  my $result = $client->get_prompt('prompt_name', {arg1 => 'value1'}, {request_state => $state});\n \n-Get a prompt from the MCP server with the specified name and arguments, returning the result. Takes the same options\n-as L</\"call_tool\">.\n+Get a prompt from the MCP server with the specified name and arguments, returning the result.\n+\n+=head2 initialize_session\n+\n+  my $result = $client->initialize_session;\n+\n+Initializes a session with the MCP server, setting up the protocol version and client information.\n \n =head2 list_prompts\n \n@@ -320,28 +210,23 @@ Lists all available resources on the MCP server.\n \n Lists all available tools on the MCP server.\n \n-=head2 listen\n+=head2 ping\n \n-  my $bool = $client->listen({toolsListChanged => \\1}, sub ($message) {...});\n+  my $result = $client->ping;\n \n-Open a C<subscriptions/listen> stream for the requested notification types and block, invoking the callback for\n-every message the server sends, starting with the acknowledgement. Returning a false value from the callback closes\n-the stream. Only servers configured with C<< streaming => 1 >> support this.\n+Sends a ping request to the MCP server to check connectivity.\n \n =head2 read_resource\n \n   my $result = $client->read_resource('file:///path/to/resource.txt');\n-  my $result = $client->read_resource('file:///path/to/resource.txt', {request_state => $state});\n \n-Reads a resource from the MCP server with the specified URI, returning the result. Takes the same options as\n-L</\"call_tool\">.\n+Reads a resource from the MCP server with the specified URI, returning the result.\n \n =head2 send_request\n \n   my $response = $client->send_request($request);\n \n-Sends a JSON-RPC request to the MCP server and returns the response, or C<undef> if the server accepted it without\n-one.\n+Sends a JSON-RPC request to the MCP server and returns the response.\n \n =head1 SEE ALSO\n \ndiff --git a/lib/MCP/Constants.pm b/lib/MCP/Constants.pm\nindex aa6fa54..72599a6 100644\n--- a/lib/MCP/Constants.pm\n+++ b/lib/MCP/Constants.pm\n@@ -2,32 +2,15 @@ package MCP::Constants;\n use Mojo::Base 'Exporter';\n \n use constant {\n-  HEADER_MISMATCH              => -32020,\n-  INSUFFICIENT_SCOPE           => -32003,\n-  INTERNAL_ERROR               => -32603,\n-  INVALID_PARAMS               => -32602,\n-  INVALID_REQUEST              => -32600,\n-  META_CLIENT_CAPABILITIES     => 'io.modelcontextprotocol/clientCapabilities',\n-  META_CLIENT_INFO             => 'io.modelcontextprotocol/clientInfo',\n-  META_LOG_LEVEL               => 'io.modelcontextprotocol/logLevel',\n-  META_PROTOCOL_VERSION        => 'io.modelcontextprotocol/protocolVersion',\n-  META_SERVER_INFO             => 'io.modelcontextprotocol/serverInfo',\n-  META_SUBSCRIPTION_ID         => 'io.modelcontextprotocol/subscriptionId',\n-  METHOD_NOT_FOUND             => -32601,\n-  MISSING_CLIENT_CAPABILITY    => -32021,\n-  PARSE_ERROR                  => -32700,\n-  PROTOCOL_VERSION             => $ENV{MOJO_MCP_VERSION} || '2026-07-28',\n-  UNSUPPORTED_PROTOCOL_VERSION => -32022\n+  INVALID_PARAMS     => -32602,\n+  INVALID_REQUEST    => -32600,\n+  METHOD_NOT_FOUND   => -32601,\n+  PARSE_ERROR        => -32700,\n+  PROTOCOL_VERSION   => $ENV{MOJO_MCP_VERSION} || '2025-11-25',\n+  RESOURCE_NOT_FOUND => -32002\n };\n \n-use constant SUPPORTED_VERSIONS => [PROTOCOL_VERSION];\n-\n-our @EXPORT_OK = (\n-  qw(HEADER_MISMATCH INSUFFICIENT_SCOPE INTERNAL_ERROR INVALID_PARAMS INVALID_REQUEST META_CLIENT_CAPABILITIES),\n-  qw(META_CLIENT_INFO META_LOG_LEVEL META_PROTOCOL_VERSION META_SERVER_INFO META_SUBSCRIPTION_ID),\n-  qw(METHOD_NOT_FOUND MISSING_CLIENT_CAPABILITY PARSE_ERROR PROTOCOL_VERSION SUPPORTED_VERSIONS),\n-  qw(UNSUPPORTED_PROTOCOL_VERSION)\n-);\n+our @EXPORT_OK = qw(INVALID_PARAMS INVALID_REQUEST METHOD_NOT_FOUND PARSE_ERROR PROTOCOL_VERSION RESOURCE_NOT_FOUND);\n \n 1;\n \n@@ -49,60 +32,18 @@ L<MCP::Constants> provides constants used in MCP (Model Context Protocol).\n \n L<MCP::Constants> exports the following constants.\n \n-=head2 HEADER_MISMATCH\n-\n-The error code for a request whose routing headers disagree with its JSON-RPC body.\n-\n-=head2 INSUFFICIENT_SCOPE\n-\n-The error code for a request whose access token lacks a required OAuth scope. This is a local authorization policy\n-rather than a spec-defined code, and lives in the C<-32000> to C<-32019> range the specification grandfathers for\n-existing SDK use; C<-32020> to C<-32099> is reserved for the specification itself.\n-\n-=head2 INTERNAL_ERROR\n-\n-The error code for a prompt, resource, or tool that died, or returned a promise that was rejected.\n-\n =head2 INVALID_PARAMS\n \n-The error code for invalid parameters. Also used for prompts, resources, and tools that were not found.\n+The error code for invalid parameters.\n \n =head2 INVALID_REQUEST\n \n The error code for an invalid request.\n \n-=head2 META_CLIENT_CAPABILITIES\n-\n-The C<_meta> key carrying the capabilities of the client making the request.\n-\n-=head2 META_CLIENT_INFO\n-\n-The C<_meta> key carrying the name and version of the client making the request.\n-\n-=head2 META_LOG_LEVEL\n-\n-The C<_meta> key carrying the minimum log level the client wants to receive for a request.\n-\n-=head2 META_PROTOCOL_VERSION\n-\n-The C<_meta> key carrying the protocol version a request is made with.\n-\n-=head2 META_SERVER_INFO\n-\n-The C<_meta> key carrying the name and version of the server in a result.\n-\n-=head2 META_SUBSCRIPTION_ID\n-\n-The C<_meta> key carrying the subscription a notification belongs to.\n-\n =head2 METHOD_NOT_FOUND\n \n The error code for a method that was not found.\n \n-=head2 MISSING_CLIENT_CAPABILITY\n-\n-The error code for a request that requires a client capability the client did not declare.\n-\n =head2 PARSE_ERROR\n \n The error code for a parse error.\n@@ -111,13 +52,9 @@ The error code for a parse error.\n \n The version of the Model Context Protocol being used.\n \n-=head2 SUPPORTED_VERSIONS\n-\n-An array reference with every protocol version the server accepts, newest first.\n-\n-=head2 UNSUPPORTED_PROTOCOL_VERSION\n+=head2 RESOURCE_NOT_FOUND\n \n-The error code for a request made with a protocol version the server does not support.\n+The error code for a resource that was not found.\n \n =head1 SEE ALSO\n \ndiff --git a/lib/MCP/Primitive.pm b/lib/MCP/Primitive.pm\ndeleted file mode 100644\nindex d15cad5..0000000\n--- a/lib/MCP/Primitive.pm\n+++ /dev/null\n@@ -1,118 +0,0 @@\n-package MCP::Primitive;\n-use Mojo::Base -base, -signatures;\n-\n-use Carp qw(croak);\n-use MCP::Server::Context;\n-\n-has cache_scope => 'public';\n-has cache_ttl   => 0;\n-has scopes      => sub { [] };\n-\n-sub context ($self) { $self->{context} || MCP::Server::Context->new }\n-\n-sub input_required ($self, $input_requests = undef, $state = undef) {\n-  croak 'Input required results need input requests or state' unless $input_requests || defined $state;\n-  my $result = {resultType => 'input_required'};\n-  $result->{inputRequests} = $input_requests                    if $input_requests;\n-  $result->{requestState}  = $self->context->seal_state($state) if defined $state;\n-  return $result;\n-}\n-\n-1;\n-\n-=encoding utf8\n-\n-=head1 NAME\n-\n-MCP::Primitive - Primitive base class\n-\n-=head1 SYNOPSIS\n-\n-  package MyMCPPrimitive;\n-  use Mojo::Base 'MCP::Primitive';\n-\n-  1;\n-\n-=head1 DESCRIPTION\n-\n-L<MCP::Primitive> is a base class for MCP (Model Context Protocol) primitives such as L<MCP::Tool>, L<MCP::Prompt>,\n-and L<MCP::Resource>.\n-\n-=head1 ATTRIBUTES\n-\n-L<MCP::Primitive> implements the following attributes.\n-\n-=head2 cache_scope\n-\n-  my $scope  = $primitive->cache_scope;\n-  $primitive = $primitive->cache_scope('private');\n-\n-Cache scope advertised for results derived from this primitive, either C<public> for results shared by every caller,\n-or C<private> for results a gateway may only cache per caller. Defaults to C<public>, and is forced to C<private>\n-whenever L</\"scopes\"> is not empty.\n-\n-=head2 cache_ttl\n-\n-  my $ttl    = $primitive->cache_ttl;\n-  $primitive = $primitive->cache_ttl(60_000);\n-\n-How long results derived from this primitive may be cached, in milliseconds. Defaults to C<0>, which means results\n-must be revalidated on every request.\n-\n-=head2 scopes\n-\n-  my $scopes = $primitive->scopes;\n-  $primitive = $primitive->scopes(['mcp:read', 'mcp:write']);\n-\n-OAuth scopes required to list or call this primitive, as an array reference; all of them must be granted. This is a\n-local authorization policy layered on the HTTP transport's L<MCP::Server::Transport::HTTP/\"auth\"> hook, not wire-level\n-MCP metadata, and is only enforced for requests that supply scopes (so it has no effect over stdio). Defaults to no\n-required scopes.\n-\n-=head1 METHODS\n-\n-L<MCP::Primitive> inherits all methods from L<Mojo::Base> and implements the following new ones.\n-\n-=head2 context\n-\n-  my $context = $primitive->context;\n-\n-Returns the L<MCP::Server::Context> for the current request. Capture this before an async boundary to keep using\n-its notification methods from later callbacks.\n-\n-  # Get controller for requests using the HTTP transport\n-  my $c = $primitive->context->controller;\n-\n-=head2 input_required\n-\n-  my $result = $primitive->input_required($input_requests);\n-  my $result = $primitive->input_required($input_requests, $state);\n-  my $result = $primitive->input_required(undef, $state);\n-\n-Returns an C<input_required> result, asking the client to gather information and call again. At least one of the two\n-arguments is required.\n-\n-  return $tool->input_required({\n-    confirm => {\n-      method => 'elicitation/create',\n-      params => {\n-        message         => 'Really deploy to production?',\n-        requestedSchema => {type => 'object', properties => {ok => {type => 'boolean'}}}\n-      }\n-    }\n-  }, {target => $args->{target}});\n-\n-Input requests are keyed by names of your choosing, which the client mirrors back in\n-L<MCP::Server::Context/\"input_responses\">. Only ask for capabilities the client declared in\n-L<MCP::Server::Context/\"client_capabilities\">.\n-\n-The optional state is any Perl data structure, and is sealed with L<MCP::Server::Context/\"seal_state\"> before it\n-travels through the client, so a retry can pick up where the first call left off without the server having to\n-remember anything. Read it back with L<MCP::Server::Context/\"request_state\">, which returns C<undef> for state that\n-cannot be trusted; treat that like a first call and ask again.\n-\n-=head1 SEE ALSO\n-\n-L<MCP>, L<https://mojolicious.org>, L<https://modelcontextprotocol.io>.\n-\n-=cut\ndiff --git a/lib/MCP/Prompt.pm b/lib/MCP/Prompt.pm\nindex 6d4c6d6..590ccd6 100644\n--- a/lib/MCP/Prompt.pm\n+++ b/lib/MCP/Prompt.pm\n@@ -1,5 +1,5 @@\n package MCP::Prompt;\n-use Mojo::Base 'MCP::Primitive', -signatures;\n+use Mojo::Base -base, -signatures;\n \n use Scalar::Util qw(blessed);\n \n@@ -15,6 +15,8 @@ sub call ($self, $args, $context) {\n   return $self->_type_check($result);\n }\n \n+sub context ($self) { $self->{context} || {} }\n+\n sub text_prompt ($self, $text, $role = 'user', $description = undef) {\n   my $result = {messages => [{role => $role, content => {type => 'text', text => \"$text\"}}]};\n   $result->{description} = $description if defined $description;\n@@ -30,7 +32,7 @@ sub validate_input ($self, $args) {\n }\n \n sub _type_check ($self, $result) {\n-  return $result if ref $result eq 'HASH' && (exists $result->{messages} || exists $result->{resultType});\n+  return $result if ref $result eq 'HASH' && exists $result->{messages};\n   return $self->text_prompt($result);\n }\n \n@@ -86,7 +88,7 @@ Name of the Prompt.\n \n =head1 METHODS\n \n-L<MCP::Prompt> inherits all methods from L<MCP::Primitive> and implements the following new ones.\n+L<MCP::Prompt> inherits all methods from L<Mojo::Base> and implements the following new ones.\n \n =head2 call\n \n@@ -95,6 +97,15 @@ L<MCP::Prompt> inherits all methods from L<MCP::Primitive> and implements the fo\n Calls the prompt with the given arguments and context, returning a result. The result can be a promise or a direct\n value.\n \n+=head2 context\n+\n+  my $context = $prompt->context;\n+\n+Returns the context in which the prompt is executed.\n+\n+  # Get controller for requests using the HTTP transport\n+  my $c = $prompt->context->{controller};\n+\n =head2 text_prompt\n \n   my $result = $prompt->text_prompt('Some text');\ndiff --git a/lib/MCP/Resource.pm b/lib/MCP/Resource.pm\nindex 548f981..68acc78 100644\n--- a/lib/MCP/Resource.pm\n+++ b/lib/MCP/Resource.pm\n@@ -1,5 +1,5 @@\n package MCP::Resource;\n-use Mojo::Base 'MCP::Primitive', -signatures;\n+use Mojo::Base -base, -signatures;\n \n use Mojo::Util   qw(b64_encode);\n use Scalar::Util qw(blessed);\n@@ -22,13 +22,15 @@ sub call ($self, $context) {\n   return $self->_type_check($result);\n }\n \n+sub context ($self) { $self->{context} || {} }\n+\n sub text_resource ($self, $text) {\n   my $result = {contents => [{uri => $self->uri, mimeType => $self->mime_type, text => $text}]};\n   return $result;\n }\n \n sub _type_check ($self, $result) {\n-  return $result if ref $result eq 'HASH' && (exists $result->{contents} || exists $result->{resultType});\n+  return $result if ref $result eq 'HASH' && exists $result->{contents};\n   return $self->text_resource($result);\n }\n \n@@ -91,7 +93,7 @@ URI of the resource.\n \n =head1 METHODS\n \n-L<MCP::Resource> inherits all methods from L<MCP::Primitive> and implements the following new ones.\n+L<MCP::Resource> inherits all methods from L<Mojo::Base> and implements the following new ones.\n \n =head2 binary_resource\n \n@@ -105,6 +107,15 @@ Returns a binary resource in the expected format.\n \n Calls the resource with context, returning a result. The result can be a promise or a direct value.\n \n+=head2 context\n+\n+  my $context = $resource->context;\n+\n+Returns the context in which the resouce is executed.\n+\n+  # Get controller for requests using the HTTP transport\n+  my $c = $resource->context->{controller};\n+\n =head2 text_resource\n \n   my $result = $resource->text_resource('Some text');\ndiff --git a/lib/MCP/Server.pm b/lib/MCP/Server.pm\nindex 63549ad..f6850f4 100644\n--- a/lib/MCP/Server.pm\n+++ b/lib/MCP/Server.pm\n@@ -1,66 +1,67 @@\n package MCP::Server;\n use Mojo::Base 'Mojo::EventEmitter', -signatures;\n \n-use Crypt::PRNG qw(random_bytes);\n-use List::Util  qw(first min);\n-use Mojo::JSON  qw(false true);\n-use Mojo::Log;\n-use MCP::Constants qw(INSUFFICIENT_SCOPE INTERNAL_ERROR INVALID_PARAMS INVALID_REQUEST META_CLIENT_CAPABILITIES),\n-  qw(META_CLIENT_INFO META_LOG_LEVEL META_PROTOCOL_VERSION META_SERVER_INFO METHOD_NOT_FOUND PARSE_ERROR),\n-  qw(SUPPORTED_VERSIONS UNSUPPORTED_PROTOCOL_VERSION);\n+use List::Util     qw(first);\n+use Mojo::JSON     qw(false true);\n+use MCP::Constants qw(INVALID_PARAMS INVALID_REQUEST METHOD_NOT_FOUND PARSE_ERROR PROTOCOL_VERSION RESOURCE_NOT_FOUND);\n use MCP::Prompt;\n use MCP::Resource;\n-use MCP::Server::Legacy qw(legacy_context legacy_result);\n-use MCP::Server::Subscription;\n use MCP::Server::Transport::HTTP;\n use MCP::Server::Transport::Stdio;\n use MCP::Tool;\n use Scalar::Util qw(blessed);\n \n-has cache_scope => 'public';\n-has cache_ttl   => 0;\n-has 'instructions';\n-has log           => sub { Mojo::Log->new };\n-has name          => 'PerlServer';\n-has prompts       => sub { [] };\n-has resources     => sub { [] };\n-has state_secret  => sub { random_bytes(32) };\n-has state_timeout => 300;\n-has tools         => sub { [] };\n+has name      => 'PerlServer';\n+has prompts   => sub { [] };\n+has resources => sub { [] };\n+has tools     => sub { [] };\n has 'transport';\n has version => '1.0.0';\n \n sub handle ($self, $request, $context) {\n-  return _jsonrpc_error(PARSE_ERROR,     'Invalid JSON-RPC request') unless ref $request eq 'HASH';\n-  return _jsonrpc_error(INVALID_REQUEST, 'Missing JSON-RPC method')  unless $request->{method};\n+  return _jsonrpc_error(PARSE_ERROR, 'Invalid JSON-RPC request') unless ref $request eq 'HASH';\n+  return _jsonrpc_error(INVALID_REQUEST, 'Missing JSON-RPC method') unless my $method = $request->{method};\n \n   # Requests\n   if (defined(my $id = $request->{id})) {\n-    my $response;\n-    return $self->_internal_error($context, $id, $@)\n-      unless eval { $response = $self->_dispatch($request, $id, $context); 1 };\n-    return $response;\n+\n+    if ($method eq 'initialize') {\n+      my $result = $self->_handle_initialize($request->{params} // {});\n+      return _jsonrpc_response($result, $id);\n+    }\n+    elsif ($method eq 'tools/list') {\n+      my $result = $self->_handle_tools_list($context);\n+      return _jsonrpc_response($result, $id);\n+    }\n+    elsif ($method eq 'tools/call') {\n+      return $self->_handle_tools_call($request->{params} // {}, $id, $context);\n+    }\n+    elsif ($method eq 'ping') {\n+      return _jsonrpc_response({}, $id);\n+    }\n+    elsif ($method eq 'prompts/list') {\n+      my $result = $self->_handle_prompts_list($context);\n+      return _jsonrpc_response($result, $id);\n+    }\n+    elsif ($method eq 'prompts/get') {\n+      return $self->_handle_prompts_get($request->{params} // {}, $id, $context);\n+    }\n+    elsif ($method eq 'resources/list') {\n+      my $result = $self->_handle_resources_list($context);\n+      return _jsonrpc_response($result, $id);\n+    }\n+    elsif ($method eq 'resources/read') {\n+      return $self->_handle_resources_read($request->{params} // {}, $id, $context);\n+    }\n+\n+    # Method not found\n+    return _jsonrpc_error(METHOD_NOT_FOUND, \"Method '$method' not found\", $id);\n   }\n \n   # Notifications (ignored for now)\n   return undef;\n }\n \n-sub notify_list_changed ($self, $kind) {\n-  return undef unless my $transport = $self->transport;\n-  return $transport->notify_all(\"notifications/$kind/list_changed\");\n-}\n-\n-sub oauth_metadata ($self, %args) {\n-  my %scopes;\n-  for my $primitive (@{$self->tools}, @{$self->prompts}, @{$self->resources}) {\n-    $scopes{$_} = 1 for @{$primitive->scopes};\n-  }\n-  my $metadata = {%args};\n-  $metadata->{scopes_supported} //= [sort keys %scopes] if keys %scopes;\n-  return $metadata;\n-}\n-\n sub prompt ($self, %args) {\n   my $prompt = MCP::Prompt->new(%args);\n   push @{$self->prompts}, $prompt;\n@@ -73,8 +74,8 @@ sub resource ($self, %args) {\n   return $resource;\n }\n \n-sub to_action ($self, $options = {}) {\n-  $self->transport(my $http = MCP::Server::Transport::HTTP->new(server => $self, %$options));\n+sub to_action ($self) {\n+  $self->transport(my $http = MCP::Server::Transport::HTTP->new(server => $self));\n   return sub ($c) { $http->handle_request($c) };\n }\n \n@@ -89,60 +90,39 @@ sub tool ($self, %args) {\n   return $tool;\n }\n \n-sub _handle_discover ($self) {\n-  my $transport = $self->transport;\n-  my $caps      = $transport && $transport->notifications ? {listChanged => true} : {};\n-\n-  my $capabilities = {extensions => {}};\n-  $capabilities->{prompts}   = $caps if @{$self->prompts};\n-  $capabilities->{resources} = $caps if @{$self->resources};\n-  $capabilities->{tools}     = $caps if @{$self->tools};\n-\n-  my $result = {supportedVersions => SUPPORTED_VERSIONS, capabilities => $capabilities};\n-  if (defined(my $instructions = $self->instructions)) { $result->{instructions} = $instructions }\n-  return $result;\n-}\n-\n-sub _handle_listen ($self, $params, $id, $context) {\n-  my $transport = $self->transport;\n-  return MCP::Server::Subscription->new(id => $id, notifications => $params->{notifications} // {})\n-    if $transport && $transport->notifications;\n-  $context->status(404);\n-  return _jsonrpc_error(METHOD_NOT_FOUND, \"Method 'subscriptions/listen' not found\", $id);\n+sub _handle_initialize ($self, $params) {\n+  return {\n+    protocolVersion => PROTOCOL_VERSION,\n+    capabilities    => {prompts => {}, resources => {}, tools => {}},\n+    serverInfo      => {name    => $self->name, version => $self->version}\n+  };\n }\n \n sub _handle_prompts_list ($self, $context) {\n-  my $all = $self->_prompts($context);\n-\n-  my (@prompts, @listed);\n-  for my $prompt (@$all) {\n-    next unless $context->has_scope(@{$prompt->scopes});\n+  my @prompts;\n+  for my $prompt (@{$self->_prompts($context)}) {\n     my $info = {name => $prompt->name, description => $prompt->description, arguments => $prompt->arguments};\n     push @prompts, $info;\n-    push @listed,  $prompt;\n   }\n \n-  return {prompts => \\@prompts}, $self->_list_cache_hints('prompts', $all, \\@listed);\n+  return {prompts => \\@prompts};\n }\n \n sub _handle_prompts_get ($self, $params, $id, $context) {\n   my $name = $params->{name}      // '';\n   my $args = $params->{arguments} // {};\n-  return _jsonrpc_error(INVALID_PARAMS, \"Prompt '$name' not found\", $id)\n+  return _jsonrpc_error(METHOD_NOT_FOUND, \"Prompt '$name' not found\")\n     unless my $prompt = first { $_->name eq $name } @{$self->_prompts($context)};\n-  if (my $err = $self->_check_scope($prompt, $context, $id)) { return $err }\n-  return _jsonrpc_error(INVALID_PARAMS, 'Invalid arguments', $id) if $prompt->validate_input($args);\n-  $self->_state($context, $params, 'prompts/get', $name);\n+  return _jsonrpc_error(INVALID_PARAMS, 'Invalid arguments') if $prompt->validate_input($args);\n \n-  return $self->_respond($prompt->call($args, $context), $id, $context);\n+  my $result = $prompt->call($args, $context);\n+  return $result->then(sub { _jsonrpc_response($_[0], $id) }) if blessed($result) && $result->isa('Mojo::Promise');\n+  return _jsonrpc_response($result, $id);\n }\n \n sub _handle_resources_list ($self, $context) {\n-  my $all = $self->_resources($context);\n-\n-  my (@resources, @listed);\n-  for my $resource (@$all) {\n-    next unless $context->has_scope(@{$resource->scopes});\n+  my @resources;\n+  for my $resource (@{$self->_resources($context)}) {\n     my $info = {\n       uri         => $resource->uri,\n       name        => $resource->name,\n@@ -150,159 +130,53 @@ sub _handle_resources_list ($self, $context) {\n       mimeType    => $resource->mime_type\n     };\n     push @resources, $info;\n-    push @listed,    $resource;\n   }\n \n-  return {resources => \\@resources}, $self->_list_cache_hints('resources', $all, \\@listed);\n+  return {resources => \\@resources};\n }\n \n sub _handle_resources_read ($self, $params, $id, $context) {\n   my $uri = $params->{uri} // '';\n-  return _jsonrpc_error(INVALID_PARAMS, \"Resource '$uri' not found\", $id)\n+  return _jsonrpc_error(RESOURCE_NOT_FOUND, 'Resource not found')\n     unless my $resource = first { $_->uri eq $uri } @{$self->_resources($context)};\n-  if (my $err = $self->_check_scope($resource, $context, $id)) { return $err }\n-  my $hints\n-    = $self->_state($context, $params, 'resources/read', $uri)\n-    ? undef\n-    : _cache_hints($resource->cache_ttl, @{$resource->scopes} ? 'private' : $resource->cache_scope);\n \n-  return $self->_respond($resource->call($context), $id, $context, $hints);\n+  my $result = $resource->call($context);\n+  return $result->then(sub { _jsonrpc_response($_[0], $id) }) if blessed($result) && $result->isa('Mojo::Promise');\n+  return _jsonrpc_response($result, $id);\n }\n \n sub _handle_tools_call ($self, $params, $id, $context) {\n   my $name = $params->{name}      // '';\n   my $args = $params->{arguments} // {};\n-  return _jsonrpc_error(INVALID_PARAMS, \"Tool '$name' not found\", $id)\n+  return _jsonrpc_error(METHOD_NOT_FOUND, \"Tool '$name' not found\")\n     unless my $tool = first { $_->name eq $name } @{$self->_tools($context)};\n-  if (my $err = $self->_check_scope($tool, $context, $id)) { return $err }\n-  return _jsonrpc_error(INVALID_PARAMS, 'Invalid arguments', $id) if $tool->validate_input($args);\n-  $self->_state($context, $params, 'tools/call', $name);\n+  return _jsonrpc_error(INVALID_PARAMS, 'Invalid arguments') if $tool->validate_input($args);\n \n-  return $self->_respond($tool->call($args, $context), $id, $context);\n+  my $result = $tool->call($args, $context);\n+  return $result->then(sub { _jsonrpc_response($_[0], $id) }) if blessed($result) && $result->isa('Mojo::Promise');\n+  return _jsonrpc_response($result, $id);\n }\n \n sub _handle_tools_list ($self, $context) {\n-  my $all = $self->_tools($context);\n-\n-  my (@tools, @listed);\n-  for my $tool (@$all) {\n-    next unless $context->has_scope(@{$tool->scopes});\n+  my @tools;\n+  for my $tool (@{$self->_tools($context)}) {\n     my $info = {name => $tool->name, description => $tool->description, inputSchema => $tool->input_schema};\n     if (my $output_schema = $tool->output_schema) { $info->{outputSchema} = $output_schema }\n \n     my $annotations = $tool->annotations;\n     $info->{annotations} = $annotations if keys %$annotations;\n-    push @tools,  $info;\n-    push @listed, $tool;\n+    push @tools, $info;\n   }\n \n-  return {tools => \\@tools}, $self->_list_cache_hints('tools', $all, \\@listed);\n+  return {tools => \\@tools};\n }\n \n-sub _cache_hints ($ttl, $scope) { return {cacheScope => $scope, ttlMs => $ttl} }\n-\n-sub _check_fields ($meta, $id) {\n-  my $version = $meta->{+META_PROTOCOL_VERSION};\n-  return _jsonrpc_error(INVALID_PARAMS, 'Missing protocol version', $id) unless defined $version;\n-  return _jsonrpc_error(UNSUPPORTED_PROTOCOL_VERSION, \"Unsupported protocol version '$version'\",\n-    $id, {supported => SUPPORTED_VERSIONS, requested => $version})\n-    unless first { $_ eq $version } @{(SUPPORTED_VERSIONS)};\n-  return _jsonrpc_error(INVALID_PARAMS, 'Missing client capabilities', $id)\n-    unless ref $meta->{+META_CLIENT_CAPABILITIES} eq 'HASH';\n-  return undef;\n+sub _jsonrpc_error ($code, $message, $id = undef) {\n+  return {jsonrpc => '2.0', id => $id, error => {code => $code, message => $message}};\n }\n \n-sub _check_meta ($self, $params, $id, $context) {\n-  my $meta = $params->{_meta} // {};\n-\n-  if (my $err = _check_fields($meta, $id)) {\n-    $context->status(400);\n-    return $err;\n-  }\n-\n-  $context->protocol_version($meta->{+META_PROTOCOL_VERSION});\n-  $context->client_capabilities($meta->{+META_CLIENT_CAPABILITIES});\n-  if (my $info = $meta->{+META_CLIENT_INFO})       { $context->client_info($info) }\n-  if (my $level = $meta->{+META_LOG_LEVEL})        { $context->log_level($level) }\n-  if (defined(my $token = $meta->{progressToken})) { $context->progress_token($token) }\n-\n-  return undef;\n-}\n-\n-sub _check_scope ($self, $primitive, $context, $id) {\n-  my $scopes = $primitive->scopes;\n-  return undef if $context->has_scope(@$scopes);\n-  $context->insufficient_scope($scopes)->status(403);\n-  return _jsonrpc_error(INSUFFICIENT_SCOPE, 'Insufficient scope', $id);\n-}\n-\n-sub _dispatch ($self, $request, $id, $context) {\n-  my $method = $request->{method};\n-  my $params = $request->{params} // {};\n-\n-  # Legacy\n-  if (my $version = $context->legacy) {\n-    if (my $result = legacy_result($self, $method, $version)) { return $self->_jsonrpc_response($result, $id) }\n-    legacy_context($params, $context);\n-  }\n-  elsif (my $err = $self->_check_meta($params, $id, $context)) { return $err }\n-\n-  if ($method eq 'server/discover') {\n-    my $hints = _cache_hints($self->cache_ttl, $self->cache_scope);\n-    return $self->_jsonrpc_response($self->_handle_discover, $id, $hints);\n-  }\n-  elsif ($method eq 'tools/list') {\n-    my ($result, $hints) = $self->_handle_tools_list($context);\n-    return $self->_jsonrpc_response($result, $id, $hints);\n-  }\n-  elsif ($method eq 'tools/call') {\n-    return $self->_handle_tools_call($params, $id, $context);\n-  }\n-  elsif ($method eq 'prompts/list') {\n-    my ($result, $hints) = $self->_handle_prompts_list($context);\n-    return $self->_jsonrpc_response($result, $id, $hints);\n-  }\n-  elsif ($method eq 'prompts/get') {\n-    return $self->_handle_prompts_get($params, $id, $context);\n-  }\n-  elsif ($method eq 'resources/list') {\n-    my ($result, $hints) = $self->_handle_resources_list($context);\n-    return $self->_jsonrpc_response($result, $id, $hints);\n-  }\n-  elsif ($method eq 'resources/read') {\n-    return $self->_handle_resources_read($params, $id, $context);\n-  }\n-  elsif ($method eq 'subscriptions/listen') {\n-    return $self->_handle_listen($params, $id, $context);\n-  }\n-\n-  # Method not found\n-  $context->status(404);\n-  return _jsonrpc_error(METHOD_NOT_FOUND, \"Method '$method' not found\", $id);\n-}\n-\n-sub _internal_error ($self, $context, $id, $err) {\n-  $self->log->error(\"MCP request failed: $err\");\n-  $context->status(500);\n-  return _jsonrpc_error(INTERNAL_ERROR, 'Internal error', $id);\n-}\n-\n-sub _jsonrpc_error ($code, $message, $id = undef, $data = undef) {\n-  my $error = {code => $code, message => $message};\n-  $error->{data} = $data if defined $data;\n-  return {jsonrpc => '2.0', id => $id, error => $error};\n-}\n-\n-sub _jsonrpc_response ($self, $result, $id = undef, $hints = undef) {\n-  my $type = $result->{resultType} // 'complete';\n-  my %new  = (%$result, ($hints && $type eq 'complete' ? %$hints : ()), resultType => $type);\n-  $new{_meta} = {%{$result->{_meta} // {}}, META_SERVER_INFO() => {name => $self->name, version => $self->version}};\n-  return {jsonrpc => '2.0', id => $id, result => \\%new};\n-}\n-\n-sub _list_cache_hints ($self, $event, $all, $listed) {\n-  my $private = $self->has_subscribers($event) || grep { @{$_->scopes} } @$all;\n-  return _cache_hints(min(map { $_->cache_ttl } @$listed) // 0, $private ? 'private' : 'public');\n+sub _jsonrpc_response ($result, $id = undef) {\n+  return {jsonrpc => '2.0', id => $id, result => $result};\n }\n \n sub _prompts ($self, $context) {\n@@ -317,20 +191,6 @@ sub _resources ($self, $context) {\n   return $resources;\n }\n \n-sub _respond ($self, $result, $id, $context, $hints = undef) {\n-  return $self->_jsonrpc_response($result, $id, $hints) unless blessed($result) && $result->isa('Mojo::Promise');\n-  return $result->then(sub { $self->_jsonrpc_response($_[0], $id, $hints) })\n-    ->catch(sub { $self->_internal_error($context, $id, $_[0]) });\n-}\n-\n-sub _state ($self, $context, $params, @binding) {\n-  $context->state_binding(join \"\\0\", @binding)->state_secret($self->state_secret);\n-  $context->state_timeout($self->state_timeout);\n-  $context->input_responses($params->{inputResponses}) if ref $params->{inputResponses} eq 'HASH';\n-  $context->raw_request_state($params->{requestState}) if defined $params->{requestState};\n-  return $context->input_responses || defined $context->raw_request_state ? 1 : 0;\n-}\n-\n sub _tools ($self, $context) {\n   my $tools = [@{$self->tools}];\n   $self->emit('tools', $tools, $context);\n@@ -410,45 +270,6 @@ Emitted whenever the list of tools is accessed.\n \n L<MCP::Server> implements the following attributes.\n \n-=head2 cache_scope\n-\n-  my $scope = $server->cache_scope;\n-  $server   = $server->cache_scope('private');\n-\n-Cache scope advertised for C<server/discover> results, either C<public> or C<private>. Defaults to C<public>. Cache\n-hints for the other cacheable results are declared per primitive with L<MCP::Primitive/\"cache_scope\">.\n-\n-=head2 cache_ttl\n-\n-  my $ttl = $server->cache_ttl;\n-  $server = $server->cache_ttl(3_600_000);\n-\n-How long C<server/discover> results may be cached, in milliseconds. Defaults to C<0>, which means the document must\n-be revalidated on every request.\n-\n-=head2 instructions\n-\n-  my $instructions = $server->instructions;\n-  $server          = $server->instructions('Use the echo tool to repeat text.');\n-\n-Free-form guidance for the model on how to use this server, returned by C<server/discover>. Omitted from the\n-document when C<undef>, which is the default.\n-\n-=head2 log\n-\n-  my $log = $server->log;\n-  $server = $server->log(Mojo::Log->new);\n-\n-Where exceptions thrown by prompts, resources, and tools are reported, defaults to a L<Mojo::Log> object writing to\n-C<STDERR>, which is where an MCP host collects the output of a stdio server.\n-\n-A server mounted in a L<Mojolicious> application with L</\"to_action\"> adopts the log of that application on its\n-first request, so exceptions end up wherever the rest of the application logs, with the same level and format. Set\n-this attribute yourself to opt out of that.\n-\n-The caller never sees the exception itself, only an C<InternalError>, since it can easily contain file system paths\n-or connection strings.\n-\n =head2 name\n \n   my $name = $server->name;\n@@ -470,26 +291,6 @@ An array reference containing registered prompts.\n \n An array reference containing registered resources.\n \n-=head2 state_secret\n-\n-  my $secret = $server->state_secret;\n-  $server    = $server->state_secret($ENV{MY_MCP_SECRET});\n-\n-Key used to authenticate the request state of C<input_required> results, which travels through the client and has to\n-be tamper proof. Defaults to 32 random bytes generated once per process.\n-\n-That default is only correct for a single process. Under a pre-forking web server or behind a load balancer every\n-worker mints state the others reject, so retries loop instead of completing, and you have to configure the same\n-secret everywhere. Use at least 32 bytes from a cryptographically secure source, and keep it out of your code.\n-\n-=head2 state_timeout\n-\n-  my $seconds = $server->state_timeout;\n-  $server     = $server->state_timeout(60);\n-\n-How long the request state of an C<input_required> result stays valid, in seconds. Defaults to C<300>, and should be\n-no longer than a client plausibly needs to gather the requested input.\n-\n =head2 tools\n \n   my $tools = $server->tools;\n@@ -513,33 +314,13 @@ The version of the server.\n \n =head1 METHODS\n \n-L<MCP::Server> inherits all methods from L<Mojo::EventEmitter> and implements the following new ones.\n+L<MCP::Tool> inherits all methods from L<Mojo::EventEmitter> and implements the following new ones.\n \n =head2 handle\n \n   my $response = $server->handle($request, $context);\n \n-Handle a JSON-RPC request and return a response, which may also be a L<Mojo::Promise>. A C<subscriptions/listen>\n-request yields an L<MCP::Server::Subscription> object instead, which the transport turns into a notification\n-stream. Notifications yield C<undef>.\n-\n-=head2 notify_list_changed\n-\n-  my $bool = $server->notify_list_changed('tools');\n-\n-Broadcast a C<notifications/$kind/list_changed> JSON-RPC notification to all connected clients. Returns true on\n-success, or C<undef> if no notification could be delivered.\n-\n-=head2 oauth_metadata\n-\n-  my $metadata = $server->oauth_metadata(\n-    resource              => 'https://example.com/mcp',\n-    authorization_servers => ['https://auth.example.com']\n-  );\n-\n-Build an OAuth 2.0 Protected Resource Metadata document from the given fields, to be served from\n-C</.well-known/oauth-protected-resource>. Unless C<scopes_supported> is provided, it is filled in with the sorted\n-union of all scopes declared by registered tools, prompts, and resources.\n+Handle a JSON-RPC request and return a response.\n \n =head2 prompt\n \n@@ -567,11 +348,8 @@ Register a new resource with the server.\n =head2 to_action\n \n   my $action = $server->to_action;\n-  my $action = $server->to_action({streaming => 1});\n \n-Convert the server to a L<Mojolicious> action. Any options are passed through to the constructor of\n-L<MCP::Server::Transport::HTTP>; in particular, C<< streaming => 1 >> opts in to C<subscriptions/listen>, the\n-long-lived notification stream.\n+Convert the server to a L<Mojolicious> action.\n \n =head2 to_stdio\n \ndiff --git a/lib/MCP/Server/Context.pm b/lib/MCP/Server/Context.pm\ndeleted file mode 100644\nindex a827c4f..0000000\n--- a/lib/MCP/Server/Context.pm\n+++ /dev/null\n@@ -1,357 +0,0 @@\n-package MCP::Server::Context;\n-use Mojo::Base 'Mojo::EventEmitter', -signatures;\n-\n-use Carp             qw(croak);\n-use Crypt::Mac::HMAC qw(hmac);\n-use Crypt::Misc      qw(slow_eq);\n-use Mojo::JSON       qw(decode_json encode_json);\n-use Mojo::Util       qw(b64_decode b64_encode);\n-\n-use constant LEVELS =>\n-  {debug => 0, info => 1, notice => 2, warning => 3, error => 4, critical => 5, alert => 6, emergency => 7};\n-\n-has buffer => sub { [] };\n-has [qw(client_capabilities client_info controller input_responses insufficient_scope legacy log_level principal)];\n-has [qw(progress_token protocol_version raw_request_state scopes state_binding state_secret)];\n-has state_timeout => 300;\n-has [qw(status stream transport)];\n-\n-sub cancel ($self) {\n-  return $self if $self->{cancelled}++;\n-  return $self->emit('cancelled');\n-}\n-\n-sub flush ($self) {\n-  return 0 unless my $stream = $self->stream;\n-  my @buffered = splice @{$self->buffer};\n-  $stream->($_) for @buffered;\n-  return scalar @buffered;\n-}\n-\n-sub has_scope ($self, @needed) {\n-  return 1 unless defined(my $scopes = $self->scopes);\n-  my %granted = map { $_ => 1 } @$scopes;\n-  for my $scope (@needed) { return 0 unless $granted{$scope} }\n-  return 1;\n-}\n-\n-sub is_cancelled ($self) { $self->{cancelled} ? 1 : 0 }\n-\n-sub notify ($self, $method, $params = {}) {\n-  return undef if $self->is_cancelled;\n-  my $notification = {jsonrpc => '2.0', method => $method, params => $params};\n-  if   (my $stream = $self->stream) { $stream->($notification) }\n-  else                              { push @{$self->buffer}, $notification }\n-  return 1;\n-}\n-\n-sub notify_log ($self, $level, $data) {\n-  return undef unless defined(my $minimum = $self->log_level);\n-  return undef unless (LEVELS->{$level} // 0) >= (LEVELS->{$minimum} // 0);\n-  return $self->notify('notifications/message', {level => $level, data => $data});\n-}\n-\n-sub notify_progress ($self, $progress, $total = undef, $message = undef) {\n-  return undef unless defined(my $token = $self->progress_token);\n-  my $params = {progressToken => $token, progress => $progress};\n-  $params->{total}   = $total   if defined $total;\n-  $params->{message} = $message if defined $message;\n-  return $self->notify('notifications/progress', $params);\n-}\n-\n-sub request_state ($self) { return $self->{request_state} //= $self->_unseal }\n-\n-sub seal_state ($self, $state) {\n-  croak 'No state secret' unless my $secret = $self->state_secret;\n-  my $data = {\n-    binding   => $self->state_binding,\n-    expires   => time + $self->state_timeout,\n-    payload   => $state,\n-    principal => $self->principal\n-  };\n-  my $payload = b64_encode(encode_json($data), '');\n-  return \"$payload.\" . b64_encode(hmac('SHA256', $secret, $payload), '');\n-}\n-\n-sub _unseal ($self) {\n-  return undef unless my $secret = $self->state_secret;\n-  return undef unless ($self->raw_request_state // '') =~ /^([^.]+)\\.([^.]+)$/;\n-  my ($payload, $mac) = ($1, $2);\n-  return undef unless slow_eq(b64_decode($mac), hmac('SHA256', $secret, $payload));\n-  return undef unless my $data = eval { decode_json(b64_decode($payload)) };\n-  return undef unless ($data->{expires}   // 0) > time;\n-  return undef unless ($data->{binding}   // '') eq ($self->state_binding // '');\n-  return undef unless ($data->{principal} // '') eq ($self->principal     // '');\n-  return $data->{payload};\n-}\n-\n-1;\n-\n-=encoding utf8\n-\n-=head1 NAME\n-\n-MCP::Server::Context - Request context container\n-\n-=head1 SYNOPSIS\n-\n-  use MCP::Server::Context;\n-\n-  my $context = MCP::Server::Context->new;\n-  $context->notify_progress(1, 2, 'halfway');\n-\n-=head1 DESCRIPTION\n-\n-L<MCP::Server::Context> is a container for per-invocation request context.\n-\n-=head1 EVENTS\n-\n-L<MCP::Server::Context> inherits all events from L<Mojo::EventEmitter> and emits the following new ones.\n-\n-=head2 cancelled\n-\n-  $context->on(cancelled => sub ($context) { ... });\n-\n-Emitted once when the client abandons the current request, either by closing its response stream or by sending a\n-C<notifications/cancelled> notification. Long running operations should stop as soon as practical.\n-\n-  my $id = Mojo::IOLoop->recurring(1 => sub { ... });\n-  $context->on(cancelled => sub ($context) { Mojo::IOLoop->remove($id) });\n-\n-=head1 ATTRIBUTES\n-\n-L<MCP::Server::Context> implements the following attributes.\n-\n-=head2 buffer\n-\n-  my $buffer = $context->buffer;\n-  $context   = $context->buffer([]);\n-\n-Notifications that have been queued up because no response L</\"stream\"> was available yet, to be delivered by\n-L</\"flush\">.\n-\n-=head2 client_capabilities\n-\n-  my $capabilities = $context->client_capabilities;\n-  $context         = $context->client_capabilities({elicitation => {}});\n-\n-Capabilities declared by the client for the current request, from\n-C<_meta.io.modelcontextprotocol/clientCapabilities>. Every request has to declare them, so this is only C<undef>\n-outside of request handling.\n-\n-=head2 client_info\n-\n-  my $info = $context->client_info;\n-  $context = $context->client_info({name => 'MyClient', version => '1.0.0'});\n-\n-Name and version of the client making the current request, from C<_meta.io.modelcontextprotocol/clientInfo>, or\n-C<undef> if the client did not identify itself.\n-\n-=head2 controller\n-\n-  my $c    = $context->controller;\n-  $context = $context->controller(Mojolicious::Controller->new);\n-\n-The L<Mojolicious::Controller> serving the current request, when the HTTP transport is in use.\n-\n-=head2 input_responses\n-\n-  my $responses = $context->input_responses;\n-  $context      = $context->input_responses({confirm => {action => 'accept', content => {ok => \\1}}});\n-\n-Responses to the input requests of an earlier C<input_required> result, keyed by the same names, or C<undef> if the\n-current request is not a retry. See L<MCP::Primitive/\"input_required\">.\n-\n-=head2 insufficient_scope\n-\n-  my $needed = $context->insufficient_scope;\n-  $context   = $context->insufficient_scope(['mcp:write']);\n-\n-Array reference of scopes a denied request was missing, set by the server so the HTTP transport can emit an\n-C<insufficient_scope> challenge. C<undef> when no scope check failed.\n-\n-=head2 legacy\n-\n-  my $version = $context->legacy;\n-  $context    = $context->legacy('2025-11-25');\n-\n-The protocol revision a legacy request was made with, or C<undef> for a current request, see\n-L<MCP::Server::Legacy>.\n-\n-=head2 log_level\n-\n-  my $level = $context->log_level;\n-  $context  = $context->log_level('info');\n-\n-The minimum severity of log messages the client wants for the current request, from\n-C<_meta.io.modelcontextprotocol/logLevel>, or C<undef> if the client asked for none.\n-\n-=head2 principal\n-\n-  my $principal = $context->principal;\n-  $context      = $context->principal('user@example.com');\n-\n-The authenticated caller the current request belongs to, populated from the C<principal> key returned by the C<auth>\n-hook of the HTTP transport. Request state is bound to it, so state minted for one caller is never accepted from\n-another. C<undef> for unauthenticated requests.\n-\n-=head2 progress_token\n-\n-  my $token = $context->progress_token;\n-  $context  = $context->progress_token('tok-1');\n-\n-The progress token provided by the client in C<_meta.progressToken>, or C<undef> if none was sent.\n-\n-=head2 protocol_version\n-\n-  my $version = $context->protocol_version;\n-  $context    = $context->protocol_version('2026-07-28');\n-\n-The protocol version the current request was made with, from C<_meta.io.modelcontextprotocol/protocolVersion>.\n-\n-=head2 raw_request_state\n-\n-  my $state = $context->raw_request_state;\n-  $context  = $context->raw_request_state('eyJ...');\n-\n-The C<requestState> string the client sent back, exactly as received and not verified in any way. Use\n-L</\"request_state\"> instead, unless you want to protect the state yourself.\n-\n-=head2 scopes\n-\n-  my $scopes = $context->scopes;\n-  $context   = $context->scopes(['mcp:read', 'mcp:write']);\n-\n-OAuth scopes granted to the current request, as an array reference, populated from the C<auth> hook of the HTTP\n-transport. C<undef> (the default) imposes no scope restriction, so scopes are only enforced for authenticated\n-requests that provide them.\n-\n-=head2 state_binding\n-\n-  my $binding = $context->state_binding;\n-  $context    = $context->state_binding(\"tools/call\\0deploy\");\n-\n-Identifies the request that request state may be used with, set by the server to the method name and the name or\n-URI of the primitive being called. State sealed for one primitive is never accepted by another.\n-\n-=head2 state_secret\n-\n-  my $secret = $context->state_secret;\n-  $context   = $context->state_secret($bytes);\n-\n-Key used to authenticate request state, copied from L<MCP::Server/\"state_secret\">.\n-\n-=head2 state_timeout\n-\n-  my $seconds = $context->state_timeout;\n-  $context    = $context->state_timeout(300);\n-\n-How long request state sealed during this request stays valid, in seconds, copied from\n-L<MCP::Server/\"state_timeout\">.\n-\n-=head2 status\n-\n-  my $status = $context->status;\n-  $context   = $context->status(404);\n-\n-HTTP status the current request has to be answered with, set by the server for the protocol errors the specification\n-maps to a specific status, such as C<404> for an unknown method. C<undef> (the default) means C<200>, and transports\n-other than L<MCP::Server::Transport::HTTP> ignore it.\n-\n-=head2 stream\n-\n-  my $cb   = $context->stream;\n-  $context = $context->stream(sub ($notification) { ... });\n-\n-Callback the transport installs once the response stream for the current request is open, to be called with every\n-notification that belongs to it. Notifications sent before that are queued up in L</\"buffer\">.\n-\n-=head2 transport\n-\n-  my $transport = $context->transport;\n-  $context      = $context->transport(MCP::Server::Transport::HTTP->new);\n-\n-The transport handling the current request.\n-\n-=head1 METHODS\n-\n-L<MCP::Server::Context> inherits all methods from L<Mojo::EventEmitter> and implements the following new ones.\n-\n-=head2 cancel\n-\n-  $context = $context->cancel;\n-\n-Mark the current request as cancelled and emit the L</\"cancelled\"> event, at most once.\n-\n-=head2 flush\n-\n-  my $num = $context->flush;\n-\n-Deliver all notifications queued up in L</\"buffer\"> to L</\"stream\">, and return the number of notifications\n-delivered.\n-\n-=head2 has_scope\n-\n-  my $bool = $context->has_scope('mcp:write');\n-  my $bool = $context->has_scope('mcp:read', 'mcp:write');\n-\n-Returns true if every given scope is present in L</\"scopes\">, or if L</\"scopes\"> is C<undef> (no restriction).\n-\n-=head2 is_cancelled\n-\n-  my $bool = $context->is_cancelled;\n-\n-Returns true if the current request has been cancelled, for operations that cannot subscribe to the\n-L</\"cancelled\"> event and have to poll instead.\n-\n-=head2 notify\n-\n-  my $bool = $context->notify($method);\n-  my $bool = $context->notify($method, {foo => 'bar'});\n-\n-Send a JSON-RPC notification on the response stream of the current request. Returns true on success, or C<undef> if\n-the request has been cancelled.\n-\n-=head2 notify_log\n-\n-  my $bool = $context->notify_log('info', 'Something happened');\n-  my $bool = $context->notify_log('error', {code => 23});\n-\n-Send a C<notifications/message> JSON-RPC notification with the given severity and payload. Returns C<undef> unless\n-the client requested logging with C<_meta.io.modelcontextprotocol/logLevel> and the given severity is at or above\n-the level it asked for.\n-\n-=head2 notify_progress\n-\n-  my $bool = $context->notify_progress($progress);\n-  my $bool = $context->notify_progress($progress, $total);\n-  my $bool = $context->notify_progress($progress, $total, $message);\n-\n-Send a C<notifications/progress> JSON-RPC notification for the progress token associated with the current request.\n-Returns true on success, or C<undef> if no progress token was provided by the client.\n-\n-=head2 request_state\n-\n-  my $state = $context->request_state;\n-\n-Verify and decode the request state the client sent back, returning the data structure that was passed to\n-L<MCP::Primitive/\"input_required\">. Returns C<undef> if there was no state, or if it was tampered with, has expired,\n-was sealed for a different primitive, or belongs to a different L</\"principal\">, so a primitive that cannot tell\n-those cases apart simply asks for input again.\n-\n-Replay within L</\"state_timeout\"> is not prevented; servers that need one-shot semantics have to enforce that\n-themselves, for example by embedding a nonce and remembering it.\n-\n-=head2 seal_state\n-\n-  my $sealed = $context->seal_state({step => 2});\n-\n-Serialize a data structure into an opaque string, authenticated with L</\"state_secret\"> and bound to\n-L</\"state_binding\">, L</\"principal\">, and L</\"state_timeout\">. Usually called through\n-L<MCP::Primitive/\"input_required\"> rather than directly.\n-\n-=head1 SEE ALSO\n-\n-L<MCP>, L<https://mojolicious.org>, L<https://modelcontextprotocol.io>.\n-\n-=cut\ndiff --git a/lib/MCP/Server/Legacy.pm b/lib/MCP/Server/Legacy.pm\ndeleted file mode 100644\nindex 9467970..0000000\n--- a/lib/MCP/Server/Legacy.pm\n+++ /dev/null\n@@ -1,100 +0,0 @@\n-package MCP::Server::Legacy;\n-use Mojo::Base 'Exporter', -signatures;\n-\n-use MCP::Constants qw(META_PROTOCOL_VERSION);\n-\n-our @EXPORT_OK = qw(legacy_context legacy_request legacy_result);\n-\n-my @VERSIONS = ('2024-10-07', '2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25');\n-\n-sub legacy_context ($params, $context) {\n-  my $meta = $params->{_meta} // {};\n-  $context->client_capabilities({})->client_info({})->protocol_version($context->legacy);\n-  $context->progress_token($meta->{progressToken}) if defined $meta->{progressToken};\n-  return $context;\n-}\n-\n-sub legacy_request ($version, $request) {\n-  my $params = $request->{params} // {};\n-\n-  # The handshake exists only in legacy revisions, so answer it with the closest version we can serve\n-  if (($request->{method} // '') eq 'initialize') {\n-    my $requested = $params->{protocolVersion} // '';\n-    return (grep { $_ eq $requested } @VERSIONS) ? $requested : $VERSIONS[-1];\n-  }\n-\n-  return undef if $params->{_meta}{+META_PROTOCOL_VERSION};\n-  return $VERSIONS[-1] unless defined $version;\n-  return (grep { $_ eq $version } @VERSIONS) ? $version : undef;\n-}\n-\n-sub legacy_result ($server, $method, $version) {\n-  return {} if $method eq 'ping';\n-  return undef unless $method eq 'initialize';\n-\n-  my $capabilities = {};\n-  $capabilities->{prompts}   = {} if @{$server->prompts};\n-  $capabilities->{resources} = {} if @{$server->resources};\n-  $capabilities->{tools}     = {} if @{$server->tools};\n-\n-  return {\n-    protocolVersion => $version,\n-    capabilities    => $capabilities,\n-    serverInfo      => {name => $server->name, version => $server->version}\n-  };\n-}\n-\n-1;\n-\n-=encoding utf8\n-\n-=head1 NAME\n-\n-MCP::Server::Legacy - Fallback for clients speaking a previous protocol version\n-\n-=head1 SYNOPSIS\n-\n-  use MCP::Server::Legacy qw(legacy_request);\n-\n-  my $version = legacy_request('2025-11-25', {jsonrpc => '2.0', id => 1, method => 'tools/call'});\n-\n-=head1 DESCRIPTION\n-\n-L<MCP::Server::Legacy> answers the C<initialize> handshake of clients speaking a protocol revision older than\n-L<MCP::Constants/\"PROTOCOL_VERSION\">, so they can still list and call tools, prompts and resources while the\n-ecosystem catches up. Nothing beyond that is supported, in particular no notifications and no multi round-trip\n-requests. It is a temporary convenience and will be removed again in a future release.\n-\n-=head1 FUNCTIONS\n-\n-L<MCP::Server::Legacy> implements the following functions, which can be imported individually.\n-\n-=head2 legacy_context\n-\n-  my $context = legacy_context($params, $context);\n-\n-Populate an L<MCP::Server::Context> with the values a current request would have carried in C<_meta>.\n-\n-=head2 legacy_request\n-\n-  my $version = legacy_request($version, $request);\n-\n-The protocol revision a request was made with, or C<undef> if it is not legacy, based on the\n-C<MCP-Protocol-Version> header and the JSON-RPC request. A request carrying\n-C<_meta.io.modelcontextprotocol/protocolVersion> is never legacy, whatever the header says, so a current client with\n-a misconfigured header still gets a proper error. Revisions before C<2025-06-18> predate the header, so a request\n-without one is assumed to be legacy.\n-\n-=head2 legacy_result\n-\n-  my $result = legacy_result($server, $method, $version);\n-\n-The result for a C<initialize> or C<ping> request, or C<undef> for every other method. The handshake echoes the\n-requested protocol revision and advertises the same primitives as C<server/discover>, but never C<listChanged>,\n-since list change notifications are not supported.\n-\n-=head1 SEE ALSO\n-\n-L<MCP>, L<https://mojolicious.org>, L<https://modelcontextprotocol.io>.\n-\n-=cut\ndiff --git a/lib/MCP/Server/Subscription.pm b/lib/MCP/Server/Subscription.pm\ndeleted file mode 100644\nindex c080576..0000000\n--- a/lib/MCP/Server/Subscription.pm\n+++ /dev/null\n@@ -1,118 +0,0 @@\n-package MCP::Server::Subscription;\n-use Mojo::Base -base, -signatures;\n-\n-use MCP::Constants qw(META_SUBSCRIPTION_ID);\n-use Mojo::JSON     qw(true);\n-\n-use constant FILTERS => {\n-  'notifications/prompts/list_changed'   => 'promptsListChanged',\n-  'notifications/resources/list_changed' => 'resourcesListChanged',\n-  'notifications/tools/list_changed'     => 'toolsListChanged'\n-};\n-\n-has 'id';\n-has notifications => sub { {} };\n-has 'stream';\n-\n-sub acknowledgement ($self) {\n-  my $params = {_meta => {META_SUBSCRIPTION_ID() => $self->id}, notifications => $self->honoured};\n-  return {jsonrpc => '2.0', method => 'notifications/subscriptions/acknowledged', params => $params};\n-}\n-\n-sub honoured ($self) {\n-  my $requested = $self->notifications;\n-  return {map { $_ => true } grep { $requested->{$_} } sort values %{(FILTERS)}};\n-}\n-\n-sub notification ($self, $method, $params = {}) {\n-  my $meta = {%{$params->{_meta} // {}}, META_SUBSCRIPTION_ID() => $self->id};\n-  return {jsonrpc => '2.0', method => $method, params => {%$params, _meta => $meta}};\n-}\n-\n-sub wants ($self, $method) {\n-  return undef unless my $key = FILTERS->{$method};\n-  return $self->notifications->{$key} ? 1 : 0;\n-}\n-\n-1;\n-\n-=encoding utf8\n-\n-=head1 NAME\n-\n-MCP::Server::Subscription - Subscription container\n-\n-=head1 SYNOPSIS\n-\n-  use MCP::Server::Subscription;\n-\n-  my $subscription = MCP::Server::Subscription->new(id => 1, notifications => {toolsListChanged => 1});\n-  my $bool         = $subscription->wants('notifications/tools/list_changed');\n-\n-=head1 DESCRIPTION\n-\n-L<MCP::Server::Subscription> is a container for a C<subscriptions/listen> notification stream.\n-\n-=head1 ATTRIBUTES\n-\n-L<MCP::Server::Subscription> implements the following attributes.\n-\n-=head2 id\n-\n-  my $id        = $subscription->id;\n-  $subscription = $subscription->id(1);\n-\n-The JSON-RPC id of the C<subscriptions/listen> request that opened this subscription, sent to the client as\n-C<_meta.io.modelcontextprotocol/subscriptionId> with every message on the stream.\n-\n-=head2 notifications\n-\n-  my $filter    = $subscription->notifications;\n-  $subscription = $subscription->notifications({toolsListChanged => 1});\n-\n-The notification filter the client requested, as a hash reference. Notification types that were not requested are\n-never delivered.\n-\n-=head2 stream\n-\n-  my $stream    = $subscription->stream;\n-  $subscription = $subscription->stream(Mojolicious::Controller->new);\n-\n-The L<Mojolicious::Controller> serving the SSE stream for this subscription, when the HTTP transport is in use.\n-\n-=head1 METHODS\n-\n-L<MCP::Server::Subscription> inherits all methods from L<Mojo::Base> and implements the following new ones.\n-\n-=head2 acknowledgement\n-\n-  my $notification = $subscription->acknowledgement;\n-\n-The C<notifications/subscriptions/acknowledged> notification a server has to send as the very first message on a\n-subscription stream, listing the notification types it will actually deliver.\n-\n-=head2 honoured\n-\n-  my $filter = $subscription->honoured;\n-\n-The subset of L</\"notifications\"> the server actually supports.\n-\n-=head2 notification\n-\n-  my $notification = $subscription->notification($method);\n-  my $notification = $subscription->notification($method, {foo => 'bar'});\n-\n-Build a JSON-RPC notification for this subscription, tagged with L</\"id\"> in\n-C<_meta.io.modelcontextprotocol/subscriptionId>.\n-\n-=head2 wants\n-\n-  my $bool = $subscription->wants('notifications/tools/list_changed');\n-\n-Returns true if the given notification method was requested in L</\"notifications\">.\n-\n-=head1 SEE ALSO\n-\n-L<MCP>, L<https://mojolicious.org>, L<https://modelcontextprotocol.io>.\n-\n-=cut\ndiff --git a/lib/MCP/Server/Transport.pm b/lib/MCP/Server/Transport.pm\nindex 8e075c7..04e6e9a 100644\n--- a/lib/MCP/Server/Transport.pm\n+++ b/lib/MCP/Server/Transport.pm\n@@ -3,8 +3,6 @@ use Mojo::Base -base, -signatures;\n \n has 'server';\n \n-sub notifications ($self) {1}\n-\n 1;\n \n =encoding utf8\n@@ -35,16 +33,6 @@ L<MCP::Server::Transport> implements the following attributes.\n \n The server instance that this transport is associated with.\n \n-=head1 METHODS\n-\n-L<MCP::Server::Transport> implements the following methods.\n-\n-=head2 notifications\n-\n-  my $bool = $transport->notifications;\n-\n-True when the transport can push server-to-client notifications outside an in-flight response.\n-\n =head1 SEE ALSO\n \n L<Mojolicious>, L<https://mojolicious.org>, L<https://modelcontextprotocol.io>.\ndiff --git a/lib/MCP/Server/Transport/HTTP.pm b/lib/MCP/Server/Transport/HTTP.pm\nindex 94e1c37..46f0766 100644\n--- a/lib/MCP/Server/Transport/HTTP.pm\n+++ b/lib/MCP/Server/Transport/HTTP.pm\n@@ -1,134 +1,20 @@\n package MCP::Server::Transport::HTTP;\n use Mojo::Base 'MCP::Server::Transport', -signatures;\n \n-use Crypt::Misc    qw(random_v4uuid);\n-use List::Util     qw(first);\n-use MCP::Constants qw(HEADER_MISMATCH META_PROTOCOL_VERSION);\n-use MCP::Server::Context;\n-use MCP::Server::Legacy qw(legacy_request);\n-use MCP::Server::Subscription;\n-use Mojo::IOLoop;\n-use Mojo::JSON   qw(to_json);\n-use Mojo::Util   qw(b64_decode decode dumper);\n-use Scalar::Util qw(blessed looks_like_number weaken);\n+use Crypt::Misc  qw(random_v4uuid);\n+use Mojo::JSON   qw(to_json true);\n+use Mojo::Util   qw(dumper);\n+use Scalar::Util qw(blessed);\n \n use constant DEBUG => $ENV{MCP_DEBUG} || 0;\n \n-use constant NAMES => {'prompts/get' => 'name', 'resources/read' => 'uri', 'tools/call' => 'name'};\n-\n-has 'auth';\n-has heartbeat => 30;\n-has 'metadata_url';\n-has 'origins';\n-has streaming     => 0;\n-has subscriptions => sub { {} };\n-\n sub handle_request ($self, $c) {\n-  my $server = $self->server;\n-  $server->log($c->app->log) unless $server->{log};\n-\n-  return $c->render(json => {error => 'Origin not allowed'}, status => 403) unless $self->_check_origin($c);\n-\n-  if (my $auth = $self->auth) {\n-    return $self->_unauthorized($c) unless my $info = $auth->($c);\n-    $c->stash('mcp.auth' => $info);\n-  }\n-\n-  return $self->_handle_post($c) if $c->req->method eq 'POST';\n+  my $method = $c->req->method;\n+  return $self->_handle_post($c) if $method eq 'POST';\n   return $c->render(json => {error => 'Method not allowed'}, status => 405);\n }\n \n-sub notifications ($self) { $self->streaming ? 1 : 0 }\n-\n-sub notify_all ($self, $method, $params = {}) {\n-  return undef unless $self->streaming;\n-  for my $subscription (values %{$self->subscriptions}) {\n-    next unless $subscription->wants($method);\n-    _write($subscription->stream, $subscription->notification($method, $params));\n-  }\n-  return 1;\n-}\n-\n-sub _arg_value ($args, $path) {\n-  my $value = $args;\n-  for my $key (@$path) {\n-    return undef unless ref $value eq 'HASH';\n-    $value = $value->{$key};\n-  }\n-  return $value;\n-}\n-\n-sub _challenge_header ($self, %extra) {\n-  my @parts;\n-  push @parts, qq{resource_metadata=\"@{[$self->metadata_url]}\"} if $self->metadata_url;\n-  push @parts, qq{error=\"$extra{error}\"}                        if $extra{error};\n-  push @parts, qq{scope=\"$extra{scope}\"}                        if defined $extra{scope};\n-  return 'Bearer' . (@parts ? ' ' . join(', ', @parts) : '');\n-}\n-\n-sub _check_headers ($self, $c, $data) {\n-  my $headers = $c->req->headers;\n-  my $params  = $data->{params} // {};\n-\n-  my $version = $headers->header('MCP-Protocol-Version');\n-  return 'Missing MCP-Protocol-Version header' unless defined $version;\n-  my $expected = ($params->{_meta} // {})->{+META_PROTOCOL_VERSION};\n-  return 'MCP-Protocol-Version header does not match the request body' if defined $expected && $expected ne $version;\n-\n-  my $method = $data->{method} // '';\n-  my $sent   = $headers->header('Mcp-Method');\n-  return 'Missing Mcp-Method header'                         unless defined $sent;\n-  return 'Mcp-Method header does not match the request body' unless $sent eq $method;\n-\n-  if (my $key = NAMES->{$method}) {\n-    my $name = $headers->header('Mcp-Name');\n-    return 'Missing Mcp-Name header'                         unless defined $name;\n-    return 'Invalid Mcp-Name header'                         unless defined($name = _decode_header($name));\n-    return 'Mcp-Name header does not match the request body' unless $name eq ($params->{$key} // '');\n-  }\n-\n-  return $method eq 'tools/call' ? $self->_check_params($c, $params) : undef;\n-}\n-\n-sub _check_origin ($self, $c) {\n-  return 1 unless defined(my $origin = $c->req->headers->origin);\n-  return 1 unless my $origins = $self->origins;\n-  return $origins->($c, $origin) ? 1 : 0 if ref $origins eq 'CODE';\n-  return first { $_ eq $origin } @$origins;\n-}\n-\n-sub _check_params ($self, $c, $params) {\n-  return undef unless my $tool = first { $_->name eq ($params->{name} // '') } @{$self->server->tools};\n-  my $args    = $params->{arguments} // {};\n-  my $headers = $c->req->headers;\n-\n-  for my $param (@{$tool->header_params}) {\n-    my $name   = \"Mcp-Param-$param->{name}\";\n-    my $value  = _arg_value($args, $param->{path});\n-    my $header = $headers->header($name);\n-\n-    if (!defined $value) {\n-      return \"Unexpected $name header\" if defined $header;\n-      next;\n-    }\n-    return \"Missing $name header\"                         unless defined $header;\n-    return \"Invalid $name header\"                         unless defined($header = _decode_header($header));\n-    return \"$name header does not match the request body\" unless _match_value($param->{type}, $header, $value);\n-  }\n-\n-  return undef;\n-}\n-\n-sub _decode_header ($value) {\n-  return decode('UTF-8', b64_decode($1)) if $value =~ /^=\\?base64\\?(.*)\\?=$/s;\n-  return $value =~ /^[\\t\\x20-\\x7e]*\\z/ ? $value : undef;\n-}\n-\n-sub _finish ($c, $context, $response) {\n-  return undef if $context->is_cancelled;\n-  $context->stream(undef);\n-  return _write($c, $response)->finish;\n-}\n+sub _extract_session_id ($self, $c) { return $c->req->headers->header('Mcp-Session-Id') }\n \n sub _handle ($self, $data, $context) {\n   warn \"-- MCP Request\\n@{[dumper($data)]}\\n\" if DEBUG;\n@@ -137,107 +23,39 @@ sub _handle ($self, $data, $context) {\n   return $result;\n }\n \n-sub _handle_post ($self, $c) {\n-  my $data = $c->req->json;\n-  return $c->render(json => {error => 'Invalid JSON'}, status => 400) unless ref $data eq 'HASH';\n+sub _handle_initialization ($self, $c, $data) {\n+  my $session_id = random_v4uuid;\n+  my $result     = $self->_handle($data, {});\n+  $c->res->headers->header('Mcp-Session-Id' => $session_id);\n+  $c->render(json => $result, status => 200);\n+}\n \n-  # Legacy\n-  my $legacy = legacy_request($c->req->headers->header('MCP-Protocol-Version'), $data);\n+sub _handle_post ($self, $c) {\n+  my $session_id = $self->_extract_session_id($c);\n \n-  if (!$legacy && defined(my $id = $data->{id})) {\n-    if (my $message = $self->_check_headers($c, $data)) {\n-      my $error = {code => HEADER_MISMATCH, message => $message};\n-      return $c->render(json => {jsonrpc => '2.0', id => $id, error => $error}, status => 400);\n-    }\n-  }\n+  return $c->render(json => {error => 'Invalid JSON'}, status => 400) unless my $data = $c->req->json;\n+  return $c->render(json => {error => 'Invalid JSON', status => 400}) unless ref $data eq 'HASH';\n \n-  return $self->_handle_request($c, $data, $legacy);\n+  if ($data->{method} && $data->{method} eq 'initialize') { $self->_handle_initialization($c, $data) }\n+  else                                                    { $self->_handle_regular_request($c, $data, $session_id) }\n }\n \n-sub _handle_request ($self, $c, $data, $legacy = undef) {\n-  my $info    = $c->stash('mcp.auth') // {};\n-  my $context = MCP::Server::Context->new(\n-    controller => $c,\n-    legacy     => $legacy,\n-    principal  => $info->{principal},\n-    scopes     => $self->_scopes($c),\n-    transport  => $self\n-  );\n-  return $c->render(data => '', status => 202) unless defined(my $result = $self->_handle($data, $context));\n-\n-  # Subscription\n-  return $self->_handle_subscription($c, $result) if blessed($result) && $result->isa('MCP::Server::Subscription');\n+sub _handle_regular_request ($self, $c, $data, $session_id) {\n+  return $c->render(json => {error => 'Missing session ID'}, status => 400) unless $session_id;\n \n-  # Async\n-  if (blessed($result) && $result->isa('Mojo::Promise')) {\n-    _stream($c, $context);\n-    return $result->then(sub { _finish($c, $context, $_[0]) });\n-  }\n-\n-  # Insufficient scope\n-  if (my $needed = $context->insufficient_scope) {\n-    $c->res->headers->header(\n-      'WWW-Authenticate' => $self->_challenge_header(error => 'insufficient_scope', scope => join(' ', @$needed)));\n-  }\n+  $c->res->headers->header('Mcp-Session-Id' => $session_id);\n+  return $c->render(data => '', status => 202)\n+    unless defined(my $result = $self->_handle($data, {session_id => $session_id, controller => $c}));\n \n   # Sync\n-  my $status = $context->status // 200;\n-  return $c->render(json => $result, status => $status) if $status != 200 || !@{$context->buffer};\n-  _stream($c, $context);\n-  return _finish($c, $context, $result);\n-}\n-\n-sub _handle_subscription ($self, $c, $subscription) {\n-  my $id = random_v4uuid;\n-  $self->subscriptions->{$id} = $subscription->stream($c);\n-  _write(_open_stream($c), $subscription->acknowledgement);\n-\n-  my $heartbeat_id;\n-  if (my $interval = $self->heartbeat) {\n-    $heartbeat_id = Mojo::IOLoop->recurring($interval => sub { $c->write_sse({comment => 'keepalive'}) });\n-  }\n-\n-  weaken(my $self_weak = $self);\n-  return $c->on(\n-    finish => sub {\n-      Mojo::IOLoop->remove($heartbeat_id)     if $heartbeat_id;\n-      delete $self_weak->subscriptions->{$id} if $self_weak;\n-    }\n-  );\n-}\n-\n-sub _match_value ($type, $header, $value) {\n-  return looks_like_number($header) && looks_like_number($value) && $header == $value if $type eq 'integer';\n-  return $header eq ($value ? 'true' : 'false')                                       if $type eq 'boolean';\n-  return $header eq \"$value\";\n-}\n+  return $c->render(json => $result, status => 200) if !blessed($result) || !$result->isa('Mojo::Promise');\n \n-sub _open_stream ($c) {\n+  # Async\n   $c->inactivity_timeout(0);\n-  $c->res->headers->header('X-Accel-Buffering' => 'no');\n-  return $c->write_sse;\n-}\n-\n-sub _scopes ($self, $c) {\n-  return undef unless $self->auth;\n-  return ($c->stash('mcp.auth') // {})->{scopes} // [];\n-}\n-\n-sub _stream ($c, $context) {\n-  _open_stream($c);\n-  $c->on(finish => sub { $context->stream(undef)->cancel if $context->stream });\n-  return $context->stream(sub ($notification) {\n-    _write($c, $notification);\n-  })->flush;\n+  $c->write_sse;\n+  $result->then(sub { $c->write_sse({text => to_json($_[0])})->finish });\n }\n \n-sub _unauthorized ($self, $c) {\n-  $c->res->headers->header('WWW-Authenticate' => $self->_challenge_header);\n-  return $c->render(json => {error => 'Unauthorized'}, status => 401);\n-}\n-\n-sub _write ($c, $message) { return $c->write_sse({text => to_json($message)}) }\n-\n 1;\n \n =encoding utf8\n@@ -257,80 +75,9 @@ MCP::Server::Transport::HTTP - HTTP transport for MCP servers\n L<MCP::Server::Transport::HTTP> is a transport for MCP (Model Context Protocol) server that uses HTTP as the\n underlying transport mechanism.\n \n-Only C<POST> requests are handled; C<GET> and C<DELETE> are answered with status C<405>. The protocol is stateless,\n-so nothing is remembered between requests and the transport can be deployed behind a load balancer and a pre-forking\n-web server. Notifications that belong to a request, such as progress reports, are delivered on the response stream\n-of that very request, which is upgraded from C<application/json> to C<text/event-stream> whenever there is something\n-to deliver.\n-\n-The one exception is L</\"streaming\">, which opts in to C<subscriptions/listen>. Long-lived notification streams\n-require per-process state and are therefore not compatible with pre-forking web servers.\n-\n-Routing headers are validated against the JSON-RPC body before a request is dispatched, so a gateway that routes on\n-C<Mcp-Method>, C<Mcp-Name>, or C<Mcp-Param-*> alone can never disagree with the server about what was called.\n-\n =head1 ATTRIBUTES\n \n-L<MCP::Server::Transport::HTTP> inherits all attributes from L<MCP::Server::Transport> and implements the following\n-new ones.\n-\n-=head2 auth\n-\n-  my $cb = $http->auth;\n-  $http  = $http->auth(sub ($c) {...});\n-\n-Optional callback to authenticate each request before it is dispatched. It receives the L<Mojolicious::Controller>\n-and returns a hash reference of authentication info on success, or a false value to reject the request with a\n-C<401> C<WWW-Authenticate> challenge. The C<scopes> and C<principal> keys of the returned hash reference are made\n-available to handlers as L<MCP::Server::Context/\"scopes\"> and L<MCP::Server::Context/\"principal\">. Token validation\n-is left to the application, so this is where you verify an OAuth 2.0 access token; when not set, requests are not\n-authenticated.\n-\n-=head2 heartbeat\n-\n-  my $seconds = $http->heartbeat;\n-  $http       = $http->heartbeat(30);\n-\n-Interval in seconds at which a keep-alive comment is sent on each open subscription stream. Defaults to C<30>; set\n-to C<0> to disable. Useful when running behind reverse proxies that close idle connections. Only used when\n-L</\"streaming\"> is enabled.\n-\n-=head2 metadata_url\n-\n-  my $url = $http->metadata_url;\n-  $http   = $http->metadata_url('https://example.com/.well-known/oauth-protected-resource');\n-\n-URL of the OAuth 2.0 Protected Resource Metadata document. When set, it is included as the C<resource_metadata>\n-parameter of the C<WWW-Authenticate> challenge sent with C<401> and C<403> responses, so clients can discover the\n-authorization server. Use an absolute URL so remote clients can fetch it. See L<MCP::Server/\"oauth_metadata\">.\n-\n-=head2 origins\n-\n-  my $origins = $http->origins;\n-  $http       = $http->origins(['https://example.com']);\n-  $http       = $http->origins(sub ($c, $origin) {...});\n-\n-Origins allowed to make browser requests, as an array reference of exact matches or a callback returning true for\n-an acceptable origin. Requests carrying an C<Origin> header that is not allowed are rejected with status C<403>,\n-which protects against DNS rebinding attacks on servers bound to localhost. Defaults to C<undef>, which accepts\n-every origin.\n-\n-=head2 streaming\n-\n-  my $bool = $http->streaming;\n-  $http    = $http->streaming(1);\n-\n-Enable C<subscriptions/listen>, so clients can open a long-lived stream for the C<list_changed> notifications they\n-ask for. Defaults to false. When enabled, the transport tracks every open stream in L</\"subscriptions\"> and\n-advertises the C<listChanged> capabilities in C<server/discover>. Progress and log notifications do not depend on\n-this, since they are delivered on the response stream of the request they belong to.\n-\n-=head2 subscriptions\n-\n-  my $subscriptions = $http->subscriptions;\n-  $http             = $http->subscriptions({});\n-\n-Per-process registry of active L<MCP::Server::Subscription> objects. Only used when L</\"streaming\"> is enabled.\n+L<MCP::Server::Transport::HTTP> inherits all attributes from L<MCP::Server::Transport>.\n \n =head1 METHODS\n \n@@ -343,20 +90,6 @@ ones.\n \n Handles an incoming HTTP request.\n \n-=head2 notifications\n-\n-  my $bool = $http->notifications;\n-\n-True when L</\"streaming\"> is enabled, false otherwise.\n-\n-=head2 notify_all\n-\n-  my $bool = $http->notify_all($method);\n-  my $bool = $http->notify_all($method, {foo => 'bar'});\n-\n-Send a JSON-RPC notification to every subscription that asked for it. Returns true on success, or C<undef> when\n-L</\"streaming\"> is disabled.\n-\n =head1 SEE ALSO\n \n L<MCP>, L<https://mojolicious.org>, L<https://modelcontextprotocol.io>.\ndiff --git a/lib/MCP/Server/Transport/Stdio.pm b/lib/MCP/Server/Transport/Stdio.pm\nindex 7846ea0..c4b9c96 100644\n--- a/lib/MCP/Server/Transport/Stdio.pm\n+++ b/lib/MCP/Server/Transport/Stdio.pm\n@@ -1,78 +1,28 @@\n package MCP::Server::Transport::Stdio;\n use Mojo::Base 'MCP::Server::Transport', -signatures;\n \n-use MCP::Server::Context;\n-use MCP::Server::Legacy qw(legacy_request);\n-use MCP::Server::Subscription;\n-use Mojo::JSON   qw(decode_json encode_json);\n+use Mojo::JSON qw(decode_json encode_json);\n+use Mojo::Log;\n use Scalar::Util qw(blessed);\n \n-has subscriptions => sub { {} };\n-\n sub handle_requests ($self) {\n   my $server = $self->server;\n \n-  binmode STDIN,  ':raw';\n-  binmode STDOUT, ':raw';\n   STDOUT->autoflush(1);\n-\n-  my $buffer = '';\n-  my $legacy = undef;\n-  while (defined(my $input = _read_line(\\$buffer))) {\n-    next if $input eq '';\n+  while (my $input = <>) {\n+    chomp $input;\n     my $request = eval { decode_json($input) };\n-    next if $self->_cancel($request);\n-\n-    # Legacy\n-    $legacy ||= legacy_request(undef, ref $request eq 'HASH' ? $request : {});\n+    next unless my $response = $server->handle($request, {});\n \n-    my $context = MCP::Server::Context->new(legacy => $legacy, stream => \\&_print_response, transport => $self);\n-    next unless my $response = $server->handle($request, $context);\n-\n-    if    (blessed($response) && $response->isa('MCP::Server::Subscription')) { $self->_subscribe($response) }\n-    elsif (blessed($response) && $response->isa('Mojo::Promise')) {\n-      $response->then(sub { _print_response($_[0]) unless $context->is_cancelled })->wait;\n+    if (blessed($response) && $response->isa('Mojo::Promise')) {\n+      $response->then(sub { _print_response($_[0]) })->wait;\n     }\n     else { _print_response($response) }\n   }\n }\n \n-sub notify_all ($self, $method, $params = {}) {\n-  for my $subscription (values %{$self->subscriptions}) {\n-    next unless $subscription->wants($method);\n-    _print_response($subscription->notification($method, $params));\n-  }\n-  return 1;\n-}\n-\n-sub _cancel ($self, $request) {\n-  return 0 unless ref $request eq 'HASH';\n-  return 0 unless ($request->{method} // '') eq 'notifications/cancelled';\n-  my $id = ($request->{params} // {})->{requestId};\n-  delete $self->subscriptions->{$id} if defined $id;\n-  return 1;\n-}\n-\n sub _print_response ($response) { print encode_json($response) . \"\\n\" }\n \n-sub _read_line ($buffer) {\n-  while (index($$buffer, \"\\n\") < 0) {\n-    last unless sysread STDIN, my $chunk, 131072;\n-    $$buffer .= $chunk;\n-  }\n-  return undef if $$buffer eq '';\n-\n-  my $pos  = index($$buffer, \"\\n\");\n-  my $line = $pos < 0 ? substr($$buffer, 0, length($$buffer), '') : substr($$buffer, 0, $pos + 1, '');\n-  $line =~ s/\\r?\\n?$//;\n-  return $line;\n-}\n-\n-sub _subscribe ($self, $subscription) {\n-  $self->subscriptions->{$subscription->id} = $subscription;\n-  return _print_response($subscription->acknowledgement);\n-}\n-\n 1;\n \n =encoding utf8\n@@ -93,24 +43,9 @@ L<MCP::Server::Transport::Stdio> is a transport for MCP (Model Context Protocol)\n standard input (STDIN) and writes responses to standard output (STDOUT). It is designed for command-line tools and\n debugging tasks.\n \n-All messages share the one channel, so a C<subscriptions/listen> request is answered with an acknowledgement\n-notification and then left open, with its JSON-RPC response withheld until the client cancels it.\n-\n-Requests are processed strictly in order, so a C<notifications/cancelled> notification cannot be read while the\n-request it refers to is still in flight; it can only cancel a subscription. Use L<MCP::Server::Transport::HTTP> for\n-workloads where cancelling a running tool matters.\n-\n =head1 ATTRIBUTES\n \n-L<MCP::Server::Transport::Stdio> inherits all attributes from L<MCP::Server::Transport> and implements the following\n-new ones.\n-\n-=head2 subscriptions\n-\n-  my $subscriptions = $stdio->subscriptions;\n-  $stdio            = $stdio->subscriptions({});\n-\n-Registry of active L<MCP::Server::Subscription> objects, keyed by the id of the request that opened them.\n+L<MCP::Server::Transport::Stdio> inherits all attributes from L<MCP::Server::Transport>.\n \n =head1 METHODS\n \n@@ -123,14 +58,6 @@ ones.\n \n Reads requests from standard input and prints responses to standard output.\n \n-=head2 notify_all\n-\n-  my $bool = $stdio->notify_all($method);\n-  my $bool = $stdio->notify_all($method, {foo => 'bar'});\n-\n-Send a JSON-RPC notification to standard output, once for every subscription in L</\"subscriptions\"> that asked for\n-it.\n-\n =head1 SEE ALSO\n \n L<MCP>, L<https://mojolicious.org>, L<https://modelcontextprotocol.io>.\ndiff --git a/lib/MCP/Tool.pm b/lib/MCP/Tool.pm\nindex 3916d28..0578193 100644\n--- a/lib/MCP/Tool.pm\n+++ b/lib/MCP/Tool.pm\n@@ -1,24 +1,16 @@\n package MCP::Tool;\n-use Mojo::Base 'MCP::Primitive', -signatures;\n+use Mojo::Base -base, -signatures;\n \n-use Carp qw(croak);\n-use JSON::Schema::Tiny;\n+use JSON::Validator;\n use Mojo::JSON   qw(false to_json true);\n use Mojo::Util   qw(b64_encode);\n use Scalar::Util qw(blessed);\n \n-use constant DIALECTS => {\n-  'http://json-schema.org/draft-07/schema#'      => 'draft7',\n-  'https://json-schema.org/draft/2019-09/schema' => 'draft2019-09',\n-  'https://json-schema.org/draft/2020-12/schema' => 'draft2020-12'\n-};\n-\n-has annotations      => sub { {} };\n-has code             => sub { die 'Tool code not implemented' };\n-has description      => 'Generic MCP tool';\n-has input_schema     => sub { {type => 'object'} };\n-has max_schema_depth => 50;\n-has name             => 'tool';\n+has annotations  => sub { {} };\n+has code         => sub { die 'Tool code not implemented' };\n+has description  => 'Generic MCP tool';\n+has input_schema => sub { {type => 'object'} };\n+has name         => 'tool';\n has 'output_schema';\n \n sub audio_result ($self, $audio, $options = {}, $is_error = 0) {\n@@ -35,7 +27,7 @@ sub call ($self, $args, $context) {\n   return $self->_type_check($result);\n }\n \n-sub header_params ($self) { return $self->{header_params} //= _header_params($self->input_schema) }\n+sub context ($self) { $self->{context} || {} }\n \n sub image_result ($self, $image, $options = {}, $is_error = 0) {\n   return {\n@@ -64,7 +56,6 @@ sub resource_link_result ($self, $uri, $options = {}, $is_error = 0) {\n }\n \n sub structured_result ($self, $data, $is_error = 0) {\n-  return $self->text_result('Structured content does not match the output schema', 1) if $self->validate_output($data);\n   my $result = $self->text_result(to_json($data), $is_error);\n   $result->{structuredContent} = $data;\n   return $result;\n@@ -75,46 +66,20 @@ sub text_result ($self, $text, $is_error = 0) {\n }\n \n sub validate_input ($self, $args) {\n-  my $schema    = $self->input_schema;\n-  my $validator = $self->{input_validator} //= $self->_validator($schema);\n-  return $validator->evaluate($args, $schema) ? 0 : 1;\n-}\n-\n-sub validate_output ($self, $data) {\n-  return 0 unless my $schema = $self->output_schema;\n-  my $validator = $self->{output_validator} //= $self->_validator($schema);\n-  return $validator->evaluate($data, $schema) ? 0 : 1;\n-}\n-\n-sub _header_params ($schema, $path = []) {\n-  return [] unless ref $schema eq 'HASH' && ref(my $properties = $schema->{properties}) eq 'HASH';\n-\n-  my @params;\n-  for my $key (sort keys %$properties) {\n-    next unless ref(my $property = $properties->{$key}) eq 'HASH';\n-    my @next = (@$path, $key);\n-    push @params, {name => $property->{'x-mcp-header'}, path => \\@next, type => $property->{type} // ''}\n-      if defined $property->{'x-mcp-header'};\n-    push @params, @{_header_params($property, \\@next)};\n+  unless ($self->{validator}) {\n+    my $validator = $self->{validator} = JSON::Validator->new;\n+    $validator->schema($self->input_schema);\n   }\n \n-  return \\@params;\n+  my @errors = $self->{validator}->validate($args);\n+  return @errors ? 1 : 0;\n }\n \n sub _type_check ($self, $result) {\n-  return $result if ref $result eq 'HASH' && (exists $result->{content} || exists $result->{resultType});\n+  return $result if ref $result eq 'HASH' && exists $result->{content};\n   return $self->text_result($result);\n }\n \n-sub _validator ($self, $schema) {\n-  my %options = (boolean_result => 1, max_depth => $self->max_schema_depth);\n-  if (my $dialect = ref $schema eq 'HASH' ? $schema->{'$schema'} : undef) {\n-    croak qq{Unsupported JSON Schema dialect \"$dialect\"} unless DIALECTS->{$dialect};\n-  }\n-  else { $options{specification_version} = 'draft2020-12' }\n-  return JSON::Schema::Tiny->new(%options);\n-}\n-\n 1;\n \n =encoding utf8\n@@ -131,9 +96,7 @@ MCP::Tool - Tool container\n \n =head1 DESCRIPTION\n \n-L<MCP::Tool> is a container for tools to be called. Arguments and structured content are validated with\n-L<JSON::Schema::Tiny>, which covers JSON Schema 2020-12, 2019-09, and draft-07, but only partially implements\n-C<$id>, C<$dynamicRef>, and C<$dynamicAnchor>.\n+L<MCP::Tool> is a container for tools to be called.\n \n =head1 ATTRIBUTES\n \n@@ -165,20 +128,7 @@ Description of the tool.\n   my $schema = $tool->input_schema;\n   $tool      = $tool->input_schema({type => 'object', properties => {foo => {type => 'string'}}});\n \n-JSON schema for validating input arguments. Schemas without a C<$schema> keyword are interpreted as JSON Schema\n-2020-12, the default dialect of the Model Context Protocol; schemas with one are interpreted as the dialect it\n-names, which has to be one of 2020-12, 2019-09, or draft-07. Any other dialect is an error.\n-\n-Properties may carry an C<x-mcp-header> keyword, naming the HTTP header a client has to mirror the argument in, for\n-the benefit of gateways that route on headers alone. See L</\"header_params\">.\n-\n-=head2 max_schema_depth\n-\n-  my $depth = $tool->max_schema_depth;\n-  $tool     = $tool->max_schema_depth(10);\n-\n-How many levels deep validation may descend into a schema before it is aborted, which bounds the work a deeply\n-nested or recursive schema can cause. Defaults to C<50>.\n+JSON schema for validating input arguments.\n \n =head2 name\n \n@@ -192,11 +142,11 @@ Name of the tool.\n   my $schema = $tool->output_schema;\n   $tool      = $tool->output_schema({type => 'object', properties => {foo => {type => 'string'}}});\n \n-JSON schema for validating output results, interpreted with the same dialect rules as L</\"input_schema\">.\n+JSON schema for validating output results.\n \n =head1 METHODS\n \n-L<MCP::Tool> inherits all methods from L<MCP::Primitive> and implements the following new ones.\n+L<MCP::Tool> inherits all methods from L<Mojo::Base> and implements the following new ones.\n \n =head2 audio_result\n \n@@ -222,14 +172,14 @@ Specifies the MIME type of the audio, defaults to C<audio/wav>.\n \n Calls the tool with the given arguments and context, returning a result. The result can be a promise or a direct value.\n \n-=head2 header_params\n+=head2 context\n \n-  my $params = $tool->header_params;\n+  my $context = $tool->context;\n \n-All properties of L</\"input_schema\"> annotated with C<x-mcp-header>, as an array reference of hash references with\n-C<name>, C<path> and C<type> keys. Only properties reachable from the schema root through a chain of C<properties>\n-keys are considered, since the protocol forbids C<x-mcp-header> anywhere else. The result is cached after the first\n-call.\n+Returns the context in which the tool is executed.\n+\n+  # Get controller for requests using the HTTP transport\n+  my $c = $tool->context->{controller};\n \n =head2 image_result\n \n@@ -295,8 +245,7 @@ Name of the resource.\n \n   my $result = $tool->structured_result({foo => 'bar'}, $is_error);\n \n-Returns a structured result in the format of L</\"output_schema\">, optionally marking it as an error. Data that does\n-not match L</\"output_schema\"> is turned into an error result instead.\n+Returns a structured result in the format of L</\"output_schema\">, optionally marking it as an error.\n \n =head2 text_result\n \n@@ -308,16 +257,7 @@ Returns a text result in the expected format, optionally marking it as an error.\n \n   my $bool = $tool->validate_input($args);\n \n-Validates the input arguments against L</\"input_schema\">. Returns true if validation failed. References are only\n-resolved as JSON pointers into the schema itself, so a C<$ref> to a remote schema is never fetched over the network\n-and simply fails validation.\n-\n-=head2 validate_output\n-\n-  my $bool = $tool->validate_output($data);\n-\n-Validates structured content against L</\"output_schema\">. Returns true if validation failed, and false if the tool\n-has no output schema.\n+Validates the input arguments against the tool's input schema. Returns true if validation failed.\n \n =head1 SEE ALSO\n \ndiff --git a/t/apps/stdio.pl b/t/apps/stdio.pl\nindex 402d196..36e6ee4 100644\n--- a/t/apps/stdio.pl\n+++ b/t/apps/stdio.pl\n@@ -23,49 +23,5 @@ $server->tool(\n     return $promise;\n   }\n );\n-$server->tool(\n-  name         => 'echo_log',\n-  description  => 'Echo the input text and log a notification',\n-  input_schema => {type => 'object', properties => {msg => {type => 'string'}}, required => ['msg']},\n-  code         => sub ($tool, $args) {\n-    $tool->context->notify_log(debug => 'too quiet');\n-    $tool->context->notify_log(info  => $args->{msg});\n-    return \"Echo: $args->{msg}\";\n-  }\n-);\n-$server->tool(\n-  name        => 'reload',\n-  description => 'Broadcast a tools list_changed notification',\n-  code        => sub ($tool, $args) {\n-    $server->notify_list_changed('tools');\n-    return 'reloaded';\n-  }\n-);\n-$server->tool(\n-  name         => 'echo_progress',\n-  description  => 'Echo the input text and report progress',\n-  input_schema => {type => 'object', properties => {msg => {type => 'string'}}, required => ['msg']},\n-  code         => sub ($tool, $args) {\n-    $tool->context->notify_progress(0.5, 1, 'half');\n-    return \"Echo: $args->{msg}\";\n-  }\n-);\n-$server->tool(\n-  name         => 'echo_scoped',\n-  description  => 'Echo the input text, requires a scope',\n-  scopes       => ['mcp:read'],\n-  input_schema => {type => 'object', properties => {msg => {type => 'string'}}, required => ['msg']},\n-  code         => sub ($tool, $args) {\n-    return \"Echo: $args->{msg}\";\n-  }\n-);\n-$server->tool(\n-  name        => 'boom',\n-  description => 'Throw an exception',\n-  code        => sub ($tool, $args) {\n-    die \"kaboom\\n\";\n-  }\n-);\n \n-$server->log->level('fatal');\n $server->to_stdio;\ndiff --git a/t/auth_scopes_app.t b/t/auth_scopes_app.t\ndeleted file mode 100644\nindex 9580d53..0000000\n--- a/t/auth_scopes_app.t\n+++ /dev/null\n@@ -1,220 +0,0 @@\n-use Mojo::Base -strict, -signatures;\n-\n-use Test::More;\n-\n-use Mojolicious::Lite;\n-use Test::Mojo;\n-use MCP::Client;\n-use MCP::Constants qw(META_CLIENT_CAPABILITIES META_PROTOCOL_VERSION PROTOCOL_VERSION);\n-use MCP::Server;\n-\n-my $server = MCP::Server->new;\n-\n-$server->tool(\n-  name   => 'read_tool',\n-  scopes => ['mcp:read'],\n-  code   => sub ($tool, $args) {\n-    return 'Read result';\n-  }\n-);\n-$server->tool(\n-  name   => 'write_tool',\n-  scopes => ['mcp:write'],\n-  code   => sub ($tool, $args) {\n-    return 'Write result';\n-  }\n-);\n-$server->tool(\n-  name   => 'custom_tool',\n-  scopes => ['mcp:something.custom'],\n-  code   => sub ($tool, $args) {\n-    return 'custom result';\n-  }\n-);\n-$server->on(\n-  tools => sub ($server, $tools, $context) {\n-    my $c    = $context->controller;\n-    my $role = $c ? $c->stash('role') : '';\n-    return if $role eq 'admin';\n-    @$tools = grep { $_->name ne 'custom_tool' } @$tools;\n-  }\n-);\n-\n-$server->prompt(\n-  name   => 'read_prompt',\n-  scopes => ['mcp:read'],\n-  code   => sub ($prompt, $args) {\n-    return 'Read prompt';\n-  }\n-);\n-$server->prompt(\n-  name   => 'write_prompt',\n-  scopes => ['mcp:write'],\n-  code   => sub ($prompt, $args) {\n-    return 'Write prompt';\n-  }\n-);\n-\n-$server->resource(\n-  uri    => 'file:///read',\n-  scopes => ['mcp:read'],\n-  code   => sub ($resource) {\n-    return 'Read resource';\n-  }\n-);\n-$server->resource(\n-  uri    => 'file:///write',\n-  scopes => ['mcp:write'],\n-  code   => sub ($resource) {\n-    return 'Write resource';\n-  }\n-);\n-\n-get '/.well-known/oauth-protected-resource' => sub ($c) {\n-  $c->render(\n-    json => $server->oauth_metadata(\n-      resource              => 'http://example.com/mcp',\n-      authorization_servers => ['https://auth.example.com']\n-    )\n-  );\n-};\n-\n-# Fake token validation, replace with real OAuth access token verification in production\n-my $tokens = {\n-  ro    => {scopes => ['mcp:read'], role => 'user'},\n-  rw    => {scopes => ['mcp:read', 'mcp:write', 'mcp:something.custom'], role => 'user'},\n-  admin => {scopes => ['mcp:read', 'mcp:write', 'mcp:something.custom'], role => 'admin'}\n-};\n-my $metadata_url = 'http://example.com/.well-known/oauth-protected-resource';\n-any '/mcp' => $server->to_action({\n-  auth => sub ($c) {\n-    return undef unless ($c->req->headers->authorization // '') =~ /^Bearer\\s+(\\S+)$/;\n-    return undef unless my $token = $tokens->{$1};\n-    $c->stash(role => $token->{role});\n-    return {scopes => $token->{scopes}};\n-  },\n-  metadata_url => $metadata_url\n-});\n-\n-my $t = Test::Mojo->new;\n-\n-subtest 'Discovery' => sub {\n-  $t->post_ok('/mcp' => json => {})\n-    ->status_is(401)\n-    ->header_like('WWW-Authenticate' => qr/^Bearer/)\n-    ->header_like('WWW-Authenticate' => qr/resource_metadata=\"\\Q$metadata_url\\E\"/);\n-  $t->post_ok('/mcp' => {Authorization => 'Bearer bogus'} => json => {})->status_is(401);\n-\n-  $t->get_ok('/.well-known/oauth-protected-resource')\n-    ->status_is(200)\n-    ->json_is('/scopes_supported'      => ['mcp:read', 'mcp:something.custom', 'mcp:write'])\n-    ->json_is('/authorization_servers' => ['https://auth.example.com']);\n-};\n-\n-subtest 'Read-only token' => sub {\n-  my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp'),\n-    headers => {Authorization => 'Bearer ro'});\n-\n-  subtest 'Tools' => sub {\n-    my $result = $client->list_tools;\n-    is $result->{tools}[0]{name}, 'read_tool', 'read tool present';\n-    is $result->{tools}[1],       undef,       'no more tools';\n-    is $result->{cacheScope},     'private',   'scope restricted list is private';\n-\n-    is $client->call_tool('read_tool')->{content}[0]{text}, 'Read result', 'read tool call result';\n-    eval { $client->call_tool('write_tool') };\n-    like $@, qr/Error -32003: Insufficient scope/, 'write tool denied';\n-  };\n-\n-  subtest 'Prompts' => sub {\n-    my $result = $client->list_prompts;\n-    is $result->{prompts}[0]{name}, 'read_prompt', 'read prompt present';\n-    is $result->{prompts}[1],       undef,         'no more prompts';\n-\n-    is $client->get_prompt('read_prompt')->{messages}[0]{content}{text}, 'Read prompt', 'read prompt result';\n-    eval { $client->get_prompt('write_prompt') };\n-    like $@, qr/Error -32003: Insufficient scope/, 'write prompt denied';\n-  };\n-\n-  subtest 'Resources' => sub {\n-    my $result = $client->list_resources;\n-    is $result->{resources}[0]{uri}, 'file:///read', 'read resource present';\n-    is $result->{resources}[1],      undef,          'no more resources';\n-\n-    is $client->read_resource('file:///read')->{contents}[0]{text}, 'Read resource', 'read resource result';\n-    eval { $client->read_resource('file:///write') };\n-    like $@, qr/Error -32003: Insufficient scope/, 'write resource denied';\n-  };\n-};\n-\n-subtest 'Read-write token' => sub {\n-  my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp'),\n-    headers => {Authorization => 'Bearer rw'});\n-\n-  subtest 'Tools' => sub {\n-    my $result = $client->list_tools;\n-    is $result->{tools}[0]{name}, 'read_tool',  'read tool present';\n-    is $result->{tools}[1]{name}, 'write_tool', 'write tool present';\n-    is $result->{tools}[2],       undef,        'custom tool hidden by role';\n-\n-    is $client->call_tool('write_tool')->{content}[0]{text}, 'Write result', 'write tool call result';\n-    eval { $client->call_tool('custom_tool') };\n-    like $@, qr/Error -32602/, 'custom tool hidden by role';\n-  };\n-\n-  subtest 'Prompts' => sub {\n-    my $result = $client->list_prompts;\n-    is $result->{prompts}[0]{name}, 'read_prompt',  'read prompt present';\n-    is $result->{prompts}[1]{name}, 'write_prompt', 'write prompt present';\n-    is $result->{prompts}[2],       undef,          'no more prompts';\n-\n-    is $client->get_prompt('write_prompt')->{messages}[0]{content}{text}, 'Write prompt', 'write prompt result';\n-  };\n-\n-  subtest 'Resources' => sub {\n-    my $result = $client->list_resources;\n-    is $result->{resources}[0]{uri}, 'file:///read',  'read resource present';\n-    is $result->{resources}[1]{uri}, 'file:///write', 'write resource present';\n-    is $result->{resources}[2],      undef,           'no more resources';\n-\n-    is $client->read_resource('file:///write')->{contents}[0]{text}, 'Write resource', 'write resource result';\n-  };\n-};\n-\n-subtest 'Admin token' => sub {\n-  my $client = MCP::Client->new(\n-    ua      => $t->ua,\n-    url     => $t->ua->server->url->path('/mcp'),\n-    headers => {Authorization => 'Bearer admin'}\n-  );\n-\n-  my $result = $client->list_tools;\n-  is $result->{tools}[0]{name}, 'read_tool',   'read tool present';\n-  is $result->{tools}[1]{name}, 'write_tool',  'write tool present';\n-  is $result->{tools}[2]{name}, 'custom_tool', 'custom tool present';\n-  is $result->{tools}[3],       undef,         'no more tools';\n-\n-  is $client->call_tool('custom_tool')->{content}[0]{text}, 'custom result', 'custom tool call result';\n-};\n-\n-subtest 'Insufficient scope challenge' => sub {\n-  my $meta = {META_CLIENT_CAPABILITIES() => {}, META_PROTOCOL_VERSION() => PROTOCOL_VERSION};\n-  my $call = {\n-    jsonrpc => '2.0',\n-    id      => 1,\n-    method  => 'tools/call',\n-    params  => {name => 'write_tool', arguments => {}, _meta => $meta}\n-  };\n-  my $headers = {\n-    Authorization          => 'Bearer ro',\n-    'MCP-Protocol-Version' => PROTOCOL_VERSION,\n-    'Mcp-Method'           => 'tools/call',\n-    'Mcp-Name'             => 'write_tool'\n-  };\n-  $t->post_ok('/mcp' => $headers => json => $call)\n-    ->status_is(403)\n-    ->header_like('WWW-Authenticate' => qr/error=\"insufficient_scope\"/)\n-    ->header_like('WWW-Authenticate' => qr/scope=\"mcp:write\"/);\n-};\n-\n-done_testing;\ndiff --git a/t/cancel.t b/t/cancel.t\ndeleted file mode 100644\nindex 50f19a7..0000000\n--- a/t/cancel.t\n+++ /dev/null\n@@ -1,83 +0,0 @@\n-use Mojo::Base -strict, -signatures;\n-\n-use Test::More;\n-\n-use Mojolicious::Lite;\n-use Test::Mojo;\n-use MCP::Constants qw(META_CLIENT_CAPABILITIES META_PROTOCOL_VERSION PROTOCOL_VERSION);\n-use MCP::Server;\n-use Mojo::IOLoop;\n-use Mojo::JSON qw(from_json);\n-use Mojo::Promise;\n-\n-my $server = MCP::Server->new;\n-\n-my $observed = {};\n-$server->tool(\n-  name => 'slow',\n-  code => sub ($tool, $args) {\n-    my $context = $tool->context;\n-    my $promise = Mojo::Promise->new;\n-    my $id      = Mojo::IOLoop->recurring(0.05 => sub { $context->notify_progress(1, 2, 'working') });\n-\n-    $observed = {cancelled => 0, before => $context->is_cancelled};\n-    $context->on(\n-      cancelled => sub ($context) {\n-        Mojo::IOLoop->remove($id);\n-        $observed->{cancelled}++;\n-        $observed->{after}  = $context->is_cancelled;\n-        $observed->{notify} = $context->notify('notifications/message', {data => 'too late'});\n-        $promise->resolve('finished');\n-      }\n-    );\n-\n-    $context->notify_progress(0, 2, 'starting');\n-    return $promise;\n-  }\n-);\n-\n-any '/mcp' => $server->to_action;\n-\n-my $t = Test::Mojo->new;\n-\n-subtest 'Cancellation by closing the response stream' => sub {\n-  my $meta = {META_CLIENT_CAPABILITIES() => {}, META_PROTOCOL_VERSION() => PROTOCOL_VERSION, progressToken => 'p1'};\n-  my $body\n-    = {jsonrpc => '2.0', id => 1, method => 'tools/call', params => {name => 'slow', arguments => {}, _meta => $meta}};\n-  my $headers = {\n-    Accept                 => 'text/event-stream',\n-    'MCP-Protocol-Version' => PROTOCOL_VERSION,\n-    'Mcp-Method'           => 'tools/call',\n-    'Mcp-Name'             => 'slow'\n-  };\n-\n-  my @messages;\n-  my $tx = $t->ua->build_tx(POST => $t->ua->server->url->path('/mcp') => $headers => json => $body);\n-  $tx->res->content->on(\n-    sse => sub ($content, $event = undef) {\n-      return unless $event && $event->{text};\n-      push @messages, from_json($event->{text});\n-      Mojo::IOLoop->remove($tx->connection);\n-    }\n-  );\n-  $t->ua->start_p($tx)->catch(sub { });\n-  Mojo::IOLoop->one_tick until $tx->is_finished || $tx->error;\n-\n-  is $tx->res->code,                                 200,                      'stream open';\n-  is $tx->res->headers->content_type,                'text/event-stream',      'right content type';\n-  is $tx->res->headers->header('X-Accel-Buffering'), 'no',                     'buffering disabled';\n-  is scalar @messages,                               1,                        'nothing written after the client left';\n-  is $messages[0]{method},                           'notifications/progress', 'buffered notification flushed';\n-  is $messages[0]{params}{message},                  'starting',               'progress message';\n-\n-  my $tick = Mojo::Promise->new;\n-  Mojo::IOLoop->timer(0.2 => sub { $tick->resolve });\n-  $tick->wait;\n-\n-  is $observed->{before},    0,     'not cancelled while running';\n-  is $observed->{cancelled}, 1,     'cancelled event emitted once';\n-  is $observed->{after},     1,     'cancelled afterwards';\n-  is $observed->{notify},    undef, 'notifications declined after cancellation';\n-};\n-\n-done_testing;\ndiff --git a/t/exceptions.t b/t/exceptions.t\ndeleted file mode 100644\nindex 331b556..0000000\n--- a/t/exceptions.t\n+++ /dev/null\n@@ -1,103 +0,0 @@\n-use Mojo::Base -strict, -signatures;\n-\n-use Test::More;\n-\n-use Mojolicious::Lite;\n-use Test::Mojo;\n-use MCP::Client;\n-use MCP::Constants qw(INTERNAL_ERROR META_PROTOCOL_VERSION PROTOCOL_VERSION);\n-use MCP::Server;\n-use Mojo::Promise;\n-\n-my $server = MCP::Server->new;\n-$server->tool(name => 'boom',       code => sub ($tool, $args) { die \"kaboom in /secret/path.pm line 23\\n\" });\n-$server->tool(name => 'boom_async', code => sub ($tool, $args) { Mojo::Promise->reject('async kaboom') });\n-$server->tool(\n-  name => 'boom_late',\n-  code => sub ($tool, $args) {\n-    Mojo::Promise->resolve->then(sub { die \"late\\n\" });\n-  }\n-);\n-$server->tool(name => 'fine', code => sub ($tool, $args) {'still here'});\n-$server->tool(\n-  name         => 'bad_dialect',\n-  input_schema => {'$schema' => 'https://example.com/nope', type => 'object'},\n-  code         => sub ($tool, $args) {'unreachable'}\n-);\n-$server->prompt(name => 'boom', code => sub ($prompt, $args) { die \"prompt kaboom\\n\" });\n-$server->resource(uri => 'file:///boom', name => 'boom', code => sub ($resource) { die \"resource kaboom\\n\" });\n-\n-$server->on(\n-  tools => sub ($server, $tools, $context) { die \"hook kaboom\\n\" if $context->client_info->{name} eq 'Boom' });\n-\n-app->log->level('fatal');\n-any '/mcp' => $server->to_action;\n-\n-my $t      = Test::Mojo->new;\n-my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp'));\n-\n-subtest 'Tool exceptions' => sub {\n-  eval { $client->call_tool('boom') };\n-  like $@, qr/^Error -32603: Internal error/, 'internal error';\n-\n-  eval { $client->call_tool('boom_async') };\n-  like $@, qr/^Error -32603: Internal error/, 'rejected promise';\n-\n-  eval { $client->call_tool('boom_late') };\n-  like $@, qr/^Error -32603: Internal error/, 'exception in a promise';\n-};\n-\n-subtest 'Exceptions outside the primitive' => sub {\n-  eval { $client->call_tool('bad_dialect') };\n-  like $@, qr/^Error -32603: Internal error/, 'unsupported schema dialect';\n-\n-  my $boom = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp'), name => 'Boom');\n-  eval { $boom->list_tools };\n-  like $@, qr/^Error -32603: Internal error/, 'dying event handler';\n-\n-  is scalar @{$client->list_tools->{tools}}, 5, 'other callers are unaffected';\n-};\n-\n-subtest 'Prompt and resource exceptions' => sub {\n-  eval { $client->get_prompt('boom') };\n-  like $@, qr/^Error -32603: Internal error/, 'prompt internal error';\n-\n-  eval { $client->read_resource('file:///boom') };\n-  like $@, qr/^Error -32603: Internal error/, 'resource internal error';\n-};\n-\n-subtest 'Nothing is leaked' => sub {\n-  my $meta = {META_PROTOCOL_VERSION() => PROTOCOL_VERSION, 'io.modelcontextprotocol/clientCapabilities' => {}};\n-  my $request\n-    = {jsonrpc => '2.0', id => 1, method => 'tools/call', params => {name => 'boom', arguments => {}, _meta => $meta}};\n-  my $headers = {'MCP-Protocol-Version' => PROTOCOL_VERSION, 'Mcp-Method' => 'tools/call', 'Mcp-Name' => 'boom'};\n-\n-  $t->post_ok('/mcp' => $headers => json => $request)\n-    ->status_is(500)\n-    ->json_is('/error/code'    => INTERNAL_ERROR)\n-    ->json_is('/error/message' => 'Internal error')\n-    ->json_hasnt('/error/data')\n-    ->content_unlike(qr/kaboom|secret/);\n-};\n-\n-subtest 'Server survives' => sub {\n-  is $client->call_tool('fine')->{content}[0]{text}, 'still here', 'still serving requests';\n-};\n-\n-subtest 'Exceptions go to the application log' => sub {\n-  is $server->log, app->log, 'application log adopted';\n-\n-  my $messages = app->log->capture('error');\n-  eval { $client->call_tool('boom') };\n-  eval { $client->get_prompt('boom') };\n-  like $messages,       qr/\\[error\\].*MCP request failed: kaboom in \\/secret\\/path\\.pm/, 'tool exception logged';\n-  like $messages->[-1], qr/MCP request failed: prompt kaboom/,                           'prompt exception logged';\n-  undef $messages;\n-\n-  $messages = app->log->capture('error');\n-  is $client->call_tool('fine')->{content}[0]{text}, 'still here', 'tool called';\n-  is_deeply [@$messages], [], 'nothing logged for a successful call';\n-  undef $messages;\n-};\n-\n-done_testing;\ndiff --git a/t/headers.t b/t/headers.t\ndeleted file mode 100644\nindex e6a0029..0000000\n--- a/t/headers.t\n+++ /dev/null\n@@ -1,186 +0,0 @@\n-use Mojo::Base -strict, -signatures;\n-\n-use Test::More;\n-\n-use Mojolicious::Lite;\n-use Test::Mojo;\n-use MCP::Constants qw(HEADER_MISMATCH INVALID_PARAMS META_CLIENT_CAPABILITIES META_PROTOCOL_VERSION),\n-  qw(METHOD_NOT_FOUND PROTOCOL_VERSION UNSUPPORTED_PROTOCOL_VERSION);\n-use MCP::Server;\n-\n-my $server = MCP::Server->new;\n-\n-$server->tool(\n-  name         => 'search',\n-  input_schema => {\n-    type       => 'object',\n-    properties => {\n-      query  => {type => 'string',           'x-mcp-header' => 'Query'},\n-      limit  => {type => 'integer',          'x-mcp-header' => 'Limit'},\n-      pretty => {type => 'boolean',          'x-mcp-header' => 'Pretty'},\n-      notes  => {type => ['string', 'null'], 'x-mcp-header' => 'Notes'},\n-      filter => {type => 'object',           properties     => {tag => {type => 'string', 'x-mcp-header' => 'Tag'}}}\n-    }\n-  },\n-  code => sub ($tool, $args) {\n-    return 'searched';\n-  }\n-);\n-$server->tool(\n-  name => \"Ünicode\",\n-  code => sub ($tool, $args) {\n-    return 'unicode';\n-  }\n-);\n-\n-any '/mcp' => $server->to_action({origins => ['https://example.com']});\n-\n-my $t = Test::Mojo->new;\n-\n-sub call ($name, $args, $headers) {\n-  my $meta = {META_CLIENT_CAPABILITIES() => {}, META_PROTOCOL_VERSION() => PROTOCOL_VERSION};\n-  my $body = {\n-    jsonrpc => '2.0',\n-    id      => 1,\n-    method  => 'tools/call',\n-    params  => {name => $name, arguments => $args, _meta => $meta}\n-  };\n-  my %all = ('MCP-Protocol-Version' => PROTOCOL_VERSION, 'Mcp-Method' => 'tools/call', 'Mcp-Name' => $name, %$headers);\n-  delete @all{grep { !defined $all{$_} } keys %all};\n-  return $t->post_ok('/mcp' => \\%all => json => $body);\n-}\n-\n-subtest 'Routing headers' => sub {\n-  call('search', {}, {})->status_is(200)->json_is('/result/content/0/text' => 'searched');\n-\n-  my $lower = {'Mcp-Method' => undef, 'Mcp-Name' => undef, 'mcp-method' => 'tools/call', 'mcp-name' => 'search'};\n-  call('search', {}, $lower)->status_is(200)->json_is('/result/content/0/text' => 'searched');\n-\n-  call('search', {}, {'Mcp-Method' => undef})\n-    ->status_is(400)\n-    ->json_is('/error/code'    => HEADER_MISMATCH)\n-    ->json_is('/error/message' => 'Missing Mcp-Method header');\n-\n-  call('search', {}, {'Mcp-Method' => 'tools/list'})\n-    ->status_is(400)\n-    ->json_is('/error/message' => 'Mcp-Method header does not match the request body');\n-\n-  call('search', {}, {'Mcp-Name' => undef})->status_is(400)->json_is('/error/message' => 'Missing Mcp-Name header');\n-\n-  call('search', {}, {'Mcp-Name' => 'other'})\n-    ->status_is(400)\n-    ->json_is('/error/message' => 'Mcp-Name header does not match the request body');\n-};\n-\n-subtest 'Protocol version' => sub {\n-  call('search', {}, {'MCP-Protocol-Version' => undef})\n-    ->status_is(400)\n-    ->json_is('/error/code'    => HEADER_MISMATCH)\n-    ->json_is('/error/message' => 'Missing MCP-Protocol-Version header');\n-\n-  call('search', {}, {'MCP-Protocol-Version' => '2025-11-25'})\n-    ->status_is(400)\n-    ->json_is('/error/message' => 'MCP-Protocol-Version header does not match the request body');\n-};\n-\n-subtest 'Base64 sentinel' => sub {\n-  call(\"Ünicode\", {}, {'Mcp-Name' => '=?base64?w5xuaWNvZGU=?='})\n-    ->status_is(200)\n-    ->json_is('/result/content/0/text' => 'unicode');\n-\n-  call(\"Ünicode\", {}, {'Mcp-Name' => \"Ünicode\"})\n-    ->status_is(400)\n-    ->json_is('/error/message' => 'Invalid Mcp-Name header');\n-\n-  call('=?base64?x?=', {}, {'Mcp-Name' => '=?base64?PT9iYXNlNjQ/eD89?='})\n-    ->status_is(200)\n-    ->json_is('/error/code' => INVALID_PARAMS);\n-\n-  call('=?base64?x?=', {}, {'Mcp-Name' => '=?base64?x?='})\n-    ->status_is(400)\n-    ->json_is('/error/message' => 'Mcp-Name header does not match the request body');\n-};\n-\n-subtest 'Parameter headers' => sub {\n-  my $args = {query => 'perl', limit => 10, pretty => \\1, filter => {tag => 'news'}};\n-  my $all\n-    = {'Mcp-Param-Query' => 'perl', 'Mcp-Param-Limit' => 10, 'Mcp-Param-Pretty' => 'true', 'Mcp-Param-Tag' => 'news'};\n-  call('search', $args, $all)->status_is(200)->json_is('/result/content/0/text' => 'searched');\n-\n-  call('search', $args, {%$all, 'Mcp-Param-Limit' => '10.0'})\n-    ->status_is(200)\n-    ->json_is('/result/content/0/text' => 'searched');\n-\n-  call('search', $args, {%$all, 'Mcp-Param-Limit' => '11'})\n-    ->status_is(400)\n-    ->json_is('/error/message' => 'Mcp-Param-Limit header does not match the request body');\n-\n-  call('search', $args, {%$all, 'Mcp-Param-Tag' => undef})\n-    ->status_is(400)\n-    ->json_is('/error/message' => 'Missing Mcp-Param-Tag header');\n-\n-  call('search', {query => 'perl'}, {'Mcp-Param-Query' => 'perl'})\n-    ->status_is(200)\n-    ->json_is('/result/content/0/text' => 'searched');\n-\n-  call('search', {query => 'perl'}, {'Mcp-Param-Query' => 'perl', 'Mcp-Param-Limit' => 10})\n-    ->status_is(400)\n-    ->json_is('/error/message' => 'Unexpected Mcp-Param-Limit header');\n-\n-  call('search', {notes => undef}, {})->status_is(200)->json_is('/result/content/0/text' => 'searched');\n-\n-  call('search', {notes => undef}, {'Mcp-Param-Notes' => ''})\n-    ->status_is(400)\n-    ->json_is('/error/message' => 'Unexpected Mcp-Param-Notes header');\n-};\n-\n-subtest 'Request meta' => sub {\n-  my $body    = {jsonrpc => '2.0', id => 1, method => 'tools/list', params => {}};\n-  my $headers = {'MCP-Protocol-Version' => PROTOCOL_VERSION, 'Mcp-Method' => 'tools/list'};\n-  $t->post_ok('/mcp' => $headers => json => $body)\n-    ->status_is(400)\n-    ->json_is('/error/code'    => INVALID_PARAMS)\n-    ->json_is('/error/message' => 'Missing protocol version');\n-\n-  $body->{params}{_meta} = {META_PROTOCOL_VERSION() => PROTOCOL_VERSION};\n-  $t->post_ok('/mcp' => $headers => json => $body)\n-    ->status_is(400)\n-    ->json_is('/error/message' => 'Missing client capabilities');\n-\n-  $body->{params}{_meta} = {META_CLIENT_CAPABILITIES() => {}, META_PROTOCOL_VERSION() => '2025-11-25'};\n-  $t->post_ok('/mcp' => {%$headers, 'MCP-Protocol-Version' => '2025-11-25'} => json => $body)\n-    ->status_is(400)\n-    ->json_is('/error/code'           => UNSUPPORTED_PROTOCOL_VERSION)\n-    ->json_is('/error/data/supported' => [PROTOCOL_VERSION])\n-    ->json_is('/error/data/requested' => '2025-11-25');\n-};\n-\n-subtest 'Unknown method' => sub {\n-  my $meta    = {META_CLIENT_CAPABILITIES() => {}, META_PROTOCOL_VERSION() => PROTOCOL_VERSION};\n-  my $body    = {jsonrpc => '2.0', id => 1, method => 'ping', params => {_meta => $meta}};\n-  my $headers = {'MCP-Protocol-Version' => PROTOCOL_VERSION, 'Mcp-Method' => 'ping'};\n-  $t->post_ok('/mcp' => $headers => json => $body)->status_is(404)->json_is('/error/code' => METHOD_NOT_FOUND);\n-};\n-\n-subtest 'Notifications' => sub {\n-  my $body = {jsonrpc => '2.0', method => 'notifications/cancelled', params => {requestId => 1}};\n-  $t->post_ok('/mcp' => json => $body)->status_is(202)->content_is('');\n-};\n-\n-subtest 'Origin' => sub {\n-  my $meta    = {META_CLIENT_CAPABILITIES() => {}, META_PROTOCOL_VERSION() => PROTOCOL_VERSION};\n-  my $body    = {jsonrpc => '2.0', id => 1, method => 'tools/list', params => {_meta => $meta}};\n-  my $headers = {'MCP-Protocol-Version' => PROTOCOL_VERSION, 'Mcp-Method' => 'tools/list'};\n-  $t->post_ok('/mcp' => {%$headers, Origin => 'https://example.com'} => json => $body)->status_is(200);\n-  $t->post_ok('/mcp' => {%$headers, Origin => 'https://evil.com'}    => json => $body)\n-    ->status_is(403)\n-    ->json_is('/error' => 'Origin not allowed');\n-};\n-\n-subtest 'Invalid JSON' => sub {\n-  $t->post_ok('/mcp' => {'Content-Type' => 'application/json'} => 'not json')\n-    ->status_is(400)\n-    ->json_is('/error' => 'Invalid JSON');\n-};\n-\n-done_testing;\ndiff --git a/t/legacy.t b/t/legacy.t\ndeleted file mode 100644\nindex 97175d8..0000000\n--- a/t/legacy.t\n+++ /dev/null\n@@ -1,162 +0,0 @@\n-use Mojo::Base -strict, -signatures;\n-\n-use Test::More;\n-\n-use Mojolicious::Lite;\n-use Test::Mojo;\n-use MCP::Constants qw(HEADER_MISMATCH META_CLIENT_CAPABILITIES META_PROTOCOL_VERSION METHOD_NOT_FOUND),\n-  qw(PROTOCOL_VERSION);\n-use MCP::Server;\n-use Mojo::File qw(curfile);\n-use lib curfile->dirname->child('lib')->to_string;\n-use MCPStdioTest;\n-\n-my $server = MCP::Server->new(name => 'LegacyServer', version => '2.0.0');\n-$server->tool(\n-  name         => 'echo',\n-  description  => 'Echo the input text',\n-  input_schema => {type => 'object', properties => {msg => {type => 'string'}}, required => ['msg']},\n-  code         => sub ($tool, $args) {\"Echo: $args->{msg}\"}\n-);\n-$server->prompt(\n-  name      => 'greet',\n-  arguments => [{name => 'who', required => \\1}],\n-  code      => sub ($prompt, $args) {\"Hello $args->{who}\"}\n-);\n-$server->resource(uri => 'file:///readme', name => 'readme', code => sub ($resource) {'Read me'});\n-\n-my $minimal = MCP::Server->new(name => 'MinimalServer', version => '1.0.0');\n-$minimal->tool(name => 'noop', input_schema => {type => 'object'}, code => sub ($tool, $args) {'ok'});\n-\n-any '/mcp'     => $server->to_action;\n-any '/minimal' => $minimal->to_action;\n-\n-my $t = Test::Mojo->new;\n-\n-sub legacy ($id, $method, $params = {}) {\n-  my $body = {jsonrpc => '2.0', id => $id, method => $method, params => $params};\n-  return $t->post_ok('/mcp' => {'MCP-Protocol-Version' => '2025-11-25'} => json => $body);\n-}\n-\n-subtest 'Handshake' => sub {\n-  my $params = {protocolVersion => '2025-11-25', capabilities => {}, clientInfo => {name => 'Old', version => '1'}};\n-  $t->post_ok('/mcp' => json => {jsonrpc => '2.0', id => 1, method => 'initialize', params => $params})\n-    ->status_is(200)\n-    ->json_is('/result/protocolVersion' => '2025-11-25')\n-    ->json_is('/result/serverInfo/name' => 'LegacyServer')\n-    ->json_is('/result/capabilities'    => {prompts => {}, resources => {}, tools => {}})\n-    ->json_hasnt('/result/capabilities/tools/listChanged');\n-\n-  $t->post_ok('/minimal' => json => {jsonrpc => '2.0', id => 1, method => 'initialize', params => $params})\n-    ->status_is(200)\n-    ->json_is('/result/capabilities' => {tools => {}});\n-\n-  $t->post_ok('/mcp' => json => {jsonrpc => '2.0', method => 'notifications/initialized'})->status_is(202);\n-\n-  legacy(2, 'ping')->status_is(200)->json_hasnt('/error');\n-};\n-\n-subtest 'Tools' => sub {\n-  legacy(3, 'tools/list')->status_is(200)->json_is('/result/tools/0/name' => 'echo');\n-\n-  legacy(4, 'tools/call', {name => 'echo', arguments => {msg => 'hi'}})\n-    ->status_is(200)\n-    ->json_is('/result/content/0/text' => 'Echo: hi');\n-\n-  legacy(5, 'tools/call', {name => 'echo', arguments => {}})\n-    ->status_is(200)\n-    ->json_is('/error/message' => 'Invalid arguments');\n-};\n-\n-subtest 'Prompts and resources' => sub {\n-  legacy(6, 'prompts/list')->status_is(200)->json_is('/result/prompts/0/name' => 'greet');\n-\n-  legacy(7, 'prompts/get', {name => 'greet', arguments => {who => 'world'}})\n-    ->status_is(200)\n-    ->json_is('/result/messages/0/content/text' => 'Hello world');\n-\n-  legacy(8, 'resources/list')->status_is(200)->json_is('/result/resources/0/uri' => 'file:///readme');\n-\n-  legacy(9, 'resources/read', {uri => 'file:///readme'})\n-    ->status_is(200)\n-    ->json_is('/result/contents/0/text' => 'Read me');\n-};\n-\n-subtest 'No routing headers required' => sub {\n-  my $body\n-    = {jsonrpc => '2.0', id => 10, method => 'tools/call', params => {name => 'echo', arguments => {msg => 'x'}}};\n-  $t->post_ok('/mcp' => {'MCP-Protocol-Version' => '2025-11-25'} => json => $body)\n-    ->status_is(200)\n-    ->json_is('/result/content/0/text' => 'Echo: x');\n-};\n-\n-subtest 'Older revisions' => sub {\n-  for my $version ('2024-11-05', '2025-03-26', '2025-06-18') {\n-    my $params = {protocolVersion => $version, capabilities => {}, clientInfo => {name => 'Old', version => '1'}};\n-    $t->post_ok('/mcp' => json => {jsonrpc => '2.0', id => 20, method => 'initialize', params => $params})\n-      ->status_is(200)\n-      ->json_is('/result/protocolVersion' => $version);\n-  }\n-\n-  my $params = {protocolVersion => '1999-01-01', capabilities => {}};\n-  $t->post_ok('/mcp' => json => {jsonrpc => '2.0', id => 21, method => 'initialize', params => $params})\n-    ->status_is(200)\n-    ->json_is('/result/protocolVersion' => '2025-11-25');\n-\n-  my $body\n-    = {jsonrpc => '2.0', id => 22, method => 'tools/call', params => {name => 'echo', arguments => {msg => 'y'}}};\n-  $t->post_ok('/mcp' => json => $body)->status_is(200)->json_is('/result/content/0/text' => 'Echo: y');\n-};\n-\n-subtest 'Current clients are unaffected' => sub {\n-  my $meta = {META_CLIENT_CAPABILITIES() => {}, META_PROTOCOL_VERSION() => PROTOCOL_VERSION};\n-  my $body = {\n-    jsonrpc => '2.0',\n-    id      => 11,\n-    method  => 'tools/call',\n-    params  => {name => 'echo', arguments => {msg => 'hi'}, _meta => $meta}\n-  };\n-\n-  $t->post_ok('/mcp' => {'MCP-Protocol-Version' => PROTOCOL_VERSION} => json => $body)\n-    ->status_is(400)\n-    ->json_is('/error/code'    => HEADER_MISMATCH)\n-    ->json_is('/error/message' => 'Missing Mcp-Method header');\n-\n-  my $headers = {'MCP-Protocol-Version' => PROTOCOL_VERSION, 'Mcp-Method' => 'tools/call', 'Mcp-Name' => 'echo'};\n-  $t->post_ok('/mcp' => $headers => json => $body)->status_is(200)->json_is('/result/content/0/text' => 'Echo: hi');\n-\n-  $t->post_ok('/mcp' => $headers => json => {%$body, method => 'ping'})->status_is(400);\n-\n-  $t->post_ok('/mcp' => {%$headers, 'MCP-Protocol-Version' => '2025-11-25'} => json => $body)\n-    ->status_is(400)\n-    ->json_is('/error/message' => 'MCP-Protocol-Version header does not match the request body');\n-\n-  my $discover = {jsonrpc => '2.0', id => 12, method => 'server/discover', params => {_meta => $meta}};\n-  $t->post_ok(\n-    '/mcp' => {'MCP-Protocol-Version' => PROTOCOL_VERSION, 'Mcp-Method' => 'server/discover'} => json => $discover)\n-    ->status_is(200)\n-    ->json_is('/result/supportedVersions/0' => PROTOCOL_VERSION);\n-};\n-\n-subtest 'Stdio' => sub {\n-  plan skip_all => 'set TEST_STDIO to enable this test (developer only!)' unless $ENV{TEST_STDIO} || $ENV{TEST_ALL};\n-\n-  my $test = MCPStdioTest->new;\n-  $test->run($^X, curfile->dirname->child('apps', 'stdio.pl')->to_string);\n-\n-  my $res = $test->send({jsonrpc => '2.0', id => 1, method => 'initialize', params => {}});\n-  is $res->{id},                       1,            'request id';\n-  is $res->{result}{protocolVersion},  '2025-11-25', 'protocol version';\n-  is $res->{result}{serverInfo}{name}, 'PerlServer', 'server name';\n-\n-  $res\n-    = $test->send({\n-    jsonrpc => '2.0', id => 2, method => 'tools/call', params => {name => 'echo', arguments => {msg => 'hi'}}\n-    });\n-  is $res->{id}, 2, 'request id';\n-  is_deeply $res->{result}{content}, [{text => 'Echo: hi', type => 'text'}], 'tool call result';\n-\n-  ok $test->stop, 'process stopped';\n-};\n-\n-done_testing;\ndiff --git a/t/lib/MCPStdioTest.pm b/t/lib/MCPStdioTest.pm\nindex 4113774..2088279 100644\n--- a/t/lib/MCPStdioTest.pm\n+++ b/t/lib/MCPStdioTest.pm\n@@ -15,33 +15,15 @@ sub notify ($self, $method, $params) {\n   return 1;\n }\n \n-sub read_line ($self) {\n-  $self->{timeout}->start(60);\n-  pump $self->{run} until $self->{stdout} =~ s/^(.*)\\n//;\n-  return eval { decode_json($1) };\n-}\n-\n sub request ($self, $method, $params) {\n-  $self->send_request($method, $params);\n-  return $self->read_line;\n-}\n-\n-sub send ($self, $request) {\n-  $self->{timeout}->start(60);\n-  $self->{stdin} .= encode_json($request) . \"\\n\";\n-  return $self->read_line;\n-}\n-\n-sub send_request ($self, $method, $params) {\n   $self->{timeout}->start(60);\n   $self->{stdin} .= encode_json($self->client->build_request($method, $params)) . \"\\n\";\n-  return 1;\n-}\n \n-sub send_request_crlf ($self, $method, $params) {\n-  $self->{timeout}->start(60);\n-  $self->{stdin} .= encode_json($self->client->build_request($method, $params)) . \"\\r\\n\";\n-  return 1;\n+  my $stdout = $self->{stdout};\n+  pump $self->{run} until $self->{stdout} =~ s/^(.*)\\n//;\n+  my $input = $1;\n+  my $res   = eval { decode_json($input) };\n+  return $res;\n }\n \n sub run ($self, @command) {\ndiff --git a/t/lite_app.t b/t/lite_app.t\nindex 2dbcbb7..8a01fb4 100644\n--- a/t/lite_app.t\n+++ b/t/lite_app.t\n@@ -7,8 +7,7 @@ use Mojo::ByteStream qw(b);\n use Mojo::File       qw(curfile);\n use Mojo::JSON       qw(from_json true false);\n use MCP::Client;\n-use MCP::Constants qw(META_SERVER_INFO PROTOCOL_VERSION);\n-use MCP::Server;\n+use MCP::Constants qw(PROTOCOL_VERSION);\n \n my $t = Test::Mojo->new(curfile->sibling('apps', 'lite_app.pl'));\n \n@@ -16,38 +15,31 @@ subtest 'Normal HTTP endpoint' => sub {\n   $t->get_ok('/')->status_is(200)->content_like(qr/Hello MCP!/);\n };\n \n-subtest 'List changed without streaming' => sub {\n-  my $server = MCP::Server->new;\n-  $server->to_action;\n-  is $server->notify_list_changed('tools'), undef, 'no broadcast without streaming';\n-};\n-\n subtest 'MCP endpoint' => sub {\n   $t->get_ok('/mcp')->status_is(405)->content_like(qr/Method not allowed/);\n-  $t->delete_ok('/mcp')->status_is(405)->content_like(qr/Method not allowed/);\n \n   my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp'));\n \n-  subtest 'Discovery' => sub {\n-    my $result = $client->discover;\n-    is_deeply $result->{supportedVersions}, [PROTOCOL_VERSION], 'supported versions';\n-    is_deeply $result->{capabilities}{extensions}, {}, 'no extensions';\n-    is_deeply $result->{capabilities}{prompts},    {}, 'no listChanged for prompts';\n-    is_deeply $result->{capabilities}{resources},  {}, 'no listChanged for resources';\n-    is_deeply $result->{capabilities}{tools},      {}, 'no listChanged for tools';\n-    is $result->{instructions},                      undef,        'no instructions';\n-    is $result->{resultType},                        'complete',   'result type';\n-    is $result->{cacheScope},                        'public',     'cache scope';\n-    is $result->{ttlMs},                             0,            'cache ttl';\n-    is $result->{_meta}{+META_SERVER_INFO}{name},    'PerlServer', 'server name';\n-    is $result->{_meta}{+META_SERVER_INFO}{version}, '1.0.0',      'server version';\n+  subtest 'Initialize session' => sub {\n+    is $client->session_id, undef, 'no session id';\n+    my $result = $client->initialize_session;\n+    is $result->{protocolVersion},     PROTOCOL_VERSION, 'protocol version';\n+    is $result->{serverInfo}{name},    'PerlServer',     'server name';\n+    is $result->{serverInfo}{version}, '1.0.0',          'server version';\n+    ok $result->{capabilities},            'has capabilities';\n+    ok $result->{capabilities}{prompts},   'has prompts capability';\n+    ok $result->{capabilities}{resources}, 'has resources capability';\n+    ok $result->{capabilities}{tools},     'has tools capability';\n+    ok $client->session_id,                'session id set';\n+  };\n+\n+  subtest 'Ping' => sub {\n+    my $result = $client->ping;\n+    is_deeply $result, {}, 'ping response';\n   };\n \n   subtest 'List tools' => sub {\n     my $result = $client->list_tools;\n-    is $result->{resultType},            'complete',            'result type';\n-    is $result->{cacheScope},            'public',              'cache scope';\n-    is $result->{ttlMs},                 0,                     'cache ttl';\n     is $result->{tools}[0]{name},        'echo',                'tool name';\n     is $result->{tools}[0]{description}, 'Echo the input text', 'tool description';\n     is_deeply $result->{tools}[0]{inputSchema},\n@@ -110,10 +102,6 @@ subtest 'MCP endpoint' => sub {\n   subtest 'Tool call' => sub {\n     my $result = $client->call_tool('echo', {msg => 'hello mojo'});\n     is $result->{content}[0]{text}, 'Echo: hello mojo', 'tool call result';\n-    is $result->{resultType},       'complete',         'result type';\n-    is $result->{cacheScope},       undef,              'no cache scope';\n-    is $result->{ttlMs},            undef,              'no cache ttl';\n-    is_deeply $result->{_meta}{+META_SERVER_INFO}, {name => 'PerlServer', version => '1.0.0'}, 'server info';\n   };\n \n   subtest 'Tool call (async)' => sub {\n@@ -193,7 +181,7 @@ subtest 'MCP endpoint' => sub {\n \n   subtest 'Invalid tool name' => sub {\n     eval { $client->call_tool('unknownTool', {}) };\n-    like $@, qr/Error -32602: Tool 'unknownTool' not found/, 'right error';\n+    like $@, qr/Error -32601: Tool 'unknownTool' not found/, 'right error';\n   };\n \n   subtest 'Invalid tool arguments' => sub {\n@@ -203,8 +191,6 @@ subtest 'MCP endpoint' => sub {\n \n   subtest 'List prompts' => sub {\n     my $result = $client->list_prompts;\n-    is $result->{cacheScope},              'public',                 'cache scope';\n-    is $result->{ttlMs},                   0,                        'cache ttl';\n     is $result->{prompts}[0]{name},        'time',                   'prompt name';\n     is $result->{prompts}[0]{description}, 'Tell the user the time', 'prompt description';\n     is_deeply $result->{prompts}[0]{arguments}, [], 'no prompt arguments';\n@@ -252,7 +238,7 @@ subtest 'MCP endpoint' => sub {\n \n   subtest 'Invalid prompt name' => sub {\n     eval { $client->get_prompt('unknownPrompt', {}) };\n-    like $@, qr/Error -32602: Prompt 'unknownPrompt' not found/, 'right error';\n+    like $@, qr/Error -32601: Prompt 'unknownPrompt' not found/, 'right error';\n   };\n \n   subtest 'Invalid prompt arguments' => sub {\n@@ -262,8 +248,6 @@ subtest 'MCP endpoint' => sub {\n \n   subtest 'List resources' => sub {\n     my $result = $client->list_resources;\n-    is $result->{cacheScope},                'public',                        'cache scope';\n-    is $result->{ttlMs},                     0,                               'cache ttl';\n     is $result->{resources}[0]{name},        'static_text',                   'resource name';\n     is $result->{resources}[0]{description}, 'A static text resource',        'resource description';\n     is $result->{resources}[0]{uri},         'file:///path/to/static.txt',    'resource uri';\n@@ -281,9 +265,6 @@ subtest 'MCP endpoint' => sub {\n \n   subtest 'Read resource (text)' => sub {\n     my $result = $client->read_resource('file:///path/to/static.txt');\n-    is $result->{resultType},            'complete',                        'result type';\n-    is $result->{cacheScope},            'public',                          'cache scope';\n-    is $result->{ttlMs},                 0,                                 'cache ttl';\n     is $result->{contents}[0]{uri},      'file:///path/to/static.txt',      'resource uri';\n     is $result->{contents}[0]{mimeType}, 'text/plain',                      'resource mime type';\n     is $result->{contents}[0]{text},     'This is a static text resource.', 'resource text';\n@@ -305,7 +286,7 @@ subtest 'MCP endpoint' => sub {\n \n   subtest 'Invalid resource uri' => sub {\n     eval { $client->read_resource('file://whatever') };\n-    like $@, qr{Error -32602: Resource 'file://whatever' not found}, 'right error';\n+    like $@, qr/Error -32002: Resource not found/, 'right error';\n   };\n };\n \ndiff --git a/t/mrtr.t b/t/mrtr.t\ndeleted file mode 100644\nindex 5d7d7df..0000000\n--- a/t/mrtr.t\n+++ /dev/null\n@@ -1,159 +0,0 @@\n-use Mojo::Base -strict, -signatures;\n-\n-use Test::More;\n-\n-use Mojolicious::Lite;\n-use Test::Mojo;\n-use MCP::Client;\n-use MCP::Server;\n-use Mojo::JSON qw(true);\n-\n-my $deploy = sub ($tool, $args) {\n-  my $context = $tool->context;\n-  my $state   = $context->request_state;\n-  my $confirm = ($context->input_responses // {})->{confirm} // {};\n-  return $tool->text_result(\"Deployed $state->{target}\") if $state && ($confirm->{action} // '') eq 'accept';\n-  return $tool->input_required(\n-    {\n-      confirm => {\n-        method => 'elicitation/create',\n-        params => {\n-          message         => \"Really deploy to $args->{target}?\",\n-          requestedSchema => {type => 'object', properties => {ok => {type => 'boolean'}}}\n-        }\n-      }\n-    },\n-    {target => $args->{target}}\n-  );\n-};\n-my $schema = {type => 'object', properties => {target => {type => 'string'}}, required => ['target']};\n-\n-my $server = MCP::Server->new(state_secret => 'test-secret');\n-$server->tool(name => 'deploy', input_schema => $schema, code => $deploy);\n-$server->tool(\n-  name => 'steal',\n-  code => sub ($tool, $args) {\n-    my $state = $tool->context->request_state;\n-    return $tool->text_result($state ? \"Stolen $state->{target}\" : 'No state');\n-  }\n-);\n-$server->prompt(\n-  name => 'plan',\n-  code => sub ($prompt, $args) {\n-    return $prompt->text_prompt('Ready to deploy') if $prompt->context->request_state;\n-    return $prompt->input_required(undef, {asked => 1});\n-  }\n-);\n-$server->resource(\n-  uri       => 'file:///report',\n-  cache_ttl => 60_000,\n-  code      => sub ($resource) {\n-    return 'Deployment report';\n-  }\n-);\n-\n-my $expired = MCP::Server->new(state_secret => 'test-secret', state_timeout => -1);\n-$expired->tool(name => 'deploy', input_schema => $schema, code => $deploy);\n-\n-any '/mcp' => $server->to_action({\n-  auth => sub ($c) {\n-    return undef unless ($c->req->headers->authorization // '') =~ /^Bearer\\s+(\\S+)$/;\n-    return {principal => $1};\n-  }\n-});\n-any '/expired' => $expired->to_action;\n-\n-my $t      = Test::Mojo->new;\n-my $client = MCP::Client->new(\n-  ua      => $t->ua,\n-  url     => $t->ua->server->url->path('/mcp'),\n-  headers => {Authorization => 'Bearer alice'}\n-);\n-\n-my $state;\n-\n-subtest 'Input required' => sub {\n-  my $result = $client->call_tool('deploy', {target => 'prod'});\n-  is $result->{resultType},                              'input_required',         'result type';\n-  is $result->{inputRequests}{confirm}{method},          'elicitation/create',     'input request method';\n-  is $result->{inputRequests}{confirm}{params}{message}, 'Really deploy to prod?', 'input request message';\n-  is $result->{content},                                 undef,                    'no content';\n-  is $result->{ttlMs},                                   undef,                    'no cache ttl';\n-  is $result->{cacheScope},                              undef,                    'no cache scope';\n-  ok $state = $result->{requestState}, 'request state';\n-};\n-\n-subtest 'Retry' => sub {\n-  my $responses = {confirm => {action => 'accept', content => {ok => true}}};\n-  my $result\n-    = $client->call_tool('deploy', {target => 'prod'}, {input_responses => $responses, request_state => $state});\n-  is $result->{resultType}, 'complete', 'result type';\n-  is_deeply $result->{content}, [{text => 'Deployed prod', type => 'text'}], 'tool call result';\n-};\n-\n-subtest 'Retry (no input responses)' => sub {\n-  my $result = $client->call_tool('deploy', {target => 'prod'}, {request_state => $state});\n-  is $result->{resultType}, 'input_required', 'asked again';\n-};\n-\n-subtest 'Tampered state' => sub {\n-  my $tampered = $state;\n-  substr $tampered, 0, 1, 'X';\n-  my $responses = {confirm => {action => 'accept', content => {ok => true}}};\n-  my $result\n-    = $client->call_tool('deploy', {target => 'prod'}, {input_responses => $responses, request_state => $tampered});\n-  is $result->{resultType}, 'input_required', 'asked again';\n-};\n-\n-subtest 'State from another tool' => sub {\n-  my $result = $client->call_tool('steal', {}, {request_state => $state});\n-  is_deeply $result->{content}, [{text => 'No state', type => 'text'}], 'tool call result';\n-};\n-\n-subtest 'State from another principal' => sub {\n-  my $bob = MCP::Client->new(\n-    ua      => $t->ua,\n-    url     => $t->ua->server->url->path('/mcp'),\n-    headers => {Authorization => 'Bearer bob'}\n-  );\n-  my $responses = {confirm => {action => 'accept', content => {ok => true}}};\n-  my $result = $bob->call_tool('deploy', {target => 'prod'}, {input_responses => $responses, request_state => $state});\n-  is $result->{resultType}, 'input_required', 'asked again';\n-};\n-\n-subtest 'Expired state' => sub {\n-  my $stale = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/expired'));\n-  my $first = $stale->call_tool('deploy', {target => 'prod'});\n-  is $first->{resultType}, 'input_required', 'result type';\n-\n-  my $responses = {confirm => {action => 'accept', content => {ok => true}}};\n-  my $result    = $stale->call_tool(\n-    'deploy',\n-    {target          => 'prod'},\n-    {input_responses => $responses, request_state => $first->{requestState}}\n-  );\n-  is $result->{resultType}, 'input_required', 'asked again';\n-};\n-\n-subtest 'Prompt' => sub {\n-  my $result = $client->get_prompt('plan');\n-  is $result->{resultType},    'input_required', 'result type';\n-  is $result->{inputRequests}, undef,            'no input requests';\n-  ok $result->{requestState}, 'request state';\n-\n-  my $retry = $client->get_prompt('plan', {}, {request_state => $result->{requestState}});\n-  is $retry->{resultType},                 'complete',        'result type';\n-  is $retry->{messages}[0]{content}{text}, 'Ready to deploy', 'prompt result';\n-};\n-\n-subtest 'Resource cache hints' => sub {\n-  my $result = $client->read_resource('file:///report');\n-  is $result->{ttlMs},      60_000,   'cache ttl';\n-  is $result->{cacheScope}, 'public', 'cache scope';\n-\n-  my $retry = $client->read_resource('file:///report', {request_state => $state});\n-  is $retry->{ttlMs},      undef, 'no cache ttl';\n-  is $retry->{cacheScope}, undef, 'no cache scope';\n-};\n-\n-done_testing;\ndiff --git a/t/notifications.t b/t/notifications.t\ndeleted file mode 100644\nindex 47fa698..0000000\n--- a/t/notifications.t\n+++ /dev/null\n@@ -1,113 +0,0 @@\n-use Mojo::Base -strict, -signatures;\n-\n-use Test::More;\n-\n-use Mojolicious::Lite;\n-use Test::Mojo;\n-use MCP::Client;\n-use MCP::Constants qw(META_LOG_LEVEL METHOD_NOT_FOUND);\n-use MCP::Server;\n-use Mojo::IOLoop;\n-use Mojo::Promise;\n-\n-my $server = MCP::Server->new;\n-\n-$server->tool(\n-  name => 'progress',\n-  code => sub ($tool, $args) {\n-    my $sent = $tool->context->notify_progress(1, 2, 'halfway');\n-    return $sent ? 'sent' : 'no token';\n-  }\n-);\n-$server->tool(\n-  name => 'async_progress',\n-  code => sub ($tool, $args) {\n-    my $context = $tool->context;\n-    my $promise = Mojo::Promise->new;\n-    Mojo::IOLoop->timer(\n-      0.1 => sub {\n-        $context->notify_progress(2, 2, 'late');\n-        $promise->resolve('done');\n-      }\n-    );\n-    return $promise;\n-  }\n-);\n-$server->tool(\n-  name => 'log',\n-  code => sub ($tool, $args) {\n-    my $context = $tool->context;\n-    my @sent    = map { $context->notify_log($_, \"$_ message\") ? 1 : 0 } qw(debug warning);\n-    return join ',', @sent;\n-  }\n-);\n-\n-any '/mcp' => $server->to_action;\n-\n-my $t      = Test::Mojo->new;\n-my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp'));\n-\n-my @notifications;\n-$client->on(notification => sub ($client, $notification) { push @notifications, $notification });\n-\n-subtest 'Progress' => sub {\n-  my $request\n-    = $client->build_request('tools/call', {name => 'progress', arguments => {}, _meta => {progressToken => 'tok-1'}});\n-  my $result = $client->send_request($request)->{result};\n-  is $result->{content}[0]{text}, 'sent', 'tool call result';\n-\n-  my $progress = shift @notifications;\n-  is $progress->{jsonrpc},               '2.0',                    'JSON-RPC version';\n-  is $progress->{method},                'notifications/progress', 'notification method';\n-  is $progress->{params}{progressToken}, 'tok-1',                  'progress token echoed';\n-  is $progress->{params}{progress},      1,                        'progress value';\n-  is $progress->{params}{total},         2,                        'total value';\n-  is $progress->{params}{message},       'halfway',                'progress message';\n-  is scalar @notifications,              0,                        'no more notifications';\n-};\n-\n-subtest 'Progress (async)' => sub {\n-  my $request = $client->build_request('tools/call',\n-    {name => 'async_progress', arguments => {}, _meta => {progressToken => 'tok-2'}});\n-  my $result = $client->send_request($request)->{result};\n-  is $result->{content}[0]{text}, 'done', 'tool call result';\n-\n-  my $progress = shift @notifications;\n-  is $progress->{method},                'notifications/progress', 'notification method';\n-  is $progress->{params}{progressToken}, 'tok-2',                  'progress token echoed';\n-  is $progress->{params}{message},       'late',                   'progress message';\n-  is scalar @notifications,              0,                        'no more notifications';\n-};\n-\n-subtest 'Progress (no token)' => sub {\n-  is $client->call_tool('progress')->{content}[0]{text}, 'no token', 'notify_progress declines without a token';\n-  is scalar @notifications,                              0,          'no notifications';\n-};\n-\n-subtest 'Logging' => sub {\n-  my $request\n-    = $client->build_request('tools/call', {name => 'log', arguments => {}, _meta => {META_LOG_LEVEL() => 'info'}});\n-  my $result = $client->send_request($request)->{result};\n-  is $result->{content}[0]{text}, '0,1', 'levels below the minimum are declined';\n-\n-  my $message = shift @notifications;\n-  is $message->{method},        'notifications/message', 'notification method';\n-  is $message->{params}{level}, 'warning',               'notification level';\n-  is $message->{params}{data},  'warning message',       'notification payload';\n-  is scalar @notifications,     0,                       'no more notifications';\n-};\n-\n-subtest 'Logging (no level)' => sub {\n-  is $client->call_tool('log')->{content}[0]{text}, '0,0', 'nothing is logged unless the client asks';\n-  is scalar @notifications,                         0,     'no notifications';\n-};\n-\n-subtest 'Subscriptions without streaming' => sub {\n-  is $server->notify_list_changed('tools'), undef, 'no broadcast without streaming';\n-\n-  my $request  = $client->build_request('subscriptions/listen', {notifications => {toolsListChanged => 1}});\n-  my $response = $client->send_request($request);\n-  is $response->{error}{code}, METHOD_NOT_FOUND, 'method not found';\n-};\n-\n-done_testing;\ndiff --git a/t/schema.t b/t/schema.t\ndeleted file mode 100644\nindex 6d79668..0000000\n--- a/t/schema.t\n+++ /dev/null\n@@ -1,80 +0,0 @@\n-use Mojo::Base -strict, -signatures;\n-\n-use Test::More;\n-use MCP::Tool;\n-use Mojo::JSON qw(false true);\n-\n-subtest 'Default dialect is JSON Schema 2020-12' => sub {\n-  my $tool\n-    = MCP::Tool->new(\n-    input_schema => {type => 'object', properties => {pair => {type => 'array', prefixItems => [{type => 'integer'}]}}}\n-    );\n-\n-  is $tool->validate_input({pair => [23, 'whatever']}), 0, 'prefixItems satisfied';\n-  is $tool->validate_input({pair => ['nope']}),         1, 'prefixItems violated';\n-\n-  my $dependent = MCP::Tool->new(input_schema => {type => 'object', dependentRequired => {a => ['b']}});\n-  is $dependent->validate_input({a => 1, b => 2}), 0, 'dependentRequired satisfied';\n-  is $dependent->validate_input({a => 1}),         1, 'dependentRequired violated';\n-};\n-\n-subtest 'Explicit dialect' => sub {\n-  my $tool = MCP::Tool->new(\n-    input_schema => {\n-      '$schema'  => 'http://json-schema.org/draft-07/schema#',\n-      type       => 'object',\n-      properties => {pair => {type => 'array', prefixItems => [{type => 'integer'}]}}\n-    }\n-  );\n-\n-  is $tool->validate_input({pair => ['nope']}), 0, 'prefixItems is not a draft-07 keyword';\n-};\n-\n-subtest 'Unsupported dialect' => sub {\n-  my $tool = MCP::Tool->new(input_schema => {'$schema' => 'https://json-schema.org/draft/2077-01/schema'});\n-\n-  eval { $tool->validate_input({}) };\n-  like $@, qr/Unsupported JSON Schema dialect/, 'unsupported dialect';\n-};\n-\n-subtest 'References' => sub {\n-  my $local = MCP::Tool->new(input_schema =>\n-      {type => 'object', properties => {msg => {'$ref' => '#/$defs/text'}}, '$defs' => {text => {type => 'string'}}});\n-  is $local->validate_input({msg => 'hello'}), 0, 'local reference resolved';\n-  is $local->validate_input({msg => 23}),      1, 'local reference enforced';\n-\n-  my $remote = MCP::Tool->new(\n-    input_schema => {type => 'object', properties => {msg => {'$ref' => 'https://example.com/schema.json'}}});\n-  is $remote->validate_input({msg => 'hello'}), 1, 'network reference is not dereferenced';\n-};\n-\n-subtest 'Composition depth' => sub {\n-  my $tool = MCP::Tool->new(\n-    max_schema_depth => 3,\n-    input_schema     => {allOf => [{allOf => [{allOf => [{allOf => [{type => 'object'}]}]}]}]}\n-  );\n-\n-  is $tool->validate_input({}), 1, 'schema nested too deeply';\n-};\n-\n-subtest 'Output validation' => sub {\n-  my $tool = MCP::Tool->new(\n-    output_schema => {type => 'object', properties => {count => {type => 'integer'}}, required => ['count']});\n-\n-  is $tool->validate_output({count => 23}),  0, 'structured content is valid';\n-  is $tool->validate_output({count => 'x'}), 1, 'structured content is invalid';\n-\n-  my $result = $tool->structured_result({count => 23});\n-  is $result->{isError}, false, 'not an error';\n-  is_deeply $result->{structuredContent}, {count => 23}, 'structured content';\n-\n-  my $invalid = $tool->structured_result({count => 'x'});\n-  is $invalid->{isError},           true,                                                  'is an error';\n-  is $invalid->{structuredContent}, undef,                                                 'no structured content';\n-  is $invalid->{content}[0]{text},  'Structured content does not match the output schema', 'error message';\n-\n-  my $schemaless = MCP::Tool->new;\n-  is $schemaless->validate_output({whatever => 1}), 0, 'no output schema';\n-};\n-\n-done_testing;\ndiff --git a/t/context_specific_app.t b/t/session_specific_app.t\nsimilarity index 88%\nrename from t/context_specific_app.t\nrename to t/session_specific_app.t\nindex 043c821..65d1ea4 100644\n--- a/t/context_specific_app.t\n+++ b/t/session_specific_app.t\n@@ -23,9 +23,9 @@ $server->tool(\n );\n $server->on(\n   tools => sub ($server, $tools, $context) {\n-    my $role = $context->controller->stash('role');\n+    my $role = $context->{controller}->stash('role');\n     return if $role eq 'admin';\n-    @$tools = grep { $_->name ne 'admin_tool' } @$tools;\n+    @$tools = grep { $_->{name} ne 'admin_tool' } @$tools;\n   }\n );\n \n@@ -43,9 +43,9 @@ $server->prompt(\n );\n $server->on(\n   prompts => sub ($server, $prompts, $context) {\n-    my $role = $context->controller->stash('role');\n+    my $role = $context->{controller}->stash('role');\n     return if $role eq 'admin';\n-    @$prompts = grep { $_->name ne 'admin_prompt' } @$prompts;\n+    @$prompts = grep { $_->{name} ne 'admin_prompt' } @$prompts;\n   }\n );\n \n@@ -63,9 +63,9 @@ $server->resource(\n );\n $server->on(\n   resources => sub ($server, $resources, $context) {\n-    my $role = $context->controller->stash('role');\n+    my $role = $context->{controller}->stash('role');\n     return if $role eq 'admin';\n-    @$resources = grep { $_->uri ne 'file:///admin_resource' } @$resources;\n+    @$resources = grep { $_->{uri} ne 'file:///admin_resource' } @$resources;\n   }\n );\n \n@@ -89,13 +89,13 @@ subtest 'Normal HTTP endpoint' => sub {\n subtest 'MCP endpoint' => sub {\n   subtest 'Admin user' => sub {\n     my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp')->query(role => 'admin'));\n+    $client->initialize_session;\n \n     subtest 'Tools' => sub {\n       my $result = $client->list_tools;\n       is $result->{tools}[0]{name}, 'user_tool',  'user tool present';\n       is $result->{tools}[1]{name}, 'admin_tool', 'admin tool present';\n       is $result->{tools}[2],       undef,        'no more tools';\n-      is $result->{cacheScope},     'private',    'per caller list is private';\n \n       my $user_result = $client->call_tool('user_tool');\n       is $user_result->{content}[0]{text}, 'Hello user!', 'user tool call result';\n@@ -130,6 +130,7 @@ subtest 'MCP endpoint' => sub {\n \n   subtest 'Normal user' => sub {\n     my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp')->query(role => 'user'));\n+    $client->initialize_session;\n \n     subtest 'Tools' => sub {\n       my $result = $client->list_tools;\n@@ -139,7 +140,7 @@ subtest 'MCP endpoint' => sub {\n       my $user_result = $client->call_tool('user_tool');\n       is $user_result->{content}[0]{text}, 'Hello user!', 'user tool call result';\n       eval { $client->call_tool('admin_tool', {}) };\n-      like $@, qr/Error -32602: Tool 'admin_tool' not found/, 'right error';\n+      like $@, qr/Error -32601: Tool 'admin_tool' not found/, 'right error';\n     };\n \n     subtest 'Prompts' => sub {\n@@ -150,7 +151,7 @@ subtest 'MCP endpoint' => sub {\n       my $user_prompt = $client->get_prompt('user_prompt');\n       is $user_prompt->{messages}[0]{content}{text}, 'This is a user prompt', 'user prompt result';\n       eval { $client->get_prompt('admin_prompt') };\n-      like $@, qr/Error -32602: Prompt 'admin_prompt' not found/, 'right error';\n+      like $@, qr/Error -32601: Prompt 'admin_prompt' not found/, 'right error';\n     };\n \n     subtest 'Resources' => sub {\n@@ -161,7 +162,7 @@ subtest 'MCP endpoint' => sub {\n       my $user_resource = $client->read_resource('file:///user_resource');\n       is $user_resource->{contents}[0]{text}, 'User resource content', 'user resource result';\n       eval { $client->read_resource('file:///admin_resource') };\n-      like $@, qr{Error -32602: Resource 'file:///admin_resource' not found}, 'right error';\n+      like $@, qr/Error -32002: Resource not found/, 'right error';\n     };\n   };\n };\ndiff --git a/t/stdio.t b/t/stdio.t\nindex 8c5f368..33809cd 100644\n--- a/t/stdio.t\n+++ b/t/stdio.t\n@@ -6,191 +6,62 @@ BEGIN {\n   plan skip_all => 'set TEST_STDIO to enable this test (developer only!)' unless $ENV{TEST_STDIO} || $ENV{TEST_ALL};\n }\n \n-use MCP::Constants qw(INTERNAL_ERROR META_LOG_LEVEL META_SERVER_INFO META_SUBSCRIPTION_ID PROTOCOL_VERSION);\n+use MCP::Constants qw(PROTOCOL_VERSION);\n use Mojo::File     qw(curfile);\n-use Mojo::JSON     qw(false true);\n+use Mojo::JSON     qw(false);\n use lib curfile->dirname->child('lib')->to_string;\n use MCPStdioTest;\n \n my $test = MCPStdioTest->new;\n $test->run($^X, curfile->dirname->child('apps', 'stdio.pl')->to_string);\n \n-subtest 'Discovery' => sub {\n-  my $res = $test->request('server/discover', {});\n-  is $res->{jsonrpc}, '2.0', 'JSON-RPC version';\n-  is $res->{id},      1,     'request id';\n-  is_deeply $res->{result}{supportedVersions}, [PROTOCOL_VERSION], 'supported versions';\n-  is_deeply $res->{result}{capabilities}{extensions}, {}, 'no extensions';\n-  is $res->{result}{capabilities}{tools}{listChanged},  true,         'tools listChanged';\n-  is $res->{result}{capabilities}{prompts},             undef,        'no prompts capability';\n-  is $res->{result}{capabilities}{resources},           undef,        'no resources capability';\n-  is $res->{result}{instructions},                      undef,        'no instructions';\n-  is $res->{result}{resultType},                        'complete',   'result type';\n-  is $res->{result}{cacheScope},                        'public',     'cache scope';\n-  is $res->{result}{ttlMs},                             0,            'cache ttl';\n-  is $res->{result}{_meta}{+META_SERVER_INFO}{name},    'PerlServer', 'server name';\n-  is $res->{result}{_meta}{+META_SERVER_INFO}{version}, '1.0.0',      'server version';\n-};\n-\n-subtest 'Unsupported protocol version' => sub {\n-  my $req = $test->client->build_request('tools/list', {});\n-  $req->{params}{_meta}{'io.modelcontextprotocol/protocolVersion'} = '2025-11-25';\n-  my $res = $test->send($req);\n-  is $res->{id},                      2,           'request id';\n-  is $res->{error}{code},            -32022,       'error code';\n-  is $res->{error}{data}{requested}, '2025-11-25', 'requested version';\n-  is_deeply $res->{error}{data}{supported}, [PROTOCOL_VERSION], 'supported versions';\n-};\n+subtest 'Initialization' => sub {\n+  my $res = $test->request(initialize =>\n+      {capabilities => {}, clientInfo => {name => 'mojo-mcp', version => '1.0.0'}, protocolVersion => '2025-06-18'});\n+  is $res->{jsonrpc},                     '2.0',            'JSON-RPC version';\n+  is $res->{id},                          1,                'request id';\n+  is $res->{result}{protocolVersion},     PROTOCOL_VERSION, 'protocol version';\n+  is $res->{result}{serverInfo}{name},    'PerlServer',     'server name';\n+  is $res->{result}{serverInfo}{version}, '1.0.0',          'server version';\n+  ok $res->{result}{capabilities}, 'has capabilities';\n \n-subtest 'Missing client capabilities' => sub {\n-  my $req = $test->client->build_request('tools/list', {});\n-  delete $req->{params}{_meta}{'io.modelcontextprotocol/clientCapabilities'};\n-  my $res = $test->send($req);\n-  is $res->{id},           3,     'request id';\n-  is $res->{error}{code}, -32602, 'error code';\n-};\n-\n-subtest 'Unknown method' => sub {\n-  my $res = $test->request('ping', {});\n-  is $res->{id},           4,     'request id';\n-  is $res->{error}{code}, -32601, 'error code';\n+  ok $test->notify('notifications/initialized', {}), 'initialized';\n };\n \n subtest 'List tools' => sub {\n   my $res = $test->request('tools/list', {});\n   is $res->{jsonrpc},                             '2.0',                 'JSON-RPC version';\n-  is $res->{id},                                  5,                     'request id';\n+  is $res->{id},                                  2,                     'request id';\n   is $res->{result}{tools}[0]{name},              'echo',                'tool name';\n   is $res->{result}{tools}[0]{description},       'Echo the input text', 'tool description';\n   is $res->{result}{tools}[0]{inputSchema}{type}, 'object',              'input schema type';\n-  is $res->{result}{cacheScope},                  'private',             'cache scope';\n-  is $res->{result}{ttlMs},                       0,                     'cache ttl';\n \n-  ok $test->notify('notifications/cancelled', {requestId => 5, reason => 'AbortError: This operation was aborted'}),\n+  ok $test->notify('notifications/cancelled', {requestId => 2, reason => 'AbortError: This operation was aborted'}),\n     'cancelled';\n };\n \n subtest 'Tool call' => sub {\n   my $res = $test->request('tools/call', {name => 'echo', arguments => {msg => 'hello mojo'}});\n   is $res->{jsonrpc}, '2.0', 'JSON-RPC version';\n-  is $res->{id},      6,     'request id';\n-  is_deeply $res->{result}{content}, [{text => 'Echo: hello mojo', type => 'text'}], 'tool call result';\n-  is $res->{result}{isError},    false,      'not an error';\n-  is $res->{result}{resultType}, 'complete', 'result type';\n-  is_deeply $res->{result}{_meta}{+META_SERVER_INFO}, {name => 'PerlServer', version => '1.0.0'}, 'server info';\n-};\n-\n-subtest 'Unknown tool' => sub {\n-  my $res = $test->request('tools/call', {name => 'nope', arguments => {}});\n-  is $res->{id},              7,                      'request id';\n-  is $res->{error}{code},    -32602,                  'error code';\n-  is $res->{error}{message}, \"Tool 'nope' not found\", 'error message';\n+  is $res->{id},      3,     'request id';\n+  is_deeply $res->{result}, {content => [{text => 'Echo: hello mojo', type => 'text'}], isError => false},\n+    'tool call result';\n };\n \n subtest 'Tool call (async)' => sub {\n   my $res = $test->request('tools/call', {name => 'echo_async', arguments => {msg => 'hello mojo'}});\n   is $res->{jsonrpc}, '2.0', 'JSON-RPC version';\n-  is $res->{id},      8,     'request id';\n-  is_deeply $res->{result}{content}, [{text => 'Echo (async): hello mojo', type => 'text'}], 'tool call result';\n+  is $res->{id},      4,     'request id';\n+  is_deeply $res->{result}, {content => [{text => 'Echo (async): hello mojo', type => 'text'}], isError => false},\n+    'tool call result';\n };\n \n subtest 'Unicode' => sub {\n   my $res = $test->request('tools/call', {name => 'echo', arguments => {msg => 'i ♥ mcp'}});\n   is $res->{jsonrpc}, '2.0', 'JSON-RPC version';\n-  is $res->{id},      9,     'request id';\n-  is_deeply $res->{result}{content}, [{text => 'Echo: i ♥ mcp', type => 'text'}], 'tool call result';\n-};\n-\n-subtest 'Tool call (log level not requested)' => sub {\n-  my $res = $test->request('tools/call', {name => 'echo_log', arguments => {msg => 'hi'}});\n-  is $res->{id}, 10, 'request id';\n-  is_deeply $res->{result}{content}, [{text => 'Echo: hi', type => 'text'}], 'tool call result';\n-};\n-\n-subtest 'Tool call (with notification)' => sub {\n-  $test->send_request('tools/call',\n-    {name => 'echo_log', arguments => {msg => 'hi'}, _meta => {META_LOG_LEVEL() => 'info'}});\n-  my $notif = $test->read_line;\n-  is $notif->{jsonrpc},       '2.0',                   'JSON-RPC version';\n-  is $notif->{id},            undef,                   'no request id';\n-  is $notif->{method},        'notifications/message', 'notification method';\n-  is $notif->{params}{level}, 'info',                  'notification level';\n-  is $notif->{params}{data},  'hi',                    'notification payload';\n-  my $res = $test->read_line;\n-  is $res->{jsonrpc}, '2.0', 'JSON-RPC version';\n-  is $res->{id},      11,    'request id';\n-  is_deeply $res->{result}{content}, [{text => 'Echo: hi', type => 'text'}], 'tool call result';\n-};\n-\n-subtest 'Subscription' => sub {\n-  $test->send_request('subscriptions/listen',\n-    {notifications => {promptsListChanged => true, toolsListChanged => true}});\n-  my $ack = $test->read_line;\n-  is $ack->{jsonrpc},                              '2.0',                                      'JSON-RPC version';\n-  is $ack->{method},                               'notifications/subscriptions/acknowledged', 'notification method';\n-  is $ack->{params}{_meta}{+META_SUBSCRIPTION_ID}, 12,                                         'subscription id';\n-  is_deeply $ack->{params}{notifications}, {promptsListChanged => true, toolsListChanged => true}, 'honoured filter';\n-\n-  $test->send_request('tools/call', {name => 'reload', arguments => {}});\n-  my $notif = $test->read_line;\n-  is $notif->{id},                                   undef,                              'no request id';\n-  is $notif->{method},                               'notifications/tools/list_changed', 'notification method';\n-  is $notif->{params}{_meta}{+META_SUBSCRIPTION_ID}, 12,                                 'subscription id';\n-  my $res = $test->read_line;\n-  is $res->{id}, 13, 'request id';\n-  is_deeply $res->{result}{content}, [{text => 'reloaded', type => 'text'}], 'tool call result';\n-};\n-\n-subtest 'Subscription (cancelled)' => sub {\n-  ok $test->notify('notifications/cancelled', {requestId => 12}), 'cancelled';\n-  my $res = $test->request('tools/call', {name => 'reload', arguments => {}});\n-  is $res->{id}, 14, 'request id';\n-  is_deeply $res->{result}{content}, [{text => 'reloaded', type => 'text'}], 'tool call result';\n-};\n-\n-subtest 'Tool call (with progress)' => sub {\n-  $test->send_request('tools/call',\n-    {name => 'echo_progress', arguments => {msg => 'hi'}, _meta => {progressToken => 'p1'}});\n-  my $notif = $test->read_line;\n-  is $notif->{jsonrpc},               '2.0',                    'JSON-RPC version';\n-  is $notif->{id},                    undef,                    'no request id';\n-  is $notif->{method},                'notifications/progress', 'notification method';\n-  is $notif->{params}{progressToken}, 'p1',                     'progress token echoed';\n-  is $notif->{params}{progress},      0.5,                      'progress value';\n-  is $notif->{params}{total},         1,                        'total value';\n-  is $notif->{params}{message},       'half',                   'progress message';\n-  my $res = $test->read_line;\n-  is $res->{jsonrpc}, '2.0', 'JSON-RPC version';\n-  is $res->{id},      15,    'request id';\n-  is_deeply $res->{result}{content}, [{text => 'Echo: hi', type => 'text'}], 'tool call result';\n-};\n-\n-subtest 'Scoped tool (no scope enforcement over stdio)' => sub {\n-  my $res = $test->request('tools/call', {name => 'echo_scoped', arguments => {msg => 'hi'}});\n-  is $res->{jsonrpc}, '2.0', 'JSON-RPC version';\n-  is $res->{id},      16,    'request id';\n-  is_deeply $res->{result}{content}, [{text => 'Echo: hi', type => 'text'}], 'tool call result';\n-};\n-\n-subtest 'Tool call (CRLF line endings)' => sub {\n-  $test->send_request_crlf('tools/call', {name => 'echo', arguments => {msg => 'hello mojo'}});\n-  my $res = $test->read_line;\n-  is $res->{jsonrpc}, '2.0', 'JSON-RPC version';\n-  is $res->{id},      17,    'request id';\n-  is_deeply $res->{result}{content}, [{text => 'Echo: hello mojo', type => 'text'}], 'tool call result';\n-};\n-\n-subtest 'Tool call (exception)' => sub {\n-  my $res = $test->request('tools/call', {name => 'boom', arguments => {}});\n-  is $res->{jsonrpc},        '2.0',            'JSON-RPC version';\n-  is $res->{id},             18,               'request id';\n-  is $res->{error}{code},    INTERNAL_ERROR,   'internal error';\n-  is $res->{error}{message}, 'Internal error', 'error message';\n-  unlike $res->{error}{message}, qr/kaboom/, 'exception is not leaked';\n-\n-  $res = $test->request('tools/call', {name => 'echo', arguments => {msg => 'hi'}});\n-  is $res->{id}, 19, 'request id';\n-  is_deeply $res->{result}{content}, [{text => 'Echo: hi', type => 'text'}], 'server survived';\n+  is $res->{id},      5,     'request id';\n+  is_deeply $res->{result}, {content => [{text => 'Echo: i ♥ mcp', type => 'text'}], isError => false},\n+    'tool call result';\n };\n \n ok $test->stop, 'process stopped';\ndiff --git a/t/streaming.t b/t/streaming.t\ndeleted file mode 100644\nindex a1fb077..0000000\n--- a/t/streaming.t\n+++ /dev/null\n@@ -1,120 +0,0 @@\n-use Mojo::Base -strict, -signatures;\n-\n-use Test::More;\n-\n-use Mojolicious::Lite;\n-use Test::Mojo;\n-use MCP::Client;\n-use MCP::Constants qw(META_SUBSCRIPTION_ID PROTOCOL_VERSION);\n-use MCP::Server;\n-use Mojo::IOLoop;\n-use Mojo::JSON qw(true);\n-use Mojo::Promise;\n-\n-my $server = MCP::Server->new;\n-\n-$server->tool(\n-  name => 'echo',\n-  code => sub ($tool, $args) {\n-    return 'echo';\n-  }\n-);\n-$server->prompt(\n-  name => 'plan',\n-  code => sub ($prompt, $args) {\n-    return 'plan';\n-  }\n-);\n-$server->resource(\n-  uri  => 'file:///report',\n-  code => sub ($resource) {\n-    return 'report';\n-  }\n-);\n-\n-any '/mcp' => $server->to_action({streaming => 1, heartbeat => 0});\n-\n-my $t      = Test::Mojo->new;\n-my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp'));\n-\n-sub settle ($delay = 0.1) {\n-  my $tick = Mojo::Promise->new;\n-  Mojo::IOLoop->timer($delay => sub { $tick->resolve });\n-  return $tick->wait;\n-}\n-\n-subtest 'Discovery' => sub {\n-  my $caps = $client->discover->{capabilities};\n-  is $caps->{prompts}{listChanged},   true, 'prompts listChanged advertised';\n-  is $caps->{resources}{listChanged}, true, 'resources listChanged advertised';\n-  is $caps->{tools}{listChanged},     true, 'tools listChanged advertised';\n-};\n-\n-subtest 'Other methods' => sub {\n-  $t->get_ok('/mcp')->status_is(405)->json_is('/error' => 'Method not allowed');\n-  $t->delete_ok('/mcp')->status_is(405)->json_is('/error' => 'Method not allowed');\n-};\n-\n-subtest 'List changed' => sub {\n-  my @messages;\n-  ok $client->listen(\n-    {promptsListChanged => true, resourceSubscriptions => true, toolsListChanged => true},\n-    sub ($message) {\n-      push @messages, $message;\n-      if (@messages == 1) {\n-        $server->notify_list_changed('resources');\n-        $server->notify_list_changed('prompts');\n-      }\n-      return @messages < 2;\n-    }\n-    ),\n-    'stream closed by callback';\n-\n-  my $ack = $messages[0];\n-  is $ack->{jsonrpc}, '2.0',                                      'JSON-RPC version';\n-  is $ack->{method},  'notifications/subscriptions/acknowledged', 'acknowledged first';\n-  is_deeply $ack->{params}{notifications}, {promptsListChanged => true, toolsListChanged => true},\n-    'unsupported notification types dropped';\n-  ok my $id = $ack->{params}{_meta}{+META_SUBSCRIPTION_ID}, 'subscription id';\n-\n-  my $changed = $messages[1];\n-  is $changed->{method}, 'notifications/prompts/list_changed', 'unsubscribed type never delivered';\n-  is $changed->{params}{_meta}{+META_SUBSCRIPTION_ID}, $id,    'subscription id';\n-  is scalar @messages,                                 2,      'no more messages';\n-};\n-\n-subtest 'List changed (no subscriptions)' => sub {\n-  settle;\n-  is_deeply $server->transport->subscriptions, {}, 'subscription removed on disconnect';\n-  ok $server->notify_list_changed('tools'), 'broadcast attempted';\n-};\n-\n-subtest 'Heartbeat' => sub {\n-  my $transport = $server->transport;\n-  $transport->heartbeat(0.1);\n-\n-  my $request = $client->build_request('subscriptions/listen', {notifications => {toolsListChanged => true}});\n-  my $headers = {\n-    Accept                 => 'text/event-stream',\n-    'MCP-Protocol-Version' => PROTOCOL_VERSION,\n-    'Mcp-Method'           => 'subscriptions/listen'\n-  };\n-  my $tx = $t->ua->build_tx(POST => $t->ua->server->url->path('/mcp') => $headers => json => $request);\n-  $t->ua->start_p($tx)->catch(sub { });\n-  Mojo::IOLoop->one_tick until $tx->res->code || $tx->error;\n-  is $tx->res->code,                  200,                 'stream open';\n-  is $tx->res->headers->content_type, 'text/event-stream', 'right content type';\n-\n-  # SSE parser strips comments\n-  my $bytes = '';\n-  Mojo::IOLoop->stream($tx->connection)->on(read => sub ($stream, $chunk) { $bytes .= $chunk });\n-  settle 0.5;\n-  like $bytes, qr/: keepalive/, 'heartbeat sent';\n-\n-  Mojo::IOLoop->remove($tx->connection);\n-  $transport->heartbeat(0);\n-  settle;\n-  is_deeply $transport->subscriptions, {}, 'subscription removed on disconnect';\n-};\n-\n-done_testing;\ndiff --git a/t/tool_validation.t b/t/tool_validation.t\ndeleted file mode 100644\nindex 0351446..0000000\n--- a/t/tool_validation.t\n+++ /dev/null\n@@ -1,31 +0,0 @@\n-use Mojo::Base -strict, -signatures;\n-\n-use Test::More;\n-use MCP::Tool;\n-use Mojo::JSON qw(false);\n-\n-subtest 'Default input schema' => sub {\n-  my $tool = MCP::Tool->new;\n-\n-  is $tool->validate_input({}),    0, 'empty object is valid';\n-  is $tool->validate_input(undef), 1, 'undefined arguments are invalid';\n-};\n-\n-subtest 'Required properties' => sub {\n-  my $tool = MCP::Tool->new(\n-    input_schema => {type => 'object', properties => {msg => {type => 'string'}}, required => ['msg']});\n-\n-  is $tool->validate_input({msg => 'hello'}), 0, 'required string is valid';\n-  is $tool->validate_input({}),               1, 'missing required property is invalid';\n-  is $tool->validate_input({msg => 23}),      1, 'wrong property type is invalid';\n-};\n-\n-subtest 'Additional properties' => sub {\n-  my $tool = MCP::Tool->new(\n-    input_schema => {type => 'object', properties => {msg => {type => 'string'}}, additionalProperties => false});\n-\n-  is $tool->validate_input({msg => 'hello'}),             0, 'known property is valid';\n-  is $tool->validate_input({msg => 'hello', extra => 1}), 1, 'unknown property is invalid';\n-};\n-\n-done_testing;\n","remote_name":"debian/latest","revision":"git-v1:66f2fee702eaa19a544056e3c13acbc2211c1d4a","role":"main"}],"result_code":"success","result_tags":[],"revision":"git-v1:66f2fee702eaa19a544056e3c13acbc2211c1d4a","start_time":"2026-11-08T04:34:53.912949Z","subpath":"","suite":"control","target_branch_url":null,"vcs_type":"git","worker":"tyr"},"run_id":"e58403b7-06ba-44e7-95b0-9cf87ec1f08f","success_probability":1.0,"suite":"control","total_previous_runs":1,"utcnow":"2026-11-09T03:14:52.241398189+00:00","vcs_type":"git","vcs_url":"git+ssh://git@salsa.debian.org/perl-team/modules/packages/libmcp-perl.git","version":"0.0.0","worker_log_name":"worker.log","worker_name":"tyr"}