Module: Mistri::MCP

Defined in:
lib/mistri/mcp.rb,
lib/mistri/mcp/oauth.rb,
lib/mistri/mcp/wires.rb,
lib/mistri/mcp/client.rb,
lib/mistri/mcp/egress.rb

Overview

Bridge Model Context Protocol servers into Mistri tools: list a server's tools, hand them to an agent, and everything the harness already does composes: approval gates on third-party write tools, retries, sub-agent pools, the ui channel.

client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
                               token: -> { connection.fresh_token })
agent = Mistri.agent("claude-opus-4-8",
                   tools: Mistri::MCP.tools(client, prefix: "linear"))

The bridge is duck-typed: any client responding to tools (an array of "description", "inputSchema" hashes) and call_tool(name, args) bridges the same way, so the official mcp gem's client plugs in too.

Defined Under Namespace

Modules: Egress, OAuth, Wires Classes: Client, Error, SessionExpired, UnsafeURLError, WireError

Constant Summary collapse

EMPTY_INPUT_SCHEMA =
{
  "type" => "object", "properties" => {}.freeze, "additionalProperties" => false
}.freeze

Class Method Summary collapse

Class Method Details

.answer(result) ⇒ Object

An MCP result becomes model-readable content: text joins, images ride as image blocks, and isError remains structured failure data end to end.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/mistri/mcp.rb', line 85

def answer(result)
  blocks = Array(result["content"]).map { |block| convert(block) }
  if result["isError"]
    text_blocks = blocks.grep(String)
    if result["structuredContent"]
      structured = JSON.generate(result["structuredContent"])
      text_blocks << structured unless text_blocks.include?(structured)
    end
    text = text_blocks.join("\n")
    content = "MCP tool error: #{text.empty? ? "unknown error" : text}"
    non_text = blocks.grep_v(String)
    content = [content, *non_text] unless non_text.empty?
    return ToolResult.new(content:, error: true)
  end
  if blocks.empty? && result["structuredContent"]
    return JSON.generate(result["structuredContent"])
  end
  return blocks.join("\n") if blocks.all?(String)

  blocks
end

.bridge(client, spec, prefix: nil, gate: false, complete_argument_validator: nil) ⇒ Object



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/mistri/mcp.rb', line 62

def bridge(client, spec, prefix: nil, gate: false, complete_argument_validator: nil)
  remote = spec.fetch("name")
  local = prefix ? "#{prefix}__#{remote}" : remote
  if spec.key?("inputSchema") && spec["inputSchema"].nil?
    raise ConfigurationError, "inputSchema must be an object, not null"
  end

  raw_schema = spec.fetch("inputSchema", EMPTY_INPUT_SCHEMA)
  input_schema = Schema.validate_mcp!(
    raw_schema, complete: !complete_argument_validator.nil?
  )
  Tool.define(local, spec["description"].to_s,
              input_schema: input_schema,
              needs_approval: gate,
              complete_argument_validator: complete_argument_validator) do |args|
    answer(client.call_tool(remote, args || {}))
  end
rescue ConfigurationError => e
  raise ConfigurationError, "MCP tool #{remote.inspect}: #{e.message}"
end

.convert(block) ⇒ Object



107
108
109
110
111
112
113
114
115
116
117
# File 'lib/mistri/mcp.rb', line 107

def convert(block)
  case block["type"]
  when "text" then block["text"].to_s
  when "image"
    Content::Image.from_bytes(block["data"].to_s.unpack1("m"),
                              mime_type: block["mimeType"] || "image/png")
  when "resource" then resource_text(block["resource"] || {})
  when "resource_link" then "[resource: #{block["uri"]}]"
  else "[unsupported #{block["type"]} content]"
  end
end

.resource_text(resource) ⇒ Object



119
120
121
# File 'lib/mistri/mcp.rb', line 119

def resource_text(resource)
  resource["text"] || "[resource: #{resource["uri"]}]"
end

.tools(client, allow: nil, deny: [], prefix: nil, needs_approval: false, gates: {}, complete_argument_validator: nil) ⇒ Object

The server's tools as Mistri tools. allow/deny filter by remote name, prefix namespaces local names ("linear__create_issue") against collisions, and gates marks tools needing human approval (gates: { "create_issue" => true }, or needs_approval: for all).



51
52
53
54
55
56
57
58
59
60
# File 'lib/mistri/mcp.rb', line 51

def tools(client, allow: nil, deny: [], prefix: nil, needs_approval: false, gates: {},
          complete_argument_validator: nil)
  listed = client.tools
  listed = listed.select { |tool| allow.include?(tool["name"]) } if allow
  listed = listed.reject { |tool| deny.include?(tool["name"]) }
  listed.map do |tool|
    bridge(client, tool, prefix: prefix, gate: gates.fetch(tool["name"], needs_approval),
                         complete_argument_validator: complete_argument_validator)
  end
end