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 +4 -4
- data/lib/stack-service-base/examples/mcp_config.ru +29 -0
- data/lib/stack-service-base/mcp/mcp_helper.rb +33 -12
- data/lib/stack-service-base/mcp/mcp_processor.rb +69 -19
- data/lib/stack-service-base/mcp/mcp_tool_registry.rb +67 -17
- data/lib/stack-service-base/stack_template/gitlab-c/.gitlab-ci.yml +1 -0
- data/lib/stack-service-base/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4637e888970a11131f20239dd6b2e64c661d40daafe1942cdd2193d6b05f495e
|
|
4
|
+
data.tar.gz: a9d68103d79f1ab23a0c9cac1c2366b42238a1e5006b8c716b126e791966cf08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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:
|
|
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) {
|
|
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
|
|
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
|
|
69
|
-
when "notifications/initialized" then
|
|
70
|
-
when "logging/setLevel" then
|
|
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
|
-
|
|
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_(
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
25
|
-
required = []
|
|
46
|
+
def input_schema(schema = nil)
|
|
47
|
+
return @input_schema || build_input_schema if schema.nil?
|
|
26
48
|
|
|
27
|
-
@
|
|
28
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
default.registry
|
|
71
121
|
end
|
|
72
122
|
|
|
73
123
|
def list
|
|
74
|
-
|
|
124
|
+
default.list
|
|
75
125
|
end
|
|
76
126
|
|
|
77
127
|
def fetch(name)
|
|
78
|
-
|
|
128
|
+
default.fetch(name)
|
|
79
129
|
end
|
|
80
130
|
end
|
|
81
131
|
|