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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '049ec03429cd7d181a0b123542e7717dafacfa3ab0ba7bebcfe91240c0d47f80'
4
- data.tar.gz: 1800e1e3f538bf4821cf870bfc6db2989024bc41aa28dcaf24252f1cbc9e4f53
3
+ metadata.gz: ffcb1e248eedd6b3c90e7bb904e866a70290d25ba3c184407aa4848199398324
4
+ data.tar.gz: cd91b4d772601f9d613d9aa4034f610fe88fd90320f0306f02a14c2e528726ef
5
5
  SHA512:
6
- metadata.gz: 187c7b64250f6589e5552b51ed36a9cb9e084c322fe0601ec455a79cafcff0d708c86f980943a5fcb425163d69f094b16076642b6d66018f2e3d4decee4a406a
7
- data.tar.gz: bd907532fe5710d2de079f6a2b144dd3fa360bcce79cb307d15920983cc8e404be4650dd56d0f4073d75a25bd9ded00f87aa6c900437c76c37bbdbfec7bacbb5
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` (or the JSON itself if you gave no
102
- summary). An older client gets the JSON in the text block.
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. Log them in `#invoke` if you need them.
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
@@ -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`, 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.
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&.to_s, value, false)
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
- private_class_method :new
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
@@ -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 and every internal error
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
- 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
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
- "tool=#{name} era=#{modern ? "modern" : "legacy"} error=#{result.error?} bytes=#{JSON.generate(shaped).bytesize} duration_ms=#{elapsed_ms}"
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 if
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 || json)], structuredContent: result.structured, isError: false}
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NanoMCP
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nano_mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov