axn-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 +15 -0
- data/LICENSE +21 -0
- data/README.md +441 -0
- data/Rakefile +13 -0
- data/lib/axn/mcp/config.rb +27 -0
- data/lib/axn/mcp/field_declarations.rb +21 -0
- data/lib/axn/mcp/schema_builder.rb +224 -0
- data/lib/axn/mcp/serializer.rb +64 -0
- data/lib/axn/mcp/tool.rb +151 -0
- data/lib/axn/mcp/version.rb +7 -0
- data/lib/axn/mcp.rb +22 -0
- data/lib/axn-mcp.rb +3 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 33bb333838d7dfe4375c7d66f95b0058c66330eb309271e752f8587d77de4ac7
|
|
4
|
+
data.tar.gz: 9bc64595d2939e420e1fa55c7cfe91b19799d56a73ca0c710dc6ced07edd643f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4fe4d4184f017f35677742055e7ab2f83c261ada3bf8d6f3da946d15f23d2c3b0dd0b9078d49aad17eddc6389a9d67c1f9b9e84d1d2e4e82023ff13d44b2622f
|
|
7
|
+
data.tar.gz: 816a6455d2f2cef47db441aa41af1d85bca24a8f6f3666a88fe5275930fdb8ad0567f3cfe5b3a602402bc1c406d9a1cbb64ec49c2c9da49c5fc3e80af76bbb9a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Initial release
|
|
6
|
+
- `Axn::MCP::Tool` base class for building MCP tools with Axn
|
|
7
|
+
- Auto-generated JSON schemas from `expects`/`exposes` declarations
|
|
8
|
+
- Automatic `model:` field handling (exposes `_id` field to LLM)
|
|
9
|
+
- Auto-serialization of exposed values to JSON-safe structures
|
|
10
|
+
- Annotation shortcuts: `read_only!`, `destructive!`, `idempotent!`, `open_world!`, `closed_world!`
|
|
11
|
+
- Factory-style `Tool.define` for quick one-off tools
|
|
12
|
+
- Dual-use: returns `Axn::Result` for direct calls, `MCP::Tool::Response` when called via MCP server
|
|
13
|
+
- Typed array element schemas: `Array` fields with `of:` emit a machine-readable `items:` entry in JSON Schema rather than a bare `array` type — scalar types, `:boolean`/`:uuid` shorthands, `Data.define` structs (bare member names), and union types (`anyOf`) are all supported
|
|
14
|
+
- Structured field contracts via `shape:` block: annotate individual element/member types and validations inline; `required` is derived automatically from optional vs non-optional members; blocks recurse for nested objects
|
|
15
|
+
- When `of: <Data.define>` and a `shape:` block are combined, Data members provide the bare-name baseline and block-declared members overlay typed properties (enrich)
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Teamshares, Inc.
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
# Axn::MCP
|
|
2
|
+
|
|
3
|
+
Build [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) tools using [Axn](https://github.com/teamshares/axn)'s declarative `expects`/`exposes` contract. This gem wraps the official [MCP Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk) and auto-generates JSON schemas from your Axn field declarations.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "axn-mcp"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
Define an MCP tool by inheriting from `Axn::MCP::Tool`:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
class GreetUser < Axn::MCP::Tool
|
|
25
|
+
description "Greet a user by name"
|
|
26
|
+
|
|
27
|
+
expects :name, type: String, description: "The user's name"
|
|
28
|
+
exposes :greeting, type: String, description: "The greeting message"
|
|
29
|
+
|
|
30
|
+
def call
|
|
31
|
+
expose greeting: "Hello, #{name}!"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That's it. The gem automatically:
|
|
37
|
+
|
|
38
|
+
- Generates `inputSchema` from your `expects` declarations
|
|
39
|
+
- Generates `outputSchema` from your `exposes` declarations
|
|
40
|
+
- Converts `Axn::Result` to `MCP::Tool::Response`
|
|
41
|
+
- Serializes exposed data to JSON-safe `structured_content`
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Basic Tool Definition
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
class CreateNote < Axn::MCP::Tool
|
|
49
|
+
description "Create a new note"
|
|
50
|
+
|
|
51
|
+
expects :title, type: String, description: "Note title"
|
|
52
|
+
expects :content, type: String, description: "Note body"
|
|
53
|
+
expects :tags, type: Array, optional: true, description: "Optional tags"
|
|
54
|
+
|
|
55
|
+
exposes :note_id, type: Integer, description: "ID of the created note"
|
|
56
|
+
|
|
57
|
+
def call
|
|
58
|
+
note = Note.create!(title:, content:, tags: tags || [])
|
|
59
|
+
expose note_id: note.id
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Field Descriptions
|
|
65
|
+
|
|
66
|
+
Use `description:` directly as a kwarg on `expects` and `exposes`:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
expects :start_date, type: Date, optional: true, description: "Inclusive lower bound (YYYY-MM-DD)"
|
|
70
|
+
exposes :results, type: Array, description: "Matching records"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
> **Note:** Do *not* wrap it in `metadata: { description: ... }`. The `metadata:` key is not recognized by `expects`/`exposes` and raises `ArgumentError` at class load time.
|
|
74
|
+
|
|
75
|
+
### Type Mappings
|
|
76
|
+
|
|
77
|
+
Axn types map to JSON Schema types:
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
| Ruby Type | JSON Schema |
|
|
81
|
+
| ------------------ | ------------------------------ |
|
|
82
|
+
| `String` | `string` |
|
|
83
|
+
| `Integer` | `integer` |
|
|
84
|
+
| `Float`, `Numeric` | `number` |
|
|
85
|
+
| `Hash` | `object` |
|
|
86
|
+
| `Array` | `array` |
|
|
87
|
+
| `:boolean` | `boolean` |
|
|
88
|
+
| `:uuid` | `string` (format: `uuid`) |
|
|
89
|
+
| `Date` | `string` (format: `date`) |
|
|
90
|
+
| `DateTime`, `Time` | `string` (format: `date-time`) |
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
### Typed member contracts with `shape:`
|
|
94
|
+
|
|
95
|
+
Add a `shape:` block to a `Hash` or `Data.define` field to declare types and validations for its members. `required` is derived automatically; unannotated members on a `Data.define` type appear as bare `{}`. The block syntax is the same on both `expects` and `exposes`. (For `Array` fields, combine `shape:` with `of:` — see the next section.)
|
|
96
|
+
|
|
97
|
+
**Hash field:**
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
exposes :config, type: Hash do
|
|
101
|
+
field :region, type: String
|
|
102
|
+
field :timeout, type: Integer, optional: true
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"type": "object",
|
|
109
|
+
"required": ["region"],
|
|
110
|
+
"properties": {
|
|
111
|
+
"region": { "type": "string" },
|
|
112
|
+
"timeout": { "type": "integer" }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**`Data.define` struct:**
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
IntegrationRecord = Data.define(:source, :provider_name, :active, :status)
|
|
121
|
+
|
|
122
|
+
exposes :integration, type: IntegrationRecord do
|
|
123
|
+
field :status, type: String, inclusion: { in: %w[connected error needs_reconnect] }
|
|
124
|
+
field :active, type: :boolean, optional: true
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"type": "object",
|
|
131
|
+
"required": ["status"],
|
|
132
|
+
"properties": {
|
|
133
|
+
"status": { "type": "string", "enum": ["connected", "error", "needs_reconnect"] },
|
|
134
|
+
"active": { "type": "boolean" },
|
|
135
|
+
"source": {},
|
|
136
|
+
"provider_name": {}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Blocks recurse naturally for nested objects:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
exposes :config, type: Hash do
|
|
145
|
+
field :region, type: String
|
|
146
|
+
field :retention, type: Hash do
|
|
147
|
+
field :days, type: Integer
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Typed array elements with `of:`
|
|
153
|
+
|
|
154
|
+
When an `Array` field carries an `of:` declaration, the generated JSON Schema includes a machine-readable `items:` entry rather than a bare `array` type.
|
|
155
|
+
|
|
156
|
+
**Scalar element type:**
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
exposes :tags, type: Array, of: String
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
```json
|
|
163
|
+
{ "type": "array", "items": { "type": "string" } }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Other supported forms: `of: Integer`, `of: :boolean`, `of: :uuid`, and union types:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
exposes :values, type: Array, of: [String, Numeric]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
{ "type": "array", "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } }
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**`Data.define` struct — bare member names as baseline:**
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
exposes :integrations, type: Array, of: IntegrationRecord
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{
|
|
184
|
+
"type": "array",
|
|
185
|
+
"items": {
|
|
186
|
+
"type": "object",
|
|
187
|
+
"properties": { "source": {}, "provider_name": {}, "active": {}, "status": {} }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**Combine `of:` with a `shape:` block to annotate element members:**
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
exposes :integrations, type: Array, of: IntegrationRecord do
|
|
196
|
+
field :status, type: String, inclusion: { in: %w[connected error needs_reconnect] }
|
|
197
|
+
field :active, type: :boolean, optional: true
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"type": "array",
|
|
204
|
+
"items": {
|
|
205
|
+
"type": "object",
|
|
206
|
+
"required": ["status"],
|
|
207
|
+
"properties": {
|
|
208
|
+
"status": { "type": "string", "enum": ["connected", "error", "needs_reconnect"] },
|
|
209
|
+
"active": { "type": "boolean" },
|
|
210
|
+
"source": {},
|
|
211
|
+
"provider_name": {}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Annotated members are fully typed; unannotated `Data.define` members (`source`, `provider_name`) remain as bare `{}`.
|
|
218
|
+
|
|
219
|
+
### ActiveRecord Model Fields
|
|
220
|
+
|
|
221
|
+
When using `model: true`, the schema automatically generates an `_id` field with an appropriate description:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
class UpdateUser < Axn::MCP::Tool
|
|
225
|
+
description "Update a user's profile"
|
|
226
|
+
|
|
227
|
+
expects :user, model: true
|
|
228
|
+
expects :name, type: String, optional: true
|
|
229
|
+
|
|
230
|
+
def call
|
|
231
|
+
user.update!(name:) if name
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Generates schema:
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"properties": {
|
|
241
|
+
"user_id": {
|
|
242
|
+
"type": "integer",
|
|
243
|
+
"description": "ID of the User record"
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Enums via Inclusion
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
expects :status, inclusion: { in: %w[active inactive pending] }
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Generates:
|
|
256
|
+
|
|
257
|
+
```json
|
|
258
|
+
{
|
|
259
|
+
"status": {
|
|
260
|
+
"type": "string",
|
|
261
|
+
"enum": ["active", "inactive", "pending"]
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Annotations
|
|
267
|
+
|
|
268
|
+
Use convenience methods or the `annotations` DSL:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
class ReadOnlyTool < Axn::MCP::Tool
|
|
272
|
+
description "Fetch data without side effects"
|
|
273
|
+
read_only!
|
|
274
|
+
|
|
275
|
+
# ...
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
class DangerousTool < Axn::MCP::Tool
|
|
279
|
+
description "Delete all the things"
|
|
280
|
+
destructive!
|
|
281
|
+
idempotent!
|
|
282
|
+
|
|
283
|
+
# ...
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
class CustomAnnotations < Axn::MCP::Tool
|
|
287
|
+
annotations(
|
|
288
|
+
read_only_hint: true,
|
|
289
|
+
idempotent_hint: true,
|
|
290
|
+
title: "My Custom Tool",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# ...
|
|
294
|
+
end
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Available shortcuts:
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
| Method | Effect |
|
|
301
|
+
| --------------- | ------------------------------------------------- |
|
|
302
|
+
| `read_only!` | `read_only_hint: true`, `destructive_hint: false` |
|
|
303
|
+
| `destructive!` | `destructive_hint: true`, `read_only_hint: false` |
|
|
304
|
+
| `idempotent!` | `idempotent_hint: true` |
|
|
305
|
+
| `open_world!` | `open_world_hint: true` |
|
|
306
|
+
| `closed_world!` | `open_world_hint: false` |
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
### Factory-Style Definition
|
|
310
|
+
|
|
311
|
+
For quick one-off tools:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
SearchTool = Axn::MCP::Tool.define(
|
|
315
|
+
description: "Search for items",
|
|
316
|
+
expects: { query: { type: String, description: "Search query" } },
|
|
317
|
+
exposes: { results: { type: Array } },
|
|
318
|
+
annotations: { read_only_hint: true },
|
|
319
|
+
) do
|
|
320
|
+
expose results: Item.search(query)
|
|
321
|
+
end
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Server Context
|
|
325
|
+
|
|
326
|
+
`server_context` is automatically available in all tools (no declaration needed):
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
class AuthenticatedTool < Axn::MCP::Tool
|
|
330
|
+
description "Do something with the current user"
|
|
331
|
+
|
|
332
|
+
def call
|
|
333
|
+
current_user = server_context&.dig(:user)
|
|
334
|
+
# ...
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Note the safe navigation (`&.dig`): `server_context` may be `nil` if the tool is invoked directly as a standard Axn action rather than through the MCP server.
|
|
340
|
+
|
|
341
|
+
The `server_context` field is excluded from the generated `inputSchema` since it's injected by the MCP server, not provided by the LLM.
|
|
342
|
+
|
|
343
|
+
### Dual-Use: MCP Server vs Direct Invocation
|
|
344
|
+
|
|
345
|
+
Tools automatically adapt their return type based on how they're called:
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
# Called FROM MCP server (server_context injected) → returns MCP::Tool::Response
|
|
349
|
+
# This happens automatically when registered with MCP::Server
|
|
350
|
+
|
|
351
|
+
# Called DIRECTLY without server_context → returns Axn::Result
|
|
352
|
+
result = MyTool.call(name: "Alice")
|
|
353
|
+
if result.ok?
|
|
354
|
+
puts result.greeting
|
|
355
|
+
else
|
|
356
|
+
puts "Error: #{result.message}"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Or use call! to raise on failure
|
|
360
|
+
result = MyTool.call!(name: "Bob")
|
|
361
|
+
puts result.greeting
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The branching is based on presence of `server_context`:
|
|
365
|
+
|
|
366
|
+
- **With `server_context`**: Returns `MCP::Tool::Response` (for MCP server compatibility)
|
|
367
|
+
- **Without `server_context`**: Returns `Axn::Result` (standard Axn semantics)
|
|
368
|
+
|
|
369
|
+
This allows you to test tools or call them from non-MCP contexts using standard Axn patterns.
|
|
370
|
+
|
|
371
|
+
## Error Handling
|
|
372
|
+
|
|
373
|
+
Use Axn's standard `fail!` method for controlled failures:
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
def call
|
|
377
|
+
fail! "User not found" unless user
|
|
378
|
+
fail! "Unauthorized" unless authorized?
|
|
379
|
+
|
|
380
|
+
# success path...
|
|
381
|
+
end
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Unhandled exceptions are also caught automatically. When an exception occurs:
|
|
385
|
+
|
|
386
|
+
1. The error is recorded on the result
|
|
387
|
+
2. Any configured `on_exception` handlers are triggered (see [Axn configuration](https://github.com/teamshares/axn))
|
|
388
|
+
3. An `MCP::Tool::Response` is returned with `error: true`
|
|
389
|
+
|
|
390
|
+
Both `fail!` calls and unhandled exceptions result in error responses to the LLM.
|
|
391
|
+
|
|
392
|
+
## Integration with MCP Server
|
|
393
|
+
|
|
394
|
+
Register your tools with an MCP server:
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
require "mcp"
|
|
398
|
+
require "axn-mcp"
|
|
399
|
+
|
|
400
|
+
server = MCP::Server.new(
|
|
401
|
+
name: "my-server",
|
|
402
|
+
version: "1.0.0",
|
|
403
|
+
tools: [GreetUser, CreateNote, SearchTool],
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Use with stdio transport
|
|
407
|
+
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
408
|
+
transport.open
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
For complete server setup, transport options, and advanced configuration, see the [MCP Ruby SDK documentation](https://github.com/modelcontextprotocol/ruby-sdk).
|
|
412
|
+
|
|
413
|
+
### Success response text: config and per-tool
|
|
414
|
+
|
|
415
|
+
By default, successful responses contain a text block with the JSON-serialized `structured_content` (a SHOULD per [MCP spec](https://modelcontextprotocol.io/specification/draft/server/tools#structured-content)). To use the Axn success message instead, set **central config** once (`Axn::MCP.config.mcp_text_content = :message`) or override **per tool** with `mcp_text_content :message`. Valid values are `:structured` (default) and `:message`; per-tool overrides config.
|
|
416
|
+
|
|
417
|
+
## Requirements
|
|
418
|
+
|
|
419
|
+
- Ruby >= 3.2.1
|
|
420
|
+
- [axn](https://github.com/teamshares/axn) >= 0.1.0-alpha.4.3
|
|
421
|
+
- [mcp](https://github.com/modelcontextprotocol/ruby-sdk) >= 0.4
|
|
422
|
+
|
|
423
|
+
## Development
|
|
424
|
+
|
|
425
|
+
```bash
|
|
426
|
+
bundle install
|
|
427
|
+
bundle exec rspec
|
|
428
|
+
bundle exec rubocop
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## License
|
|
432
|
+
|
|
433
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
434
|
+
|
|
435
|
+
## Contributing
|
|
436
|
+
|
|
437
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/teamshares/axn-mcp](https://github.com/teamshares/axn-mcp).
|
|
438
|
+
|
|
439
|
+
## Acknowledgments
|
|
440
|
+
|
|
441
|
+
This gem wraps the excellent [MCP Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk) from the Model Context Protocol team.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
require "rubocop/rake_task"
|
|
6
|
+
|
|
7
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
8
|
+
|
|
9
|
+
RuboCop::RakeTask.new
|
|
10
|
+
|
|
11
|
+
task default: %i[spec rubocop]
|
|
12
|
+
|
|
13
|
+
Rake::Task["build"].enhance([:default])
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module MCP
|
|
5
|
+
class Config
|
|
6
|
+
VALID_MCP_TEXT_CONTENT = %i[structured message].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :mcp_text_content
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@mcp_text_content = :structured
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def mcp_text_content=(value)
|
|
15
|
+
self.class.validate_mcp_text_content!(value)
|
|
16
|
+
@mcp_text_content = value
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.validate_mcp_text_content!(value)
|
|
20
|
+
return if VALID_MCP_TEXT_CONTENT.include?(value)
|
|
21
|
+
|
|
22
|
+
raise ArgumentError,
|
|
23
|
+
"mcp_text_content must be one of #{VALID_MCP_TEXT_CONTENT.map(&:inspect).join(", ")}; got #{value.inspect}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module MCP
|
|
5
|
+
module FieldDeclarations
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def hydrate(declarations)
|
|
9
|
+
return declarations if declarations.is_a?(Hash)
|
|
10
|
+
|
|
11
|
+
Array(declarations).each_with_object({}) do |item, acc|
|
|
12
|
+
if item.is_a?(Hash)
|
|
13
|
+
item.each { |k, v| acc[k] = v }
|
|
14
|
+
else
|
|
15
|
+
acc[item] = {}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Axn
|
|
6
|
+
module MCP
|
|
7
|
+
module SchemaBuilder
|
|
8
|
+
TYPE_MAP = {
|
|
9
|
+
String => "string",
|
|
10
|
+
Integer => "integer",
|
|
11
|
+
Float => "number",
|
|
12
|
+
Numeric => "number",
|
|
13
|
+
Hash => "object",
|
|
14
|
+
Array => "array",
|
|
15
|
+
TrueClass => "boolean",
|
|
16
|
+
FalseClass => "boolean",
|
|
17
|
+
Date => "string",
|
|
18
|
+
DateTime => "string",
|
|
19
|
+
Time => "string",
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
FORMAT_MAP = {
|
|
23
|
+
Date => "date",
|
|
24
|
+
DateTime => "date-time",
|
|
25
|
+
Time => "date-time",
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
EXCLUDED_FROM_SCHEMA = %i[server_context].freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def build_input(field_configs, subfield_configs = [])
|
|
33
|
+
properties = {}
|
|
34
|
+
required = []
|
|
35
|
+
|
|
36
|
+
subfields_by_parent = subfield_configs.group_by(&:on)
|
|
37
|
+
|
|
38
|
+
field_configs.each do |config|
|
|
39
|
+
next if EXCLUDED_FROM_SCHEMA.include?(config.field)
|
|
40
|
+
|
|
41
|
+
if config.validations[:model]
|
|
42
|
+
build_model_property(config, properties, required)
|
|
43
|
+
else
|
|
44
|
+
prop = build_property(config)
|
|
45
|
+
nested_subfields = subfields_by_parent[config.field]
|
|
46
|
+
if nested_subfields&.any? && prop[:type] == "object"
|
|
47
|
+
prop[:properties] ||= {}
|
|
48
|
+
prop[:required] ||= []
|
|
49
|
+
nested_subfields.each do |subconfig|
|
|
50
|
+
subprop = build_property(subconfig)
|
|
51
|
+
prop[:properties][subconfig.field] = subprop
|
|
52
|
+
prop[:required] << subconfig.field.to_s unless optional?(subconfig)
|
|
53
|
+
end
|
|
54
|
+
prop[:required] = nil if prop[:required].empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
properties[config.field] = prop.compact
|
|
58
|
+
required << config.field.to_s unless optional?(config)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
schema = { type: "object", properties: }
|
|
63
|
+
schema[:required] = required unless required.empty?
|
|
64
|
+
schema
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_output(field_configs)
|
|
68
|
+
properties = {}
|
|
69
|
+
required = []
|
|
70
|
+
|
|
71
|
+
field_configs.each do |config|
|
|
72
|
+
prop = build_property(config, for_output: true)
|
|
73
|
+
properties[config.field] = prop.compact
|
|
74
|
+
required << config.field.to_s unless optional?(config)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
schema = { type: "object", properties: }
|
|
78
|
+
schema[:required] = required unless required.empty?
|
|
79
|
+
schema
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_property(config, for_output: false)
|
|
83
|
+
prop = {}
|
|
84
|
+
prop[:description] = config.description if config.description
|
|
85
|
+
|
|
86
|
+
type_info = json_type_for(config.validations, for_output:)
|
|
87
|
+
prop[:type] = type_info[:type] if type_info[:type]
|
|
88
|
+
prop[:format] = type_info[:format] if type_info[:format]
|
|
89
|
+
|
|
90
|
+
prop[:default] = config.default if config.respond_to?(:default) && !config.default.nil?
|
|
91
|
+
|
|
92
|
+
if (inclusion = config.validations[:inclusion])
|
|
93
|
+
enum_values = inclusion[:in] || inclusion[:within] if inclusion.is_a?(Hash)
|
|
94
|
+
prop[:enum] = enum_values if enum_values
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
apply_structured_schema!(prop, config, for_output:)
|
|
98
|
+
|
|
99
|
+
prop
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Combine of: (bare element baseline) and shape: (typed member contracts) into
|
|
103
|
+
# items:/properties: schema. Precedence: shape: enriches/overrides of: baseline.
|
|
104
|
+
def apply_structured_schema!(prop, config, for_output:)
|
|
105
|
+
of = config.validations[:of]
|
|
106
|
+
shape = config.validations[:shape]
|
|
107
|
+
return unless of || shape
|
|
108
|
+
|
|
109
|
+
if prop[:type] == "array"
|
|
110
|
+
items = of ? items_schema_for(of, for_output:) : {}
|
|
111
|
+
if shape
|
|
112
|
+
member_props, required = member_properties(shape[:members], for_output:)
|
|
113
|
+
base_props = items[:properties] || {}
|
|
114
|
+
items = items.merge(type: "object", properties: base_props.merge(member_props))
|
|
115
|
+
items[:required] = required unless required.empty?
|
|
116
|
+
end
|
|
117
|
+
prop[:items] = items unless items.empty?
|
|
118
|
+
elsif shape
|
|
119
|
+
# Hash / class field — shape: members are the object's own properties.
|
|
120
|
+
# If the field type is a Data.define subclass, use its members as the bare
|
|
121
|
+
# baseline so unannotated members still appear (same enrich logic as of:).
|
|
122
|
+
member_props, required = member_properties(shape[:members], for_output:)
|
|
123
|
+
type_klass = config.validations.dig(:type, :klass)
|
|
124
|
+
base_props = type_klass.is_a?(Class) && type_klass < Data ? type_klass.members.to_h { |m| [m, {}] } : {}
|
|
125
|
+
prop[:properties] = base_props.merge(member_props)
|
|
126
|
+
prop[:required] = required unless required.empty?
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Build a JSON Schema items: value from the of: validation hash.
|
|
131
|
+
def items_schema_for(of_validations, for_output: false)
|
|
132
|
+
klasses = Array(of_validations[:klass])
|
|
133
|
+
if klasses.size == 1
|
|
134
|
+
single_items_schema(klasses.first, for_output:)
|
|
135
|
+
else
|
|
136
|
+
{ anyOf: klasses.map { |k| single_items_schema(k, for_output:) } }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def single_items_schema(klass, for_output: false)
|
|
141
|
+
if klass.is_a?(Class) && klass < Data
|
|
142
|
+
# Data.define subclass → object with named (but untyped) properties as baseline
|
|
143
|
+
{ type: "object", properties: klass.members.to_h { |m| [m, {}] } }
|
|
144
|
+
else
|
|
145
|
+
json_type_for({ type: klass }, for_output:)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Build properties/required from a shape: block's members. Recurses for nested shape/of.
|
|
150
|
+
def member_properties(members, for_output:)
|
|
151
|
+
props = {}
|
|
152
|
+
required = []
|
|
153
|
+
members.each do |m|
|
|
154
|
+
props[m.field] = build_property(m, for_output:).compact
|
|
155
|
+
required << m.field.to_s unless optional?(m)
|
|
156
|
+
end
|
|
157
|
+
[props, required]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def build_model_property(config, properties, required)
|
|
161
|
+
model_opts = config.validations[:model]
|
|
162
|
+
klass = model_opts[:klass]
|
|
163
|
+
klass_name = klass.is_a?(Class) ? klass.name : klass.to_s
|
|
164
|
+
|
|
165
|
+
id_field = :"#{config.field}_id"
|
|
166
|
+
prop = {
|
|
167
|
+
type: "integer",
|
|
168
|
+
description: config.description || "ID of the #{klass_name} record",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
properties[id_field] = prop.compact
|
|
172
|
+
required << id_field.to_s unless optional?(config)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def json_type_for(validations, for_output: false)
|
|
176
|
+
if validations[:type]
|
|
177
|
+
type_opt = validations[:type]
|
|
178
|
+
klass = type_opt.is_a?(Hash) ? type_opt[:klass] : type_opt
|
|
179
|
+
klasses = Array(klass)
|
|
180
|
+
|
|
181
|
+
klass = klasses.first
|
|
182
|
+
return { type: "boolean" } if klass == :boolean
|
|
183
|
+
return { type: "string", format: "uuid" } if klass == :uuid
|
|
184
|
+
|
|
185
|
+
if TYPE_MAP.key?(klass)
|
|
186
|
+
result = { type: TYPE_MAP[klass] }
|
|
187
|
+
result[:format] = FORMAT_MAP[klass] if FORMAT_MAP.key?(klass)
|
|
188
|
+
return result
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
return { type: "object" } if for_output
|
|
192
|
+
|
|
193
|
+
return { type: "string" }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
if validations[:inclusion]
|
|
197
|
+
inclusion = validations[:inclusion]
|
|
198
|
+
enum_values = inclusion[:in] || inclusion[:within] if inclusion.is_a?(Hash)
|
|
199
|
+
if enum_values&.any?
|
|
200
|
+
sample = enum_values.first
|
|
201
|
+
return { type: "string" } if sample.is_a?(String)
|
|
202
|
+
return { type: "integer" } if sample.is_a?(Integer)
|
|
203
|
+
return { type: "number" } if sample.is_a?(Float)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
if validations[:numericality]
|
|
208
|
+
numericality = validations[:numericality]
|
|
209
|
+
return { type: "integer" } if numericality.is_a?(Hash) && numericality[:only_integer]
|
|
210
|
+
|
|
211
|
+
return { type: "number" }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
return { type: "string" } if validations[:presence] || validations[:length]
|
|
215
|
+
|
|
216
|
+
{}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def optional?(config)
|
|
220
|
+
Axn::Internal::FieldConfig.optional?(config)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "active_support/core_ext/object/blank"
|
|
5
|
+
|
|
6
|
+
module Axn
|
|
7
|
+
module MCP
|
|
8
|
+
module Serializer
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def serialize_exposed(result, field_configs)
|
|
12
|
+
field_configs.each_with_object({}) do |config, hash|
|
|
13
|
+
value = result.public_send(config.field)
|
|
14
|
+
hash[config.field.to_s] = serialize_value(value)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def serialize_value(value)
|
|
19
|
+
case value
|
|
20
|
+
when nil, String, Integer, Float, TrueClass, FalseClass
|
|
21
|
+
value
|
|
22
|
+
when Hash
|
|
23
|
+
value.transform_keys(&:to_s).transform_values { |v| serialize_value(v) }
|
|
24
|
+
when Array
|
|
25
|
+
value.map { |v| serialize_value(v) }
|
|
26
|
+
else
|
|
27
|
+
if value.respond_to?(:as_json)
|
|
28
|
+
value.as_json
|
|
29
|
+
elsif value.respond_to?(:to_h)
|
|
30
|
+
serialize_value(value.to_h)
|
|
31
|
+
else
|
|
32
|
+
value.to_s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def result_to_mcp_response(result, field_configs, text_content: :structured)
|
|
38
|
+
if result.ok?
|
|
39
|
+
exposed = serialize_exposed(result, field_configs)
|
|
40
|
+
success_text = success_response_text(result, exposed, text_content)
|
|
41
|
+
::MCP::Tool::Response.new(
|
|
42
|
+
[{ type: "text", text: success_text }],
|
|
43
|
+
structured_content: exposed.presence,
|
|
44
|
+
)
|
|
45
|
+
else
|
|
46
|
+
::MCP::Tool::Response.new(
|
|
47
|
+
[{ type: "text", text: result.error }],
|
|
48
|
+
error: true,
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def success_response_text(result, exposed, text_content)
|
|
54
|
+
use_message = text_content == :message
|
|
55
|
+
success_message = result.respond_to?(:success) ? result.success : result.message
|
|
56
|
+
if use_message || exposed.blank?
|
|
57
|
+
success_message
|
|
58
|
+
else
|
|
59
|
+
JSON.generate(exposed)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/axn/mcp/tool.rb
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module MCP
|
|
5
|
+
class Tool < ::MCP::Tool
|
|
6
|
+
include Axn
|
|
7
|
+
|
|
8
|
+
expects :server_context, optional: true, description: "MCP server context (injected automatically)"
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
NOT_SET = Object.new.freeze
|
|
12
|
+
|
|
13
|
+
def mcp_text_content(value = NOT_SET)
|
|
14
|
+
if value == NOT_SET
|
|
15
|
+
resolved_mcp_text_content
|
|
16
|
+
else
|
|
17
|
+
Config.validate_mcp_text_content!(value)
|
|
18
|
+
@mcp_text_content = value
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def resolved_mcp_text_content
|
|
23
|
+
if instance_variable_defined?(:@mcp_text_content) && !@mcp_text_content.nil?
|
|
24
|
+
@mcp_text_content
|
|
25
|
+
else
|
|
26
|
+
Axn::MCP.config.mcp_text_content
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def input_schema(value = NOT_SET)
|
|
31
|
+
if value != NOT_SET
|
|
32
|
+
super
|
|
33
|
+
elsif @input_schema_value
|
|
34
|
+
@input_schema_value
|
|
35
|
+
else
|
|
36
|
+
@input_schema_value = ::MCP::Tool::InputSchema.new(
|
|
37
|
+
SchemaBuilder.build_input(internal_field_configs, subfield_configs),
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def input_schema_value
|
|
43
|
+
@input_schema_value || input_schema
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def output_schema(value = NOT_SET)
|
|
47
|
+
if value != NOT_SET
|
|
48
|
+
super
|
|
49
|
+
elsif @output_schema_value
|
|
50
|
+
@output_schema_value
|
|
51
|
+
elsif external_field_configs.empty?
|
|
52
|
+
nil
|
|
53
|
+
else
|
|
54
|
+
@output_schema_value = ::MCP::Tool::OutputSchema.new(
|
|
55
|
+
SchemaBuilder.build_output(external_field_configs),
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def output_schema_value
|
|
61
|
+
return @output_schema_value if @output_schema_value
|
|
62
|
+
return nil if external_field_configs.empty?
|
|
63
|
+
|
|
64
|
+
output_schema
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_h
|
|
68
|
+
input_schema
|
|
69
|
+
output_schema unless external_field_configs.empty?
|
|
70
|
+
super
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def call(**kwargs)
|
|
74
|
+
result = new(**kwargs).tap(&:_run).result
|
|
75
|
+
|
|
76
|
+
# Branch on presence of server_context:
|
|
77
|
+
# - Present: called from MCP server, return MCP::Tool::Response
|
|
78
|
+
# - Absent: called directly as Axn, return Axn::Result
|
|
79
|
+
return result unless kwargs.key?(:server_context)
|
|
80
|
+
|
|
81
|
+
Serializer.result_to_mcp_response(result, external_field_configs, text_content: resolved_mcp_text_content)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def call!(**)
|
|
85
|
+
result = call(**)
|
|
86
|
+
|
|
87
|
+
# For MCP calls (with server_context), just return the response
|
|
88
|
+
return result if result.is_a?(::MCP::Tool::Response)
|
|
89
|
+
|
|
90
|
+
# For direct Axn calls, raise on failure
|
|
91
|
+
return result if result.ok?
|
|
92
|
+
|
|
93
|
+
raise result.exception
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Convenience DSL for annotations
|
|
97
|
+
# See: https://github.com/modelcontextprotocol/ruby-sdk#tool-annotations
|
|
98
|
+
#
|
|
99
|
+
# Available annotations:
|
|
100
|
+
# destructive_hint: true/false - Indicates if tool performs destructive operations (default: true)
|
|
101
|
+
# idempotent_hint: true/false - Indicates if tool's operations are idempotent (default: false)
|
|
102
|
+
# open_world_hint: true/false - Indicates if tool operates in open world context (default: true)
|
|
103
|
+
# read_only_hint: true/false - Indicates if tool only reads data (default: false)
|
|
104
|
+
# title: "string" - Human-readable title for the tool
|
|
105
|
+
|
|
106
|
+
def read_only!
|
|
107
|
+
annotations(read_only_hint: true, destructive_hint: false)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def destructive!
|
|
111
|
+
annotations(destructive_hint: true, read_only_hint: false)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def idempotent!
|
|
115
|
+
annotations(idempotent_hint: true)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def open_world!
|
|
119
|
+
annotations(open_world_hint: true)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def closed_world!
|
|
123
|
+
annotations(open_world_hint: false)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Factory-style tool definition for quick one-off tools
|
|
127
|
+
def define(description:, expects: [], exposes: [], annotations: nil, mcp_text_content: NOT_SET, **_opts, &block)
|
|
128
|
+
tool_class = Class.new(self) do
|
|
129
|
+
include Axn unless self < Axn
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
FieldDeclarations.hydrate(expects).each do |field, field_opts|
|
|
133
|
+
tool_class.expects(field, **field_opts)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
FieldDeclarations.hydrate(exposes).each do |field, field_opts|
|
|
137
|
+
tool_class.exposes(field, **field_opts)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
tool_class.description(description)
|
|
141
|
+
tool_class.annotations(annotations) if annotations
|
|
142
|
+
tool_class.mcp_text_content(mcp_text_content) if mcp_text_content != NOT_SET
|
|
143
|
+
|
|
144
|
+
tool_class.define_method(:call, &block) if block
|
|
145
|
+
|
|
146
|
+
tool_class
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
data/lib/axn/mcp.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "axn"
|
|
5
|
+
require "mcp"
|
|
6
|
+
|
|
7
|
+
require_relative "mcp/version"
|
|
8
|
+
require_relative "mcp/config"
|
|
9
|
+
require_relative "mcp/serializer"
|
|
10
|
+
require_relative "mcp/schema_builder"
|
|
11
|
+
require_relative "mcp/field_declarations"
|
|
12
|
+
require_relative "mcp/tool"
|
|
13
|
+
|
|
14
|
+
module Axn
|
|
15
|
+
module MCP
|
|
16
|
+
class SchemaError < StandardError; end
|
|
17
|
+
|
|
18
|
+
def self.config
|
|
19
|
+
@config ||= Config.new
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/axn-mcp.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: axn-mcp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kali Donovan
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: axn
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0.pre.alpha.4.3
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: 0.2.0
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 0.1.0.pre.alpha.4.3
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: 0.2.0
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: mcp
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '0.4'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '1.0'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '0.4'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '1.0'
|
|
52
|
+
description: Build MCP tools using Axn's expects/exposes contract with auto-generated
|
|
53
|
+
JSON schemas and responses.
|
|
54
|
+
email:
|
|
55
|
+
- kali@teamshares.com
|
|
56
|
+
executables: []
|
|
57
|
+
extensions: []
|
|
58
|
+
extra_rdoc_files: []
|
|
59
|
+
files:
|
|
60
|
+
- CHANGELOG.md
|
|
61
|
+
- LICENSE
|
|
62
|
+
- README.md
|
|
63
|
+
- Rakefile
|
|
64
|
+
- lib/axn-mcp.rb
|
|
65
|
+
- lib/axn/mcp.rb
|
|
66
|
+
- lib/axn/mcp/config.rb
|
|
67
|
+
- lib/axn/mcp/field_declarations.rb
|
|
68
|
+
- lib/axn/mcp/schema_builder.rb
|
|
69
|
+
- lib/axn/mcp/serializer.rb
|
|
70
|
+
- lib/axn/mcp/tool.rb
|
|
71
|
+
- lib/axn/mcp/version.rb
|
|
72
|
+
homepage: https://github.com/teamshares/axn-mcp
|
|
73
|
+
licenses:
|
|
74
|
+
- MIT
|
|
75
|
+
metadata:
|
|
76
|
+
homepage_uri: https://github.com/teamshares/axn-mcp
|
|
77
|
+
source_code_uri: https://github.com/teamshares/axn-mcp
|
|
78
|
+
changelog_uri: https://github.com/teamshares/axn-mcp/blob/main/CHANGELOG.md
|
|
79
|
+
rubygems_mfa_required: 'true'
|
|
80
|
+
rdoc_options: []
|
|
81
|
+
require_paths:
|
|
82
|
+
- lib
|
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: 3.2.1
|
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0'
|
|
93
|
+
requirements: []
|
|
94
|
+
rubygems_version: 3.6.8
|
|
95
|
+
specification_version: 4
|
|
96
|
+
summary: MCP Tool wrapper for Axn actions
|
|
97
|
+
test_files: []
|