mistri 0.0.3 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/README.md +367 -3
- data/lib/generators/mistri/install/install_generator.rb +54 -0
- data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
- data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
- data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
- data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
- data/lib/mistri/abort_signal.rb +63 -0
- data/lib/mistri/agent.rb +389 -0
- data/lib/mistri/budget.rb +29 -0
- data/lib/mistri/compaction.rb +78 -0
- data/lib/mistri/compactor.rb +182 -0
- data/lib/mistri/content.rb +89 -0
- data/lib/mistri/edit.rb +238 -0
- data/lib/mistri/errors.rb +94 -0
- data/lib/mistri/event.rb +54 -0
- data/lib/mistri/mcp/client.rb +156 -0
- data/lib/mistri/mcp/oauth.rb +286 -0
- data/lib/mistri/mcp/wires.rb +164 -0
- data/lib/mistri/mcp.rb +96 -0
- data/lib/mistri/memory.rb +26 -0
- data/lib/mistri/message.rb +90 -0
- data/lib/mistri/models.rb +43 -0
- data/lib/mistri/partial_json.rb +210 -0
- data/lib/mistri/providers/anthropic/assembler.rb +205 -0
- data/lib/mistri/providers/anthropic/serializer.rb +106 -0
- data/lib/mistri/providers/anthropic.rb +106 -0
- data/lib/mistri/providers/fake.rb +109 -0
- data/lib/mistri/providers/gemini/assembler.rb +163 -0
- data/lib/mistri/providers/gemini/serializer.rb +109 -0
- data/lib/mistri/providers/gemini.rb +73 -0
- data/lib/mistri/providers/openai/assembler.rb +205 -0
- data/lib/mistri/providers/openai/serializer.rb +104 -0
- data/lib/mistri/providers/openai.rb +72 -0
- data/lib/mistri/reminder.rb +36 -0
- data/lib/mistri/result.rb +32 -0
- data/lib/mistri/retry_policy.rb +47 -0
- data/lib/mistri/schema.rb +162 -0
- data/lib/mistri/session.rb +124 -0
- data/lib/mistri/sinks/action_cable.rb +30 -0
- data/lib/mistri/sinks/coalesced.rb +61 -0
- data/lib/mistri/sinks/sse.rb +26 -0
- data/lib/mistri/skill.rb +15 -0
- data/lib/mistri/skills.rb +81 -0
- data/lib/mistri/sse.rb +50 -0
- data/lib/mistri/stop_reason.rb +25 -0
- data/lib/mistri/stores/active_record.rb +47 -0
- data/lib/mistri/stores/jsonl.rb +37 -0
- data/lib/mistri/stores/memory.rb +22 -0
- data/lib/mistri/sub_agent.rb +211 -0
- data/lib/mistri/tool.rb +95 -0
- data/lib/mistri/tool_call.rb +18 -0
- data/lib/mistri/tool_context.rb +15 -0
- data/lib/mistri/tool_executor.rb +87 -0
- data/lib/mistri/tool_result.rb +23 -0
- data/lib/mistri/tools/edit_file.rb +37 -0
- data/lib/mistri/tools/find_in_file.rb +36 -0
- data/lib/mistri/tools/list_files.rb +16 -0
- data/lib/mistri/tools/read_file.rb +38 -0
- data/lib/mistri/tools/read_memory.rb +16 -0
- data/lib/mistri/tools/update_memory.rb +22 -0
- data/lib/mistri/tools/write_file.rb +20 -0
- data/lib/mistri/tools.rb +50 -0
- data/lib/mistri/transport.rb +228 -0
- data/lib/mistri/usage.rb +79 -0
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +47 -0
- data/lib/mistri/workspace/directory.rb +52 -0
- data/lib/mistri/workspace/memory.rb +40 -0
- data/lib/mistri/workspace/single.rb +48 -0
- data/lib/mistri.rb +89 -0
- metadata +79 -10
data/lib/mistri/event.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# One event in a streamed assistant turn. A stream is one :start, then a
|
|
5
|
+
# start/delta/end trio per content block (text, thinking, or toolcall), then
|
|
6
|
+
# exactly one terminal event: :done on success or :error on failure, carrying
|
|
7
|
+
# the complete message and its stop reason.
|
|
8
|
+
#
|
|
9
|
+
# `partial` is an immutable snapshot of the assistant message so far, safe to
|
|
10
|
+
# hold across events. `content_index` is the block's position in that
|
|
11
|
+
# message's content list.
|
|
12
|
+
# origin names the sub-agent an event came from: nil for this agent's own
|
|
13
|
+
# turns, and nesting joins names left to right ("researcher>writer").
|
|
14
|
+
# duration is the tool's execution time in seconds on :tool_result
|
|
15
|
+
# events; nil where nothing ran (denials, interruptions).
|
|
16
|
+
class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
|
|
17
|
+
:reason, :message, :error_message, :partial, :origin,
|
|
18
|
+
:duration)
|
|
19
|
+
# The stream types come from a provider mid-turn; the loop adds
|
|
20
|
+
# :tool_result after it runs each tool, :approval_needed when a gated
|
|
21
|
+
# call parks for a human, and :compacting/:compaction around a context
|
|
22
|
+
# compaction, so one subscription sees the whole exchange.
|
|
23
|
+
TYPES = %i[
|
|
24
|
+
start
|
|
25
|
+
text_start text_delta text_end
|
|
26
|
+
thinking_start thinking_delta thinking_end
|
|
27
|
+
toolcall_start toolcall_delta toolcall_end
|
|
28
|
+
done error
|
|
29
|
+
tool_result approval_needed
|
|
30
|
+
compacting compaction
|
|
31
|
+
retry
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
|
|
35
|
+
reason: nil, message: nil, error_message: nil, partial: nil, origin: nil,
|
|
36
|
+
duration: nil)
|
|
37
|
+
raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
|
|
38
|
+
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def done? = type == :done
|
|
43
|
+
|
|
44
|
+
def error? = type == :error
|
|
45
|
+
|
|
46
|
+
def terminal? = done? || error?
|
|
47
|
+
|
|
48
|
+
# Partials are ephemeral streaming state and stay out of serialization.
|
|
49
|
+
def to_h
|
|
50
|
+
{ type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
|
|
51
|
+
reason:, message: message&.to_h, error_message:, origin:, duration: }.compact
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
module MCP
|
|
7
|
+
# A Model Context Protocol client: the initialize handshake, tools/list
|
|
8
|
+
# with pagination, and tools/call, over one of two wires. url: speaks
|
|
9
|
+
# Streamable HTTP on the same persistent transport the providers use;
|
|
10
|
+
# command: spawns a local stdio server with credentials in its
|
|
11
|
+
# environment.
|
|
12
|
+
#
|
|
13
|
+
# Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
|
|
14
|
+
# token: -> { connection.bearer_token })
|
|
15
|
+
# Mistri::MCP::Client.new(command: ["npx", "-y", "some-mcp-server"],
|
|
16
|
+
# env: { "API_KEY" => key })
|
|
17
|
+
#
|
|
18
|
+
# HTTP auth is a headers hash or token: a string or a callable. A
|
|
19
|
+
# callable resolves per request, and a 401 retries once after
|
|
20
|
+
# re-resolving, so a host's refresh logic lives in one lambda. A session
|
|
21
|
+
# the server expires (404 with a session attached) transparently
|
|
22
|
+
# re-initializes, per spec.
|
|
23
|
+
#
|
|
24
|
+
# One client serializes its calls; parallel tool calls against one
|
|
25
|
+
# server queue rather than interleave.
|
|
26
|
+
class Client
|
|
27
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
28
|
+
SUPPORTED_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
|
|
29
|
+
LOOPBACK = %w[localhost 127.0.0.1 ::1].freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :server_info
|
|
32
|
+
|
|
33
|
+
def initialize(url: nil, command: nil, env: {}, token: nil, headers: {},
|
|
34
|
+
client_name: "mistri", open_timeout: 15, read_timeout: 120)
|
|
35
|
+
if [url, command].compact.length != 1
|
|
36
|
+
raise ConfigurationError, "pass exactly one of url: or command:"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if url && token && URI(url).scheme == "http" && !LOOPBACK.include?(URI(url).host)
|
|
40
|
+
raise ConfigurationError,
|
|
41
|
+
"refusing to send a bearer token over plain HTTP to #{URI(url).host}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@wire = if url
|
|
45
|
+
Wires::Http.new(url: url, token: token, headers: headers,
|
|
46
|
+
open_timeout: open_timeout, read_timeout: read_timeout)
|
|
47
|
+
else
|
|
48
|
+
Wires::Stdio.new(command: command, env: env, read_timeout: read_timeout)
|
|
49
|
+
end
|
|
50
|
+
@client_name = client_name
|
|
51
|
+
@mutex = Mutex.new
|
|
52
|
+
@serial = 0
|
|
53
|
+
@connected = false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The server's tools as it describes them: hashes with "name",
|
|
57
|
+
# "description", and "inputSchema". Cached; refresh: true re-lists.
|
|
58
|
+
def tools(refresh: false)
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
@tools = nil if refresh
|
|
61
|
+
@tools ||= list_tools
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def call_tool(name, arguments = {})
|
|
66
|
+
@mutex.synchronize { request("tools/call", { name: name, arguments: arguments }) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def connect
|
|
70
|
+
@mutex.synchronize { ensure_connected }
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def close
|
|
75
|
+
@wire.close
|
|
76
|
+
@connected = false
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def ensure_connected
|
|
83
|
+
return if @connected
|
|
84
|
+
|
|
85
|
+
result = rpc("initialize", {
|
|
86
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
87
|
+
capabilities: {},
|
|
88
|
+
clientInfo: { name: @client_name, version: Mistri::VERSION }
|
|
89
|
+
})
|
|
90
|
+
version = result["protocolVersion"].to_s
|
|
91
|
+
unless SUPPORTED_VERSIONS.include?(version)
|
|
92
|
+
raise Error, "server negotiated unsupported protocol version #{version.inspect}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
@wire.protocol_version = version
|
|
96
|
+
@server_info = result["serverInfo"]
|
|
97
|
+
@wire.notify({ jsonrpc: "2.0", method: "notifications/initialized" })
|
|
98
|
+
@connected = true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def request(method, params, reconnected: false, refreshed: false)
|
|
102
|
+
ensure_connected
|
|
103
|
+
rpc(method, params)
|
|
104
|
+
rescue AuthenticationError
|
|
105
|
+
raise if refreshed || !@wire.refreshable?
|
|
106
|
+
|
|
107
|
+
# The token callable resolves fresh on retry; hosts refresh there.
|
|
108
|
+
request(method, params, reconnected: reconnected, refreshed: true)
|
|
109
|
+
rescue SessionExpired
|
|
110
|
+
raise Error, "the server expired the session twice in a row" if reconnected
|
|
111
|
+
|
|
112
|
+
@connected = false
|
|
113
|
+
@wire.reset_session
|
|
114
|
+
request(method, params, reconnected: true, refreshed: refreshed)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def rpc(method, params)
|
|
118
|
+
id = (@serial += 1)
|
|
119
|
+
payload = { jsonrpc: "2.0", id: id, method: method, params: params }
|
|
120
|
+
result = nil
|
|
121
|
+
responded = false
|
|
122
|
+
@wire.call(payload) do |record|
|
|
123
|
+
next unless record.is_a?(Hash) && record["id"] == id
|
|
124
|
+
|
|
125
|
+
responded = true
|
|
126
|
+
raise rpc_error(record["error"]) if record["error"]
|
|
127
|
+
|
|
128
|
+
result = record["result"]
|
|
129
|
+
end
|
|
130
|
+
raise Error, "the server sent no response to #{method}" unless responded
|
|
131
|
+
|
|
132
|
+
result
|
|
133
|
+
rescue ProviderError => e
|
|
134
|
+
raise SessionExpired if e.status == 404 && @wire.session?
|
|
135
|
+
|
|
136
|
+
raise
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def list_tools
|
|
140
|
+
collected = []
|
|
141
|
+
cursor = nil
|
|
142
|
+
loop do
|
|
143
|
+
result = request("tools/list", cursor ? { cursor: cursor } : {})
|
|
144
|
+
collected.concat(Array(result["tools"]))
|
|
145
|
+
cursor = result["nextCursor"]
|
|
146
|
+
break unless cursor
|
|
147
|
+
end
|
|
148
|
+
collected
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def rpc_error(error)
|
|
152
|
+
Error.new(error["message"] || "MCP request failed", code: error["code"])
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Mistri
|
|
10
|
+
module MCP
|
|
11
|
+
# The OAuth 2.1 subset the MCP spec requires of clients, as three
|
|
12
|
+
# storage-agnostic services a host calls from anywhere: a controller, a
|
|
13
|
+
# GraphQL mutation, a job. Each returns a string-keyed hash ready to
|
|
14
|
+
# persist on the host's own connection record.
|
|
15
|
+
#
|
|
16
|
+
# flow = Mistri::MCP::OAuth.start(url: params[:url],
|
|
17
|
+
# client_name: "Sendoso",
|
|
18
|
+
# redirect_uri: mcp_callback_url)
|
|
19
|
+
# # persist flow, redirect the user to flow["authorize_url"]
|
|
20
|
+
#
|
|
21
|
+
# tokens = Mistri::MCP::OAuth.complete(code: params[:code], **persisted)
|
|
22
|
+
# tokens = Mistri::MCP::OAuth.refresh(**persisted)
|
|
23
|
+
#
|
|
24
|
+
# Registration happens as the APPLICATION, never as the harness:
|
|
25
|
+
# client_name has no default because that identity is the host's call.
|
|
26
|
+
# Servers without dynamic registration take client_id:/client_secret:
|
|
27
|
+
# directly and skip it.
|
|
28
|
+
module OAuth
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Discover the server's authorization setup, register the application,
|
|
32
|
+
# and build the authorize URL. Returns everything the callback and
|
|
33
|
+
# refresh need: authorize_url, state, code_verifier, client_id,
|
|
34
|
+
# client_secret, token_auth_method, token_endpoint, resource,
|
|
35
|
+
# redirect_uri.
|
|
36
|
+
#
|
|
37
|
+
# With no scope given, the server's advertised scopes_supported are
|
|
38
|
+
# requested, and offline_access rides along when the authorization
|
|
39
|
+
# server supports it, which is what earns a refresh token from
|
|
40
|
+
# providers that require it.
|
|
41
|
+
def start(url:, client_name:, redirect_uri:, scope: nil,
|
|
42
|
+
client_id: nil, client_secret: nil)
|
|
43
|
+
resource = canonical(url)
|
|
44
|
+
resource_metadata = resource_metadata_for(url)
|
|
45
|
+
metadata = server_metadata(Array(resource_metadata["authorization_servers"]).first)
|
|
46
|
+
validate_endpoints(metadata)
|
|
47
|
+
registration = register(metadata, client_name, redirect_uri, client_id, client_secret)
|
|
48
|
+
verifier = SecureRandom.urlsafe_base64(48)
|
|
49
|
+
state = SecureRandom.urlsafe_base64(32)
|
|
50
|
+
grant = { client_id: registration["client_id"], redirect_uri: redirect_uri,
|
|
51
|
+
verifier: verifier, state: state, resource: resource,
|
|
52
|
+
scope: resolve_scope(scope, resource_metadata, metadata) }
|
|
53
|
+
{
|
|
54
|
+
"authorize_url" => authorize_url(metadata, grant),
|
|
55
|
+
"state" => state, "code_verifier" => verifier,
|
|
56
|
+
"client_id" => registration["client_id"],
|
|
57
|
+
"client_secret" => registration["client_secret"],
|
|
58
|
+
"token_auth_method" => registration["token_endpoint_auth_method"],
|
|
59
|
+
"token_endpoint" => metadata.fetch("token_endpoint"),
|
|
60
|
+
"resource" => resource, "redirect_uri" => redirect_uri
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Exchange the callback's code for tokens.
|
|
65
|
+
def complete(code:, code_verifier:, client_id:, token_endpoint:, resource:,
|
|
66
|
+
redirect_uri:, client_secret: nil, token_auth_method: nil, **)
|
|
67
|
+
form = { "grant_type" => "authorization_code", "code" => code,
|
|
68
|
+
"code_verifier" => code_verifier, "client_id" => client_id,
|
|
69
|
+
"redirect_uri" => redirect_uri, "resource" => resource }
|
|
70
|
+
token_request(token_endpoint, form, client_secret, token_auth_method)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Trade a refresh token for a fresh set; OAuth 2.1 rotates refresh
|
|
74
|
+
# tokens, so persist the returned one.
|
|
75
|
+
def refresh(refresh_token:, client_id:, token_endpoint:, resource:,
|
|
76
|
+
client_secret: nil, token_auth_method: nil, **)
|
|
77
|
+
form = { "grant_type" => "refresh_token", "refresh_token" => refresh_token,
|
|
78
|
+
"client_id" => client_id, "resource" => resource }
|
|
79
|
+
token_request(token_endpoint, form, client_secret, token_auth_method)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# -- discovery ---------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
# RFC 9728: a 401's WWW-Authenticate names the resource metadata URL;
|
|
85
|
+
# servers that skip the header serve the well-known path.
|
|
86
|
+
def resource_metadata_for(url)
|
|
87
|
+
metadata_url = challenge_metadata_url(url) || well_known_resource_url(url)
|
|
88
|
+
document = get_json(metadata_url)
|
|
89
|
+
if Array(document["authorization_servers"]).empty?
|
|
90
|
+
raise Error, "#{metadata_url} names no authorization servers"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
document
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def challenge_metadata_url(url)
|
|
97
|
+
uri = URI(url)
|
|
98
|
+
response = http(uri) do |connection|
|
|
99
|
+
request = Net::HTTP::Post.new(uri)
|
|
100
|
+
request["Accept"] = "application/json, text/event-stream"
|
|
101
|
+
request["Content-Type"] = "application/json"
|
|
102
|
+
request.body = JSON.generate({ jsonrpc: "2.0", id: 0, method: "ping" })
|
|
103
|
+
connection.request(request)
|
|
104
|
+
end
|
|
105
|
+
challenge = response["WWW-Authenticate"].to_s
|
|
106
|
+
challenge[/resource_metadata="([^"]+)"/i, 1]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def well_known_resource_url(url)
|
|
110
|
+
uri = URI(url)
|
|
111
|
+
path = uri.path.chomp("/")
|
|
112
|
+
origin = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
113
|
+
"#{origin}/.well-known/oauth-protected-resource#{path unless path.empty?}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# RFC 8414 metadata, with the OpenID Connect path as a fallback since
|
|
117
|
+
# large providers often serve only that document.
|
|
118
|
+
def server_metadata(authority)
|
|
119
|
+
uri = URI(authority)
|
|
120
|
+
origin = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
121
|
+
path = uri.path.chomp("/")
|
|
122
|
+
candidates = ["#{origin}/.well-known/oauth-authorization-server#{path unless path.empty?}",
|
|
123
|
+
"#{origin}#{path}/.well-known/openid-configuration"]
|
|
124
|
+
candidates.each do |candidate|
|
|
125
|
+
document = try_json(candidate)
|
|
126
|
+
return document if document&.key?("token_endpoint")
|
|
127
|
+
end
|
|
128
|
+
raise Error, "no authorization server metadata at #{authority}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# RFC 7591 dynamic registration, as the application. Servers without a
|
|
132
|
+
# registration endpoint require a pre-registered client id. The
|
|
133
|
+
# returned hash keeps the token endpoint auth method the server
|
|
134
|
+
# granted, so token requests authenticate the way it expects.
|
|
135
|
+
def register(metadata, client_name, redirect_uri, client_id, client_secret)
|
|
136
|
+
return { "client_id" => client_id, "client_secret" => client_secret } if client_id
|
|
137
|
+
|
|
138
|
+
endpoint = metadata["registration_endpoint"]
|
|
139
|
+
unless endpoint
|
|
140
|
+
raise Error, "the server does not offer dynamic client registration; " \
|
|
141
|
+
"pass client_id:/client_secret: from a manual registration"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
registration = post_json(endpoint, {
|
|
145
|
+
"client_name" => client_name,
|
|
146
|
+
"redirect_uris" => [redirect_uri],
|
|
147
|
+
"grant_types" => %w[authorization_code refresh_token],
|
|
148
|
+
"response_types" => ["code"],
|
|
149
|
+
"token_endpoint_auth_method" => "client_secret_post"
|
|
150
|
+
})
|
|
151
|
+
{
|
|
152
|
+
"client_id" => presence(registration["client_id"]) ||
|
|
153
|
+
raise(Error, "registration returned no client_id"),
|
|
154
|
+
"client_secret" => presence(registration["client_secret"]),
|
|
155
|
+
"token_endpoint_auth_method" => presence(registration["token_endpoint_auth_method"])
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# No scope given: request what the resource advertises, and add
|
|
160
|
+
# offline_access when the authorization server supports it (that is
|
|
161
|
+
# what earns a refresh token from providers that require it). An
|
|
162
|
+
# unsupported offline_access is stripped rather than sent blind.
|
|
163
|
+
def resolve_scope(scope, resource_metadata, metadata)
|
|
164
|
+
scopes = scope.to_s.split
|
|
165
|
+
scopes = Array(resource_metadata["scopes_supported"]) if scopes.empty?
|
|
166
|
+
supported = Array(metadata["scopes_supported"])
|
|
167
|
+
if supported.include?("offline_access")
|
|
168
|
+
scopes |= ["offline_access"]
|
|
169
|
+
else
|
|
170
|
+
scopes -= ["offline_access"]
|
|
171
|
+
end
|
|
172
|
+
scopes.empty? ? nil : scopes.join(" ")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The spec requires authorization server endpoints over HTTPS;
|
|
176
|
+
# loopback stays allowed for development.
|
|
177
|
+
def validate_endpoints(metadata)
|
|
178
|
+
%w[authorization_endpoint token_endpoint registration_endpoint].each do |key|
|
|
179
|
+
value = metadata[key] or next
|
|
180
|
+
uri = URI(value)
|
|
181
|
+
next if uri.scheme == "https" || %w[localhost 127.0.0.1 ::1].include?(uri.host)
|
|
182
|
+
|
|
183
|
+
raise Error, "#{key} #{value} is not HTTPS"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def presence(value)
|
|
188
|
+
value.to_s.strip.empty? ? nil : value
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def authorize_url(metadata, grant)
|
|
192
|
+
challenge = Digest::SHA256.base64digest(grant[:verifier]).tr("+/", "-_").delete("=")
|
|
193
|
+
params = { "response_type" => "code", "client_id" => grant[:client_id],
|
|
194
|
+
"redirect_uri" => grant[:redirect_uri], "state" => grant[:state],
|
|
195
|
+
"code_challenge" => challenge, "code_challenge_method" => "S256",
|
|
196
|
+
"resource" => grant[:resource] }
|
|
197
|
+
params["scope"] = grant[:scope] if grant[:scope]
|
|
198
|
+
endpoint = URI(metadata.fetch("authorization_endpoint"))
|
|
199
|
+
endpoint.query = [endpoint.query, URI.encode_www_form(params)].compact.join("&")
|
|
200
|
+
endpoint.to_s
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# -- plumbing ----------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
# RFC 8707 canonical form: lowercase scheme and host, no fragment.
|
|
206
|
+
def canonical(url)
|
|
207
|
+
uri = URI(url)
|
|
208
|
+
uri.fragment = nil
|
|
209
|
+
uri.scheme = uri.scheme.downcase
|
|
210
|
+
uri.host = uri.host.downcase if uri.host
|
|
211
|
+
uri.to_s
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def token_request(endpoint, form, client_secret, auth_method = nil)
|
|
215
|
+
basic = client_secret && auth_method == "client_secret_basic"
|
|
216
|
+
form = form.merge("client_secret" => client_secret) if client_secret && !basic
|
|
217
|
+
credentials = basic ? [form["client_id"], client_secret] : nil
|
|
218
|
+
payload = post_form(endpoint, form, basic_auth: credentials)
|
|
219
|
+
expires_in = payload["expires_in"]
|
|
220
|
+
{
|
|
221
|
+
"access_token" => payload.fetch("access_token"),
|
|
222
|
+
"refresh_token" => payload["refresh_token"],
|
|
223
|
+
"scope" => payload["scope"],
|
|
224
|
+
"expires_at" => expires_in ? Time.now.utc + expires_in.to_i : nil
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def get_json(url)
|
|
229
|
+
uri = URI(url)
|
|
230
|
+
response = http(uri) { |connection| connection.request(Net::HTTP::Get.new(uri)) }
|
|
231
|
+
raise Error, "GET #{url} answered #{response.code}" unless response.code.to_i == 200
|
|
232
|
+
|
|
233
|
+
JSON.parse(response.body)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def try_json(url)
|
|
237
|
+
get_json(url)
|
|
238
|
+
rescue Error, JSON::ParserError
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def post_json(url, body)
|
|
243
|
+
uri = URI(url)
|
|
244
|
+
response = http(uri) do |connection|
|
|
245
|
+
request = Net::HTTP::Post.new(uri)
|
|
246
|
+
request["Content-Type"] = "application/json"
|
|
247
|
+
request.body = JSON.generate(body)
|
|
248
|
+
connection.request(request)
|
|
249
|
+
end
|
|
250
|
+
unless %w[200 201].include?(response.code)
|
|
251
|
+
raise Error, "POST #{url} answered #{response.code}: #{response.body.to_s[0, 200]}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
JSON.parse(response.body)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def post_form(url, form, basic_auth: nil)
|
|
258
|
+
uri = URI(url)
|
|
259
|
+
response = http(uri) do |connection|
|
|
260
|
+
request = Net::HTTP::Post.new(uri)
|
|
261
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
262
|
+
request["Accept"] = "application/json"
|
|
263
|
+
request.basic_auth(*basic_auth) if basic_auth
|
|
264
|
+
request.body = URI.encode_www_form(form)
|
|
265
|
+
connection.request(request)
|
|
266
|
+
end
|
|
267
|
+
payload = begin
|
|
268
|
+
JSON.parse(response.body)
|
|
269
|
+
rescue StandardError
|
|
270
|
+
{}
|
|
271
|
+
end
|
|
272
|
+
unless response.code.to_i == 200
|
|
273
|
+
reason = payload["error_description"] || payload["error"] || response.body.to_s[0, 200]
|
|
274
|
+
raise Error, "token request failed (#{response.code}): #{reason}"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
payload
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def http(uri, &)
|
|
281
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
282
|
+
open_timeout: 15, read_timeout: 30, &)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/wait"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Mistri
|
|
7
|
+
module MCP
|
|
8
|
+
# The two ways an MCP conversation travels. A wire takes one JSON-RPC
|
|
9
|
+
# payload, yields every decoded message the server sends back until the
|
|
10
|
+
# payload's own response arrives, and knows nothing about MCP semantics;
|
|
11
|
+
# the Client owns those.
|
|
12
|
+
module Wires
|
|
13
|
+
# Streamable HTTP: requests POST to one endpoint, responses arrive as
|
|
14
|
+
# JSON or an SSE stream. Sessions and bearer auth live here.
|
|
15
|
+
class Http
|
|
16
|
+
def initialize(url:, token:, headers:, open_timeout:, read_timeout:)
|
|
17
|
+
uri = URI(url)
|
|
18
|
+
@path = uri.path.empty? ? "/" : uri.path
|
|
19
|
+
@path = "#{@path}?#{uri.query}" if uri.query
|
|
20
|
+
@transport = Transport.new(origin: "#{uri.scheme}://#{uri.host}:#{uri.port}",
|
|
21
|
+
open_timeout: open_timeout, read_timeout: read_timeout)
|
|
22
|
+
@token = token
|
|
23
|
+
@headers = headers
|
|
24
|
+
@session_id = nil
|
|
25
|
+
@protocol_version = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_writer :protocol_version
|
|
29
|
+
|
|
30
|
+
def call(payload, &)
|
|
31
|
+
meta = @transport.post_either(@path, body: payload, headers: request_headers, &)
|
|
32
|
+
capture_session(meta)
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def notify(payload)
|
|
37
|
+
discard = ->(_record) {}
|
|
38
|
+
@transport.post_either(@path, body: payload, headers: request_headers, &discard)
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def session? = !@session_id.nil?
|
|
43
|
+
|
|
44
|
+
def refreshable? = @token.respond_to?(:call)
|
|
45
|
+
|
|
46
|
+
def reset_session = @session_id = nil
|
|
47
|
+
|
|
48
|
+
def close = @transport.close
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def request_headers
|
|
53
|
+
headers = { "Accept" => "application/json, text/event-stream" }
|
|
54
|
+
headers.merge!(@headers)
|
|
55
|
+
headers["Authorization"] = "Bearer #{resolve_token}" if @token
|
|
56
|
+
headers["Mcp-Session-Id"] = @session_id if @session_id
|
|
57
|
+
headers["MCP-Protocol-Version"] = @protocol_version if @protocol_version
|
|
58
|
+
headers
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resolve_token
|
|
62
|
+
@token.respond_to?(:call) ? @token.call : @token
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def capture_session(meta)
|
|
66
|
+
session = meta && meta["mcp-session-id"]
|
|
67
|
+
@session_id = session if session
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Stdio: a spawned child process, one JSON-RPC message per line, with
|
|
72
|
+
# credentials in its environment, as the spec prescribes for local
|
|
73
|
+
# servers. Its stderr stays attached for honest local debugging.
|
|
74
|
+
class Stdio
|
|
75
|
+
def initialize(command:, env: {}, read_timeout: 120)
|
|
76
|
+
@command = Array(command).map(&:to_s)
|
|
77
|
+
@env = env.transform_keys(&:to_s).transform_values(&:to_s)
|
|
78
|
+
@read_timeout = read_timeout
|
|
79
|
+
@pid = nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def call(payload)
|
|
83
|
+
spawn_server unless @pid
|
|
84
|
+
write(payload)
|
|
85
|
+
loop do
|
|
86
|
+
record = read_record
|
|
87
|
+
yield record
|
|
88
|
+
break if record.is_a?(Hash) && record["id"] == payload[:id]
|
|
89
|
+
end
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def notify(payload)
|
|
94
|
+
spawn_server unless @pid
|
|
95
|
+
write(payload)
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def session? = false
|
|
100
|
+
|
|
101
|
+
def refreshable? = false
|
|
102
|
+
|
|
103
|
+
def reset_session = nil
|
|
104
|
+
|
|
105
|
+
def protocol_version=(_version); end
|
|
106
|
+
|
|
107
|
+
def close
|
|
108
|
+
return unless @pid
|
|
109
|
+
|
|
110
|
+
[@stdin, @stdout].each { |io| io.close unless io.closed? }
|
|
111
|
+
terminate
|
|
112
|
+
@pid = nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def spawn_server
|
|
118
|
+
child_in, @stdin = IO.pipe
|
|
119
|
+
@stdout, child_out = IO.pipe
|
|
120
|
+
@pid = Process.spawn(@env, *@command, in: child_in, out: child_out)
|
|
121
|
+
child_in.close
|
|
122
|
+
child_out.close
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def write(payload)
|
|
126
|
+
@stdin.write("#{JSON.generate(payload)}\n")
|
|
127
|
+
@stdin.flush
|
|
128
|
+
rescue Errno::EPIPE
|
|
129
|
+
raise Error, "the MCP server closed its input"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The spec requires stdout to carry only protocol messages, so a
|
|
133
|
+
# line that is not one is corruption worth failing loudly on.
|
|
134
|
+
def read_record
|
|
135
|
+
loop do
|
|
136
|
+
ready = @stdout.wait_readable(@read_timeout)
|
|
137
|
+
raise Error, "timed out waiting for the MCP server" unless ready
|
|
138
|
+
|
|
139
|
+
line = @stdout.gets
|
|
140
|
+
raise Error, "the MCP server exited" if line.nil?
|
|
141
|
+
next if line.strip.empty?
|
|
142
|
+
|
|
143
|
+
return JSON.parse(line)
|
|
144
|
+
end
|
|
145
|
+
rescue JSON::ParserError
|
|
146
|
+
raise Error, "the MCP server wrote non-protocol output on stdout"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def terminate
|
|
150
|
+
Process.kill("TERM", @pid)
|
|
151
|
+
20.times do
|
|
152
|
+
return if Process.waitpid(@pid, Process::WNOHANG)
|
|
153
|
+
|
|
154
|
+
sleep(0.05)
|
|
155
|
+
end
|
|
156
|
+
Process.kill("KILL", @pid)
|
|
157
|
+
Process.waitpid(@pid)
|
|
158
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|