diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 0000000..bb80816 --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,58 @@ +name: Ruby + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + name: Ruby ${{ matrix.ruby }} + strategy: + matrix: + ruby: ['3.1.3', '3.2', '3.3', '3.4'] + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.16 + + - name: Install dependencies for typescript-mcp && pagination-server + run: | + cd spec/fixtures/typescript-mcp + bun install + cd ../pagination-server + bun install + cd ../../.. + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Install dependencies for gem and fast-mcp + run: | + bundle install + cd spec/fixtures/fast-mcp-ruby + bundle install + cd ../../.. + + + - name: Generate random ports + id: portgen + run: | + echo "PORT1=$(( (RANDOM % 1000) + 3000 ))" >> $GITHUB_ENV + echo "PORT2=$(( (RANDOM % 1000) + 4000 ))" >> $GITHUB_ENV + echo "PORT3=$(( (RANDOM % 1000) + 5000 ))" >> $GITHUB_ENV + + - name: Run the test suite + run: PORT1=$PORT1 PORT2=$PORT2 PORT3=$PORT3 bundle exec rake + env: + PORT1: ${{ env.PORT1 }} + PORT2: ${{ env.PORT2 }} + PORT3: ${{ env.PORT3 }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index e906bec..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Ruby - -on: - push: - branches: - - main - pull_request: - -jobs: - test: - runs-on: ubuntu-latest - name: Ruby ${{ matrix.ruby }} - strategy: - matrix: - ruby: ['3.1.3', '3.2', '3.3', '3.4'] - - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.2.16 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - - run: bundle install - - name: Run the default task - run: bundle exec rake diff --git a/README.md b/README.md index cc52efc..b9108e5 100644 --- a/README.md +++ b/README.md @@ -229,23 +229,6 @@ content = log_template.to_content(arguments: { puts content ``` -#### Resource Argument Completion - -For resource templates, you can get suggested values for arguments: - -```ruby -template = client.resource_template("user_profile") - -# Search for possible values for a specific argument -suggestions = template.complete("username", "john") -puts "Suggested usernames:" -suggestions.values.each do |value| - puts "- #{value}" -end -puts "Total matches: #{suggestions.total}" -puts "Has more: #{suggestions.has_more}" -``` - ### Working with Prompts MCP servers can provide predefined prompts that can be used in conversations: @@ -308,7 +291,7 @@ response = chat.ask("Please review the recent commits using the checklist and su puts response ``` -## Argument Completion +### Argument Completion Some MCP servers support argument completion for prompts and resource templates: @@ -325,7 +308,13 @@ puts "Total matches: #{suggestions.total}" puts "Has more results: #{suggestions.has_more}" ``` -## Additional Chat Methods +### Pagination + +MCP servers can support pagination for their lists. The client will automatically paginate the lists to include all items from the list you wanted to pull. + +Pagination is supported for tools, resources, prompts, and resource templates. + +### Additional Chat Methods The gem extends RubyLLM's chat interface with convenient methods for MCP integration: diff --git a/examples/tools/streamable_mcp.rb b/examples/tools/streamable_mcp.rb index 2e9fa4a..bb09f49 100644 --- a/examples/tools/streamable_mcp.rb +++ b/examples/tools/streamable_mcp.rb @@ -16,7 +16,7 @@ name: "streamable_mcp", transport_type: :streamable, config: { - url: "http://localhost:3005/mcp" + url: "http://localhost:#{ENV.fetch('PORT1', 3005)}/mcp" } ) diff --git a/lib/ruby_llm/mcp/coordinator.rb b/lib/ruby_llm/mcp/coordinator.rb index c74079b..474becc 100644 --- a/lib/ruby_llm/mcp/coordinator.rb +++ b/lib/ruby_llm/mcp/coordinator.rb @@ -51,7 +51,7 @@ def start_transport @transport.set_protocol_version(@protocol_version) end - @capabilities = RubyLLM::MCP::Capabilities.new(initialize_response.value["capabilities"]) + @capabilities = RubyLLM::MCP::ServerCapabilities.new(initialize_response.value["capabilities"]) initialize_notification end @@ -98,11 +98,15 @@ def initialize_request RubyLLM::MCP::Requests::Initialization.new(self).call end - def tool_list - result = RubyLLM::MCP::Requests::ToolList.new(self).call + def tool_list(cursor: nil) + result = RubyLLM::MCP::Requests::ToolList.new(self, cursor: cursor).call result.raise_error! if result.error? - result.value["tools"] + if result.next_cursor? + result.value["tools"] + tool_list(cursor: result.next_cursor) + else + result.value["tools"] + end end def execute_tool(**args) @@ -125,33 +129,45 @@ def execute_tool(**args) RubyLLM::MCP::Requests::ToolCall.new(self, **args).call end - def resource_list - result = RubyLLM::MCP::Requests::ResourceList.new(self).call + def resource_list(cursor: nil) + result = RubyLLM::MCP::Requests::ResourceList.new(self, cursor: cursor).call result.raise_error! if result.error? - result.value["resources"] + if result.next_cursor? + result.value["resources"] + resource_list(cursor: result.next_cursor) + else + result.value["resources"] + end end def resource_read(**args) RubyLLM::MCP::Requests::ResourceRead.new(self, **args).call end - def resource_template_list - result = RubyLLM::MCP::Requests::ResourceTemplateList.new(self).call + def resource_template_list(cursor: nil) + result = RubyLLM::MCP::Requests::ResourceTemplateList.new(self, cursor: cursor).call result.raise_error! if result.error? - result.value["resourceTemplates"] + if result.next_cursor? + result.value["resourceTemplates"] + resource_template_list(cursor: result.next_cursor) + else + result.value["resourceTemplates"] + end end def resources_subscribe(**args) RubyLLM::MCP::Requests::ResourcesSubscribe.new(self, **args).call end - def prompt_list - result = RubyLLM::MCP::Requests::PromptList.new(self).call + def prompt_list(cursor: nil) + result = RubyLLM::MCP::Requests::PromptList.new(self, cursor: cursor).call result.raise_error! if result.error? - result.value["prompts"] + if result.next_cursor? + result.value["prompts"] + prompt_list(cursor: result.next_cursor) + else + result.value["prompts"] + end end def execute_prompt(**args) diff --git a/lib/ruby_llm/mcp/requests/base.rb b/lib/ruby_llm/mcp/requests/base.rb deleted file mode 100644 index d71e737..0000000 --- a/lib/ruby_llm/mcp/requests/base.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require "json" - -module RubyLLM - module MCP - module Requests - class Base - attr_reader :coordinator - - def initialize(coordinator) - @coordinator = coordinator - end - - def call - raise "Not implemented" - end - - private - - def validate_response!(response, body) - # TODO: Implement response validation - end - - def raise_error(error) - raise "MCP Error: code: #{error['code']} message: #{error['message']} data: #{error['data']}" - end - end - end - end -end diff --git a/lib/ruby_llm/mcp/requests/initialization.rb b/lib/ruby_llm/mcp/requests/initialization.rb index b047fcf..ed99e72 100644 --- a/lib/ruby_llm/mcp/requests/initialization.rb +++ b/lib/ruby_llm/mcp/requests/initialization.rb @@ -3,9 +3,13 @@ module RubyLLM module MCP module Requests - class Initialization < RubyLLM::MCP::Requests::Base + class Initialization + def initialize(coordinator) + @coordinator = coordinator + end + def call - coordinator.request(initialize_body) + @coordinator.request(initialize_body) end private @@ -15,8 +19,8 @@ def initialize_body jsonrpc: "2.0", method: "initialize", params: { - protocolVersion: coordinator.protocol_version, - capabilities: coordinator.client_capabilities, + protocolVersion: @coordinator.protocol_version, + capabilities: @coordinator.client_capabilities, clientInfo: { name: "RubyLLM-MCP Client", version: RubyLLM::MCP::VERSION diff --git a/lib/ruby_llm/mcp/requests/meta.rb b/lib/ruby_llm/mcp/requests/meta.rb deleted file mode 100644 index 9cd79e9..0000000 --- a/lib/ruby_llm/mcp/requests/meta.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require "securerandom" - -module RubyLLM - module MCP - module Requests - module Meta - def merge_meta(body) - meta = {} - meta.merge!(progress_token) if @coordinator.client.tracking_progress? - - body[:params] ||= {} - body[:params].merge!({ _meta: meta }) unless meta.empty? - body - end - - private - - def progress_token - { progressToken: generate_progress_token } - end - - def generate_progress_token - SecureRandom.uuid - end - end - end - end -end diff --git a/lib/ruby_llm/mcp/requests/ping.rb b/lib/ruby_llm/mcp/requests/ping.rb index 146fe1f..cbeafde 100644 --- a/lib/ruby_llm/mcp/requests/ping.rb +++ b/lib/ruby_llm/mcp/requests/ping.rb @@ -3,9 +3,13 @@ module RubyLLM module MCP module Requests - class Ping < Base + class Ping + def initialize(coordinator) + @coordinator = coordinator + end + def call - coordinator.request(ping_body) + @coordinator.request(ping_body) end def ping_body diff --git a/lib/ruby_llm/mcp/requests/prompt_list.rb b/lib/ruby_llm/mcp/requests/prompt_list.rb index 28f08e5..a454014 100644 --- a/lib/ruby_llm/mcp/requests/prompt_list.rb +++ b/lib/ruby_llm/mcp/requests/prompt_list.rb @@ -3,9 +3,17 @@ module RubyLLM module MCP module Requests - class PromptList < Base + class PromptList + include Shared::Pagination + + def initialize(coordinator, cursor: nil) + @coordinator = coordinator + @cursor = cursor + end + def call - coordinator.request(request_body) + body = merge_pagination(request_body) + @coordinator.request(body) end private diff --git a/lib/ruby_llm/mcp/requests/resource_list.rb b/lib/ruby_llm/mcp/requests/resource_list.rb index a469c6e..e9908cd 100644 --- a/lib/ruby_llm/mcp/requests/resource_list.rb +++ b/lib/ruby_llm/mcp/requests/resource_list.rb @@ -3,11 +3,21 @@ module RubyLLM module MCP module Requests - class ResourceList < Base + class ResourceList + include Shared::Pagination + + def initialize(coordinator, cursor: nil) + @coordinator = coordinator + @cursor = cursor + end + def call - coordinator.request(resource_list_body) + body = merge_pagination(resource_list_body) + @coordinator.request(body) end + private + def resource_list_body { jsonrpc: "2.0", diff --git a/lib/ruby_llm/mcp/requests/resource_template_list.rb b/lib/ruby_llm/mcp/requests/resource_template_list.rb index 75c77fd..720367e 100644 --- a/lib/ruby_llm/mcp/requests/resource_template_list.rb +++ b/lib/ruby_llm/mcp/requests/resource_template_list.rb @@ -3,11 +3,21 @@ module RubyLLM module MCP module Requests - class ResourceTemplateList < Base + class ResourceTemplateList + include Shared::Pagination + + def initialize(coordinator, cursor: nil) + @coordinator = coordinator + @cursor = cursor + end + def call - coordinator.request(resource_template_list_body) + body = merge_pagination(resource_template_list_body) + @coordinator.request(body) end + private + def resource_template_list_body { jsonrpc: "2.0", diff --git a/lib/ruby_llm/mcp/requests/shared/meta.rb b/lib/ruby_llm/mcp/requests/shared/meta.rb new file mode 100644 index 0000000..2202c9e --- /dev/null +++ b/lib/ruby_llm/mcp/requests/shared/meta.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "securerandom" + +module RubyLLM + module MCP + module Requests + module Shared + module Meta + def merge_meta(body) + meta = {} + meta.merge!(progress_token) if @coordinator.client.tracking_progress? + + body[:params] ||= {} + body[:params].merge!({ _meta: meta }) unless meta.empty? + body + end + + private + + def progress_token + { progressToken: generate_progress_token } + end + + def generate_progress_token + SecureRandom.uuid + end + end + end + end + end +end diff --git a/lib/ruby_llm/mcp/requests/shared/pagination.rb b/lib/ruby_llm/mcp/requests/shared/pagination.rb new file mode 100644 index 0000000..27b3767 --- /dev/null +++ b/lib/ruby_llm/mcp/requests/shared/pagination.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RubyLLM + module MCP + module Requests + module Shared + module Pagination + def merge_pagination(body) + body[:params] ||= {} + body[:params].merge!({ cursor: @cursor }) if @cursor + body + end + end + end + end + end +end diff --git a/lib/ruby_llm/mcp/requests/tool_call.rb b/lib/ruby_llm/mcp/requests/tool_call.rb index 343320a..7585044 100644 --- a/lib/ruby_llm/mcp/requests/tool_call.rb +++ b/lib/ruby_llm/mcp/requests/tool_call.rb @@ -4,7 +4,7 @@ module RubyLLM module MCP module Requests class ToolCall - include Meta + include Shared::Meta def initialize(coordinator, name:, parameters: {}) @coordinator = coordinator diff --git a/lib/ruby_llm/mcp/requests/tool_list.rb b/lib/ruby_llm/mcp/requests/tool_list.rb index e09f7cd..9e75cd0 100644 --- a/lib/ruby_llm/mcp/requests/tool_list.rb +++ b/lib/ruby_llm/mcp/requests/tool_list.rb @@ -3,9 +3,17 @@ module RubyLLM module MCP module Requests - class ToolList < RubyLLM::MCP::Requests::Base + class ToolList + include Shared::Pagination + + def initialize(coordinator, cursor: nil) + @coordinator = coordinator + @cursor = cursor + end + def call - coordinator.request(tool_list_body) + body = merge_pagination(tool_list_body) + @coordinator.request(body) end private diff --git a/lib/ruby_llm/mcp/result.rb b/lib/ruby_llm/mcp/result.rb index 16e1998..98701d4 100644 --- a/lib/ruby_llm/mcp/result.rb +++ b/lib/ruby_llm/mcp/result.rb @@ -12,7 +12,7 @@ def initialize(response) end class Result - attr_reader :result, :error, :params, :id, :response, :session_id, :method + attr_reader :response, :session_id, :id, :method, :result, :params, :error, :next_cursor REQUEST_METHODS = { ping: "ping", @@ -30,6 +30,7 @@ def initialize(response, session_id: nil) @error = response["error"] || {} @result_is_error = response.dig("result", "isError") || false + @next_cursor = response.dig("result", "nextCursor") end REQUEST_METHODS.each do |method_name, method_value| @@ -66,6 +67,10 @@ def notification? @method&.include?("notifications") || false end + def next_cursor? + !@next_cursor.nil? + end + def request? @method && !notification? && @result.none? && @error.none? end diff --git a/lib/ruby_llm/mcp/capabilities.rb b/lib/ruby_llm/mcp/server_capabilities.rb similarity index 97% rename from lib/ruby_llm/mcp/capabilities.rb rename to lib/ruby_llm/mcp/server_capabilities.rb index d5272e6..23f49d2 100644 --- a/lib/ruby_llm/mcp/capabilities.rb +++ b/lib/ruby_llm/mcp/server_capabilities.rb @@ -2,7 +2,7 @@ module RubyLLM module MCP - class Capabilities + class ServerCapabilities attr_accessor :capabilities def initialize(capabilities = {}) diff --git a/spec/fixtures/fast-mcp-ruby/lib/app.rb b/spec/fixtures/fast-mcp-ruby/lib/app.rb index 4472bd4..5d56cda 100644 --- a/spec/fixtures/fast-mcp-ruby/lib/app.rb +++ b/spec/fixtures/fast-mcp-ruby/lib/app.rb @@ -9,6 +9,7 @@ require "rack/handler/puma" is_silent = ARGV.include?("--silent") +port = ENV.fetch("PORT2", 3006) class NullWriter def write(*args) @@ -107,10 +108,10 @@ def content unless is_silent # Run the Rack application with Puma - puts "Starting Rack application with MCP middleware on http://localhost:3006" + puts "Starting Rack application with MCP middleware on http://localhost:#{port}" puts "MCP endpoints:" - puts " - http://localhost:3006/mcp/sse (SSE endpoint)" - puts " - http://localhost:3006/mcp/messages (JSON-RPC endpoint)" + puts " - http://localhost:#{port}/mcp/sse (SSE endpoint)" + puts " - http://localhost:#{port}/mcp/messages (JSON-RPC endpoint)" puts "Press Ctrl+C to stop" end @@ -124,7 +125,7 @@ def content log_writer = is_silent ? Puma::LogWriter.new(NullWriter.new, NullWriter.new) : Puma::LogWriter.stdio config = Puma::Configuration.new(log_writer: log_writer) do |user_config| - user_config.bind "tcp://localhost:3006" + user_config.bind "tcp://127.0.0.1:#{port}" user_config.app app end diff --git a/spec/fixtures/pagination-server/.gitignore b/spec/fixtures/pagination-server/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/spec/fixtures/pagination-server/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/spec/fixtures/pagination-server/README.md b/spec/fixtures/pagination-server/README.md new file mode 100644 index 0000000..3577e3d --- /dev/null +++ b/spec/fixtures/pagination-server/README.md @@ -0,0 +1,112 @@ +# MCP Pagination Server Test + +A Model Context Protocol (MCP) server using Streamable HTTP transport to test pagination functionality in the Ruby MCP client. This server demonstrates how MCP pagination works by implementing tools, resources, prompts, and resource templates with 1 item per page. + +## Pagination Implementation + +This server implements **pagination support** for all MCP list operations: + +### Tools Pagination + +- **Page 1**: `add_numbers` tool - adds two numbers together +- **Page 2**: `multiply_numbers` tool - multiplies two numbers together + +### Resources Pagination + +- **Page 1**: `config` resource - application configuration (JSON) +- **Page 2**: `data` resource - sample CSV data + +### Prompts Pagination + +- **Page 1**: `code_review` prompt - reviews code for best practices +- **Page 2**: `summarize_text` prompt - generates text summaries + +### Resource Templates Pagination + +- **Page 1**: `user-profile` template - dynamic user profile information (users://{userId}/profile) +- **Page 2**: `file-content` template - access file content by path (files://{path}) + +## How Pagination Works + +The server uses the MCP pagination protocol: + +1. **First request** (no cursor): Returns first item + `nextCursor: "page_2"` +2. **Second request** (with cursor): Returns second item (no nextCursor = last page) +3. **Invalid cursor**: Returns empty array + +Example API calls: + +```json +// Get first page of tools +{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}} +// Response: {"tools":[{...}], "nextCursor":"page_2"} + +// Get second page of tools +{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"cursor":"page_2"}} +// Response: {"tools":[{...}]} // No nextCursor = last page + +// Get first page of resource templates +{"jsonrpc":"2.0","id":3,"method":"resources/templates/list","params":{}} +// Response: {"resourceTemplates":[{...}], "nextCursor":"page_2"} + +// Get second page of resource templates +{"jsonrpc":"2.0","id":4,"method":"resources/templates/list","params":{"cursor":"page_2"}} +// Response: {"resourceTemplates":[{...}]} // No nextCursor = last page +``` + +## Testing the Server + +### Method 1: Direct stdio testing + +```bash +./test_stdio.sh +``` + +### Method 2: HTTP mode + +```bash +# Start server +bun src/index.ts + +# Test with Ruby client (in separate terminal) +ruby test_pagination.rb +``` + +### Method 3: Manual stdio + +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"initialize",...}' | bun src/index.ts --stdio +``` + +## Usage + +### Development + +```bash +# Install dependencies +bun install + +# Start the server +bun start + +# Start with auto-reload during development +bun run dev +``` + +The server will start on port 3005 (or the port specified in the `PORT` environment variable). + +### Endpoints + +- **MCP Protocol**: `http://localhost:3007/mcp` - Main MCP endpoint (supports GET, POST, DELETE) +- **Health Check**: `http://localhost:3007/health` - Server health status + +## Implementation Details + +The pagination is implemented by: + +1. **Overriding list handlers** in each setup function using `server.server.setRequestHandler()` +2. **Using Zod schemas** to validate requests with optional cursor parameter +3. **Returning appropriate responses** with `nextCursor` when more pages exist +4. **Supporting all four list types**: tools, resources, prompts, and resource templates + +This demonstrates how to properly implement pagination in MCP servers and helps test that MCP clients correctly handle paginated responses. diff --git a/spec/fixtures/pagination-server/bun.lock b/spec/fixtures/pagination-server/bun.lock new file mode 100644 index 0000000..025bb01 --- /dev/null +++ b/spec/fixtures/pagination-server/bun.lock @@ -0,0 +1,278 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "mcp-streamable", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.13.1", + "agents": "^0.0.94", + "express": "^5.1.0", + "zod": "^3.25.51", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/express": "^5.0.2", + }, + "peerDependencies": { + "typescript": "^5.0.0", + }, + }, + }, + "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], + + "@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="], + + "@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250620.0", "", {}, "sha512-EVvRB/DJEm6jhdKg+A4Qm4y/ry1cIvylSgSO3/f/Bv161vldDRxaXM2YoQQWFhLOJOw0qtrHsKOD51KYxV1XCw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.13.1", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-8q6+9aF0yA39/qWT/uaIj6zTpC+Qu07DnN/lb9mjoquCJsAh6l3HyYqc9O3t2j7GilseOQOQimLg7W3By6jqvg=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + + "@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="], + + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="], + + "@types/express": ["@types/express@5.0.3", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "*" } }, "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.0.6", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], + + "@types/node": ["@types/node@24.0.4", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA=="], + + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + + "@types/send": ["@types/send@0.17.5", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w=="], + + "@types/serve-static": ["@types/serve-static@1.15.8", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "*" } }, "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "agents": ["agents@0.0.94", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", "ai": "^4.3.16", "cron-schedule": "^5.0.4", "nanoid": "^5.1.5", "partyserver": "^0.0.71", "partysocket": "1.1.4", "zod": "^3.25.28" }, "peerDependencies": { "react": "*" } }, "sha512-mY0CaX4WTYEdc+u38t3dZC78JfQ6J+VqrMCRICoE2ZMwmoIkG6gprLV8adZpSfd54Gm/gy21Jo0hPHQCGoqy8g=="], + + "ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], + + "bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], + + "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + + "cron-schedule": ["cron-schedule@5.0.4", "", {}, "sha512-nH0a49E/kSVk6BeFgKZy4uUsy6D2A16p120h5bYD9ILBhQu7o2sJFH+WI4R731TSBQ0dB1Ik7inB/dRAB4C8QQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "event-target-polyfill": ["event-target-polyfill@0.0.4", "", {}, "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="], + + "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], + + "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "partyserver": ["partyserver@0.0.71", "", { "dependencies": { "nanoid": "^5.1.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0" } }, "sha512-PJZoX08tyNcNJVXqWJedZ6Jzj8EOFGBA/PJ37KhAnWmTkq6A8SqA4u2ol+zq8zwSfRy9FPvVgABCY0yLpe62Dg=="], + + "partysocket": ["partysocket@1.1.4", "", { "dependencies": { "event-target-polyfill": "^0.0.4" } }, "sha512-jXP7PFj2h5/v4UjDS8P7MZy6NJUQ7sspiFyxL4uc/+oKOL+KdtXzHnTV8INPGxBrLTXgalyG3kd12Qm7WrYc3A=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="], + + "pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.0", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "unpipe": "1.0.0" } }, "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g=="], + + "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], + + "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], + + "serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="], + + "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="], + + "@ai-sdk/provider-utils/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + } +} diff --git a/spec/fixtures/pagination-server/index.ts b/spec/fixtures/pagination-server/index.ts new file mode 100644 index 0000000..e85d92b --- /dev/null +++ b/spec/fixtures/pagination-server/index.ts @@ -0,0 +1,2 @@ +// Entry point - import and run the server +import "./src/index.ts"; diff --git a/spec/fixtures/pagination-server/package.json b/spec/fixtures/pagination-server/package.json new file mode 100644 index 0000000..94c09ec --- /dev/null +++ b/spec/fixtures/pagination-server/package.json @@ -0,0 +1,23 @@ +{ + "name": "mcp-streamable", + "module": "index.ts", + "type": "module", + "scripts": { + "start:http": "bun src/index.ts", + "start:cli": "bun src/index.ts --stdio", + "dev": "bun --watch src/index.ts" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/express": "^5.0.2" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.13.1", + "agents": "^0.0.94", + "express": "^5.1.0", + "zod": "^3.25.51" + } +} diff --git a/spec/fixtures/pagination-server/src/index.ts b/spec/fixtures/pagination-server/src/index.ts new file mode 100644 index 0000000..32b5aa3 --- /dev/null +++ b/spec/fixtures/pagination-server/src/index.ts @@ -0,0 +1,304 @@ +import express from "express"; +import { randomUUID } from "node:crypto"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { setupTools } from "./tools/index.js"; +import { setupResources } from "./resources/index.js"; +import { setupPrompts } from "./prompts/index.js"; + +// Check for silent flag +const isSilent = + process.argv.includes("--silent") || process.env.SILENT === "true"; + +// Conditional logging function +const log = (...args: any[]) => { + if (!isSilent) { + console.log(...args); + } +}; + +const app = express(); +app.use(express.json()); + +// Request/Response logging middleware +app.use((req, res, next) => { + if (isSilent) { + // Skip all logging if silent mode is enabled + next(); + return; + } + + const start = Date.now(); + const timestamp = new Date().toISOString(); + let responseBody: any = null; + + // Filter sensitive headers + const filteredHeaders = { ...req.headers }; + const sensitiveHeaders = [ + "authorization", + "cookie", + "x-api-key", + "x-auth-token", + ]; + sensitiveHeaders.forEach((header) => { + if (filteredHeaders[header]) { + filteredHeaders[header] = "[REDACTED]"; + } + }); + + // Log the incoming request + log(`\n🌐 === HTTP REQUEST [${timestamp}] ===`); + log(`šŸ“ ${req.method} ${req.url}`); + log(`šŸ  IP: ${req.ip}`); + log(`šŸ¤– User-Agent: ${req.get("User-Agent") || "N/A"}`); + log(`šŸ“ Content-Type: ${req.get("Content-Type") || "N/A"}`); + + // Log query parameters + if (Object.keys(req.query).length > 0) { + log(`ā“ Query Params:`, JSON.stringify(req.query, null, 2)); + } + + // Log headers (filtered) + log(`šŸ“‹ Headers:`, JSON.stringify(filteredHeaders, null, 2)); + + // Log request body (limit size) + if (req.body && Object.keys(req.body).length > 0) { + const bodyStr = JSON.stringify(req.body, null, 2); + if (bodyStr.length > 1000) { + log( + `šŸ“¦ Request Body: ${bodyStr.substring(0, 1000)}... [TRUNCATED - ${ + bodyStr.length + } chars total]` + ); + } else { + log(`šŸ“¦ Request Body:`, req.body); + } + } + + // Capture response body by intercepting response methods + const originalSend = res.send.bind(res); + const originalJson = res.json.bind(res); + const originalWrite = res.write.bind(res); + const originalEnd = res.end.bind(res); + + const chunks: Buffer[] = []; + + res.send = function (body: any) { + responseBody = body; + return originalSend(body); + }; + + res.json = function (body: any) { + responseBody = body; + return originalJson(body); + }; + + res.write = function (chunk: any, encoding?: any, cb?: any) { + if (chunk) { + if (Buffer.isBuffer(chunk)) { + chunks.push(chunk); + } else { + const enc = + typeof encoding === "string" && encoding + ? (encoding as BufferEncoding) + : "utf8"; + chunks.push(Buffer.from(chunk, enc)); + } + } + return originalWrite(chunk, encoding, cb); + }; + + res.end = function (chunk?: any, encoding?: any, cb?: any) { + if (chunk) { + if (Buffer.isBuffer(chunk)) { + chunks.push(chunk); + } else { + const enc = + typeof encoding === "string" && encoding + ? (encoding as BufferEncoding) + : "utf8"; + chunks.push(Buffer.from(chunk, enc)); + } + } + + // If we haven't captured response body via send/json, try to get it from chunks + if (responseBody === null && chunks.length > 0) { + const fullResponse = Buffer.concat(chunks).toString("utf8"); + try { + responseBody = JSON.parse(fullResponse); + } catch { + responseBody = fullResponse; + } + } + + return originalEnd(chunk, encoding, cb); + }; + + // Log response when finished + res.on("finish", () => { + const duration = Date.now() - start; + + // Filter sensitive response headers + const responseHeaders = res.getHeaders(); + const filteredResponseHeaders = { ...responseHeaders }; + const sensitiveResponseHeaders = [ + "set-cookie", + "authorization", + "x-api-key", + "x-auth-token", + ]; + sensitiveResponseHeaders.forEach((header) => { + if (filteredResponseHeaders[header]) { + filteredResponseHeaders[header] = "[REDACTED]"; + } + }); + + log(`\nšŸ“¤ === HTTP RESPONSE [${new Date().toISOString()}] ===`); + log(`šŸ“ ${req.method} ${req.url} - ${res.statusCode} - ${duration}ms`); + log( + `šŸ“‹ Response Headers:`, + JSON.stringify(filteredResponseHeaders, null, 2) + ); + + // Log response body (limit size) + if (responseBody !== null) { + if (typeof responseBody === "string") { + if (responseBody.length > 1000) { + log( + `šŸ“¦ Response Body: ${responseBody.substring( + 0, + 1000 + )}... [TRUNCATED - ${responseBody.length} chars total]` + ); + } else { + log(`šŸ“¦ Response Body: ${responseBody}`); + } + } else { + const bodyStr = JSON.stringify(responseBody, null, 2); + if (bodyStr.length > 1000) { + log( + `šŸ“¦ Response Body: ${bodyStr.substring(0, 1000)}... [TRUNCATED - ${ + bodyStr.length + } chars total]` + ); + } else { + log(`šŸ“¦ Response Body:`, responseBody); + } + } + } + + log(`šŸ === END REQUEST/RESPONSE ===\n`); + }); + + next(); +}); + +// Store transports for session management +const transports: Record = {}; + +// Function to create a new server instance +function createServer(): McpServer { + const server = new McpServer( + { + name: "mcp-server", + version: "1.0.0", + }, + { + capabilities: { + completions: {}, + logging: {}, + resources: { + subscribe: true, + }, + }, + } + ); + + // Setup tools, resources, notifications, and prompts + setupTools(server); + setupResources(server); + setupPrompts(server); + + return server; +} + +// Main MCP endpoint - handles all HTTP methods (GET, POST, DELETE) +app.all("/mcp", async (req, res) => { + // WORKAROUND: Custom ping handling required due to TypeScript SDK limitation + // The MCP specification states that ping should work before initialization, + // but the SDK's Streamable HTTP transport requires session management which + // blocks ping requests without a session. This is a known issue similar to + // Python SDK issue #423: https://github.com/modelcontextprotocol/python-sdk/issues/423 + // + // VERIFIED: TypeScript SDK shows "Server not initialized" error (same issue) + if (req.method === "POST" && req.body && req.body.method === "ping") { + log(`šŸ“ Processing ping request (SDK workaround) - ID: ${req.body.id}`); + + const pingResponse = { + jsonrpc: "2.0", + id: req.body.id, + result: {}, + }; + + res.status(200).json(pingResponse); + return; + } + + const sessionId = req.headers["mcp-session-id"] as string; + + let transport = transports[sessionId]; + + if (!transport) { + // Create new transport for new session + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + transports[sessionId] = transport; + }, + }); + + // Clean up transport when closed + transport.onclose = () => { + if (transport.sessionId) { + delete transports[transport.sessionId]; + } + }; + + // Create and connect server to transport + const server = createServer(); + await server.connect(transport); + } + + // Handle the request + await transport.handleRequest(req, res, req.body); +}); + +// Health check endpoint +app.get("/health", (req, res) => { + res.json({ status: "healthy", timestamp: new Date().toISOString() }); +}); + +async function stdioServer() { + const server = createServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + if (!isSilent) { + console.error("Secure MCP Server running on stdio"); + } +} + +if (process.argv.includes("--stdio")) { + stdioServer().catch((error) => { + console.error("Fatal error running server:", error); + process.exit(1); + }); +} else { + const PORT = process.env.PORT3 || 3007; + app.listen(PORT, () => { + log(`šŸš€ MCP Streamable server running on port ${PORT}`); + log(`šŸ“” Endpoint: http://localhost:${PORT}/mcp`); + log(`ā¤ļø Health Check: http://localhost:${PORT}/health`); + }); +} diff --git a/spec/fixtures/pagination-server/src/prompts/index.ts b/spec/fixtures/pagination-server/src/prompts/index.ts new file mode 100644 index 0000000..717db6b --- /dev/null +++ b/spec/fixtures/pagination-server/src/prompts/index.ts @@ -0,0 +1,120 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ListPromptsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +export function setupPrompts(server: McpServer) { + // Prompt 1: Code Review - will appear on page 1 + server.prompt( + "code_review", + "Review code for best practices and potential issues", + { + code: z.string().describe("The code to review"), + language: z.string().optional().describe("Programming language"), + }, + async ({ code, language }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Please review the following ${ + language || "code" + } and provide feedback on best practices, potential issues, and improvements:\n\n${code}`, + }, + }, + ], + }) + ); + + // Prompt 2: Summary Generator - will appear on page 2 + server.prompt( + "summarize_text", + "Generate a concise summary of the provided text", + { + text: z.string().describe("The text to summarize"), + length: z + .enum(["short", "medium", "long"]) + .optional() + .describe("Desired summary length"), + }, + async ({ text, length = "medium" }) => { + const lengthInstructions = { + short: "in 1-2 sentences", + medium: "in 3-4 sentences", + long: "in a detailed paragraph", + }; + + return { + messages: [ + { + role: "user", + content: { + type: "text", + text: `Please provide a ${length} summary ${lengthInstructions[length]} of the following text:\n\n${text}`, + }, + }, + ], + }; + } + ); + + // Override the default prompts/list handler to implement pagination + const rawServer = server.server; + rawServer.setRequestHandler(ListPromptsRequestSchema, async (request) => { + const cursor = request.params?.cursor; + const prompts = [ + { + name: "code_review", + description: "Review code for best practices and potential issues", + arguments: [ + { + name: "code", + description: "The code to review", + required: true, + }, + { + name: "language", + description: "Programming language", + required: false, + }, + ], + }, + { + name: "summarize_text", + description: "Generate a concise summary of the provided text", + arguments: [ + { + name: "text", + description: "The text to summarize", + required: true, + }, + { + name: "length", + description: "Desired summary length", + required: false, + }, + ], + }, + ]; + + // Pagination logic: 1 prompt per page + if (!cursor) { + // Page 1: Return first prompt + return { + prompts: [prompts[0]], + nextCursor: "page_2", + }; + } else if (cursor === "page_2") { + // Page 2: Return second prompt + return { + prompts: [prompts[1]], + // No nextCursor - this is the last page + }; + } else { + // Invalid cursor or beyond available pages + return { + prompts: [], + }; + } + }); +} diff --git a/spec/fixtures/pagination-server/src/resources/index.ts b/spec/fixtures/pagination-server/src/resources/index.ts new file mode 100644 index 0000000..5288dc7 --- /dev/null +++ b/spec/fixtures/pagination-server/src/resources/index.ts @@ -0,0 +1,215 @@ +import { + McpServer, + ResourceTemplate, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +export function setupResources(server: McpServer) { + // Resource 1: Configuration Data - will appear on page 1 + server.resource( + "config", + "file://config.json", + { + name: "Configuration", + description: "Application configuration data", + mimeType: "application/json", + }, + async (uri) => { + const configData = { + app_name: "Pagination Server", + version: "1.0.0", + environment: "development", + features: { + pagination: true, + tools: true, + resources: true, + }, + }; + + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(configData, null, 2), + }, + ], + }; + } + ); + + // Resource 2: Sample Data - will appear on page 2 + server.resource( + "data", + "file://data.csv", + { + name: "Sample Data", + description: "Sample CSV data for testing", + mimeType: "text/csv", + }, + async (uri) => { + const csvData = `id,name,value +1,Item A,100 +2,Item B,200 +3,Item C,300 +4,Item D,400`; + + return { + contents: [ + { + uri: uri.href, + text: csvData, + }, + ], + }; + } + ); + + // Resource Template 1: User Profile - will appear on page 1 + server.resource( + "user-profile", + new ResourceTemplate("users://{userId}/profile", { list: undefined }), + { + name: "User Profile", + description: "Dynamic user profile information", + mimeType: "application/json", + }, + async (uri, { userId }) => { + const profileData = { + userId: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + joinDate: "2024-01-01", + preferences: { + theme: "dark", + notifications: true, + }, + }; + + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(profileData, null, 2), + }, + ], + }; + } + ); + + // Resource Template 2: File Content - will appear on page 2 + server.resource( + "file-content", + new ResourceTemplate("files://{path}", { list: undefined }), + { + name: "File Content", + description: "Access file content by path", + mimeType: "text/plain", + }, + async (uri, { path }) => { + const fileContent = `This is the content of file: ${path} + +File information: +- Path: ${path} +- Type: Text file +- Generated: ${new Date().toISOString()} +- Server: Pagination Test Server + +Lorem ipsum content for demonstration purposes...`; + + return { + contents: [ + { + uri: uri.href, + text: fileContent, + }, + ], + }; + } + ); + + // Override the default resources/list handler to implement pagination + const rawServer = server.server; + rawServer.setRequestHandler(ListResourcesRequestSchema, async (request) => { + const cursor = request.params?.cursor; + const resources = [ + { + uri: "file://config.json", + name: "Configuration", + description: "Application configuration data", + mimeType: "application/json", + }, + { + uri: "file://data.csv", + name: "Sample Data", + description: "Sample CSV data for testing", + mimeType: "text/csv", + }, + ]; + + // Pagination logic: 1 resource per page + if (!cursor) { + // Page 1: Return first resource + return { + resources: [resources[0]], + nextCursor: "page_2", + }; + } else if (cursor === "page_2") { + // Page 2: Return second resource + return { + resources: [resources[1]], + // No nextCursor - this is the last page + }; + } else { + // Invalid cursor or beyond available pages + return { + resources: [], + }; + } + }); + + // Add pagination for resource templates + // Note: Using a manual schema since ListResourceTemplatesRequestSchema might not be exported + rawServer.setRequestHandler( + ListResourceTemplatesRequestSchema, + async (request) => { + const cursor = request.params?.cursor; + const resourceTemplates = [ + { + uriTemplate: "users://{userId}/profile", + name: "User Profile", + description: "Dynamic user profile information", + mimeType: "application/json", + }, + { + uriTemplate: "files://{path}", + name: "File Content", + description: "Access file content by path", + mimeType: "text/plain", + }, + ]; + + // Pagination logic: 1 resource template per page + if (!cursor) { + // Page 1: Return first resource template + return { + resourceTemplates: [resourceTemplates[0]], + nextCursor: "page_2", + }; + } else if (cursor === "page_2") { + // Page 2: Return second resource template + return { + resourceTemplates: [resourceTemplates[1]], + // No nextCursor - this is the last page + }; + } else { + // Invalid cursor or beyond available pages + return { + resourceTemplates: [], + }; + } + } + ); +} diff --git a/spec/fixtures/pagination-server/src/tools/index.ts b/spec/fixtures/pagination-server/src/tools/index.ts new file mode 100644 index 0000000..6acb442 --- /dev/null +++ b/spec/fixtures/pagination-server/src/tools/index.ts @@ -0,0 +1,99 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +export function setupTools(server: McpServer) { + // Tool 1: Add Numbers - will appear on page 1 + server.tool( + "add_numbers", + "Add two numbers together", + { + a: z.number().describe("First number"), + b: z.number().describe("Second number"), + }, + async ({ a, b }) => { + const result = a + b; + return { + content: [ + { + type: "text", + text: `The sum of ${a} and ${b} is ${result}`, + }, + ], + }; + } + ); + + // Tool 2: Multiply Numbers - will appear on page 2 + server.tool( + "multiply_numbers", + "Multiply two numbers together", + { + a: z.number().describe("First number"), + b: z.number().describe("Second number"), + }, + async ({ a, b }) => { + const result = a * b; + return { + content: [ + { + type: "text", + text: `The product of ${a} and ${b} is ${result}`, + }, + ], + }; + } + ); + + // Override the default tools/list handler to implement pagination + const rawServer = server.server; + rawServer.setRequestHandler(ListToolsRequestSchema, async (request) => { + const cursor = request.params?.cursor; + const tools = [ + { + name: "add_numbers", + description: "Add two numbers together", + inputSchema: { + type: "object", + properties: { + a: { type: "number", description: "First number" }, + b: { type: "number", description: "Second number" }, + }, + required: ["a", "b"], + }, + }, + { + name: "multiply_numbers", + description: "Multiply two numbers together", + inputSchema: { + type: "object", + properties: { + a: { type: "number", description: "First number" }, + b: { type: "number", description: "Second number" }, + }, + required: ["a", "b"], + }, + }, + ]; + + // Pagination logic: 1 tool per page + if (!cursor) { + // Page 1: Return first tool + return { + tools: [tools[0]], + nextCursor: "page_2", + }; + } else if (cursor === "page_2") { + // Page 2: Return second tool + return { + tools: [tools[1]], + // No nextCursor - this is the last page + }; + } else { + // Invalid cursor or beyond available pages + return { + tools: [], + }; + } + }); +} diff --git a/spec/fixtures/pagination-server/test_stdio.sh b/spec/fixtures/pagination-server/test_stdio.sh new file mode 100755 index 0000000..d6dc5ed --- /dev/null +++ b/spec/fixtures/pagination-server/test_stdio.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +echo "Testing MCP Pagination Server via stdio..." + +# Create a temporary file for MCP commands +cat > mcp_test_commands.json << 'EOF' +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"tools":{},"resources":{},"prompts":{}},"clientInfo":{"name":"test-client","version":"1.0.0"}}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} +{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"cursor":"page_2"}} +{"jsonrpc":"2.0","id":4,"method":"resources/list","params":{}} +{"jsonrpc":"2.0","id":5,"method":"resources/list","params":{"cursor":"page_2"}} +{"jsonrpc":"2.0","id":6,"method":"prompts/list","params":{}} +{"jsonrpc":"2.0","id":7,"method":"prompts/list","params":{"cursor":"page_2"}} +{"jsonrpc":"2.0","id":8,"method":"resources/templates/list","params":{}} +{"jsonrpc":"2.0","id":9,"method":"resources/templates/list","params":{"cursor":"page_2"}} +EOF + +echo "Sending MCP commands..." +echo + +# Send commands to the server +bun src/index.ts --stdio < mcp_test_commands.json | while IFS= read -r line; do + if [[ $line == *"\"method\":"* ]]; then + echo ">>> Server response: $line" + elif [[ $line == *"\"id\":1"* ]]; then + echo "āœ… Initialization: $line" + elif [[ $line == *"\"id\":2"* ]]; then + echo "šŸ”§ Tools Page 1: $line" + elif [[ $line == *"\"id\":3"* ]]; then + echo "šŸ”§ Tools Page 2: $line" + elif [[ $line == *"\"id\":4"* ]]; then + echo "šŸ“ Resources Page 1: $line" + elif [[ $line == *"\"id\":5"* ]]; then + echo "šŸ“ Resources Page 2: $line" + elif [[ $line == *"\"id\":6"* ]]; then + echo "šŸ’¬ Prompts Page 1: $line" + elif [[ $line == *"\"id\":7"* ]]; then + echo "šŸ’¬ Prompts Page 2: $line" + elif [[ $line == *"\"id\":8"* ]]; then + echo "šŸ”— Resource Templates Page 1: $line" + elif [[ $line == *"\"id\":9"* ]]; then + echo "šŸ”— Resource Templates Page 2: $line" + elif [[ -n "$line" ]]; then + echo "šŸ“„ Response: $line" + fi +done + +# Clean up +rm -f mcp_test_commands.json + +echo +echo "Test completed!" diff --git a/spec/fixtures/pagination-server/tsconfig.json b/spec/fixtures/pagination-server/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/spec/fixtures/pagination-server/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/spec/fixtures/typescript-mcp/src/index.ts b/spec/fixtures/typescript-mcp/src/index.ts index 0cceecb..e7093fe 100644 --- a/spec/fixtures/typescript-mcp/src/index.ts +++ b/spec/fixtures/typescript-mcp/src/index.ts @@ -297,7 +297,7 @@ if (process.argv.includes("--stdio")) { process.exit(1); }); } else { - const PORT = process.env.PORT || 3005; + const PORT = process.env.PORT1 || 3005; app.listen(PORT, () => { log(`šŸš€ MCP Streamable server running on port ${PORT}`); log(`šŸ“” Endpoint: http://localhost:${PORT}/mcp`); diff --git a/spec/ruby_llm/mcp/client_spec.rb b/spec/ruby_llm/mcp/client_spec.rb index 986d925..ce6d524 100644 --- a/spec/ruby_llm/mcp/client_spec.rb +++ b/spec/ruby_llm/mcp/client_spec.rb @@ -59,7 +59,7 @@ describe "initialization" do it "initializes with correct transport type and capabilities" do expect(client.transport_type).to eq(config[:options][:transport_type]) - expect(client.capabilities).to be_a(RubyLLM::MCP::Capabilities) + expect(client.capabilities).to be_a(RubyLLM::MCP::ServerCapabilities) end it "initializes with a custom request_timeout" do diff --git a/spec/ruby_llm/mcp/prompt_spec.rb b/spec/ruby_llm/mcp/prompt_spec.rb index 82a8dde..fa825ea 100644 --- a/spec/ruby_llm/mcp/prompt_spec.rb +++ b/spec/ruby_llm/mcp/prompt_spec.rb @@ -10,11 +10,30 @@ ClientRunner.stop_all end + context "with #{PAGINATION_CLIENT_CONFIG[:name]}" do + let(:client) { RubyLLM::MCP::Client.new(**PAGINATION_CLIENT_CONFIG) } + + before do + client.start + end + + after do + client.stop + end + + describe "prompts_list" do + it "paginates prompts list to get all prompts" do + prompts = client.prompts + expect(prompts.count).to eq(2) + end + end + end + CLIENT_OPTIONS.each do |config| context "with #{config[:name]}" do let(:client) { ClientRunner.fetch_client(config[:name]) } - describe "prompts" do + describe "prompts_list" do it "returns array of prompts" do prompts = client.prompts expect(prompts).to be_a(Array) diff --git a/spec/ruby_llm/mcp/resource_spec.rb b/spec/ruby_llm/mcp/resource_spec.rb index c22864a..10da6c5 100644 --- a/spec/ruby_llm/mcp/resource_spec.rb +++ b/spec/ruby_llm/mcp/resource_spec.rb @@ -10,6 +10,25 @@ ClientRunner.stop_all end + context "with #{PAGINATION_CLIENT_CONFIG[:name]}" do + let(:client) { RubyLLM::MCP::Client.new(**PAGINATION_CLIENT_CONFIG) } + + before do + client.start + end + + after do + client.stop + end + + describe "resource_list" do + it "paginates resources list to get all resources" do + resources = client.resources + expect(resources.count).to eq(2) + end + end + end + CLIENT_OPTIONS.each do |config| context "with #{config[:name]}" do let(:client) { ClientRunner.fetch_client(config[:name]) } diff --git a/spec/ruby_llm/mcp/resource_template_spec.rb b/spec/ruby_llm/mcp/resource_template_spec.rb index bc9d158..817e97a 100644 --- a/spec/ruby_llm/mcp/resource_template_spec.rb +++ b/spec/ruby_llm/mcp/resource_template_spec.rb @@ -10,6 +10,25 @@ ClientRunner.stop_all end + context "with #{PAGINATION_CLIENT_CONFIG[:name]}" do + let(:client) { RubyLLM::MCP::Client.new(**PAGINATION_CLIENT_CONFIG) } + + before do + client.start + end + + after do + client.stop + end + + describe "resource_template_list" do + it "paginates resource templates list to get all resource templates" do + resource_templates = client.resource_templates + expect(resource_templates.count).to eq(2) + end + end + end + CLIENT_OPTIONS.each do |config| context "with #{config[:name]}" do let(:client) { ClientRunner.fetch_client(config[:name]) } diff --git a/spec/ruby_llm/mcp/capabilities_spec.rb b/spec/ruby_llm/mcp/server_capabilities_spec.rb similarity index 99% rename from spec/ruby_llm/mcp/capabilities_spec.rb rename to spec/ruby_llm/mcp/server_capabilities_spec.rb index e68ff43..95e1042 100644 --- a/spec/ruby_llm/mcp/capabilities_spec.rb +++ b/spec/ruby_llm/mcp/server_capabilities_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe RubyLLM::MCP::Capabilities do +RSpec.describe RubyLLM::MCP::ServerCapabilities do describe "#initialize" do context "when no capabilities are provided" do let(:capabilities) { described_class.new } diff --git a/spec/ruby_llm/mcp/tool_spec.rb b/spec/ruby_llm/mcp/tool_spec.rb index 5793722..8a63d2a 100644 --- a/spec/ruby_llm/mcp/tool_spec.rb +++ b/spec/ruby_llm/mcp/tool_spec.rb @@ -22,6 +22,25 @@ end end + context "with #{PAGINATION_CLIENT_CONFIG[:name]}" do + let(:client) { RubyLLM::MCP::Client.new(**PAGINATION_CLIENT_CONFIG) } + + before do + client.start + end + + after do + client.stop + end + + describe "tool_list" do + it "paginates tool list to get all tools" do + tools = client.tools + expect(tools.count).to eq(2) + end + end + end + CLIENT_OPTIONS.each do |config| context "with #{config[:name]}" do let(:client) { ClientRunner.fetch_client(config[:name]) } diff --git a/spec/ruby_llm/mcp/transport/sse_spec.rb b/spec/ruby_llm/mcp/transport/sse_spec.rb index acf4ead..37b00df 100644 --- a/spec/ruby_llm/mcp/transport/sse_spec.rb +++ b/spec/ruby_llm/mcp/transport/sse_spec.rb @@ -20,7 +20,7 @@ def client name: "fast-mcp-ruby", transport_type: :sse, config: { - url: "http://127.0.0.1:3006/mcp/sse", + url: "http://127.0.0.1:#{TestServerManager::PORTS[:sse]}/mcp/sse", request_timeout: 100 } ) diff --git a/spec/ruby_llm/mcp/transport/streamable_http_spec.rb b/spec/ruby_llm/mcp/transport/streamable_http_spec.rb index f788e89..596921b 100644 --- a/spec/ruby_llm/mcp/transport/streamable_http_spec.rb +++ b/spec/ruby_llm/mcp/transport/streamable_http_spec.rb @@ -9,7 +9,7 @@ transport_type: :streamable, request_timeout: 5000, config: { - url: "http://localhost:3005/mcp" + url: TestServerManager::HTTP_SERVER_URL } ) end @@ -17,7 +17,7 @@ let(:mock_coordinator) { instance_double(RubyLLM::MCP::Coordinator) } let(:transport) do RubyLLM::MCP::Transport::StreamableHTTP.new( - "http://localhost:3005/mcp", + TestServerManager::HTTP_SERVER_URL, request_timeout: 5000, coordinator: mock_coordinator ) @@ -38,7 +38,7 @@ # If protocol version negotiation succeeds, the client should be alive expect(client).to be_alive - expect(client.capabilities).to be_a(RubyLLM::MCP::Capabilities) + expect(client.capabilities).to be_a(RubyLLM::MCP::ServerCapabilities) client.stop end @@ -166,7 +166,7 @@ describe "connection errors" do it "handles connection refused errors" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_raise(Errno::ECONNREFUSED) expect do @@ -175,7 +175,7 @@ end it "handles timeout errors" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_timeout expect do @@ -184,7 +184,7 @@ end it "handles network errors" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_raise(SocketError.new("Failed to open TCP connection")) expect do @@ -195,7 +195,7 @@ describe "HTTP status errors" do it "handles 400 Bad Request with JSON error" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return( status: 400, headers: { "Content-Type" => "application/json" }, @@ -208,7 +208,7 @@ end it "handles 400 Bad Request with malformed JSON error" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return( status: 400, headers: { "Content-Type" => "application/json" }, @@ -221,7 +221,7 @@ end it "handles 401 Unauthorized" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return(status: 401) result = transport.request({ "method" => "initialize", "id" => 1 }, wait_for_response: false) @@ -229,7 +229,7 @@ end it "handles 404 Not Found (session expired)" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return(status: 404) expect do @@ -238,7 +238,7 @@ end it "handles 405 Method Not Allowed" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return(status: 405) result = transport.request({ "method" => "unsupported", "id" => 1 }, wait_for_response: false) @@ -246,7 +246,7 @@ end it "handles 500 Internal Server Error" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return( status: 500, body: "Internal Server Error" @@ -258,7 +258,7 @@ end it "handles session-related errors in error message" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return( status: 400, headers: { "Content-Type" => "application/json" }, @@ -275,7 +275,7 @@ describe "response content errors" do it "handles invalid JSON in successful response" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return( status: 200, headers: { "Content-Type" => "application/json" }, @@ -288,7 +288,7 @@ end it "handles unexpected content type" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return( status: 200, headers: { "Content-Type" => "text/plain" }, @@ -303,7 +303,7 @@ describe "SSE (Server-Sent Events) errors" do it "handles SSE 400 errors" do - stub_request(:get, "http://localhost:3005/mcp") + stub_request(:get, TestServerManager::HTTP_SERVER_URL) .with(headers: { "Accept" => "text/event-stream" }) .to_return(status: 400) @@ -315,7 +315,7 @@ end it "handles SSE 405 Method Not Allowed gracefully" do - stub_request(:get, "http://localhost:3005/mcp") + stub_request(:get, TestServerManager::HTTP_SERVER_URL) .with(headers: { "Accept" => "text/event-stream" }) .to_return(status: 405) @@ -405,7 +405,7 @@ it "handles session termination failure" do transport.instance_variable_set(:@session_id, "test-session") - stub_request(:delete, "http://localhost:3005/mcp") + stub_request(:delete, TestServerManager::HTTP_SERVER_URL) .to_return(status: 500, body: "Server Error") expect do @@ -416,7 +416,7 @@ it "handles session termination connection error" do transport.instance_variable_set(:@session_id, "test-session") - stub_request(:delete, "http://localhost:3005/mcp") + stub_request(:delete, TestServerManager::HTTP_SERVER_URL) .to_raise(Errno::ECONNREFUSED) expect do @@ -427,7 +427,7 @@ it "accepts 405 status for session termination" do transport.instance_variable_set(:@session_id, "test-session") - stub_request(:delete, "http://localhost:3005/mcp") + stub_request(:delete, TestServerManager::HTTP_SERVER_URL) .to_return(status: 405) # Should not raise an error for 405 (acceptable per spec) @@ -442,7 +442,7 @@ transport.instance_variable_set(:@session_id, nil) # Should return early without making any requests - expect(WebMock).not_to have_requested(:delete, "http://localhost:3005/mcp") + expect(WebMock).not_to have_requested(:delete, TestServerManager::HTTP_SERVER_URL) transport.send(:terminate_session) end @@ -451,7 +451,7 @@ before do transport.instance_variable_set(:@session_id, "test-session") - stub_request(:delete, "http://localhost:3005/mcp") + stub_request(:delete, TestServerManager::HTTP_SERVER_URL) .to_return(status: 400, body: "Bad Request") end @@ -540,7 +540,7 @@ describe "202 Accepted response handling" do it "starts SSE stream on initialization with 202" do allow(transport).to receive(:start_sse_stream) - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return(status: 202) transport.request({ "method" => "initialize", "id" => 1 }, wait_for_response: false) @@ -550,7 +550,7 @@ it "does not start SSE stream on non-initialization 202" do allow(transport).to receive(:start_sse_stream) - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return(status: 202) result = transport.request({ "method" => "other", "id" => 1 }, wait_for_response: false) @@ -573,7 +573,7 @@ let(:transport_with_options) do RubyLLM::MCP::Transport::StreamableHTTP.new( - "http://localhost:3005/mcp", + TestServerManager::HTTP_SERVER_URL, request_timeout: 5000, coordinator: mock_coordinator, reconnection_options: reconnection_options @@ -592,7 +592,7 @@ let(:reconnection_options) { RubyLLM::MCP::Transport::ReconnectionOptions.new(max_retries: 1) } let(:transport_with_options) do RubyLLM::MCP::Transport::StreamableHTTP.new( - "http://localhost:3005/mcp", + TestServerManager::HTTP_SERVER_URL, request_timeout: 1000, coordinator: mock_coordinator, reconnection_options: reconnection_options @@ -600,7 +600,7 @@ end before do - stub_request(:get, "http://localhost:3005/mcp") + stub_request(:get, TestServerManager::HTTP_SERVER_URL) .with(headers: { "Accept" => "text/event-stream" }) .to_raise(Errno::ECONNREFUSED) end @@ -617,7 +617,7 @@ it "stops retrying when transport is closed" do transport.instance_variable_set(:@running, false) - stub_request(:get, "http://localhost:3005/mcp") + stub_request(:get, TestServerManager::HTTP_SERVER_URL) .with(headers: { "Accept" => "text/event-stream" }) .to_raise(Errno::ECONNREFUSED) @@ -628,10 +628,24 @@ end.to raise_error(RubyLLM::MCP::Errors::TransportError, /Connection refused/) end + it "returns a 400 error if server is not running" do + transport.instance_variable_set(:@running, false) + + stub_request(:get, "http://fakeurl:4000/mcp") + .with(headers: { "Accept" => "text/event-stream" }) + .to_raise(Errno::ECONNREFUSED) + + options = RubyLLM::MCP::Transport::StartSSEOptions.new + + expect do + transport.send(:start_sse, options) + end.to raise_error(RubyLLM::MCP::Errors::TransportError, /Failed to open SSE stream: 400/) + end + it "stops retrying when abort controller is set" do transport.instance_variable_set(:@abort_controller, true) - stub_request(:get, "http://localhost:3005/mcp") + stub_request(:get, TestServerManager::HTTP_SERVER_URL) .with(headers: { "Accept" => "text/event-stream" }) .to_raise(Errno::ECONNREFUSED) @@ -645,7 +659,7 @@ describe "edge cases and boundary conditions" do it "handles bad JSON format request body gracefully" do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return( status: 200, headers: { "Content-Type" => "application/json" }, @@ -661,7 +675,7 @@ it "handles request without ID gracefully" do session_id = SecureRandom.uuid - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return( status: 200, headers: { "Content-Type" => "application/json", "mcp-session-id" => session_id }, @@ -676,7 +690,7 @@ it "handles very large response gracefully" do large_response = { "result" => { "content" => [{ "type" => "text", "value" => "x" * 10_000 }] } } - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return( status: 200, headers: { "Content-Type" => "application/json" }, @@ -690,7 +704,7 @@ it "handles response with event-stream content type" do allow(transport).to receive(:start_sse_stream) - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return( status: 200, headers: { "Content-Type" => "text/event-stream" }, @@ -705,7 +719,7 @@ context "when handling session ID extraction from response headers" do before do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return( status: 200, headers: { @@ -724,7 +738,7 @@ context "when response has malformed JSON" do before do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return( status: 200, headers: { "Content-Type" => "application/json" }, @@ -741,7 +755,7 @@ context "when handling HTTPX error response in main request" do before do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_raise(Net::ReadTimeout.new("Connection timeout")) end @@ -754,7 +768,7 @@ context "when HTTPX error response has no error message" do before do - stub_request(:post, "http://localhost:3005/mcp") + stub_request(:post, TestServerManager::HTTP_SERVER_URL) .to_return(status: 500, body: "Internal Server Error") end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 59ca56d..ccd3469 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -104,12 +104,20 @@ name: "streamable-server", transport_type: :streamable, config: { - url: "http://localhost:3005/mcp" + url: TestServerManager::HTTP_SERVER_URL }, request_timeout: 10_000 } } ].freeze +PAGINATION_CLIENT_CONFIG = { + name: "pagination", + transport_type: :streamable, + config: { + url: TestServerManager::PAGINATION_SERVER_URL + } +}.freeze + COMPLEX_FUNCTION_MODELS = [ { provider: :anthropic, model: "claude-3-5-sonnet-20240620" }, { provider: :gemini, model: "gemini-2.0-flash" }, @@ -129,11 +137,11 @@ c.syntax = :expect end - config.before(:all) do + config.before(:suite) do TestServerManager.start_server end - config.after(:all) do + config.after(:suite) do TestServerManager.stop_server end end diff --git a/spec/support/test_server_manager.rb b/spec/support/test_server_manager.rb index f50ae06..ec9ea0c 100644 --- a/spec/support/test_server_manager.rb +++ b/spec/support/test_server_manager.rb @@ -1,39 +1,59 @@ # frozen_string_literal: true -class TestServerManager - @stdio_server_pid = nil - @http_server_pid = nil - - COMMAND = "bun" - STDIO_ARGS = "spec/fixtures/typescript-mcp/index.ts" - HTTP_ARGS = "spec/fixtures/typescript-mcp/index.ts" - FLAGS = ["--silent"].freeze +require "socket" +require "timeout" - SSE_COMMAND = "ruby" - SSE_ARGS = "lib/app.rb" - SSE_DIR = "spec/fixtures/fast-mcp-ruby" +class TestServerManager + PORTS = { + http: ENV.fetch("PORT1", 3005), + pagination: ENV.fetch("PORT3", 3007), + sse: ENV.fetch("PORT2", 3006) + }.freeze + + HTTP_SERVER_URL = "http://localhost:#{PORTS[:http]}/mcp".freeze + PAGINATION_SERVER_URL = "http://localhost:#{PORTS[:pagination]}/mcp".freeze + SSE_SERVER_URL = "http://localhost:#{PORTS[:sse]}/mcp/sse".freeze + + SERVERS = { + stdio: { + command: "bun", + args: ["spec/fixtures/typescript-mcp/index.ts", "--", "--silent", "--stdio"], + pid_accessor: :stdio_server_pid + }, + http: { + command: "bun", + args: ["spec/fixtures/typescript-mcp/index.ts", "--", "--silent"], + pid_accessor: :http_server_pid, + port: PORTS[:http] + }, + pagination: { + command: "bun", + args: ["spec/fixtures/pagination-server/index.ts", "--", "--silent"], + pid_accessor: :pagination_server_pid, + port: PORTS[:pagination] + }, + sse: { + command: "ruby", + args: ["lib/app.rb", "--silent"], + chdir: "spec/fixtures/fast-mcp-ruby", + pid_accessor: :sse_server_pid, + port: PORTS[:sse] + } + }.freeze class << self - attr_accessor :stdio_server_pid, :http_server_pid, :sse_server_pid + attr_accessor :stdio_server_pid, :http_server_pid, :sse_server_pid, :pagination_server_pid def start_server - return if stdio_server_pid && http_server_pid + puts "Starting test servers with ports: #{ENV.fetch('PORT1', + 3005)}, #{ENV.fetch('PORT2', + 3006)}, #{ENV.fetch('PORT3', 3007)}" + return if stdio_server_pid && http_server_pid && pagination_server_pid begin - # Start stdio server - unless stdio_server_pid - self.stdio_server_pid = spawn(COMMAND, STDIO_ARGS, "--", *FLAGS, "--stdio") - Process.detach(stdio_server_pid) - end - - # Start HTTP streamable server - unless http_server_pid - self.http_server_pid = spawn(COMMAND, HTTP_ARGS, "--", *FLAGS) - Process.detach(http_server_pid) - end - - # Give servers time to start - sleep 1.0 + start_server_type(:stdio) + start_server_type(:http) + start_server_type(:pagination) rescue StandardError => e puts "Failed to start test server: #{e.message}" stop_server @@ -42,60 +62,29 @@ def start_server end def start_sse_server - unless sse_server_pid - self.sse_server_pid = spawn(SSE_COMMAND, SSE_ARGS, *FLAGS, chdir: SSE_DIR) - Process.detach(sse_server_pid) - end + start_server_type(:sse) end def stop_server stop_stdio_server stop_http_server + stop_pagination_server end def stop_stdio_server - return unless stdio_server_pid - - begin - Process.kill("TERM", stdio_server_pid) - Process.wait(stdio_server_pid) - rescue Errno::ESRCH, Errno::ECHILD - # Process already dead or doesn't exist - rescue StandardError => e - puts "Warning: Failed to cleanly shutdown stdio server: #{e.message}" - ensure - self.stdio_server_pid = nil - end + stop_server_type(:stdio) end def stop_http_server - return unless http_server_pid + stop_server_type(:http) + end - begin - Process.kill("TERM", http_server_pid) - Process.wait(http_server_pid) - rescue Errno::ESRCH, Errno::ECHILD - # Process already dead or doesn't exist - rescue StandardError => e - puts "Warning: Failed to cleanly shutdown HTTP server: #{e.message}" - ensure - self.http_server_pid = nil - end + def stop_pagination_server + stop_server_type(:pagination) end def stop_sse_server - return unless sse_server_pid - - begin - Process.kill("TERM", sse_server_pid) - Process.wait(sse_server_pid) - rescue Errno::ESRCH, Errno::ECHILD - # Process already dead or doesn't exist - rescue StandardError => e - puts "Warning: Failed to cleanly shutdown SSE server: #{e.message}" - ensure - self.sse_server_pid = nil - end + stop_server_type(:sse) end def ensure_cleanup @@ -106,11 +95,59 @@ def ensure_cleanup def running? stdio_server_pid && process_exists?(stdio_server_pid) && http_server_pid && process_exists?(http_server_pid) && - sse_server_pid && process_exists?(sse_server_pid) + sse_server_pid && process_exists?(sse_server_pid) && + pagination_server_pid && process_exists?(pagination_server_pid) end private + def start_server_type(server_type) + config = SERVERS[server_type] + pid_accessor = config[:pid_accessor] + + return if send(pid_accessor) + + spawn_options = {} + spawn_options[:chdir] = config[:chdir] if config[:chdir] + + pid = spawn(config[:command], *config[:args], **spawn_options) + Process.detach(pid) + send("#{pid_accessor}=", pid) + + # Wait for the server to start, ensure they are ready to start + wait_for_port(config[:port]) if config[:port] + end + + def stop_server_type(server_type) + config = SERVERS[server_type] + pid_accessor = config[:pid_accessor] + pid = send(pid_accessor) + + return unless pid + + begin + Process.kill("TERM", pid) + Process.wait(pid) + rescue Errno::ESRCH, Errno::ECHILD + # Process already dead or doesn't exist + rescue StandardError => e + puts "Warning: Failed to cleanly shutdown #{server_type} server: #{e.message}" + ensure + send("#{pid_accessor}=", nil) + end + end + + def wait_for_port(port, host = "127.0.0.1", timeout = 15) + Timeout.timeout(timeout) do + loop do + Socket.tcp(host, port, connect_timeout: 1).close + break + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH + sleep 0.1 + end + end + end + def process_exists?(pid) Process.kill(0, pid) true