mcp-inspector 0.1.0

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,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "fileutils"
6
+
7
+ module MCPInspector
8
+ module Data
9
+ class ConfigManager
10
+ class ConfigError < Error; end
11
+
12
+ DEFAULT_USER_CONFIG_PATH = File.expand_path("~/.mcp-inspector.json")
13
+ DEFAULT_PROJECT_CONFIG_PATH = "./.mcp-inspector.json"
14
+
15
+ def initialize(config_path: nil)
16
+ @config_path = config_path
17
+ @config = load_merged_config
18
+ validate_config!
19
+ end
20
+
21
+ def servers
22
+ @servers ||= build_server_configs
23
+ end
24
+
25
+ def server_names
26
+ servers.keys
27
+ end
28
+
29
+ def find_server(name)
30
+ servers[name] or raise ConfigError, "Server '#{name}' not found"
31
+ end
32
+
33
+ def defaults
34
+ @config["defaults"] || {}
35
+ end
36
+
37
+ def output_format
38
+ defaults["output"] || "json"
39
+ end
40
+
41
+ def pretty_print?
42
+ defaults.fetch("pretty", true)
43
+ end
44
+
45
+ def to_h
46
+ @config
47
+ end
48
+
49
+ def self.config_file_exists?(path = nil)
50
+ paths_to_check = if path
51
+ [path]
52
+ else
53
+ [DEFAULT_PROJECT_CONFIG_PATH, DEFAULT_USER_CONFIG_PATH]
54
+ end
55
+
56
+ paths_to_check.any? { |p| File.exist?(p) }
57
+ end
58
+
59
+ def self.create_example_config(path = DEFAULT_USER_CONFIG_PATH)
60
+ example_config = {
61
+ "servers" => [
62
+ {
63
+ "name" => "filesystem-server",
64
+ "transport" => "stdio",
65
+ "command" => ["npx", "-y", "@modelcontextprotocol/server-filesystem"],
66
+ "args" => ["/tmp"],
67
+ "env" => {}
68
+ },
69
+ {
70
+ "name" => "github-server",
71
+ "transport" => "stdio",
72
+ "command" => ["npx", "-y", "@modelcontextprotocol/server-github"],
73
+ "env" => {
74
+ "GITHUB_TOKEN" => "${GITHUB_TOKEN}"
75
+ }
76
+ }
77
+ ],
78
+ "defaults" => {
79
+ "output" => "json",
80
+ "pretty" => true
81
+ }
82
+ }
83
+
84
+ File.write(path, JSON.pretty_generate(example_config))
85
+ path
86
+ end
87
+
88
+ private
89
+
90
+ attr_reader :config_path
91
+
92
+ def load_merged_config
93
+ configs = []
94
+
95
+ # Load in order of precedence (last wins)
96
+ configs << load_user_config if user_config_exists?
97
+ configs << load_project_config if project_config_exists?
98
+ configs << load_custom_config if custom_config_specified?
99
+
100
+ if configs.empty?
101
+ # Auto-create default config file like Claude Desktop does
102
+ created_config_path = auto_create_default_config
103
+ raise ConfigError, build_no_config_error_message(created_config_path)
104
+ end
105
+
106
+ merge_configs(configs)
107
+ end
108
+
109
+ def load_user_config
110
+ load_config_file(DEFAULT_USER_CONFIG_PATH)
111
+ end
112
+
113
+ def load_project_config
114
+ load_config_file(DEFAULT_PROJECT_CONFIG_PATH)
115
+ end
116
+
117
+ def load_custom_config
118
+ load_config_file(config_path)
119
+ end
120
+
121
+ def load_config_file(path)
122
+ content = File.read(path)
123
+ JSON.parse(content)
124
+ rescue JSON::ParserError => e
125
+ raise ConfigError, "Invalid JSON in config file '#{path}': #{e.message}"
126
+ rescue Errno::ENOENT
127
+ raise ConfigError, "Configuration file not found: #{path}"
128
+ rescue Errno::EACCES
129
+ raise ConfigError, "Configuration file not readable: #{path}"
130
+ end
131
+
132
+ def merge_configs(configs)
133
+ base_config = { "servers" => [], "defaults" => {} }
134
+
135
+ configs.each do |config|
136
+ base_config = deep_merge(base_config, config)
137
+ end
138
+
139
+ base_config
140
+ end
141
+
142
+ def deep_merge(hash1, hash2)
143
+ result = hash1.dup
144
+
145
+ hash2.each do |key, value|
146
+ if result[key].is_a?(Hash) && value.is_a?(Hash)
147
+ result[key] = deep_merge(result[key], value)
148
+ elsif key == "servers" && result[key].is_a?(Array) && value.is_a?(Array)
149
+ result[key] = merge_servers(result[key], value)
150
+ else
151
+ result[key] = value
152
+ end
153
+ end
154
+
155
+ result
156
+ end
157
+
158
+ def merge_servers(servers1, servers2)
159
+ merged = servers1.dup
160
+ servers2.each do |server|
161
+ existing_index = merged.find_index { |s| s["name"] == server["name"] }
162
+ if existing_index
163
+ merged[existing_index] = server
164
+ else
165
+ merged << server
166
+ end
167
+ end
168
+ merged
169
+ end
170
+
171
+ def validate_config!
172
+ raise ConfigError, "Configuration must be a hash" unless @config.is_a?(Hash)
173
+ raise ConfigError, "No 'servers' section found in configuration" unless @config["servers"]
174
+ raise ConfigError, "'servers' must be an array" unless @config["servers"].is_a?(Array)
175
+ raise ConfigError, "No servers configured" if @config["servers"].empty?
176
+ end
177
+
178
+ def build_server_configs
179
+ server_configs = {}
180
+
181
+ @config["servers"].each do |server_hash|
182
+ begin
183
+ server_config = MCPInspector::Transport::ServerConfig.new(server_hash)
184
+ server_configs[server_config.name] = server_config
185
+ rescue MCPInspector::Transport::ServerConfig::ValidationError => e
186
+ raise ConfigError, "Invalid server configuration: #{e.message}"
187
+ end
188
+ end
189
+
190
+ server_configs
191
+ end
192
+
193
+ def user_config_exists?
194
+ File.exist?(DEFAULT_USER_CONFIG_PATH)
195
+ end
196
+
197
+ def project_config_exists?
198
+ File.exist?(DEFAULT_PROJECT_CONFIG_PATH)
199
+ end
200
+
201
+ def custom_config_specified?
202
+ config_path && File.exist?(config_path)
203
+ end
204
+
205
+ def auto_create_default_config
206
+ # Determine where to create the config file
207
+ config_file_path = config_path || DEFAULT_USER_CONFIG_PATH
208
+
209
+ # Ensure the parent directory exists
210
+ parent_dir = File.dirname(config_file_path)
211
+ FileUtils.mkdir_p(parent_dir) unless File.directory?(parent_dir)
212
+
213
+ # Create the config file with example servers
214
+ self.class.create_example_config(config_file_path)
215
+
216
+ config_file_path
217
+ end
218
+
219
+ def build_no_config_error_message(created_config_path)
220
+ <<~ERROR
221
+ No configuration file found, so I created one for you at:
222
+ #{created_config_path}
223
+
224
+ This file contains example MCP server configurations. Please edit it to:
225
+ 1. Add your actual MCP servers
226
+ 2. Remove or modify the example servers as needed
227
+ 3. Set any custom defaults
228
+
229
+ Then run your command again.
230
+
231
+ Example servers included:
232
+ - filesystem-server: For file system operations
233
+ - github-server: For GitHub operations (requires GITHUB_TOKEN)
234
+ ERROR
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module MCPInspector
6
+ module Data
7
+ class InputAdapter
8
+ class ValidationError < Error; end
9
+
10
+ def initialize(args = [], options = {})
11
+ @args = args
12
+ @options = options
13
+ end
14
+
15
+ def parse_json_arguments(json_string)
16
+ return {} if json_string.nil? || json_string.empty?
17
+
18
+ JSON.parse(json_string)
19
+ rescue JSON::ParserError => e
20
+ raise ValidationError, "Invalid JSON arguments: #{e.message}\nExpected format: '{\"key\": \"value\"}'"
21
+ end
22
+
23
+ def validate_server_name!(server_name, available_servers)
24
+ return if available_servers.include?(server_name)
25
+
26
+ if available_servers.empty?
27
+ raise ValidationError, "No servers configured. Please add servers to your configuration file."
28
+ else
29
+ raise ValidationError, "Server '#{server_name}' not found. Available servers: #{available_servers.join(', ')}"
30
+ end
31
+ end
32
+
33
+ def validate_tool_name!(tool_name)
34
+ raise ValidationError, "Tool name is required" if tool_name.nil? || tool_name.empty?
35
+ end
36
+
37
+ def validate_resource_uri!(uri)
38
+ raise ValidationError, "Resource URI is required" if uri.nil? || uri.empty?
39
+ end
40
+
41
+ def validate_prompt_name!(prompt_name)
42
+ raise ValidationError, "Prompt name is required" if prompt_name.nil? || prompt_name.empty?
43
+ end
44
+
45
+ def validate_config_file!(config_path)
46
+ unless File.exist?(config_path)
47
+ raise ValidationError, "Configuration file not found: #{config_path}"
48
+ end
49
+
50
+ unless File.readable?(config_path)
51
+ raise ValidationError, "Configuration file is not readable: #{config_path}"
52
+ end
53
+ end
54
+
55
+ def transform_to_operation_params(command, subcommand = nil, *args)
56
+ {
57
+ command: command,
58
+ subcommand: subcommand,
59
+ target: args.first,
60
+ arguments: parse_json_arguments(@options[:args])
61
+ }.compact
62
+ end
63
+
64
+ private
65
+
66
+ attr_reader :args, :options
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPInspector
4
+ module Data
5
+ class OutputAdapter
6
+ DEFAULT_FORMAT = "json"
7
+ VALID_FORMATS = %w[json terminal].freeze
8
+
9
+ def initialize(format: DEFAULT_FORMAT, pretty: true, output_destination: $stdout)
10
+ @format = validate_format(format)
11
+ @pretty = pretty
12
+ @output_destination = output_destination
13
+ @formatter = create_formatter
14
+ end
15
+
16
+ def output_success(data, metadata = {})
17
+ formatted_output = @formatter.format_success(data, metadata)
18
+ write_output(formatted_output)
19
+ end
20
+
21
+ def output_error(error, metadata = {})
22
+ formatted_output = @formatter.format_error(error, metadata)
23
+ write_output(formatted_output)
24
+ end
25
+
26
+ def output_tools_list(tools, metadata = {})
27
+ formatted_output = @formatter.format_tools_list(tools, metadata)
28
+ write_output(formatted_output)
29
+ end
30
+
31
+ def output_resources_list(resources, metadata = {})
32
+ formatted_output = @formatter.format_resources_list(resources, metadata)
33
+ write_output(formatted_output)
34
+ end
35
+
36
+ def output_prompts_list(prompts, metadata = {})
37
+ formatted_output = @formatter.format_prompts_list(prompts, metadata)
38
+ write_output(formatted_output)
39
+ end
40
+
41
+ def output_tool_result(result, metadata = {})
42
+ formatted_output = @formatter.format_tool_result(result, metadata)
43
+ write_output(formatted_output)
44
+ end
45
+
46
+ def output_resource_content(content, metadata = {})
47
+ formatted_output = @formatter.format_resource_content(content, metadata)
48
+ write_output(formatted_output)
49
+ end
50
+
51
+ def output_prompt_result(result, metadata = {})
52
+ formatted_output = @formatter.format_prompt_result(result, metadata)
53
+ write_output(formatted_output)
54
+ end
55
+
56
+ def output_server_info(info, metadata = {})
57
+ formatted_output = @formatter.format_server_info(info, metadata)
58
+ write_output(formatted_output)
59
+ end
60
+
61
+ def output_config_list(servers, metadata = {})
62
+ formatted_output = @formatter.format_config_list(servers, metadata)
63
+ write_output(formatted_output)
64
+ end
65
+
66
+ def output_config_details(server_config, metadata = {})
67
+ formatted_output = @formatter.format_config_details(server_config, metadata)
68
+ write_output(formatted_output)
69
+ end
70
+
71
+ private
72
+
73
+ attr_reader :format, :pretty, :output_destination, :formatter
74
+
75
+ def validate_format(format)
76
+ unless VALID_FORMATS.include?(format)
77
+ raise ArgumentError, "Invalid output format '#{format}'. Valid options: #{VALID_FORMATS.join(', ')}"
78
+ end
79
+ format
80
+ end
81
+
82
+ def create_formatter
83
+ case format
84
+ when "json"
85
+ MCPInspector::Presentation::JSONFormatter.new(pretty: pretty)
86
+ when "terminal"
87
+ # Placeholder for future terminal formatter
88
+ MCPInspector::Presentation::JSONFormatter.new(pretty: pretty)
89
+ else
90
+ raise ArgumentError, "Unsupported formatter: #{format}"
91
+ end
92
+ end
93
+
94
+ def write_output(formatted_output)
95
+ output_destination.puts(formatted_output)
96
+ output_destination.flush if output_destination.respond_to?(:flush)
97
+ end
98
+
99
+ def build_metadata(operation:, server: nil, **additional)
100
+ base_metadata = {
101
+ operation: operation,
102
+ timestamp: Time.now.iso8601
103
+ }
104
+
105
+ base_metadata[:server] = server if server
106
+ base_metadata.merge(additional)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPInspector
4
+ module Presentation
5
+ class BaseFormatter
6
+ def initialize(pretty: true)
7
+ @pretty = pretty
8
+ end
9
+
10
+ def format_success(data, metadata = {})
11
+ raise NotImplementedError, "Subclasses must implement #format_success"
12
+ end
13
+
14
+ def format_error(error, metadata = {})
15
+ raise NotImplementedError, "Subclasses must implement #format_error"
16
+ end
17
+
18
+ def format_tools_list(tools, metadata = {})
19
+ raise NotImplementedError, "Subclasses must implement #format_tools_list"
20
+ end
21
+
22
+ def format_resources_list(resources, metadata = {})
23
+ raise NotImplementedError, "Subclasses must implement #format_resources_list"
24
+ end
25
+
26
+ def format_prompts_list(prompts, metadata = {})
27
+ raise NotImplementedError, "Subclasses must implement #format_prompts_list"
28
+ end
29
+
30
+ def format_tool_result(result, metadata = {})
31
+ raise NotImplementedError, "Subclasses must implement #format_tool_result"
32
+ end
33
+
34
+ def format_resource_content(content, metadata = {})
35
+ raise NotImplementedError, "Subclasses must implement #format_resource_content"
36
+ end
37
+
38
+ def format_prompt_result(result, metadata = {})
39
+ raise NotImplementedError, "Subclasses must implement #format_prompt_result"
40
+ end
41
+
42
+ def format_server_info(info, metadata = {})
43
+ raise NotImplementedError, "Subclasses must implement #format_server_info"
44
+ end
45
+
46
+ def format_config_list(servers, metadata = {})
47
+ raise NotImplementedError, "Subclasses must implement #format_config_list"
48
+ end
49
+
50
+ def format_config_details(server_config, metadata = {})
51
+ raise NotImplementedError, "Subclasses must implement #format_config_details"
52
+ end
53
+
54
+ protected
55
+
56
+ attr_reader :pretty
57
+
58
+ def build_response(status:, data: nil, error: nil, metadata: {})
59
+ response = {
60
+ status: status,
61
+ metadata: build_metadata(metadata)
62
+ }
63
+
64
+ response[:data] = data if data
65
+ response[:error] = format_error_message(error) if error
66
+
67
+ response
68
+ end
69
+
70
+ def build_metadata(metadata)
71
+ base_metadata = {
72
+ timestamp: Time.now.iso8601
73
+ }
74
+
75
+ base_metadata.merge(metadata.compact)
76
+ end
77
+
78
+ def format_error_message(error)
79
+ case error
80
+ when String
81
+ error
82
+ when StandardError
83
+ {
84
+ type: error.class.name,
85
+ message: error.message
86
+ }
87
+ else
88
+ error.to_s
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module MCPInspector
6
+ module Presentation
7
+ class JSONFormatter < BaseFormatter
8
+ def format_success(data, metadata = {})
9
+ response = build_response(status: "success", data: data, metadata: metadata)
10
+ to_json(response)
11
+ end
12
+
13
+ def format_error(error, metadata = {})
14
+ response = build_response(status: "error", error: error, metadata: metadata)
15
+ to_json(response)
16
+ end
17
+
18
+ def format_tools_list(tools, metadata = {})
19
+ data = {
20
+ tools: normalize_list_data(tools),
21
+ count: Array(tools).length
22
+ }
23
+
24
+ metadata = metadata.merge(operation: "list_tools")
25
+ format_success(data, metadata)
26
+ end
27
+
28
+ def format_resources_list(resources, metadata = {})
29
+ data = {
30
+ resources: normalize_list_data(resources),
31
+ count: Array(resources).length
32
+ }
33
+
34
+ metadata = metadata.merge(operation: "list_resources")
35
+ format_success(data, metadata)
36
+ end
37
+
38
+ def format_prompts_list(prompts, metadata = {})
39
+ data = {
40
+ prompts: normalize_list_data(prompts),
41
+ count: Array(prompts).length
42
+ }
43
+
44
+ metadata = metadata.merge(operation: "list_prompts")
45
+ format_success(data, metadata)
46
+ end
47
+
48
+ def format_tool_result(result, metadata = {})
49
+ data = {
50
+ result: result
51
+ }
52
+
53
+ metadata = metadata.merge(operation: "execute_tool")
54
+ format_success(data, metadata)
55
+ end
56
+
57
+ def format_resource_content(content, metadata = {})
58
+ data = {
59
+ content: content
60
+ }
61
+
62
+ metadata = metadata.merge(operation: "read_resource")
63
+ format_success(data, metadata)
64
+ end
65
+
66
+ def format_prompt_result(result, metadata = {})
67
+ data = {
68
+ result: result
69
+ }
70
+
71
+ metadata = metadata.merge(operation: "get_prompt")
72
+ format_success(data, metadata)
73
+ end
74
+
75
+ def format_server_info(info, metadata = {})
76
+ data = {
77
+ server_info: info
78
+ }
79
+
80
+ metadata = metadata.merge(operation: "server_info")
81
+ format_success(data, metadata)
82
+ end
83
+
84
+ def format_config_list(servers, metadata = {})
85
+ server_list = servers.map do |name, config|
86
+ {
87
+ name: name,
88
+ transport: config.transport,
89
+ description: build_server_description(config)
90
+ }
91
+ end
92
+
93
+ data = {
94
+ servers: server_list,
95
+ count: servers.length
96
+ }
97
+
98
+ metadata = metadata.merge(operation: "config_list")
99
+ format_success(data, metadata)
100
+ end
101
+
102
+ def format_config_details(server_config, metadata = {})
103
+ data = {
104
+ server_config: server_config.to_h
105
+ }
106
+
107
+ metadata = metadata.merge(operation: "config_show")
108
+ format_success(data, metadata)
109
+ end
110
+
111
+ private
112
+
113
+ def to_json(data)
114
+ if pretty
115
+ JSON.pretty_generate(data, {
116
+ indent: " ",
117
+ space: " ",
118
+ object_nl: "\n",
119
+ array_nl: "\n"
120
+ })
121
+ else
122
+ JSON.generate(data)
123
+ end
124
+ end
125
+
126
+ def normalize_list_data(data)
127
+ case data
128
+ when Hash
129
+ data.fetch("items", [])
130
+ when Array
131
+ data
132
+ when NilClass
133
+ []
134
+ else
135
+ [data]
136
+ end
137
+ end
138
+
139
+ def build_server_description(config)
140
+ case config.transport
141
+ when "stdio"
142
+ "#{config.command.join(' ')} (stdio)"
143
+ when "sse"
144
+ "#{config.url} (sse)"
145
+ when "websocket"
146
+ "#{config.url} (websocket)"
147
+ else
148
+ "#{config.transport} transport"
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end