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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +237 -0
- data/lib/nano_mcp/arguments.rb +122 -0
- data/lib/nano_mcp/result.rb +56 -0
- data/lib/nano_mcp/server.rb +351 -0
- data/lib/nano_mcp/tool.rb +119 -0
- data/lib/nano_mcp/version.rb +5 -0
- data/lib/nano_mcp.rb +57 -0
- metadata +130 -0
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
|
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: []
|