nano_mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +28 -5
- data/lib/nano_mcp/result.rb +16 -6
- data/lib/nano_mcp/server.rb +30 -15
- data/lib/nano_mcp/tool.rb +3 -2
- data/lib/nano_mcp/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ffcb1e248eedd6b3c90e7bb904e866a70290d25ba3c184407aa4848199398324
|
|
4
|
+
data.tar.gz: cd91b4d772601f9d613d9aa4034f610fe88fd90320f0306f02a14c2e528726ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5faa4fd9cedf6fe30faf267960a5d8342bbb2d55d4510da1f3cfc6d325e48e5080f56e00409c24f064d58ba7c5bbc359167fa124e87dc07c63cc3eac9f4dab1d
|
|
7
|
+
data.tar.gz: 305575f794a6ef8ea7845dc5b6b9196d580a2f09c639b711ce8054036139d3bd2d5927b3f73dbc70a7807104dbd9aa749c838c70f5d16b0ed6ea53be43bbf3bc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- `Result.json` without a `summary:` now puts a line like "12 workflows in structuredContent."
|
|
6
|
+
in the text block for current clients, rather than the data again as JSON. This changes what
|
|
7
|
+
an existing call sends, which the compatibility rules in the README say we don't do - but both
|
|
8
|
+
apps using nano_mcp were working around the old behaviour, so it changes now rather than never.
|
|
9
|
+
- `Server.new` takes `on_call:`, which hears about every tool call, for audit logging.
|
|
10
|
+
- `Tool.annotations` set on a base class apply to its subclasses.
|
|
11
|
+
|
|
3
12
|
## 0.1.0
|
|
4
13
|
|
|
5
14
|
Initial release, extracted from the MCP servers in geneva_drive_admin and Comptoir.
|
data/README.md
CHANGED
|
@@ -98,8 +98,12 @@ or run it from a `config.ru`. See [examples/](examples) for a complete server, w
|
|
|
98
98
|
|
|
99
99
|
- `Result.text(string)` - prose
|
|
100
100
|
- `Result.json(value, summary: nil)` - structured data. A current client gets it as
|
|
101
|
-
`structuredContent`, next to a text block holding `summary
|
|
102
|
-
|
|
101
|
+
`structuredContent`, next to a text block holding `summary`, or a line like "12 workflows in
|
|
102
|
+
structuredContent." if you gave none. An older client gets the JSON in the text block.
|
|
103
|
+
|
|
104
|
+
The spec suggests repeating the data as JSON in the text block. We don't: a client on the
|
|
105
|
+
current revision reads `structuredContent`, and a second copy of a large answer just fills the
|
|
106
|
+
agent's context window.
|
|
103
107
|
- `Result.error(message)` - a refusal the agent should read. Raising `NanoMCP::ToolError` does the same.
|
|
104
108
|
|
|
105
109
|
Bad arguments come back as error results too, worded so an agent can fix its call. Any other
|
|
@@ -120,9 +124,12 @@ There are two hooks for house rules, both meant to be overridden in your own bas
|
|
|
120
124
|
|
|
121
125
|
```ruby
|
|
122
126
|
class MyTool < NanoMCP::Tool
|
|
127
|
+
# Every tool of ours unless it says otherwise - annotations are inherited
|
|
128
|
+
annotations readOnlyHint: true, openWorldHint: false
|
|
129
|
+
|
|
123
130
|
def self.mutating
|
|
124
131
|
@mutating = true
|
|
125
|
-
annotations readOnlyHint: false, destructiveHint: false
|
|
132
|
+
annotations readOnlyHint: false, destructiveHint: false, openWorldHint: false
|
|
126
133
|
end
|
|
127
134
|
|
|
128
135
|
def self.mutating? = !!@mutating
|
|
@@ -186,7 +193,23 @@ The server stays small by leaving these to the host:
|
|
|
186
193
|
- **Building the server from runtime settings.** Constructing one is cheap, so build it per request
|
|
187
194
|
if its instructions or tools depend on configuration.
|
|
188
195
|
- **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.
|
|
196
|
+
Arguments are left out because they may carry secrets. For an audit trail, see `on_call` below.
|
|
197
|
+
|
|
198
|
+
### Auditing calls
|
|
199
|
+
|
|
200
|
+
Pass `on_call:` and it gets a frozen Hash after every tool call, including ones that raised:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
on_call: ->(event) do
|
|
204
|
+
Rails.logger.info "mcp tool=#{event[:tool]} caller=#{event[:context].user.email} error=#{event[:error]}"
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The keys are `tool`, `era` (`"modern"` or `"legacy"`), `context` (what your block returned),
|
|
209
|
+
`arguments` (as they came off the wire), `error`, `bytes` (of the response, nil if the tool
|
|
210
|
+
raised), `duration_ms` and `exception` (nil unless the tool raised). With an `on_call` the logger no longer gets a line per call, since yours replaces it.
|
|
211
|
+
An `on_call` that raises is logged and otherwise ignored. It runs after the tool, so it can't wrap the tool's own logging -
|
|
212
|
+
put log tags in `#invoke` for that.
|
|
190
213
|
|
|
191
214
|
## Public API
|
|
192
215
|
|
|
@@ -198,7 +221,7 @@ This is all of it. Everything else is private, whatever Ruby visibility it happe
|
|
|
198
221
|
- `NanoMCP::Tool`: class methods `tool_name`, `title`, `description`, `input_schema`, `annotations`,
|
|
199
222
|
`descriptor(context)`. Instance methods `#initialize(context)`, `#context`, `#invoke(arguments)`, `#call(**)`
|
|
200
223
|
- `NanoMCP::Result`: `.text`, `.json`, `.error`, `#text`, `#structured`, `#error?`
|
|
201
|
-
- `NanoMCP::Server`: `.new(name:, version:, tools:, title:, instructions:, allowed_origins:, logger:, &authenticate)` and `#call(env)`.
|
|
224
|
+
- `NanoMCP::Server`: `.new(name:, version:, tools:, title:, instructions:, allowed_origins:, logger:, on_call:, &authenticate)` and `#call(env)`.
|
|
202
225
|
The block is called with `(env, meta)`
|
|
203
226
|
|
|
204
227
|
## Protocol behaviour
|
data/lib/nano_mcp/result.rb
CHANGED
|
@@ -12,17 +12,16 @@ module NanoMCP
|
|
|
12
12
|
new(string.to_s, nil, false)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Structured data. A current client gets it as `structuredContent`,
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# to avoid sending a large answer twice.
|
|
15
|
+
# Structured data. A current client gets it as `structuredContent`, with `summary`
|
|
16
|
+
# in the text block next to it - or, without one, a line like "12 workflows in
|
|
17
|
+
# structuredContent.". An older client gets the data serialized into the text block.
|
|
19
18
|
#
|
|
20
19
|
# @param value [Hash, Array, String, Numeric, true, false] anything JSON can represent
|
|
21
20
|
# @param summary [String, nil] a line saying what came back
|
|
22
21
|
# @return [Result]
|
|
23
22
|
def self.json(value, summary: nil)
|
|
24
23
|
raise ArgumentError, "Result.json needs a value, use Result.text for an empty answer" if value.nil?
|
|
25
|
-
new(summary
|
|
24
|
+
new((summary || summarize(value)).to_s, value, false)
|
|
26
25
|
end
|
|
27
26
|
|
|
28
27
|
# A refusal or failure the agent should read and act on. Same as raising ToolError.
|
|
@@ -33,7 +32,18 @@ module NanoMCP
|
|
|
33
32
|
new(message.to_s, nil, true)
|
|
34
33
|
end
|
|
35
34
|
|
|
36
|
-
|
|
35
|
+
# The spec suggests repeating the data as JSON in the text block, but a client
|
|
36
|
+
# asking for the current revision reads structuredContent, and a second copy of a
|
|
37
|
+
# large answer just fills the agent's context window
|
|
38
|
+
def self.summarize(value)
|
|
39
|
+
count, noun = case value
|
|
40
|
+
when Array then [value.length, "items"]
|
|
41
|
+
when Hash then value.select { |_key, member| member.is_a?(Array) }.max_by { |_key, member| member.length }&.then { |key, member| [member.length, key] }
|
|
42
|
+
end
|
|
43
|
+
count ? "#{count} #{noun} in structuredContent." : "See structuredContent."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_class_method :new, :summarize
|
|
37
47
|
|
|
38
48
|
# @return [String, nil] the text, or the summary of a structured result
|
|
39
49
|
attr_reader :text
|
data/lib/nano_mcp/server.rb
CHANGED
|
@@ -71,11 +71,12 @@ module NanoMCP
|
|
|
71
71
|
# @param title [String, nil] `serverInfo.title` - say which app and environment this is
|
|
72
72
|
# @param instructions [String, nil] handed to the client up front, usually ends up in the system prompt
|
|
73
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
|
|
74
|
+
# @param logger [Logger, nil] gets every internal error, and a line per tool call unless there is an `on_call`
|
|
75
|
+
# @param on_call [#call, nil] receives a frozen Hash after every tool call - see the README
|
|
75
76
|
# @yieldparam env [Hash] the Rack env
|
|
76
77
|
# @yieldparam meta [Hash] the request's `params._meta`, empty when there is none
|
|
77
78
|
# @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
|
+
def initialize(name:, version:, tools:, title: nil, instructions: nil, allowed_origins: [], logger: nil, on_call: nil, &authenticate)
|
|
79
80
|
raise ArgumentError, "Pass a block which authenticates the request and returns the tool context, or nil to refuse" unless authenticate
|
|
80
81
|
|
|
81
82
|
@name = name.to_s
|
|
@@ -84,6 +85,7 @@ module NanoMCP
|
|
|
84
85
|
@instructions = instructions&.to_s
|
|
85
86
|
@allowed_origins = Array(allowed_origins).map(&:to_s)
|
|
86
87
|
@logger = logger
|
|
88
|
+
@on_call = on_call
|
|
87
89
|
@authenticate = authenticate
|
|
88
90
|
@tools = tools.each_with_object({}) do |tool, by_name|
|
|
89
91
|
raise ArgumentError, "#{tool.inspect} is not a NanoMCP::Tool subclass" unless tool.is_a?(Class) && tool < Tool
|
|
@@ -264,24 +266,37 @@ module NanoMCP
|
|
|
264
266
|
tool = @tools.fetch(name) { raise ProtocolError.new("Unknown tool: #{name}. Call tools/list to see what this server offers.", code: INVALID_PARAMS) }
|
|
265
267
|
|
|
266
268
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
269
|
+
era = modern ? "modern" : "legacy"
|
|
270
|
+
begin
|
|
271
|
+
result = begin
|
|
272
|
+
tool.new(context).invoke(params["arguments"])
|
|
273
|
+
rescue ToolError => e
|
|
274
|
+
Result.error(e.message)
|
|
275
|
+
end
|
|
276
|
+
raise TypeError, "#{tool}#call returned #{result.class}, not a NanoMCP::Result" unless result.is_a?(Result)
|
|
277
|
+
shaped = shape(result, modern)
|
|
278
|
+
bytes = JSON.generate(shaped).bytesize
|
|
279
|
+
rescue => e
|
|
280
|
+
exception = e
|
|
281
|
+
raise
|
|
282
|
+
ensure
|
|
276
283
|
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1)
|
|
277
|
-
|
|
284
|
+
error = exception ? true : result.error?
|
|
285
|
+
log(:info) { "tool=#{name} era=#{era} error=#{error} bytes=#{bytes || "-"} duration_ms=#{elapsed_ms}" } unless @on_call
|
|
286
|
+
report_call(tool: name, era: era, context: context, arguments: params["arguments"], error: error,
|
|
287
|
+
bytes: bytes, duration_ms: elapsed_ms, exception: exception)
|
|
278
288
|
end
|
|
279
289
|
shaped
|
|
280
290
|
end
|
|
281
291
|
|
|
292
|
+
def report_call(event)
|
|
293
|
+
@on_call&.call(event.freeze)
|
|
294
|
+
rescue => e
|
|
295
|
+
log(:error) { "on_call raised #{e.class}: #{e.message}" }
|
|
296
|
+
end
|
|
297
|
+
|
|
282
298
|
# 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
|
|
284
|
-
# there is one. An answer over the size cap goes out as truncated text: a clipped
|
|
299
|
+
# data as JSON in prose. A current one gets the data once, plus the summary. An answer over the size cap goes out as truncated text: a clipped
|
|
285
300
|
# structure would be a lie about its own shape
|
|
286
301
|
def shape(result, modern)
|
|
287
302
|
return {content: [text_block(result.text)], isError: result.error?} if result.structured.nil?
|
|
@@ -294,7 +309,7 @@ module NanoMCP
|
|
|
294
309
|
if json.bytesize > MAX_RESPONSE_BYTES
|
|
295
310
|
return {content: [text_block(JSON.pretty_generate(result.structured))], isError: false}
|
|
296
311
|
end
|
|
297
|
-
{content: [text_block(result.text
|
|
312
|
+
{content: [text_block(result.text)], structuredContent: result.structured, isError: false}
|
|
298
313
|
end
|
|
299
314
|
|
|
300
315
|
def text_block(string)
|
data/lib/nano_mcp/tool.rb
CHANGED
|
@@ -61,12 +61,13 @@ module NanoMCP
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Behaviour hints for the client, passed through verbatim. Use the spec's names:
|
|
64
|
-
# `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`.
|
|
64
|
+
# `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Set on a
|
|
65
|
+
# base class, they apply to its subclasses until one sets its own.
|
|
65
66
|
#
|
|
66
67
|
# @param value [Hash]
|
|
67
68
|
def annotations(value = nil)
|
|
68
69
|
@annotations = Ractor.make_shareable(value, copy: true) if value
|
|
69
|
-
@annotations
|
|
70
|
+
@annotations || (superclass.annotations unless superclass == Tool)
|
|
70
71
|
end
|
|
71
72
|
|
|
72
73
|
# The `tools/list` entry for this tool. Override it (calling super) to vary the
|
data/lib/nano_mcp/version.rb
CHANGED