Skip to content

Commit 3d7f369

Browse files
committed
update
1 parent b71766d commit 3d7f369

8 files changed

Lines changed: 279 additions & 50 deletions

File tree

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
stack-service-base (0.0.97)
4+
stack-service-base (0.0.98)
55
async
66
debug
77
dotenv

lib/stack-service-base/examples/mcp_config.ru

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,35 @@ Tool :fetch do
3232
end
3333
end
3434

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+
3564
Tool :service_status do
3665
description 'Check current status of a service'
3766
input service_name: { type: "string", description: "Service name to inspect", required: true }

lib/stack-service-base/mcp/mcp_helper.rb

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@
44
MCP_PROCESSOR = McpProcessor.new
55

66
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+
722
def self.included(base)
823
base.class_eval do
924

@@ -18,28 +33,34 @@ def self.included(base)
1833
end
1934

2035
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
2536
request.body&.rewind
26-
body = request.body.read.to_s
2737

2838
response_body =
2939
begin
30-
MCP_PROCESSOR.rpc_endpoint(body)
40+
MCP_PROCESSOR.rpc_endpoint(request.body.read.to_s)
3141
rescue McpProcessor::ParseError => e
3242
status e.status
3343
e.body
3444
end
3545

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
3858

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
4364
end
4465
end
4566
end

lib/stack-service-base/mcp/mcp_processor.rb

Lines changed: 69 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
require 'json'
2+
13
class JsonRpcError < StandardError
24
attr_reader :code
35

@@ -15,6 +17,11 @@ def rpc_error!(code, message)
1517

1618
class McpProcessor
1719
PROTOCOL_VERSION = '2025-06-18'
20+
DEFAULT_SERVER_INFO = {
21+
name: 'mcp-server',
22+
title: 'MCP Server',
23+
version: '1.0.0'
24+
}.freeze
1825

1926
include RpcErrorHelpers
2027
ParseError = Class.new(StandardError) do
@@ -27,7 +34,9 @@ def initialize(body:, status:)
2734
end
2835
end
2936

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
3140
@logger = logger
3241
end
3342

@@ -37,14 +46,22 @@ def root_endpoint
3746

3847
def rpc_endpoint(raw_body)
3948
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}")
4259
body = error_response(id: nil, code: -32700, message: "Parse error")
4360
raise ParseError.new(body: body, status: 400)
4461
end
4562

4663
def list_tools
47-
{ tools: ToolRegistry.list, nextCursor: 'no-more' }
64+
{ tools: registry.list, nextCursor: 'no-more' }
4865
end
4966

5067
def root_response
@@ -56,32 +73,47 @@ def error_response(id:, code:, message:)
5673
end
5774

5875
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
6085
end
6186

62-
def handle(method:, params:, body: )
87+
def handle(method:, params:)
6388
case method
6489
when "tools/list" then list_tools
6590
# when "resources/list" then {}
6691
# when "prompts/list" then {}
6792
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); {}
7196
else
7297
rpc_error!(-32601, "Unknown method #{method}")
7398
end
7499
end
75100

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
77109

78-
def initialize_(body)
110+
def initialize_(_body = nil)
111+
initialize_response
112+
end
113+
114+
def initialize_response
79115
{
80-
serverInfo: {
81-
name: 'mcp-server',
82-
title: 'MCP Server',
83-
version: '1.0.0'
84-
},
116+
serverInfo: @server_info,
85117
protocolVersion: PROTOCOL_VERSION,
86118
capabilities: {
87119
logging: {},
@@ -98,7 +130,7 @@ def json_rpc_response(id:)
98130
body = { jsonrpc: "2.0", id: id }
99131

100132
begin
101-
result = yield(body)
133+
result = yield
102134
body[:result] = result unless body[:error] || result.nil?
103135
rescue JsonRpcError => e
104136
body[:error] = { code: e.code, message: e.message }
@@ -114,11 +146,29 @@ def json_rpc_response(id:)
114146
def call_tool(params)
115147
name = params["name"]
116148
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}")
118150
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)
119169
{
120170
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) }
122172
],
123173
isError: false
124174
}

0 commit comments

Comments
 (0)