stack-service-base 0.0.97 → 0.0.98

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a29686255562ea2b36806d5d50db270dda9c65d6b9d819f0f9c241a3a4f85049
4
- data.tar.gz: 2e33c64c8f927a7ac034c39d2b2ea1b5118bedb3315d44d2265f80d463fafdad
3
+ metadata.gz: 4637e888970a11131f20239dd6b2e64c661d40daafe1942cdd2193d6b05f495e
4
+ data.tar.gz: a9d68103d79f1ab23a0c9cac1c2366b42238a1e5006b8c716b126e791966cf08
5
5
  SHA512:
6
- metadata.gz: 0f8d15d2b3e8c33e8e321464df2068351da9e6fdb5b0439405635ed7876f4fb014d9525284a87c6237ee13c4731cc177ebfd0f5328f9d44fcc8a32b36f811ff9
7
- data.tar.gz: ea9223839e6ccabe433121647be6a8c93ce62d6d807c482aad9c3a1fdf48b18a226a8d7eb1449b71c7ab93e41c4c390f7379d092e6403de8136d0249e3ce4647
6
+ metadata.gz: 747b9ae740e563c28057aaee05321183280a44152ac748fe949e2d04e9077b5bc8c66add36340daa79759cbce31a8f75dfb3da0811745aed259c35e3370a7059
7
+ data.tar.gz: a70bdbb1c4915a65db80745a470c905114add9e35df94c7ea4b9a3b057abfee7af29488c64b612cf9ed0fc609b0f98a3a402509583dda244d7d08ad5157dba9f
@@ -32,6 +32,35 @@ Tool :fetch do
32
32
  end
33
33
  end
34
34
 
35
+ Tool :schema_echo do
36
+ description 'Echo a value using a direct JSON schema'
37
+ input_schema type: "object",
38
+ properties: {
39
+ value: { type: "string", description: "Value to echo" }
40
+ },
41
+ required: ["value"]
42
+ annotations readOnlyHint: true
43
+ call do |inputs|
44
+ { value: inputs[:value] }
45
+ end
46
+ end
47
+
48
+ Tool :full_response_echo do
49
+ description 'Echo a value using a complete MCP tool response'
50
+ input value: { type: "string", description: "Value to echo", required: true }
51
+ call do |inputs|
52
+ {
53
+ content: [
54
+ { type: "text", text: inputs[:value] }
55
+ ],
56
+ structuredContent: {
57
+ value: inputs[:value]
58
+ },
59
+ isError: false
60
+ }
61
+ end
62
+ end
63
+
35
64
  Tool :service_status do
36
65
  description 'Check current status of a service'
37
66
  input service_name: { type: "string", description: "Service name to inspect", required: true }
@@ -4,6 +4,21 @@ require_relative 'mcp_tool_registry'
4
4
  MCP_PROCESSOR = McpProcessor.new
5
5
 
6
6
  module McpHelper
7
+ VALID_TRANSPORTS = [:sse, :json].freeze
8
+
9
+ class << self
10
+ def transport
11
+ @transport ||= :sse
12
+ end
13
+
14
+ def transport=(value)
15
+ value = value.to_sym
16
+ raise ArgumentError, "Unknown MCP transport: #{value}" unless VALID_TRANSPORTS.include?(value)
17
+
18
+ @transport = value
19
+ end
20
+ end
21
+
7
22
  def self.included(base)
8
23
  base.class_eval do
9
24
 
@@ -18,28 +33,34 @@ module McpHelper
18
33
  end
19
34
 
20
35
  post '/mcp' do
21
- content_type 'text/event-stream'
22
- headers['Cache-Control'] = 'no-cache'
23
- headers['X-Accel-Buffering'] = 'no'
24
- headers['mcp-session-id'] = SecureRandom.uuid
25
36
  request.body&.rewind
26
- body = request.body.read.to_s
27
37
 
28
38
  response_body =
29
39
  begin
30
- MCP_PROCESSOR.rpc_endpoint(body)
40
+ MCP_PROCESSOR.rpc_endpoint(request.body.read.to_s)
31
41
  rescue McpProcessor::ParseError => e
32
42
  status e.status
33
43
  e.body
34
44
  end
35
45
 
36
- LOGGER.debug "request body: #{body}"
37
- LOGGER.debug "response body: #{response_body}"
46
+ if response_body.nil?
47
+ status 202
48
+ headers 'Content-Length' => '0'
49
+ ''
50
+ elsif McpHelper.transport == :json
51
+ content_type :json
52
+ response_body
53
+ else
54
+ content_type 'text/event-stream'
55
+ headers['Cache-Control'] = 'no-cache'
56
+ headers['X-Accel-Buffering'] = 'no'
57
+ headers['mcp-session-id'] = SecureRandom.uuid
38
58
 
39
- stream true do |s|
40
- s.callback { LOGGER.debug "stream closed: #{s}" }
41
- s << "event: message\ndata: #{response_body}\n\n"
42
- s.close
59
+ stream true do |s|
60
+ s.callback { LOGGER.debug "stream closed: #{s}" }
61
+ s << "event: message\ndata: #{response_body}\n\n"
62
+ s.close
63
+ end
43
64
  end
44
65
  end
45
66
  end
@@ -1,3 +1,5 @@
1
+ require 'json'
2
+
1
3
  class JsonRpcError < StandardError
2
4
  attr_reader :code
3
5
 
@@ -15,6 +17,11 @@ end
15
17
 
16
18
  class McpProcessor
17
19
  PROTOCOL_VERSION = '2025-06-18'
20
+ DEFAULT_SERVER_INFO = {
21
+ name: 'mcp-server',
22
+ title: 'MCP Server',
23
+ version: '1.0.0'
24
+ }.freeze
18
25
 
19
26
  include RpcErrorHelpers
20
27
  ParseError = Class.new(StandardError) do
@@ -27,7 +34,9 @@ class McpProcessor
27
34
  end
28
35
  end
29
36
 
30
- def initialize(logger: LOGGER)
37
+ def initialize(registry: nil, server_info: DEFAULT_SERVER_INFO, logger: (defined?(LOGGER) ? LOGGER : nil))
38
+ @registry = registry
39
+ @server_info = server_info
31
40
  @logger = logger
32
41
  end
33
42
 
@@ -37,14 +46,22 @@ class McpProcessor
37
46
 
38
47
  def rpc_endpoint(raw_body)
39
48
  req = JSON.parse(raw_body.to_s)
40
- rpc_response(id: req["id"], method: req["method"], params: req["params"])
41
- rescue JSON::ParserError
49
+ method = req["method"]
50
+ params = req["params"]
51
+
52
+ if req.key?("id")
53
+ rpc_response(id: req["id"], method: method, params: params)
54
+ else
55
+ notification_response(method: method, params: params)
56
+ end
57
+ rescue JSON::ParserError => e
58
+ @logger&.warn("MCP JSON parse failed: #{e.message}")
42
59
  body = error_response(id: nil, code: -32700, message: "Parse error")
43
60
  raise ParseError.new(body: body, status: 400)
44
61
  end
45
62
 
46
63
  def list_tools
47
- { tools: ToolRegistry.list, nextCursor: 'no-more' }
64
+ { tools: registry.list, nextCursor: 'no-more' }
48
65
  end
49
66
 
50
67
  def root_response
@@ -56,32 +73,47 @@ class McpProcessor
56
73
  end
57
74
 
58
75
  def rpc_response(id:, method:, params:)
59
- json_rpc_response(id: id) { |body| handle(method: method, params: params, body: body) }
76
+ json_rpc_response(id: id) { handle(method: method, params: params) }
77
+ end
78
+
79
+ def notification_response(method:, params:)
80
+ handle_notification(method: method, params: params)
81
+ nil
82
+ rescue => e
83
+ @logger&.error("Unhandled MCP notification error: #{e.class}: #{e.message}")
84
+ nil
60
85
  end
61
86
 
62
- def handle(method:, params:, body: )
87
+ def handle(method:, params:)
63
88
  case method
64
89
  when "tools/list" then list_tools
65
90
  # when "resources/list" then {}
66
91
  # when "prompts/list" then {}
67
92
  when "tools/call" then call_tool(params || {})
68
- when "initialize" then initialize_(body)
69
- when "notifications/initialized" then LOGGER.debug params; {}
70
- when "logging/setLevel" then LOGGER.debug params; {}
93
+ when "initialize" then initialize_response
94
+ when "notifications/initialized" then @logger&.debug(params); {}
95
+ when "logging/setLevel" then @logger&.debug(params); {}
71
96
  else
72
97
  rpc_error!(-32601, "Unknown method #{method}")
73
98
  end
74
99
  end
75
100
 
76
- # https://gist.github.com/ruvnet/7b6843c457822cbcf42fc4aa635eadbb
101
+ def handle_notification(method:, params:)
102
+ case method
103
+ when "notifications/initialized", "notifications/cancelled"
104
+ @logger&.debug("MCP notification accepted: #{method}")
105
+ else
106
+ @logger&.debug("MCP notification ignored: #{method}")
107
+ end
108
+ end
77
109
 
78
- def initialize_(body)
110
+ def initialize_(_body = nil)
111
+ initialize_response
112
+ end
113
+
114
+ def initialize_response
79
115
  {
80
- serverInfo: {
81
- name: 'mcp-server',
82
- title: 'MCP Server',
83
- version: '1.0.0'
84
- },
116
+ serverInfo: @server_info,
85
117
  protocolVersion: PROTOCOL_VERSION,
86
118
  capabilities: {
87
119
  logging: {},
@@ -98,7 +130,7 @@ class McpProcessor
98
130
  body = { jsonrpc: "2.0", id: id }
99
131
 
100
132
  begin
101
- result = yield(body)
133
+ result = yield
102
134
  body[:result] = result unless body[:error] || result.nil?
103
135
  rescue JsonRpcError => e
104
136
  body[:error] = { code: e.code, message: e.message }
@@ -114,11 +146,29 @@ class McpProcessor
114
146
  def call_tool(params)
115
147
  name = params["name"]
116
148
  arguments = params["arguments"] || {}
117
- tool = ToolRegistry.fetch(name) || rpc_error!(-32601, "Unknown tool #{name}")
149
+ tool = registry.fetch(name) || rpc_error!(-32601, "Unknown tool #{name}")
118
150
  response = tool.call_tool(arguments)
151
+ return response if mcp_tool_response?(response)
152
+
153
+ wrap_tool_response(response)
154
+ end
155
+
156
+ def registry
157
+ @registry || ToolRegistry.default
158
+ end
159
+
160
+ def mcp_tool_response?(response)
161
+ return false unless response.is_a?(Hash)
162
+
163
+ [:content, :structuredContent, :isError, "content", "structuredContent", "isError"].any? do |key|
164
+ response.key?(key)
165
+ end
166
+ end
167
+
168
+ def wrap_tool_response(response)
119
169
  {
120
170
  content: [
121
- { "type": "text", "text": response.is_a?(String) ? response : response.to_json }
171
+ { "type": "text", "text": response.is_a?(String) ? response : JSON.dump(response) }
122
172
  ],
123
173
  isError: false
124
174
  }
@@ -3,12 +3,35 @@ module ToolRegistry
3
3
  include RpcErrorHelpers
4
4
  end
5
5
 
6
+ class Registry
7
+ attr_reader :registry
8
+
9
+ def initialize
10
+ @registry = {}
11
+ end
12
+
13
+ def define(name, &block)
14
+ definition = Definition.new(name)
15
+ definition.instance_eval(&block)
16
+ @registry[definition.name] = definition
17
+ end
18
+
19
+ def list
20
+ registry.values.map(&:to_h)
21
+ end
22
+
23
+ def fetch(name)
24
+ registry[name.to_s]
25
+ end
26
+ end
27
+
6
28
  class Definition
7
29
  attr_reader :name
8
30
 
9
31
  def initialize(name)
10
32
  @name = name.to_s
11
33
  @inputs = {}
34
+ @annotations = {}
12
35
  end
13
36
 
14
37
  def description(text = nil)
@@ -20,62 +43,89 @@ module ToolRegistry
20
43
  @inputs.merge!(fields.transform_keys(&:to_s))
21
44
  end
22
45
 
23
- def input_schema
24
- properties = {}
25
- required = []
46
+ def input_schema(schema = nil)
47
+ return @input_schema || build_input_schema if schema.nil?
26
48
 
27
- @inputs.each do |field, config|
28
- cfg = config.transform_keys { |key| key.is_a?(Symbol) ? key : key.to_sym }
29
- required << field if cfg.delete(:required)
30
- properties[field] = cfg.transform_keys(&:to_s)
31
- end
49
+ @input_schema = stringify_keys(schema)
50
+ end
32
51
 
33
- { type: 'object', properties: properties, required: required }
52
+ def annotations(value = nil)
53
+ return @annotations if value.nil?
54
+
55
+ @annotations = stringify_keys(value)
34
56
  end
35
57
 
36
58
  def call(&block) = @executor = block
37
59
 
38
60
  def to_h
39
- {
61
+ payload = {
40
62
  name: name,
41
63
  description: description,
42
64
  inputSchema: input_schema
43
65
  }
66
+ payload[:annotations] = annotations unless annotations.empty?
67
+ payload
44
68
  end
45
69
 
46
70
  def call_tool(arguments)
47
- raise JsonRpcError.new(code: 500, message: "Tool #{name} missing executor") unless @executor
71
+ raise JsonRpcError.new(code: -32603, message: "Tool #{name} missing executor") unless @executor
48
72
 
49
73
  ExecutionContext.new.instance_exec(symbolize_keys(arguments || {}), &@executor)
50
74
  end
51
75
 
52
76
  private
53
77
 
78
+ def build_input_schema
79
+ properties = {}
80
+ required = []
81
+
82
+ @inputs.each do |field, config|
83
+ cfg = stringify_keys(config)
84
+ required << field if cfg.delete("required")
85
+ properties[field] = cfg
86
+ end
87
+
88
+ { type: 'object', properties: properties, required: required }
89
+ end
90
+
54
91
  def symbolize_keys(hash)
55
92
  hash.each_with_object({}) do |(key, value), memo|
56
93
  memo[key.to_sym] = value
57
94
  end
58
95
  end
96
+
97
+ def stringify_keys(value)
98
+ case value
99
+ when Hash
100
+ value.each_with_object({}) { |(key, item), memo| memo[key.to_s] = stringify_keys(item) }
101
+ when Array
102
+ value.map { |item| stringify_keys(item) }
103
+ else
104
+ value
105
+ end
106
+ end
59
107
  end
60
108
 
61
109
  module_function
62
110
 
111
+ def default
112
+ @default ||= Registry.new
113
+ end
114
+
63
115
  def define(name, &block)
64
- definition = Definition.new(name)
65
- definition.instance_eval(&block)
66
- registry[definition.name] = definition
116
+ default.define(name, &block)
67
117
  end
68
118
 
69
119
  def registry
70
- @registry ||= {}
120
+ default.registry
71
121
  end
72
122
 
73
123
  def list
74
- registry.values.map(&:to_h)
124
+ default.list
75
125
  end
76
126
 
77
127
  def fetch(name)
78
- registry[name.to_s]
128
+ default.fetch(name)
79
129
  end
80
130
  end
81
131
 
@@ -1,3 +1,4 @@
1
+ # Long ....
1
2
  stages:
2
3
  - deploy
3
4
 
@@ -1,3 +1,3 @@
1
1
  module StackServiceBase
2
- VERSION = '0.0.97'
2
+ VERSION = '0.0.98'
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stack-service-base
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.97
4
+ version: 0.0.98
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artyom B