nano_mcp 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '049ec03429cd7d181a0b123542e7717dafacfa3ab0ba7bebcfe91240c0d47f80'
4
+ data.tar.gz: 1800e1e3f538bf4821cf870bfc6db2989024bc41aa28dcaf24252f1cbc9e4f53
5
+ SHA512:
6
+ metadata.gz: 187c7b64250f6589e5552b51ed36a9cb9e084c322fe0601ec455a79cafcff0d708c86f980943a5fcb425163d69f094b16076642b6d66018f2e3d4decee4a406a
7
+ data.tar.gz: bd907532fe5710d2de079f6a2b144dd3fa360bcce79cb307d15920983cc8e404be4650dd56d0f4073d75a25bd9ded00f87aa6c900437c76c37bbdbfec7bacbb5
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release, extracted from the MCP servers in geneva_drive_admin and Comptoir.
6
+
7
+ - `NanoMCP::Server`, a Rack app serving tools over stateless Streamable HTTP per MCP revision 2026-07-28,
8
+ which also answers clients of 2025-11-25 and earlier
9
+ - `NanoMCP::Tool` and `NanoMCP::Result` for writing tools, with argument checking against the input schema
10
+ - `NanoMCP.bearer_token` and `NanoMCP.token_matches?` for token authentication
11
+ - Servers and tools work from non-main Ractors, and a server can be made shareable between them
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Julik Tarkhanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,237 @@
1
+ # nano_mcp
2
+
3
+ A (slightly unhinged) library for building small MCPs.
4
+
5
+ > [!IMPORTANT]
6
+ > **This gem is for my own private use.** It is public so that my other gems can depend on it,
7
+ > not because I am looking to support it for anyone else. It sits underneath several of my
8
+ > gems and apps at once - a diamond dependency - and that comes with **strict
9
+ > backwards-compatibility constraints**:
10
+ >
11
+ > - The public API is the short list under [Public API](#public-api), and nothing else.
12
+ > - Within a major version that list only ever grows. Nothing on it gets renamed, removed or
13
+ > changes meaning.
14
+ > - Anything not on the list - private constants, the log line format, error message wording -
15
+ > can change in any release.
16
+ > - Feature requests that grow the surface will most likely be declined. Bug reports are welcome.
17
+ >
18
+ > If you need something bigger, use [the official Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk).
19
+
20
+ ## What it is
21
+
22
+ A Rack app that serves [MCP](https://modelcontextprotocol.io) tools over Streamable HTTP, written
23
+ against the **stateless** revision of the spec, [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28).
24
+ It has:
25
+
26
+ - no sessions, no SSE, no GET stream - every request is one POST, and any process can serve it
27
+ - no runtime dependencies - not even Rack, only `json` and `openssl` from the standard library
28
+ - tools only - no resources, prompts, sampling or elicitation
29
+
30
+ Clients of revision 2025-11-25 and earlier open with `initialize` instead of putting the protocol
31
+ version in every request. They get answered the way they expect, from the same endpoint, but still
32
+ without a session.
33
+
34
+ ## Installation
35
+
36
+ ```ruby
37
+ gem "nano_mcp"
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ A tool is a class with a name, a description, an input schema and a `#call`:
43
+
44
+ ```ruby
45
+ class GetWorkflow < NanoMCP::Tool
46
+ tool_name "get_workflow"
47
+ title "Get workflow"
48
+ description "One workflow by id, with its state and hero"
49
+ input_schema properties: {id: {type: "integer", minimum: 1}}, required: ["id"]
50
+ annotations readOnlyHint: true, openWorldHint: false
51
+
52
+ def call(id:)
53
+ workflow = context.store.find(id) or raise NanoMCP::ToolError, "No workflow with id #{id}"
54
+ NanoMCP::Result.json(workflow.to_h)
55
+ end
56
+ end
57
+ ```
58
+
59
+ A server is a Rack app with a list of tools and a block that authenticates. The block gets the Rack
60
+ env and the request's `_meta`, and returns whatever your tools should see as `context`, or `nil` to
61
+ refuse with a 401:
62
+
63
+ ```ruby
64
+ MCP_SERVER = NanoMCP::Server.new(
65
+ name: "workflows",
66
+ title: "Workflows on #{Rails.application.class.module_parent_name} (#{Rails.env})",
67
+ version: MyGem::VERSION,
68
+ instructions: "Read this before drawing conclusions: `paused` is not a crash...",
69
+ tools: [GetWorkflow, SearchWorkflows],
70
+ logger: Rails.logger
71
+ ) do |env, meta|
72
+ key = ApiToken.find_by(token: NanoMCP.bearer_token(env))
73
+ next if key.nil? || key.expired?
74
+ MyContext.new(user: key.user, client: meta.dig("io.modelcontextprotocol/clientInfo", "name"))
75
+ end
76
+ ```
77
+
78
+ The context is how tools know who is calling: whatever the block returns is `context` in every tool
79
+ for that request, and nano_mcp never looks inside it. Copy into it anything from the request that
80
+ tools need. They don't see the Rack env themselves.
81
+
82
+ `_meta` is the hash as the client sent it, with string keys. A current client sends its name and
83
+ capabilities in it on every request. An older client sends them only once, in `initialize`, so for
84
+ its tool calls `_meta` is usually empty. A lambda taking only `env` works too.
85
+
86
+ Mount it:
87
+
88
+ ```ruby
89
+ # config/routes.rb
90
+ mount MCP_SERVER => "/mcp"
91
+ ```
92
+
93
+ or run it from a `config.ru`. See [examples/](examples) for a complete server, which the tests drive.
94
+
95
+ ### Results
96
+
97
+ `#call` returns a `NanoMCP::Result`:
98
+
99
+ - `Result.text(string)` - prose
100
+ - `Result.json(value, summary: nil)` - structured data. A current client gets it as
101
+ `structuredContent`, next to a text block holding `summary` (or the JSON itself if you gave no
102
+ summary). An older client gets the JSON in the text block.
103
+ - `Result.error(message)` - a refusal the agent should read. Raising `NanoMCP::ToolError` does the same.
104
+
105
+ Bad arguments come back as error results too, worded so an agent can fix its call. Any other
106
+ exception becomes a JSON-RPC internal error that names the exception class. The message stays in
107
+ your logs, since it may carry your data.
108
+
109
+ ### Arguments
110
+
111
+ Arguments are checked against `input_schema` before `#call` sees them, and passed as keywords.
112
+ The checker handles the top level of the schema: `type`, `required`, `enum`, `minimum`, `maximum`,
113
+ `minLength`, `maxLength`, `minItems`, `maxItems`, `items` and `default`. Unknown arguments are
114
+ refused, and the schema sent to clients says `additionalProperties: false` to match. Integers sent
115
+ as strings are accepted, because agents do that. A number over `maximum` is clamped down to it rather than refused.
116
+
117
+ ### Wrapping every tool
118
+
119
+ There are two hooks for house rules, both meant to be overridden in your own base class:
120
+
121
+ ```ruby
122
+ class MyTool < NanoMCP::Tool
123
+ def self.mutating
124
+ @mutating = true
125
+ annotations readOnlyHint: false, destructiveHint: false
126
+ end
127
+
128
+ def self.mutating? = !!@mutating
129
+
130
+ # Around every call: permission gates, log tags, timeouts
131
+ def invoke(arguments)
132
+ if self.class.mutating? && !context.mutations_permitted?
133
+ return NanoMCP::Result.error("Refused: mutations are disabled on this server")
134
+ end
135
+ Rails.logger.tagged("mcp_tool=#{self.class.tool_name}") { super }
136
+ end
137
+
138
+ # The tools/list entry, per caller
139
+ def self.descriptor(context)
140
+ entry = super
141
+ entry[:description] += "\n\nDISABLED for you" if mutating? && !context.mutations_permitted?
142
+ entry
143
+ end
144
+ end
145
+
146
+ class PauseWorkflows < MyTool
147
+ tool_name "pause_workflows"
148
+ mutating
149
+ # ...
150
+ end
151
+ ```
152
+
153
+ ### Ractors
154
+
155
+ A server works from non-main Ractors in two ways, and the test suite checks both.
156
+
157
+ **One server per Ractor.** Build it inside the Ractor, and anything goes: any block, any logger.
158
+
159
+ **One server shared by every Ractor**, which is what a Ractor-based Rack server loading a
160
+ `config.ru` would want. The server's own state is all frozen, so `Ractor.make_shareable` works on it
161
+ as long as the two things you hand it are shareable too:
162
+
163
+ - The block has to be a shareable proc. A block written in `config.ru` has the Rack builder as its
164
+ `self`, which is not shareable, so create it with a `nil` self instead. It may only refer to
165
+ shareable things, such as frozen constants.
166
+ - The logger has to be shareable or left out. A standard `Logger` is not, and freezing it breaks it.
167
+
168
+ ```ruby
169
+ # Ruby 4.0
170
+ authenticate = Ractor.shareable_proc { |env, meta| ... }
171
+ # Ruby 3.2 and up
172
+ authenticate = nil.instance_eval { proc { |env, meta| ... } }
173
+
174
+ run Ractor.make_shareable(NanoMCP::Server.new(name: "workflows", version: "1", tools: TOOLS, &authenticate))
175
+ ```
176
+
177
+ Tool classes store whatever you set on them as frozen copies, so they can be read from any Ractor.
178
+
179
+ ### Things the server does not do for you
180
+
181
+ The server stays small by leaving these to the host:
182
+
183
+ - **Turning it off.** Wrap it: `->(env) { enabled? ? MCP_SERVER.call(env) : [404, {}, []] }`.
184
+ - **Pretending to be absent.** To answer 404 rather than 401 to a caller without a token, check the
185
+ token in a wrapper like the one above.
186
+ - **Building the server from runtime settings.** Constructing one is cheap, so build it per request
187
+ if its instructions or tools depend on configuration.
188
+ - **Logging arguments.** The log line per call has the tool, era, error flag, size and duration.
189
+ Arguments are left out because they may carry secrets. Log them in `#invoke` if you need them.
190
+
191
+ ## Public API
192
+
193
+ This is all of it. Everything else is private, whatever Ruby visibility it happens to have.
194
+
195
+ - `NanoMCP::VERSION`, `NanoMCP::PROTOCOL_VERSION`, `NanoMCP::MINIMUM_TOKEN_LENGTH`
196
+ - `NanoMCP.bearer_token(env)`, `NanoMCP.token_matches?(presented, configured)`
197
+ - `NanoMCP::Error`, `NanoMCP::ToolError`
198
+ - `NanoMCP::Tool`: class methods `tool_name`, `title`, `description`, `input_schema`, `annotations`,
199
+ `descriptor(context)`. Instance methods `#initialize(context)`, `#context`, `#invoke(arguments)`, `#call(**)`
200
+ - `NanoMCP::Result`: `.text`, `.json`, `.error`, `#text`, `#structured`, `#error?`
201
+ - `NanoMCP::Server`: `.new(name:, version:, tools:, title:, instructions:, allowed_origins:, logger:, &authenticate)` and `#call(env)`.
202
+ The block is called with `(env, meta)`
203
+
204
+ ## Protocol behaviour
205
+
206
+ | | 2026-07-28 (`_meta` in every request) | 2025-11-25 and earlier (`initialize`) |
207
+ |-|-|-|
208
+ | Handshake | `server/discover`, optional | `initialize`, answered and forgotten |
209
+ | Version | checked on every request, 400 + `-32022` if unsupported | negotiated in `initialize` |
210
+ | `MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name` | required, must match the body (400 + `-32020`) | ignored |
211
+ | Unknown method | 404 + `-32601` | 200 + `-32601` |
212
+ | `ping` | gone | `{}` |
213
+ | Results | `resultType: "complete"`, `ttlMs`/`cacheScope` on lists | plain |
214
+ | Structured results | `structuredContent` plus a text block | JSON in a text block |
215
+
216
+ In both eras:
217
+
218
+ - GET, DELETE and other verbs get a 405.
219
+ - No session id is ever issued.
220
+ - A notification gets an empty 202.
221
+ - Batches are refused.
222
+ - A request with an `Origin` header is refused with a 403 unless that origin is in
223
+ `allowed_origins`. MCP clients don't send `Origin`, browsers do. Comparing it against the
224
+ server's own host would not help: after a DNS rebind, the attacker's page sends its own name in both.
225
+ - Bodies over 1MB get a 413. Responses over 1MB are truncated, with a note saying so.
226
+
227
+ ## Development
228
+
229
+ ```
230
+ bundle install
231
+ bundle exec rake # tests and standard
232
+ WORKFLOWS_MCP_TOKEN=$(ruby -rsecurerandom -e 'puts SecureRandom.hex(32)') rackup examples/config.ru
233
+ ```
234
+
235
+ ## License
236
+
237
+ MIT
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NanoMCP
4
+ # The slice of JSON Schema tool arguments get checked against, by hand. Deliberately
5
+ # not a general implementation - a tool that needs more than this is asking for too much.
6
+ #
7
+ # Checked: `type`, `required`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`,
8
+ # `minItems`, `maxItems` and `items`, plus `default`. Nested object properties are
9
+ # not descended into. Unknown arguments are refused rather than ignored, so a misspelled
10
+ # filter is heard about instead of silently returning everything.
11
+ module Arguments
12
+ module_function
13
+
14
+ # @param arguments [Hash, nil] as they came off the wire
15
+ # @param schema [Hash] the input schema with string keys
16
+ # @return [Hash] symbol-keyed, coerced, with defaults filled in
17
+ def validate!(arguments, schema)
18
+ arguments = {} if arguments.nil?
19
+ raise ToolError, "Arguments must be an object, got #{json_type(arguments)}" unless arguments.is_a?(Hash)
20
+
21
+ properties = schema.fetch("properties", {})
22
+ unknown = arguments.keys.map(&:to_s) - properties.keys
23
+ if unknown.any?
24
+ accepted = properties.empty? ? "no arguments" : properties.keys.join(", ")
25
+ raise ToolError, "Unknown argument(s): #{unknown.join(", ")}. This tool accepts: #{accepted}"
26
+ end
27
+
28
+ missing = Array(schema["required"]) - arguments.keys.map(&:to_s)
29
+ raise ToolError, "Missing required argument(s): #{missing.join(", ")}" if missing.any?
30
+
31
+ properties.each_with_object({}) do |(name, property), validated|
32
+ value = arguments.fetch(name) { arguments[name.to_sym] }
33
+ if !value.nil?
34
+ validated[name.to_sym] = coerce(value, property, name)
35
+ elsif property.key?("default")
36
+ validated[name.to_sym] = property["default"]
37
+ end
38
+ end
39
+ end
40
+
41
+ def coerce(value, property, name)
42
+ value = coerce_type(value, property["type"], name)
43
+
44
+ if (enum = property["enum"]) && !enum.include?(value)
45
+ raise ToolError, "#{name} must be one of: #{enum.join(", ")} (got #{value.inspect})"
46
+ end
47
+
48
+ case value
49
+ when Numeric
50
+ if property["minimum"] && value < property["minimum"]
51
+ raise ToolError, "#{name} must be at least #{property["minimum"]}"
52
+ end
53
+ # Clamped rather than refused: an agent asking for more rows than we serve
54
+ # wants as many as it can get, and an error just costs it a round trip
55
+ value = property["maximum"] if property["maximum"] && value > property["maximum"]
56
+ when String
57
+ if property["minLength"] && value.length < property["minLength"]
58
+ raise ToolError, "#{name} must be at least #{property["minLength"]} characters"
59
+ end
60
+ if property["maxLength"] && value.length > property["maxLength"]
61
+ raise ToolError, "#{name} must be at most #{property["maxLength"]} characters"
62
+ end
63
+ when Array
64
+ if property["minItems"] && value.length < property["minItems"]
65
+ raise ToolError, "#{name} needs at least #{property["minItems"]} items, got #{value.length}"
66
+ end
67
+ if property["maxItems"] && value.length > property["maxItems"]
68
+ raise ToolError, "#{name} accepts at most #{property["maxItems"]} items, got #{value.length}"
69
+ end
70
+ if (items = property["items"]).is_a?(Hash)
71
+ value = value.each_with_index.map { |item, index| coerce(item, items, "#{name}[#{index}]") }
72
+ end
73
+ end
74
+
75
+ value
76
+ end
77
+
78
+ def coerce_type(value, type, name)
79
+ case type
80
+ when "string"
81
+ raise ToolError, "#{name} must be a string, got #{json_type(value)}" unless value.is_a?(String)
82
+ value
83
+ when "integer"
84
+ # Agents routinely send ids as strings, and refusing those just buys a retry
85
+ return value if value.is_a?(Integer)
86
+ return value.to_i if value.is_a?(Float) && value == value.floor
87
+ return value.to_i if value.is_a?(String) && /\A-?\d+\z/.match?(value)
88
+ raise ToolError, "#{name} must be an integer, got #{value.inspect}"
89
+ when "number"
90
+ return value if value.is_a?(Numeric)
91
+ return Float(value) if value.is_a?(String) && /\A-?\d+(\.\d+)?\z/.match?(value)
92
+ raise ToolError, "#{name} must be a number, got #{value.inspect}"
93
+ when "boolean"
94
+ return value if value == true || value == false
95
+ return value == "true" if value == "true" || value == "false"
96
+ raise ToolError, "#{name} must be a boolean, got #{json_type(value)}"
97
+ when "array"
98
+ raise ToolError, "#{name} must be an array, got #{json_type(value)}" unless value.is_a?(Array)
99
+ value
100
+ when "object"
101
+ raise ToolError, "#{name} must be an object, got #{json_type(value)}" unless value.is_a?(Hash)
102
+ value
103
+ else
104
+ value
105
+ end
106
+ end
107
+
108
+ def json_type(value)
109
+ case value
110
+ when nil then "null"
111
+ when true, false then "boolean"
112
+ when Integer then "integer"
113
+ when Numeric then "number"
114
+ when String then "string"
115
+ when Array then "array"
116
+ when Hash then "object"
117
+ else value.class.name
118
+ end
119
+ end
120
+ end
121
+ private_constant :Arguments
122
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NanoMCP
4
+ # What a tool returns from #call. Build one with {.text}, {.json} or {.error};
5
+ # the server shapes it for whichever protocol revision the caller speaks.
6
+ class Result
7
+ # Prose for the agent to read.
8
+ #
9
+ # @param string [String]
10
+ # @return [Result]
11
+ def self.text(string)
12
+ new(string.to_s, nil, false)
13
+ end
14
+
15
+ # Structured data. A current client gets it as `structuredContent`, an older one
16
+ # gets it serialized into a text block. The text block a current client gets next
17
+ # to the data is `summary` when given, and the data as JSON when not - pass a summary
18
+ # to avoid sending a large answer twice.
19
+ #
20
+ # @param value [Hash, Array, String, Numeric, true, false] anything JSON can represent
21
+ # @param summary [String, nil] a line saying what came back
22
+ # @return [Result]
23
+ def self.json(value, summary: nil)
24
+ raise ArgumentError, "Result.json needs a value, use Result.text for an empty answer" if value.nil?
25
+ new(summary&.to_s, value, false)
26
+ end
27
+
28
+ # A refusal or failure the agent should read and act on. Same as raising ToolError.
29
+ #
30
+ # @param message [String]
31
+ # @return [Result]
32
+ def self.error(message)
33
+ new(message.to_s, nil, true)
34
+ end
35
+
36
+ private_class_method :new
37
+
38
+ # @return [String, nil] the text, or the summary of a structured result
39
+ attr_reader :text
40
+
41
+ # @return [Object, nil] the structured value, nil for text and error results
42
+ attr_reader :structured
43
+
44
+ def initialize(text, structured, error)
45
+ @text = text
46
+ @structured = structured
47
+ @error = error
48
+ freeze
49
+ end
50
+
51
+ # @return [Boolean]
52
+ def error?
53
+ @error
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NanoMCP
4
+ # The MCP endpoint, as a Rack app. Mount it wherever you like:
5
+ #
6
+ # SERVER = NanoMCP::Server.new(name: "comptoir", version: "1", tools: [ListSnapshots]) do |env|
7
+ # Context.new(...) if NanoMCP.token_matches?(NanoMCP.bearer_token(env), ENV["MCP_TOKEN"])
8
+ # end
9
+ # mount SERVER => "/mcp"
10
+ #
11
+ # Stateless all the way down: no sessions, no streams, nothing remembered between
12
+ # requests, so any process can answer any request. It speaks revision 2026-07-28,
13
+ # where every request carries its own protocol version in `_meta`. A client of
14
+ # 2025-11-25 or earlier opens with `initialize` instead, and gets answered the way
15
+ # those revisions expect - minus the session, which they never strictly needed.
16
+ #
17
+ # The block authenticates. It receives the Rack env and the request's `_meta`, and
18
+ # returns the context every tool in this request gets, or nil/false to refuse the
19
+ # request with a 401. A block that raises refuses too.
20
+ #
21
+ # `_meta` is the hash as the client sent it, string keys and all. A current client
22
+ # puts its identity there on every request, under "io.modelcontextprotocol/clientInfo".
23
+ # An older one sends it only once, in `initialize`, so for its tool calls `_meta`
24
+ # is usually empty.
25
+ class Server
26
+ MODERN_VERSIONS = [PROTOCOL_VERSION].freeze
27
+ LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"].freeze
28
+
29
+ META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion"
30
+ META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"
31
+
32
+ PARSE_ERROR = -32700
33
+ INVALID_REQUEST = -32600
34
+ METHOD_NOT_FOUND = -32601
35
+ INVALID_PARAMS = -32602
36
+ INTERNAL_ERROR = -32603
37
+ UNAUTHORIZED = -32001
38
+ HEADER_MISMATCH = -32020
39
+ UNSUPPORTED_PROTOCOL_VERSION = -32022
40
+
41
+ # How long a client may cache the tool list. It only changes on a deploy, and
42
+ # "private" because it may vary with the caller
43
+ TOOL_LIST_TTL_MS = 300_000
44
+ CACHE_SCOPE = "private"
45
+
46
+ MAX_REQUEST_BYTES = 1_000_000
47
+ MAX_RESPONSE_BYTES = 1_000_000
48
+
49
+ BASE64_SENTINEL = /\A=\?base64\?(.*)\?=\z/m
50
+
51
+ # Methods whose `Mcp-Name` header mirrors a body param
52
+ NAMED_METHODS = {"tools/call" => "name", "prompts/get" => "name", "resources/read" => "uri"}.freeze
53
+
54
+ private_constant(*constants)
55
+
56
+ class ProtocolError < StandardError
57
+ attr_reader :code, :status, :data
58
+
59
+ def initialize(message, code:, status: 200, data: nil)
60
+ super(message)
61
+ @code = code
62
+ @status = status
63
+ @data = data
64
+ end
65
+ end
66
+ private_constant :ProtocolError
67
+
68
+ # @param name [String] `serverInfo.name`, which clients key on - keep it fixed
69
+ # @param version [String] `serverInfo.version`
70
+ # @param tools [Array<Class>] NanoMCP::Tool subclasses, listed in this order
71
+ # @param title [String, nil] `serverInfo.title` - say which app and environment this is
72
+ # @param instructions [String, nil] handed to the client up front, usually ends up in the system prompt
73
+ # @param allowed_origins [Array<String>] browser origins allowed to call, none by default
74
+ # @param logger [Logger, nil] gets a line per tool call and every internal error
75
+ # @yieldparam env [Hash] the Rack env
76
+ # @yieldparam meta [Hash] the request's `params._meta`, empty when there is none
77
+ # @yieldreturn [Object, nil] the tool context, or nil/false to refuse with a 401
78
+ def initialize(name:, version:, tools:, title: nil, instructions: nil, allowed_origins: [], logger: nil, &authenticate)
79
+ raise ArgumentError, "Pass a block which authenticates the request and returns the tool context, or nil to refuse" unless authenticate
80
+
81
+ @name = name.to_s
82
+ @version = version.to_s
83
+ @title = title&.to_s
84
+ @instructions = instructions&.to_s
85
+ @allowed_origins = Array(allowed_origins).map(&:to_s)
86
+ @logger = logger
87
+ @authenticate = authenticate
88
+ @tools = tools.each_with_object({}) do |tool, by_name|
89
+ raise ArgumentError, "#{tool.inspect} is not a NanoMCP::Tool subclass" unless tool.is_a?(Class) && tool < Tool
90
+ tool_name = tool.tool_name.to_s
91
+ raise ArgumentError, "#{tool} has no tool_name" if tool_name.empty?
92
+ raise ArgumentError, "Two tools are named #{tool_name}" if by_name.key?(tool_name)
93
+ by_name[tool_name] = tool
94
+ end.freeze
95
+ end
96
+
97
+ # @param env [Hash] the Rack env
98
+ # @return [Array] a Rack response
99
+ def call(env)
100
+ # GET opened a stream and DELETE ended a session in older revisions. The current
101
+ # one tells a server with neither to answer exactly this
102
+ unless env["REQUEST_METHOD"] == "POST"
103
+ return respond(405, error_body(nil, METHOD_NOT_FOUND, "This MCP server is stateless and POST-only. POST JSON-RPC requests to this same URL."), "allow" => "POST")
104
+ end
105
+
106
+ # Required by the transport to defeat DNS rebinding. MCP clients send no Origin
107
+ unless origin_permitted?(env["HTTP_ORIGIN"])
108
+ return respond(403, error_body(nil, INVALID_REQUEST, "Origin not allowed"))
109
+ end
110
+
111
+ # Read before authenticating, since the block gets the request's _meta - but
112
+ # answered after, so a caller without a token learns nothing from its body
113
+ body = read_body(env)
114
+ request = begin
115
+ body && JSON.parse(body)
116
+ rescue JSON::ParserError
117
+ :unparseable
118
+ end
119
+
120
+ context = authenticate(env, request_meta(request))
121
+ unless context
122
+ return respond(401, error_body(nil, UNAUTHORIZED, "Unauthorized: send a valid bearer token in the Authorization header"),
123
+ "www-authenticate" => %(Bearer realm="#{@name}"))
124
+ end
125
+
126
+ return respond(413, error_body(nil, INVALID_REQUEST, "Request body over #{MAX_REQUEST_BYTES} bytes")) unless body
127
+ return respond(400, error_body(nil, PARSE_ERROR, "Parse error: body is not valid JSON")) if request == :unparseable
128
+
129
+ status, payload = handle(request, env, context)
130
+ payload ? respond(status, payload) : [status, {}, []]
131
+ end
132
+
133
+ private
134
+
135
+ def handle(request, env, context)
136
+ return [400, error_body(nil, INVALID_REQUEST, "Batches are not supported, send one JSON-RPC request per POST")] if request.is_a?(Array)
137
+ return [400, error_body(nil, INVALID_REQUEST, "Request must be a JSON object")] unless request.is_a?(Hash)
138
+
139
+ id = request["id"]
140
+ method = request["method"]
141
+ params = request.fetch("params", nil) || {}
142
+ return [400, error_body(id, INVALID_REQUEST, "Request has no method")] unless method.is_a?(String)
143
+ return [400, error_body(id, INVALID_REQUEST, "params must be an object")] unless params.is_a?(Hash)
144
+
145
+ # A notification. None of them need anything from us
146
+ return [202, nil] unless request.key?("id")
147
+
148
+ modern = modern?(method, params, env)
149
+ validate_modern_request!(method, params, env) if modern
150
+ result = dispatch(method, params, context, modern)
151
+ result[:resultType] = "complete" if modern
152
+ [200, {jsonrpc: "2.0", id: id, result: result}]
153
+ rescue ProtocolError => e
154
+ [e.status, error_body(id, e.code, e.message, e.data)]
155
+ rescue => e
156
+ log(:error) { "#{method} raised #{e.class}: #{e.message}\n#{Array(e.backtrace).first(10).join("\n")}" }
157
+ # The message may carry the host's data, so it stays in the host's logs
158
+ [200, error_body(id, INTERNAL_ERROR, "Internal error (#{e.class}). See the server logs.")]
159
+ end
160
+
161
+ # The spec's rule for serving both eras: `initialize` always selects the old one,
162
+ # and per-request `_meta` the new one. A modern header with no `_meta` is a broken
163
+ # modern request rather than an old one, and gets told so
164
+ def modern?(method, params, env)
165
+ return false if method == "initialize"
166
+ !meta_version(params).nil? || MODERN_VERSIONS.include?(env["HTTP_MCP_PROTOCOL_VERSION"])
167
+ end
168
+
169
+ def request_meta(request)
170
+ params = request.is_a?(Hash) ? request["params"] : nil
171
+ meta = params.is_a?(Hash) ? params["_meta"] : nil
172
+ meta.is_a?(Hash) ? meta.freeze : {}.freeze
173
+ end
174
+
175
+ def meta_version(params)
176
+ meta = params["_meta"]
177
+ meta.is_a?(Hash) ? meta[META_PROTOCOL_VERSION] : nil
178
+ end
179
+
180
+ # The headers mirror the body so a load balancer can route without parsing it.
181
+ # That only holds if both say the same, so anything that disagrees is refused
182
+ def validate_modern_request!(method, params, env)
183
+ version = meta_version(params)
184
+ if version.nil?
185
+ raise ProtocolError.new("Header mismatch: request has no _meta[#{META_PROTOCOL_VERSION}]", code: HEADER_MISMATCH, status: 400)
186
+ end
187
+ unless MODERN_VERSIONS.include?(version)
188
+ raise ProtocolError.new("Unsupported protocol version: #{version}",
189
+ code: UNSUPPORTED_PROTOCOL_VERSION, status: 400,
190
+ data: {supported: MODERN_VERSIONS + LEGACY_VERSIONS, requested: version})
191
+ end
192
+
193
+ header_matches!("MCP-Protocol-Version", env["HTTP_MCP_PROTOCOL_VERSION"], version)
194
+ header_matches!("Mcp-Method", env["HTTP_MCP_METHOD"], method)
195
+ if (param = NAMED_METHODS[method]) && params.key?(param)
196
+ header_matches!("Mcp-Name", decode_header(env["HTTP_MCP_NAME"]), params[param].to_s)
197
+ end
198
+ end
199
+
200
+ def header_matches!(header_name, header_value, body_value)
201
+ if header_value.nil?
202
+ raise ProtocolError.new("Header mismatch: #{header_name} header is required", code: HEADER_MISMATCH, status: 400)
203
+ end
204
+ return if header_value == body_value
205
+ raise ProtocolError.new("Header mismatch: #{header_name} header value #{header_value.inspect} does not match body value #{body_value.inspect}",
206
+ code: HEADER_MISMATCH, status: 400)
207
+ end
208
+
209
+ def decode_header(value)
210
+ match = BASE64_SENTINEL.match(value.to_s)
211
+ return value unless match
212
+ match[1].unpack1("m0").force_encoding(Encoding::UTF_8)
213
+ rescue ArgumentError
214
+ raise ProtocolError.new("Header mismatch: malformed base64 in Mcp-Name", code: HEADER_MISMATCH, status: 400)
215
+ end
216
+
217
+ def dispatch(method, params, context, modern)
218
+ case [method, modern]
219
+ in ["server/discover", true] then discover_result
220
+ in ["initialize", false] then initialize_result(params)
221
+ in ["ping", false] then {}
222
+ in ["tools/list", _] then tools_list_result(context, modern)
223
+ in ["tools/call", _] then call_tool(params, context, modern)
224
+ else
225
+ # In the current revision the 404 is how a client tells "no such method"
226
+ # apart from "not an MCP endpoint at all"
227
+ raise ProtocolError.new("Method not found: #{method}", code: METHOD_NOT_FOUND, status: modern ? 404 : 200)
228
+ end
229
+ end
230
+
231
+ def discover_result
232
+ {
233
+ supportedVersions: MODERN_VERSIONS,
234
+ capabilities: {tools: {listChanged: false}},
235
+ instructions: @instructions,
236
+ ttlMs: TOOL_LIST_TTL_MS,
237
+ cacheScope: CACHE_SCOPE,
238
+ _meta: {META_SERVER_INFO => server_info}
239
+ }.compact
240
+ end
241
+
242
+ def initialize_result(params)
243
+ requested = params["protocolVersion"]
244
+ {
245
+ protocolVersion: LEGACY_VERSIONS.include?(requested) ? requested : LEGACY_VERSIONS.first,
246
+ capabilities: {tools: {listChanged: false}},
247
+ serverInfo: server_info,
248
+ instructions: @instructions
249
+ }.compact
250
+ end
251
+
252
+ def server_info
253
+ {name: @name, title: @title, version: @version}.compact
254
+ end
255
+
256
+ def tools_list_result(context, modern)
257
+ result = {tools: @tools.values.map { |tool| tool.descriptor(context) }}
258
+ modern ? result.merge(ttlMs: TOOL_LIST_TTL_MS, cacheScope: CACHE_SCOPE) : result
259
+ end
260
+
261
+ def call_tool(params, context, modern)
262
+ name = params["name"]
263
+ raise ProtocolError.new("tools/call needs a tool name", code: INVALID_PARAMS) unless name.is_a?(String)
264
+ tool = @tools.fetch(name) { raise ProtocolError.new("Unknown tool: #{name}. Call tools/list to see what this server offers.", code: INVALID_PARAMS) }
265
+
266
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
267
+ result = begin
268
+ tool.new(context).invoke(params["arguments"])
269
+ rescue ToolError => e
270
+ Result.error(e.message)
271
+ end
272
+ raise TypeError, "#{tool}#call returned #{result.class}, not a NanoMCP::Result" unless result.is_a?(Result)
273
+
274
+ shaped = shape(result, modern)
275
+ log(:info) do
276
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1)
277
+ "tool=#{name} era=#{modern ? "modern" : "legacy"} error=#{result.error?} bytes=#{JSON.generate(shaped).bytesize} duration_ms=#{elapsed_ms}"
278
+ end
279
+ shaped
280
+ end
281
+
282
+ # A client of an older revision may not read `structuredContent`, so it gets the
283
+ # data as JSON in prose. A current one gets the data once, plus the summary if
284
+ # there is one. An answer over the size cap goes out as truncated text: a clipped
285
+ # structure would be a lie about its own shape
286
+ def shape(result, modern)
287
+ return {content: [text_block(result.text)], isError: result.error?} if result.structured.nil?
288
+
289
+ unless modern
290
+ return {content: [text_block(JSON.pretty_generate(result.structured))], isError: false}
291
+ end
292
+
293
+ json = JSON.generate(result.structured)
294
+ if json.bytesize > MAX_RESPONSE_BYTES
295
+ return {content: [text_block(JSON.pretty_generate(result.structured))], isError: false}
296
+ end
297
+ {content: [text_block(result.text || json)], structuredContent: result.structured, isError: false}
298
+ end
299
+
300
+ def text_block(string)
301
+ string = string.to_s
302
+ if string.bytesize > MAX_RESPONSE_BYTES
303
+ string = string.byteslice(0, MAX_RESPONSE_BYTES).scrub("") + "\n\n[truncated: response exceeded #{MAX_RESPONSE_BYTES} bytes]"
304
+ end
305
+ {type: "text", text: string}
306
+ end
307
+
308
+ def authenticate(env, meta)
309
+ # A lambda taking just the env would raise on a second argument, a block would not
310
+ if @authenticate.lambda? && @authenticate.arity == 1
311
+ @authenticate.call(env)
312
+ else
313
+ @authenticate.call(env, meta)
314
+ end
315
+ rescue => e
316
+ log(:error) { "authentication raised #{e.class}: #{e.message} - refusing" }
317
+ nil
318
+ end
319
+
320
+ # Absent is fine, since MCP clients are not browsers. A present one has to be listed:
321
+ # comparing it to our own Host proves nothing after a rebind, because the browser
322
+ # then sends the attacker's name in both
323
+ def origin_permitted?(origin)
324
+ return true if origin.nil? || origin.empty?
325
+ @allowed_origins.any? { |allowed| origin.casecmp?(allowed) }
326
+ end
327
+
328
+ def read_body(env)
329
+ input = env["rack.input"]
330
+ return "" unless input
331
+ input.rewind if input.respond_to?(:rewind)
332
+ body = input.read(MAX_REQUEST_BYTES + 1).to_s
333
+ input.rewind if input.respond_to?(:rewind)
334
+ (body.bytesize > MAX_REQUEST_BYTES) ? nil : body
335
+ end
336
+
337
+ def respond(status, payload, headers = {})
338
+ [status, {"content-type" => "application/json"}.merge(headers), [JSON.generate(payload)]]
339
+ end
340
+
341
+ def error_body(id, code, message, data = nil)
342
+ error = {code: code, message: message}
343
+ error[:data] = data if data
344
+ {jsonrpc: "2.0", id: id, error: error}
345
+ end
346
+
347
+ def log(level, &message)
348
+ @logger&.public_send(level) { "[nano_mcp] server=#{@name} #{message.call}" }
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NanoMCP
4
+ # Base class for tools. A tool is a name, a description an agent picks it by,
5
+ # an input schema, and a #call returning a Result.
6
+ #
7
+ # class GetWorkflow < NanoMCP::Tool
8
+ # tool_name "get_workflow"
9
+ # description "One workflow by id"
10
+ # input_schema properties: {id: {type: "integer"}}, required: ["id"]
11
+ # annotations readOnlyHint: true
12
+ #
13
+ # def call(id:)
14
+ # NanoMCP::Result.json(context.store.find(id))
15
+ # end
16
+ # end
17
+ #
18
+ # A tool is instantiated once per call, with whatever the server's authentication
19
+ # block returned as its context.
20
+ class Tool
21
+ EMPTY_JSON_SCHEMA = Ractor.make_shareable({"type" => "object", "properties" => {}, "additionalProperties" => false})
22
+ private_constant :EMPTY_JSON_SCHEMA
23
+
24
+ # Everything set on the class is stored as a deep-frozen copy and nothing is computed
25
+ # lazily, because other Ractors can only read a class's instance variables if they are shareable
26
+ class << self
27
+ # @param value [String] the name agents call the tool by, `[A-Za-z0-9_.-]{1,128}`
28
+ def tool_name(value = nil)
29
+ @tool_name = -value.to_s if value
30
+ @tool_name
31
+ end
32
+
33
+ # @param value [String] a human-readable name for client UIs
34
+ def title(value = nil)
35
+ @title = -value.to_s if value
36
+ @title
37
+ end
38
+
39
+ # @param value [String] what the tool does, written for the agent choosing it
40
+ def description(value = nil)
41
+ @description = -value.to_s.strip if value
42
+ @description
43
+ end
44
+
45
+ # JSON Schema for the arguments, keys as symbols or strings. Only the top-level
46
+ # `properties` and `required` are given meaning by the validator, and arguments
47
+ # not in `properties` are always refused - `additionalProperties` is forced to
48
+ # false to say so.
49
+ #
50
+ # @param value [Hash]
51
+ def input_schema(value = nil)
52
+ if value
53
+ @input_schema = Ractor.make_shareable(value, copy: true)
54
+ schema = JSON.parse(JSON.generate(value))
55
+ schema["type"] = "object"
56
+ schema["properties"] ||= {}
57
+ schema["additionalProperties"] = false
58
+ @json_schema = Ractor.make_shareable(schema)
59
+ end
60
+ @input_schema || EMPTY_JSON_SCHEMA
61
+ end
62
+
63
+ # Behaviour hints for the client, passed through verbatim. Use the spec's names:
64
+ # `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`.
65
+ #
66
+ # @param value [Hash]
67
+ def annotations(value = nil)
68
+ @annotations = Ractor.make_shareable(value, copy: true) if value
69
+ @annotations
70
+ end
71
+
72
+ # The `tools/list` entry for this tool. Override it (calling super) to vary the
73
+ # entry with the caller, for example to say a tool is disabled for them.
74
+ #
75
+ # @param context [Object] what the server's authentication block returned
76
+ # @return [Hash]
77
+ def descriptor(context)
78
+ {
79
+ name: tool_name,
80
+ title: title,
81
+ description: description.to_s,
82
+ inputSchema: json_schema,
83
+ annotations: annotations
84
+ }.compact
85
+ end
86
+
87
+ private
88
+
89
+ def json_schema
90
+ @json_schema || EMPTY_JSON_SCHEMA
91
+ end
92
+ end
93
+
94
+ # @return [Object] what the server's authentication block returned
95
+ attr_reader :context
96
+
97
+ def initialize(context)
98
+ @context = context
99
+ end
100
+
101
+ # Validates the arguments and calls #call with them as keywords. This is the
102
+ # seam for wrapping every tool of yours at once - a permission check, log tags,
103
+ # a timeout: override it in your own base class and call super.
104
+ #
105
+ # @param arguments [Hash, nil] as they came off the wire
106
+ # @return [Result]
107
+ def invoke(arguments)
108
+ call(**Arguments.validate!(arguments, self.class.send(:json_schema)))
109
+ end
110
+
111
+ # Implement this. Receives the validated arguments as keywords; ones the caller
112
+ # left out and that have no `default` are not passed at all.
113
+ #
114
+ # @return [Result]
115
+ def call(**)
116
+ raise NotImplementedError, "#{self.class} must implement #call"
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NanoMCP
4
+ VERSION = "0.1.0"
5
+ end
data/lib/nano_mcp.rb ADDED
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ # A Rack app serving MCP tools over stateless Streamable HTTP.
7
+ #
8
+ # The public API is what this file, NanoMCP::Tool, NanoMCP::Result and NanoMCP::Server
9
+ # document. Anything else - including any constant marked private - can change in any release.
10
+ module NanoMCP
11
+ # The MCP revision this library is written against
12
+ PROTOCOL_VERSION = "2026-07-28"
13
+
14
+ # A configured token shorter than this never matches - see {.token_matches?}
15
+ MINIMUM_TOKEN_LENGTH = 32
16
+
17
+ class Error < StandardError
18
+ end
19
+
20
+ # Raise from a tool to refuse or fail in a way the agent can act on. It goes back
21
+ # as a tool result with `isError` set and the message as its text, not as a JSON-RPC error.
22
+ class ToolError < Error
23
+ end
24
+
25
+ # The bearer token from a Rack env, or nil when there is none.
26
+ #
27
+ # @param env [Hash] the Rack env
28
+ # @return [String, nil]
29
+ def self.bearer_token(env)
30
+ token = env["HTTP_AUTHORIZATION"].to_s[/\ABearer +(.+)\z/i, 1].to_s.strip
31
+ token.empty? ? nil : token
32
+ end
33
+
34
+ # Whether a presented token is the configured one. Both are hashed before comparing,
35
+ # so the comparison takes the same time whatever they are. A configured token shorter
36
+ # than MINIMUM_TOKEN_LENGTH matches nothing.
37
+ #
38
+ # @param presented [String, nil] what the caller sent
39
+ # @param configured [String, nil] what you expect
40
+ # @return [Boolean]
41
+ def self.token_matches?(presented, configured)
42
+ return false if presented.nil? || configured.nil?
43
+ return false if configured.to_s.length < MINIMUM_TOKEN_LENGTH
44
+ # Not OpenSSL::Digest::SHA256: its methods come from define_method, and those
45
+ # cannot be called from a Ractor other than the one that loaded them
46
+ OpenSSL.fixed_length_secure_compare(
47
+ OpenSSL::Digest.digest("SHA256", presented.to_s),
48
+ OpenSSL::Digest.digest("SHA256", configured.to_s)
49
+ )
50
+ end
51
+ end
52
+
53
+ require_relative "nano_mcp/version"
54
+ require_relative "nano_mcp/arguments"
55
+ require_relative "nano_mcp/result"
56
+ require_relative "nano_mcp/tool"
57
+ require_relative "nano_mcp/server"
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nano_mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Julik Tarkhanov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-10-22 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.6'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rack
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '2.2'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '4'
50
+ type: :development
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '2.2'
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '4'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rake
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '13.0'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '13.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: standard
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '1.50'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '1.50'
88
+ description: A Rack app serving MCP tools over stateless Streamable HTTP, per MCP
89
+ revision 2026-07-28. No sessions, no streams, no dependencies.
90
+ email:
91
+ - me@julik.nl
92
+ executables: []
93
+ extensions: []
94
+ extra_rdoc_files: []
95
+ files:
96
+ - CHANGELOG.md
97
+ - LICENSE.txt
98
+ - README.md
99
+ - lib/nano_mcp.rb
100
+ - lib/nano_mcp/arguments.rb
101
+ - lib/nano_mcp/result.rb
102
+ - lib/nano_mcp/server.rb
103
+ - lib/nano_mcp/tool.rb
104
+ - lib/nano_mcp/version.rb
105
+ homepage: https://github.com/julik/nano_mcp
106
+ licenses:
107
+ - MIT
108
+ metadata:
109
+ source_code_uri: https://github.com/julik/nano_mcp
110
+ changelog_uri: https://github.com/julik/nano_mcp/blob/main/CHANGELOG.md
111
+ allowed_push_host: https://rubygems.org
112
+ rubygems_mfa_required: 'true'
113
+ rdoc_options: []
114
+ require_paths:
115
+ - lib
116
+ required_ruby_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: 3.2.0
121
+ required_rubygems_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ requirements: []
127
+ rubygems_version: 3.6.2
128
+ specification_version: 4
129
+ summary: A (slightly unhinged) library for building small MCPs
130
+ test_files: []