swagger_mcp_tool 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'net/http'
5
+ require 'openssl'
6
+ require 'json'
7
+ require 'mcp'
8
+ require_relative 'helpers/request'
9
+ require_relative 'logging'
10
+ require_relative 'config'
11
+
12
+ module SwaggerMCPTool
13
+ # The SwaggerMCPTool module provides tools and utilities for interacting with APIs
14
+ # using Swagger/OpenAPI specifications. It includes classes and helpers for making
15
+ # HTTP requests, handling responses, logging, and managing configuration.
16
+ #
17
+ # This module is intended to be used as part of the swagger_mcp_tool library,
18
+ # facilitating API client operations with robust error handling and logging.
19
+ #
20
+ # Example usage:
21
+ # client = SwaggerMCPTool::ApiClient.new("https://api.example.com")
22
+ # response = client.make_request(:get, "/v1/resource", { id: 123 })
23
+ #
24
+ # @see SwaggerMCPTool::ApiClient
25
+ # @see SwaggerMCPTool::Helpers::Request
26
+ # @see SwaggerMCPTool::Logging
27
+ class ApiClient
28
+ include SwaggerMCPTool::Helpers::Request
29
+ include SwaggerMCPTool::Logging
30
+
31
+ def initialize(base_url)
32
+ @base_url = base_url.chomp('/')
33
+ @config = Config.instance
34
+ end
35
+
36
+ def make_request(method, path, params = {}, headers = {}, server_context = {})
37
+ request_context = build_request_context(method, path, params, headers, server_context)
38
+
39
+ log_request_details(request_context)
40
+
41
+ response = execute_http_request(request_context)
42
+ process_response(response)
43
+ rescue StandardError => e
44
+ handle_request_error(e)
45
+ end
46
+
47
+ private
48
+
49
+ def execute_http_request(context)
50
+ processed_path, remaining_params = process_path_parameters(context[:original_path], context[:params])
51
+ uri = build_request_uri(processed_path, context[:method], remaining_params)
52
+
53
+ @config.logger.debug "Final URI: #{uri}"
54
+
55
+ http_client = create_http_client(uri)
56
+ request = create_http_request(context[:method], uri, remaining_params)
57
+
58
+ add_headers_to_request(request, context[:headers], context[:auth_token])
59
+
60
+ log_request_execution(context[:method], uri)
61
+ http_client.request(request)
62
+ end
63
+
64
+ def create_http_client(uri)
65
+ http = Net::HTTP.new(uri.host, uri.port)
66
+
67
+ configure_ssl(http, uri)
68
+ configure_timeouts(http)
69
+
70
+ http
71
+ end
72
+
73
+ def configure_ssl(http, uri)
74
+ return unless uri.scheme == 'https'
75
+
76
+ http.use_ssl = true
77
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
78
+ end
79
+
80
+ def configure_timeouts(http)
81
+ http.open_timeout = 10
82
+ http.read_timeout = 30
83
+ end
84
+
85
+ def process_response(response)
86
+ if response.is_a?(Net::HTTPSuccess)
87
+ process_success_response(response)
88
+ else
89
+ process_error_response(response)
90
+ end
91
+ end
92
+
93
+ def process_success_response(response)
94
+ parsed_response = parse_response_body(response.body)
95
+
96
+ @config.logger.debug "API Response: #{parsed_response.inspect}"
97
+
98
+ create_mcp_response(format_success_response(parsed_response))
99
+ end
100
+
101
+ def process_error_response(response)
102
+ error_message = build_error_message(response)
103
+
104
+ @config.logger.error "API request failed: #{response.code} #{response.message}"
105
+ @config.logger.error "Response body: #{response.body}"
106
+
107
+ create_mcp_response(error_message)
108
+ end
109
+
110
+ def parse_response_body(body)
111
+ JSON.parse(body)
112
+ rescue JSON::ParserError
113
+ body
114
+ end
115
+
116
+ def format_success_response(parsed_response)
117
+ case parsed_response
118
+ when Hash, Array
119
+ JSON.pretty_generate(parsed_response)
120
+ else
121
+ parsed_response.to_s
122
+ end
123
+ end
124
+
125
+ def build_error_message(response)
126
+ error_message = "Error: #{response.code} #{response.message}\n"
127
+
128
+ error_body = parse_response_body(response.body)
129
+ error_message += case error_body
130
+ when Hash, Array
131
+ JSON.pretty_generate(error_body)
132
+ else
133
+ error_body.to_s
134
+ end
135
+
136
+ error_message
137
+ end
138
+
139
+ def create_mcp_response(text)
140
+ MCP::Tool::Response.new([{ type: 'text', text: text }])
141
+ end
142
+
143
+ def handle_request_error(error)
144
+ @config.logger.error "Error making API request: #{error.message}"
145
+ @config.logger.error error.backtrace.join("\n")
146
+
147
+ create_mcp_response("Error: #{error.message}")
148
+ end
149
+
150
+ def sanitize_headers_for_logging(headers)
151
+ headers.inspect.gsub(/"Authorization"=>"[^"]+"/, '"Authorization"=>"[REDACTED]"')
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwaggerMCPTool
4
+ class AuthHandler
5
+ # Initialize with optional default auth settings
6
+ def initialize(default_auth = {})
7
+ @config = Config.instance
8
+ @default_auth = default_auth || {}
9
+ @auth_tokens = {}
10
+ end
11
+
12
+ # Set auth token for a user
13
+ def set_auth_token(user_id, token)
14
+ @auth_tokens[user_id.to_s] = token
15
+ @config.logger.info "Auth token set for user: #{user_id}"
16
+ end
17
+
18
+ # Get auth token for a user
19
+ def get_auth_token(user_id)
20
+ @auth_tokens[user_id.to_s] || @default_auth[:token] || @config.default_token
21
+ end
22
+
23
+ # Clear auth token for a user
24
+ def clear_auth_token(user_id)
25
+ @auth_tokens.delete(user_id.to_s)
26
+ @config.logger.info "Auth token cleared for user: #{user_id}"
27
+ end
28
+
29
+ # Check if a user has an auth token
30
+ def has_auth_token?(user_id)
31
+ @auth_tokens.key?(user_id.to_s) || @default_auth[:token].nil? == false || @config.default_token.nil? == false
32
+ end
33
+
34
+ # Get auth headers for a request
35
+ def get_auth_headers(user_id, api_key_header = nil)
36
+ headers = {}
37
+
38
+ token = get_auth_token(user_id)
39
+ if token
40
+ headers['Authorization'] = token.start_with?('Bearer ', 'Token ', 'Basic ') ? token : "Bearer #{token}"
41
+ end
42
+
43
+ # Add API key if provided
44
+ headers[api_key_header] = @default_auth[:api_key] if api_key_header && @default_auth[:api_key]
45
+
46
+ headers
47
+ end
48
+
49
+ # Process auth from Swagger security definitions
50
+ def process_swagger_security(swagger_spec)
51
+ return {} unless swagger_spec['securityDefinitions'] || swagger_spec['components']&.dig('securitySchemes')
52
+
53
+ # Get security definitions based on Swagger/OpenAPI version
54
+ security_defs = if swagger_spec['swagger'] == '2.0'
55
+ swagger_spec['securityDefinitions'] || {}
56
+ else
57
+ swagger_spec['components']&.dig('securitySchemes') || {}
58
+ end
59
+
60
+ auth_config = {}
61
+
62
+ security_defs.each_value do |definition|
63
+ type = definition['type']&.downcase
64
+
65
+ case type
66
+ when 'apikey'
67
+ auth_config[:api_key_name] = definition['name']
68
+ auth_config[:api_key_in] = definition['in'] # 'header' or 'query'
69
+ when 'oauth2', 'http'
70
+ scheme = definition['scheme']&.downcase
71
+ if scheme == 'bearer' || definition['bearerFormat'] || type == 'oauth2'
72
+ auth_config[:token_type] = 'Bearer'
73
+ elsif scheme == 'basic'
74
+ auth_config[:token_type] = 'Basic'
75
+ end
76
+ end
77
+ end
78
+
79
+ auth_config
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'singleton'
5
+
6
+ module SwaggerMCPTool
7
+ # Configuration management for SwaggerMCPTool
8
+ #
9
+ # This class uses the Singleton pattern to ensure a single configuration
10
+ # instance throughout the application lifecycle.
11
+ class Config
12
+ include Singleton
13
+
14
+ attr_accessor :server_port, :server_bind, :server_type,
15
+ :log_level, :log_file, :swagger_url, :mcp_name,
16
+ :mcp_name_for_human, :mcp_description_for_human,
17
+ :mcp_description_for_model, :auth_type, :default_token,
18
+ :prompts, :resources, :auth_header_name, :tools
19
+
20
+ DEFAULT_SERVER_PORT = 3001
21
+ DEFAULT_SERVER_BIND = '0.0.0.0'
22
+ DEFAULT_SERVER_TYPE = :puma
23
+ DEFAULT_LOG_LEVEL = Logger::INFO
24
+ DEFAULT_AUTH_HEADER_NAME = 'Token'
25
+ DEFAULT_SWAGGER_URL = 'https://petstore.swagger.io/v2/swagger.json'
26
+ DEFAULT_MCP_NAME = 'swagger_api_tools'
27
+ DEFAULT_MCP_NAME_FOR_HUMAN = 'Swagger API Tools'
28
+ DEFAULT_MCP_DESCRIPTION_FOR_HUMAN = 'Tools for interacting with APIs via Swagger/OpenAPI specifications'
29
+ DEFAULT_MCP_DESCRIPTION_FOR_MODEL = 'This MCP server provides tools for interacting with APIs via Swagger/OpenAPI specifications.'
30
+ DEFAULT_AUTH_TYPE = 'none'
31
+ DEFAULT_LOG_FILE = nil
32
+
33
+ def initialize
34
+ # Server settings
35
+ setup_server_defaults
36
+
37
+ # Swagger settings
38
+ setup_swagger_defaults
39
+
40
+ # MCP settings
41
+ setup_mcp_defaults
42
+
43
+ # Auth settings
44
+ setup_auth_defaults
45
+
46
+ # Tools, Prompts and resources
47
+ @tools = []
48
+ @resources = []
49
+ @prompts = [] # Will be generated lazily
50
+ end
51
+
52
+ def setup_server_defaults
53
+ @server_port = DEFAULT_SERVER_PORT
54
+ @server_bind = DEFAULT_SERVER_BIND
55
+ @server_type = DEFAULT_SERVER_TYPE
56
+ @log_level = DEFAULT_LOG_LEVEL
57
+ @log_file = nil
58
+ @auth_header_name = DEFAULT_AUTH_HEADER_NAME
59
+ end
60
+
61
+ def setup_swagger_defaults
62
+ @swagger_url = DEFAULT_SWAGGER_URL
63
+ end
64
+
65
+ def setup_mcp_defaults
66
+ @mcp_name = DEFAULT_MCP_NAME
67
+ @mcp_name_for_human = DEFAULT_MCP_NAME_FOR_HUMAN
68
+ @mcp_description_for_human = DEFAULT_MCP_DESCRIPTION_FOR_HUMAN
69
+ @mcp_description_for_model = DEFAULT_MCP_DESCRIPTION_FOR_MODEL
70
+ end
71
+
72
+ def setup_auth_defaults
73
+ @auth_type = DEFAULT_AUTH_TYPE
74
+ @default_token = nil
75
+ end
76
+
77
+ # Get the logger
78
+ def logger
79
+ @logger ||= create_logger
80
+ end
81
+
82
+ def create_logger
83
+ create_file_logger
84
+ rescue StandardError => e
85
+ logger = Logger.new($stdout)
86
+ logger.level = @log_level
87
+ logger.warn "Could not create log file #{@log_file} (#{e.message}), using stderr for logging"
88
+ logger
89
+ end
90
+
91
+ def create_file_logger
92
+ target = case @log_file
93
+ when $stdout, $stdout, 'STDOUT', 'stdout'
94
+ $stdout
95
+ when $stderr, $stderr, 'STDERR', 'stderr'
96
+ $stderr
97
+ when nil, ''
98
+ $stderr
99
+ else
100
+ @log_file
101
+ end
102
+ logger = Logger.new(target)
103
+ logger.level = @log_level
104
+ logger
105
+ end
106
+
107
+ # Configure the instance
108
+ def self.configure
109
+ return unless block_given?
110
+
111
+ config_instance = instance
112
+ yield config_instance
113
+ log_configuration_details(config_instance)
114
+ config_instance
115
+ rescue StandardError => e
116
+ config_instance.logger.error "Configuration failed: #{e.message}"
117
+ raise e
118
+ end
119
+
120
+ # Get server context
121
+ def to_context(request)
122
+ auth_header_name = self.auth_header_name || 'Authorization'
123
+ {
124
+ user_id: request.env['HTTP_X_USER_ID'] || '1',
125
+ auth_header_name.to_sym => request.env['HTTP_X_AUTH_TOKEN'],
126
+ username: request.env['HTTP_X_USERNAME'],
127
+ email: request.env['HTTP_X_EMAIL']
128
+ }
129
+ end
130
+
131
+ def self.log_configuration_details(config_instance)
132
+ logger = config_instance.logger
133
+ logger.info '=== CONFIGURATION COMPLETE ==='
134
+ logger.info 'Final config values:'
135
+ logger.info " server_port: #{config_instance.server_port}"
136
+ logger.info " mcp_name: #{config_instance.mcp_name}"
137
+ logger.info " auth_header_name: #{config_instance.auth_header_name}"
138
+ logger.info " prompts: #{config_instance.prompts}"
139
+ end
140
+
141
+ private_class_method :log_configuration_details
142
+ end
143
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwaggerMCPTool
4
+ module Helpers
5
+ module ParameterProcessor
6
+ SWAGGER_TO_JSON_SCHEMA_TYPES = {
7
+ 'file' => 'string',
8
+ 'integer' => 'integer',
9
+ 'int' => 'integer',
10
+ 'int32' => 'integer',
11
+ 'int64' => 'integer',
12
+ 'number' => 'number',
13
+ 'float' => 'number',
14
+ 'double' => 'number',
15
+ 'boolean' => 'boolean',
16
+ 'bool' => 'boolean',
17
+ 'array' => 'array',
18
+ 'object' => 'object'
19
+ }.freeze
20
+
21
+ private
22
+
23
+ def add_parameters_to_tool(tool_definition, operation)
24
+ parameters = operation['parameters']
25
+ return unless parameters.is_a?(Array)
26
+
27
+ parameters.each do |param|
28
+ process_single_parameter(tool_definition, param)
29
+ end
30
+ end
31
+
32
+ def process_single_parameter(tool_definition, param)
33
+ return if skip_parameter?(param)
34
+
35
+ resolved_param = resolve_parameter_reference(param)
36
+ return unless valid_parameter?(resolved_param)
37
+
38
+ add_parameter_to_schema(tool_definition, resolved_param)
39
+ end
40
+
41
+ def skip_parameter?(param)
42
+ param['in'] == 'header'
43
+ end
44
+
45
+ def resolve_parameter_reference(param)
46
+ return param unless param['$ref']
47
+
48
+ resolve_swagger_reference(param['$ref'])
49
+ rescue StandardError => e
50
+ @config.logger.debug "Failed to resolve parameter reference: #{e.message}"
51
+ param
52
+ end
53
+
54
+ def resolve_swagger_reference(ref_path)
55
+ return nil unless ref_path.is_a?(String)
56
+
57
+ path_parts = ref_path.sub('#/', '').split('/')
58
+ path_parts.reduce(@swagger_client.swagger_spec) { |acc, part| acc[part] }
59
+ end
60
+
61
+ def valid_parameter?(param)
62
+ param.is_a?(Hash) && param['name'].is_a?(String)
63
+ end
64
+
65
+ def add_parameter_to_schema(tool_definition, param)
66
+ param_name = param['name']
67
+ param_type = normalize_parameter_type(param['type'])
68
+
69
+ tool_definition['parameters']['properties'][param_name] = {
70
+ 'type' => param_type,
71
+ 'description' => param['description'] || param_name
72
+ }
73
+
74
+ return unless param['required']
75
+
76
+ tool_definition['parameters']['required'] << param_name
77
+ end
78
+
79
+ def normalize_parameter_type(swagger_type)
80
+ SWAGGER_TO_JSON_SCHEMA_TYPES[swagger_type] || 'string'
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'net/http'
5
+ require 'json'
6
+
7
+ module SwaggerMCPTool
8
+ module Helpers
9
+ # Helper for HTTP request building and execution
10
+ module Request
11
+ def build_request_context(method, path, params, headers, server_context)
12
+ {
13
+ method: method.downcase,
14
+ original_path: path,
15
+ params: params,
16
+ headers: headers,
17
+ auth_token: server_context[@config.auth_header_name]
18
+ }
19
+ end
20
+
21
+ def create_http_request(method, uri, params)
22
+ req_method = request_methods(method.downcase)
23
+ request = req_method.new(uri.request_uri)
24
+
25
+ set_json_body(request, params) if body_required?(method.downcase)
26
+ request
27
+ end
28
+
29
+ def body_required?(method)
30
+ %w[post put patch].include?(method)
31
+ end
32
+
33
+ def request_methods(method)
34
+ {
35
+ get: Net::HTTP::Get,
36
+ post: Net::HTTP::Post,
37
+ put: Net::HTTP::Put,
38
+ delete: Net::HTTP::Delete,
39
+ patch: Net::HTTP::Patch
40
+ }[method] || Net::HTTP::Get
41
+ end
42
+
43
+ def set_json_body(request, params)
44
+ return if params.empty?
45
+
46
+ request.body = params.to_json
47
+ request.content_type = 'application/json'
48
+ end
49
+
50
+ def build_request_uri(path, method, params)
51
+ uri = URI("#{@base_url}#{path}")
52
+
53
+ # Add query parameters only for GET requests
54
+ uri.query = URI.encode_www_form(params) if method == 'get' && !params.empty?
55
+
56
+ uri
57
+ end
58
+
59
+ def add_headers_to_request(request, headers, auth_token)
60
+ # Add custom headers
61
+ headers.each { |key, value| request[key] = value }
62
+
63
+ # Add authorization header if available
64
+ add_authorization_header(request, auth_token)
65
+ end
66
+
67
+ def add_authorization_header(request, auth_token)
68
+ return unless auth_token && @config.auth_header_name
69
+
70
+ request[@config.auth_header_name] = auth_token
71
+ @config.logger.info 'Added authorization header for request'
72
+ end
73
+
74
+ def process_path_parameters(path, params)
75
+ return [path, params] unless path_has_placeholders?(path)
76
+
77
+ processed_path = path.dup
78
+ used_params = substitute_path_placeholders!(processed_path, params)
79
+ remaining_params = filter_unused_params(params, used_params)
80
+
81
+ [processed_path, remaining_params]
82
+ end
83
+
84
+ private
85
+
86
+ def path_has_placeholders?(path)
87
+ path.include?('{')
88
+ end
89
+
90
+ def substitute_path_placeholders!(path, params)
91
+ used_params = []
92
+
93
+ params.each do |key, value|
94
+ placeholder = "{#{key}}"
95
+
96
+ if path.include?(placeholder)
97
+ path.gsub!(placeholder, encode_path_value(value))
98
+ used_params << key
99
+ end
100
+ end
101
+
102
+ used_params
103
+ end
104
+
105
+ def encode_path_value(value)
106
+ URI.encode_www_form_component(value.to_s)
107
+ end
108
+
109
+ def filter_unused_params(params, used_params)
110
+ params.reject { |key, _| used_params.include?(key) }
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwaggerMCPTool
4
+ module Helpers
5
+ module RequestBodyProcessor
6
+ private
7
+
8
+ def add_request_body_to_tool(tool_definition, operation)
9
+ request_body = operation['requestBody']
10
+ return unless request_body_present?(request_body)
11
+
12
+ process_request_body_content(tool_definition, request_body['content'])
13
+ rescue StandardError => e
14
+ @config.logger.debug "Failed to process request body: #{e.message}"
15
+ end
16
+
17
+ def request_body_present?(request_body)
18
+ request_body && request_body['content']
19
+ end
20
+
21
+ def process_request_body_content(tool_definition, content)
22
+ return unless content.is_a?(Hash)
23
+
24
+ content_type = select_content_type(content)
25
+ return unless content_type
26
+
27
+ schema = extract_schema_from_content(content, content_type)
28
+ add_schema_properties_to_tool(tool_definition, schema) if schema
29
+ end
30
+
31
+ def select_content_type(content)
32
+ content.keys.first
33
+ end
34
+
35
+ def extract_schema_from_content(content, content_type)
36
+ content.dig(content_type, 'schema')
37
+ end
38
+
39
+ def add_schema_properties_to_tool(tool_definition, schema)
40
+ properties = schema['properties']
41
+ return unless properties.is_a?(Hash)
42
+
43
+ properties.each do |name, property|
44
+ add_property_to_tool(tool_definition, name, property)
45
+ end
46
+
47
+ add_required_fields_to_tool(tool_definition, schema['required'])
48
+ end
49
+
50
+ def add_property_to_tool(tool_definition, name, property)
51
+ tool_definition['parameters']['properties'][name] = {
52
+ 'type' => property['type'] || 'string',
53
+ 'description' => property['description'] || name
54
+ }
55
+ end
56
+
57
+ def add_required_fields_to_tool(tool_definition, required_fields)
58
+ return unless required_fields.is_a?(Array)
59
+
60
+ required_fields.each do |field_name|
61
+ tool_definition['parameters']['required'] << field_name
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json-schema'
4
+
5
+ module SwaggerMCPTool
6
+ module Helpers
7
+ # Provides validation methods for Swagger (OpenAPI) specification structures.
8
+ # This module includes methods to validate the overall structure of a Swagger spec,
9
+ # ensure required keys are present, and check the structure of the 'paths' section.
10
+ # Intended to be included in classes that require Swagger spec validation logic.
11
+ #
12
+ # Example usage:
13
+ # include SwaggerValidator
14
+ # validate_swagger_spec!(swagger_spec_hash)
15
+ #
16
+ # Raises ArgumentError if the spec or paths are invalid.
17
+ #
18
+ # @see https://swagger.io/specification/
19
+ module SwaggerValidator
20
+ def validate_swagger_spec!(swagger_spec)
21
+ validate_spec_structure!(swagger_spec)
22
+ validate_paths_structure!(swagger_spec['paths'])
23
+ end
24
+
25
+ def validate_spec_structure!(swagger_spec)
26
+ raise ArgumentError, 'Swagger spec must be a Hash' unless swagger_spec.is_a?(Hash)
27
+
28
+ return if swagger_spec.key?('paths')
29
+
30
+ raise ArgumentError, 'Swagger spec must contain paths'
31
+ end
32
+
33
+ def validate_paths_structure!(paths)
34
+ raise ArgumentError, 'Paths must be a Hash' unless paths.is_a?(Hash)
35
+
36
+ return unless paths.empty?
37
+
38
+ @config.logger.warn 'No paths found in Swagger spec'
39
+ end
40
+ end
41
+ end
42
+ end