micro_mcp 0.1.0 → 0.1.4

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.
@@ -1,22 +1,32 @@
1
+ use async_trait::async_trait;
1
2
  use rust_mcp_sdk::{
2
3
  mcp_server::{server_runtime, ServerHandler, ServerRuntime},
3
4
  schema::{
4
5
  schema_utils::CallToolError, CallToolRequest, CallToolResult, Implementation,
5
6
  InitializeResult, ListToolsRequest, ListToolsResult, RpcError, ServerCapabilities,
6
- ServerCapabilitiesTools, LATEST_PROTOCOL_VERSION, Tool, ToolInputSchema,
7
+ ServerCapabilitiesTools, Tool, ToolInputSchema, LATEST_PROTOCOL_VERSION,
7
8
  },
8
9
  McpServer, StdioTransport, TransportOptions,
9
10
  };
10
- use tokio::runtime::Runtime;
11
- use async_trait::async_trait;
12
- use std::sync::{Mutex, OnceLock};
11
+ use serde_json::{Map as JsonMap, Value as JsonValue};
13
12
  use std::collections::HashMap;
13
+ use std::sync::atomic::{AtomicBool, Ordering};
14
+ use std::sync::{Arc, Mutex, OnceLock};
15
+ use tokio::runtime::Runtime;
14
16
 
15
- use magnus::{block::Proc, Error, Ruby};
17
+ use magnus::{block::Proc, value::ReprValue, Error, Ruby, Value};
18
+ use magnus::{typed_data::DataTypeFunctions, TypedData};
19
+ use std::cell::RefCell;
20
+ use std::rc::Rc;
16
21
 
17
22
  use crate::utils::nogvl;
18
23
 
19
24
  static RUNTIME: OnceLock<Runtime> = OnceLock::new();
25
+ static SHUTDOWN_FLAG: OnceLock<Arc<AtomicBool>> = OnceLock::new();
26
+
27
+ fn shutdown_flag() -> &'static Arc<AtomicBool> {
28
+ SHUTDOWN_FLAG.get_or_init(|| Arc::new(AtomicBool::new(false)))
29
+ }
20
30
 
21
31
  type ToolHandler = RubyHandler;
22
32
 
@@ -39,17 +49,149 @@ fn tools() -> &'static Mutex<HashMap<String, ToolEntry>> {
39
49
  TOOLS.get_or_init(|| Mutex::new(HashMap::new()))
40
50
  }
41
51
 
42
- pub fn register_tool(_ruby: &Ruby, name: String, description: Option<String>, handler: Proc) -> Result<(), Error> {
52
+ fn ruby_value_to_json_value(ruby: &Ruby, val: Value) -> Result<JsonValue, Error> {
53
+ let json_str: String = magnus::eval!(ruby, "require 'json'; JSON.generate(obj)", obj = val)?;
54
+ serde_json::from_str(&json_str)
55
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))
56
+ }
57
+
58
+ fn json_value_to_ruby_value(ruby: &Ruby, val: &JsonValue) -> Result<Value, Error> {
59
+ let json_str = serde_json::to_string(val)
60
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
61
+ Ok(magnus::eval!(
62
+ ruby,
63
+ "require 'json'; JSON.parse(str)",
64
+ str = json_str
65
+ )?)
66
+ }
67
+
68
+ fn parse_tool_input_schema(json: JsonValue) -> ToolInputSchema {
69
+ if let JsonValue::Object(obj) = json {
70
+ let required = obj
71
+ .get("required")
72
+ .and_then(|v| v.as_array())
73
+ .map(|arr| {
74
+ arr.iter()
75
+ .filter_map(|v| v.as_str().map(|s| s.to_string()))
76
+ .collect::<Vec<_>>()
77
+ })
78
+ .unwrap_or_default();
79
+
80
+ let properties = obj
81
+ .get("properties")
82
+ .and_then(|v| v.as_object())
83
+ .map(|props| {
84
+ props
85
+ .iter()
86
+ .filter_map(|(k, v)| match v {
87
+ JsonValue::Object(map) => Some((k.clone(), map.clone())),
88
+ _ => None,
89
+ })
90
+ .collect::<HashMap<String, JsonMap<String, JsonValue>>>()
91
+ });
92
+
93
+ ToolInputSchema::new(required, properties)
94
+ } else {
95
+ ToolInputSchema::new(Vec::new(), None)
96
+ }
97
+ }
98
+
99
+ #[derive(Clone, TypedData)]
100
+ #[magnus(class = "MicroMcp::Runtime", free_immediately, unsafe_generics)]
101
+ pub struct RubyMcpServer<'a> {
102
+ inner: Rc<RefCell<Option<&'a dyn McpServer>>>,
103
+ }
104
+
105
+ impl<'a> DataTypeFunctions for RubyMcpServer<'a> {}
106
+
107
+ // SAFETY: the wrapped reference is only used while valid
108
+ unsafe impl<'a> Send for RubyMcpServer<'a> {}
109
+
110
+ impl<'a> RubyMcpServer<'a> {
111
+ fn new(runtime: &'a dyn McpServer) -> Self {
112
+ Self {
113
+ inner: Rc::new(RefCell::new(Some(runtime))),
114
+ }
115
+ }
116
+
117
+ fn invalidate(&self) {
118
+ *self.inner.borrow_mut() = None;
119
+ }
120
+
121
+ fn runtime(&self) -> Result<&'a dyn McpServer, Error> {
122
+ match *self.inner.borrow() {
123
+ Some(ptr) => Ok(ptr),
124
+ None => {
125
+ let ruby = Ruby::get().unwrap();
126
+ Err(Error::new(
127
+ ruby.exception_runtime_error(),
128
+ "McpServer reference is no longer valid",
129
+ ))
130
+ }
131
+ }
132
+ }
133
+
134
+ pub fn is_initialized(&self) -> Result<bool, Error> {
135
+ Ok(self.runtime()?.is_initialized())
136
+ }
137
+
138
+ pub fn client_supports_sampling(&self) -> Result<Option<bool>, Error> {
139
+ Ok(self.runtime()?.client_supports_sampling())
140
+ }
141
+
142
+ pub fn create_message(&self, params: Value) -> Result<Value, Error> {
143
+ let ruby = Ruby::get().unwrap();
144
+ let runtime = self.runtime()?;
145
+ let json_value = ruby_value_to_json_value(&ruby, params)?;
146
+ let request_params: rust_mcp_sdk::schema::CreateMessageRequestParams =
147
+ serde_json::from_value(json_value)
148
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
149
+
150
+ let runtime_handle = RUNTIME.get().expect("Tokio not initialised");
151
+ let handle = runtime_handle.handle();
152
+
153
+ let result = if tokio::runtime::Handle::try_current().is_ok() {
154
+ tokio::task::block_in_place(|| {
155
+ handle.block_on(async { runtime.create_message(request_params).await })
156
+ })
157
+ } else {
158
+ handle.block_on(async { runtime.create_message(request_params).await })
159
+ }
160
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
161
+
162
+ let json_result = serde_json::to_value(result)
163
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
164
+ json_value_to_ruby_value(&ruby, &json_result)
165
+ }
166
+ }
167
+
168
+ pub fn register_tool(
169
+ ruby: &Ruby,
170
+ name: String,
171
+ description: Option<String>,
172
+ arg_schema: Option<Value>,
173
+ handler: Proc,
174
+ ) -> Result<(), Error> {
175
+ let schema = match arg_schema {
176
+ Some(val) => {
177
+ let json = ruby_value_to_json_value(ruby, val)?;
178
+ parse_tool_input_schema(json)
179
+ }
180
+ None => ToolInputSchema::new(Vec::new(), None),
181
+ };
182
+
43
183
  let tool = Tool {
44
184
  annotations: None,
45
185
  description,
46
- input_schema: ToolInputSchema::new(Vec::new(), None),
186
+ input_schema: schema,
47
187
  name: name.clone(),
48
188
  };
49
189
 
50
190
  let handler_fn = RubyHandler(handler);
51
191
 
52
- let mut map = tools().lock().unwrap();
192
+ let mut map = tools()
193
+ .lock()
194
+ .map_err(|_| Error::new(ruby.exception_runtime_error(), "tools mutex poisoned"))?;
53
195
  map.insert(
54
196
  name,
55
197
  ToolEntry {
@@ -60,7 +202,6 @@ pub fn register_tool(_ruby: &Ruby, name: String, description: Option<String>, ha
60
202
  Ok(())
61
203
  }
62
204
 
63
-
64
205
  pub struct MyServerHandler;
65
206
 
66
207
  #[async_trait]
@@ -71,7 +212,9 @@ impl ServerHandler for MyServerHandler {
71
212
  _runtime: &dyn McpServer,
72
213
  ) -> Result<ListToolsResult, RpcError> {
73
214
  let tools = {
74
- let map = tools().lock().unwrap();
215
+ let map = tools().lock().map_err(|_| {
216
+ RpcError::internal_error().with_message("tools mutex poisoned".to_string())
217
+ })?;
75
218
  map.values().map(|t| t.tool.clone()).collect()
76
219
  };
77
220
  Ok(ListToolsResult {
@@ -84,14 +227,35 @@ impl ServerHandler for MyServerHandler {
84
227
  async fn handle_call_tool_request(
85
228
  &self,
86
229
  request: CallToolRequest,
87
- _runtime: &dyn McpServer,
230
+ runtime: &dyn McpServer,
88
231
  ) -> Result<CallToolResult, CallToolError> {
89
- let map = tools().lock().unwrap();
232
+ let map = tools()
233
+ .lock()
234
+ .map_err(|_| CallToolError::new(std::io::Error::other("tools mutex poisoned")))?;
90
235
  match map.get(request.tool_name()) {
91
236
  Some(entry) => {
92
237
  let proc = entry.handler.0;
93
- let text_result: Result<String, Error> =
94
- crate::utils::with_gvl(|| proc.call::<_, String>(()));
238
+ let wrapper = RubyMcpServer::new(runtime);
239
+ let args_value = if let Some(map) = &request.params.arguments {
240
+ let json = JsonValue::Object(map.clone());
241
+ Some(
242
+ crate::utils::with_gvl(|| {
243
+ let ruby = Ruby::get().unwrap();
244
+ json_value_to_ruby_value(&ruby, &json)
245
+ })
246
+ .map_err(|e: Error| {
247
+ CallToolError::new(std::io::Error::other(e.to_string()))
248
+ })?,
249
+ )
250
+ } else {
251
+ None
252
+ };
253
+ let text_result: Result<String, Error> = crate::utils::with_gvl(|| {
254
+ let ruby = Ruby::get().unwrap();
255
+ let args = args_value.unwrap_or_else(|| ruby.qnil().as_value());
256
+ proc.call::<_, String>((args, wrapper.clone()))
257
+ });
258
+ wrapper.invalidate();
95
259
  match text_result {
96
260
  Ok(text) => Ok(CallToolResult::text_content(text, None)),
97
261
  Err(e) => Err(CallToolError::new(std::io::Error::other(e.to_string()))),
@@ -103,50 +267,115 @@ impl ServerHandler for MyServerHandler {
103
267
  }
104
268
 
105
269
  pub fn start_server() -> String {
106
- let runtime = RUNTIME
107
- .get_or_init(|| Runtime::new().expect("Failed to create Tokio runtime"));
108
-
109
- let _ = nogvl(|| runtime.block_on(async {
110
- let server_details = InitializeResult {
111
- server_info: Implementation {
112
- name: "Hello World MCP Server".to_string(),
113
- version: "0.1.0".to_string(),
114
- },
115
- capabilities: ServerCapabilities {
116
- tools: Some(ServerCapabilitiesTools { list_changed: None }),
117
- ..Default::default()
118
- },
119
- meta: None,
120
- instructions: Some("server instructions...".to_string()),
121
- protocol_version: LATEST_PROTOCOL_VERSION.to_string(),
122
- };
270
+ let runtime = RUNTIME.get_or_init(|| Runtime::new().expect("Failed to create Tokio runtime"));
271
+
272
+ // Reset shutdown flag for new server start
273
+ shutdown_flag().store(false, Ordering::Relaxed);
274
+
275
+ let _ = nogvl(|| {
276
+ runtime.block_on(async {
277
+ let server_details = InitializeResult {
278
+ server_info: Implementation {
279
+ name: "Hello World MCP Server".to_string(),
280
+ version: "0.1.0".to_string(),
281
+ },
282
+ capabilities: ServerCapabilities {
283
+ tools: Some(ServerCapabilitiesTools { list_changed: None }),
284
+ ..Default::default()
285
+ },
286
+ meta: None,
287
+ instructions: Some("server instructions...".to_string()),
288
+ protocol_version: LATEST_PROTOCOL_VERSION.to_string(),
289
+ };
290
+
291
+ let handler = MyServerHandler {};
292
+ let transport = StdioTransport::new(TransportOptions::default())?;
293
+ let server: ServerRuntime =
294
+ server_runtime::create_server(server_details, transport, handler);
295
+
296
+ // Use select! to wait for either server completion or shutdown signal
297
+ tokio::select! {
298
+ result = server.start() => {
299
+ result
300
+ }
301
+ _ = shutdown_monitor() => {
302
+ // Server was requested to shutdown
303
+ Ok(())
304
+ }
305
+ _ = signal_handler() => {
306
+ // System signal received
307
+ Ok(())
308
+ }
309
+ }
310
+ })
311
+ });
312
+
313
+ "Ok".into()
314
+ }
315
+
316
+ async fn signal_handler() {
317
+ use tokio::signal;
318
+
319
+ let mut sigint = signal::unix::signal(signal::unix::SignalKind::interrupt()).unwrap();
320
+ let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()).unwrap();
123
321
 
124
- let handler = MyServerHandler {};
125
- let transport = StdioTransport::new(TransportOptions::default())?;
126
- let server: ServerRuntime =
127
- server_runtime::create_server(server_details, transport, handler);
322
+ tokio::select! {
323
+ _ = sigint.recv() => {},
324
+ _ = sigterm.recv() => {},
325
+ }
326
+ }
128
327
 
129
- server.start().await
130
- }));
328
+ async fn shutdown_monitor() {
329
+ let flag = shutdown_flag();
330
+ loop {
331
+ if flag.load(Ordering::Relaxed) {
332
+ break;
333
+ }
334
+ tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
335
+ }
336
+ }
131
337
 
338
+ pub fn shutdown_server() -> String {
339
+ shutdown_flag().store(true, Ordering::Relaxed);
132
340
  "Ok".into()
133
341
  }
134
342
 
135
343
  #[cfg(test)]
136
344
  mod tests {
345
+ use async_trait::async_trait;
137
346
  use rust_mcp_sdk::{
138
347
  mcp_client::client_runtime,
139
- schema::{CallToolRequestParams, ClientCapabilities, Implementation, InitializeRequestParams, LATEST_PROTOCOL_VERSION},
348
+ schema::{
349
+ CallToolRequestParams, ClientCapabilities, CreateMessageRequest, CreateMessageResult,
350
+ Implementation, InitializeRequestParams, Role, RpcError, TextContent,
351
+ LATEST_PROTOCOL_VERSION,
352
+ },
140
353
  McpClient, StdioTransport, TransportOptions,
141
354
  };
142
- use async_trait::async_trait;
355
+ use serde_json::json;
143
356
 
144
357
  struct TestClientHandler;
145
358
  #[async_trait]
146
- impl rust_mcp_sdk::mcp_client::ClientHandler for TestClientHandler {}
359
+ impl rust_mcp_sdk::mcp_client::ClientHandler for TestClientHandler {
360
+ async fn handle_create_message_request(
361
+ &self,
362
+ _request: CreateMessageRequest,
363
+ _runtime: &dyn McpClient,
364
+ ) -> std::result::Result<CreateMessageResult, RpcError> {
365
+ Ok(CreateMessageResult {
366
+ content: TextContent::new("hello".to_string(), None).into(),
367
+ meta: None,
368
+ model: "test-model".to_string(),
369
+ role: Role::Assistant,
370
+ stop_reason: None,
371
+ })
372
+ }
373
+ }
374
+
375
+ use rust_mcp_sdk::error::SdkResult;
147
376
 
148
377
  #[tokio::test]
149
- async fn hello_world_tool_works() {
378
+ async fn hello_world_tool_works() -> SdkResult<()> {
150
379
  let transport = StdioTransport::create_with_server_launch(
151
380
  "ruby",
152
381
  vec![
@@ -157,8 +386,7 @@ mod tests {
157
386
  ],
158
387
  None,
159
388
  TransportOptions::default(),
160
- )
161
- .unwrap();
389
+ )?;
162
390
 
163
391
  let client_details = InitializeRequestParams {
164
392
  capabilities: ClientCapabilities::default(),
@@ -171,18 +399,210 @@ mod tests {
171
399
 
172
400
  let client = client_runtime::create_client(client_details, transport, TestClientHandler);
173
401
 
174
- client.clone().start().await.unwrap();
402
+ client.clone().start().await?;
175
403
 
176
- let tools = client.list_tools(None).await.unwrap();
404
+ let tools = client.list_tools(None).await?;
177
405
  assert_eq!(tools.tools.len(), 1);
178
406
  assert_eq!(tools.tools[0].name, "say_hello_world");
179
407
 
180
408
  let result = client
181
- .call_tool(CallToolRequestParams { name: "say_hello_world".into(), arguments: None })
182
- .await
183
- .unwrap();
184
- let text = result.content[0].as_text_content().unwrap().text.clone();
409
+ .call_tool(CallToolRequestParams {
410
+ name: "say_hello_world".into(),
411
+ arguments: None,
412
+ })
413
+ .await?;
414
+ let text = result.content[0].as_text_content()?.text.clone();
185
415
  assert_eq!(text, "Hello World!");
416
+ Ok(())
186
417
  }
187
- }
188
418
 
419
+ #[tokio::test]
420
+ async fn tools_with_arguments_work() -> SdkResult<()> {
421
+ let transport = StdioTransport::create_with_server_launch(
422
+ "ruby",
423
+ vec![
424
+ "-I".into(),
425
+ "../../lib".into(),
426
+ "../../bin/mcp".into(),
427
+ "../../test/support/argument_tools.rb".into(),
428
+ ],
429
+ None,
430
+ TransportOptions::default(),
431
+ )?;
432
+
433
+ let client_details = InitializeRequestParams {
434
+ capabilities: ClientCapabilities::default(),
435
+ client_info: Implementation {
436
+ name: "test-client".into(),
437
+ version: "0.1.0".into(),
438
+ },
439
+ protocol_version: LATEST_PROTOCOL_VERSION.into(),
440
+ };
441
+
442
+ let client = client_runtime::create_client(client_details, transport, TestClientHandler);
443
+
444
+ client.clone().start().await?;
445
+
446
+ let tools = client.list_tools(None).await?;
447
+ assert_eq!(tools.tools.len(), 2);
448
+ assert!(tools.tools.iter().any(|t| t.name == "add_numbers"));
449
+ assert!(tools.tools.iter().any(|t| t.name == "echo_message"));
450
+
451
+ let result = client
452
+ .call_tool(CallToolRequestParams {
453
+ name: "add_numbers".into(),
454
+ arguments: Some(
455
+ [("a".to_string(), json!(5)), ("b".to_string(), json!(7))]
456
+ .into_iter()
457
+ .collect(),
458
+ ),
459
+ })
460
+ .await?;
461
+ let text = result.content[0].as_text_content()?.text.clone();
462
+ assert_eq!(text, "12");
463
+
464
+ let result = client
465
+ .call_tool(CallToolRequestParams {
466
+ name: "echo_message".into(),
467
+ arguments: Some([("message".to_string(), json!("hi"))].into_iter().collect()),
468
+ })
469
+ .await?;
470
+ let text = result.content[0].as_text_content()?.text.clone();
471
+ assert_eq!(text, "hi");
472
+ Ok(())
473
+ }
474
+
475
+ #[tokio::test]
476
+ async fn runtime_lifetime_enforced() -> SdkResult<()> {
477
+ let transport = StdioTransport::create_with_server_launch(
478
+ "ruby",
479
+ vec![
480
+ "-I".into(),
481
+ "../../lib".into(),
482
+ "../../bin/mcp".into(),
483
+ "../../test/support/runtime_lifetime_tool.rb".into(),
484
+ ],
485
+ None,
486
+ TransportOptions::default(),
487
+ )?;
488
+
489
+ let client_details = InitializeRequestParams {
490
+ capabilities: ClientCapabilities::default(),
491
+ client_info: Implementation {
492
+ name: "test-client".into(),
493
+ version: "0.1.0".into(),
494
+ },
495
+ protocol_version: LATEST_PROTOCOL_VERSION.into(),
496
+ };
497
+
498
+ let client = client_runtime::create_client(client_details, transport, TestClientHandler);
499
+
500
+ client.clone().start().await?;
501
+
502
+ let tools = client.list_tools(None).await?;
503
+ assert_eq!(tools.tools.len(), 2);
504
+
505
+ // first call stores the runtime
506
+ let result = client
507
+ .call_tool(CallToolRequestParams {
508
+ name: "capture_runtime".into(),
509
+ arguments: None,
510
+ })
511
+ .await?;
512
+ let text = result.content[0].as_text_content()?.text.clone();
513
+ assert_eq!(text, "true");
514
+
515
+ // second call should fail as runtime was invalidated
516
+ let result = client
517
+ .call_tool(CallToolRequestParams {
518
+ name: "use_captured_runtime".into(),
519
+ arguments: None,
520
+ })
521
+ .await?;
522
+ assert!(result.is_error.unwrap_or(false));
523
+ let text = result.content[0].as_text_content()?.text.clone();
524
+ assert!(text.contains("McpServer reference"));
525
+
526
+ Ok(())
527
+ }
528
+
529
+ #[tokio::test]
530
+ async fn client_supports_sampling_exposed() -> SdkResult<()> {
531
+ let transport = StdioTransport::create_with_server_launch(
532
+ "ruby",
533
+ vec![
534
+ "-I".into(),
535
+ "../../lib".into(),
536
+ "../../bin/mcp".into(),
537
+ "../../test/support/client_capabilities_tool.rb".into(),
538
+ ],
539
+ None,
540
+ TransportOptions::default(),
541
+ )?;
542
+
543
+ let client_details = InitializeRequestParams {
544
+ capabilities: ClientCapabilities::default(),
545
+ client_info: Implementation {
546
+ name: "test-client".into(),
547
+ version: "0.1.0".into(),
548
+ },
549
+ protocol_version: LATEST_PROTOCOL_VERSION.into(),
550
+ };
551
+
552
+ let client = client_runtime::create_client(client_details, transport, TestClientHandler);
553
+
554
+ client.clone().start().await?;
555
+
556
+ let result = client
557
+ .call_tool(CallToolRequestParams {
558
+ name: "client_sampling_supported".into(),
559
+ arguments: None,
560
+ })
561
+ .await?;
562
+ let text = result.content[0].as_text_content()?.text.clone();
563
+ assert_eq!(text, "false");
564
+
565
+ Ok(())
566
+ }
567
+
568
+ #[tokio::test]
569
+ async fn create_message_exposed() -> SdkResult<()> {
570
+ let transport = StdioTransport::create_with_server_launch(
571
+ "ruby",
572
+ vec![
573
+ "-I".into(),
574
+ "../../lib".into(),
575
+ "../../bin/mcp".into(),
576
+ "../../test/support/create_message_tool.rb".into(),
577
+ ],
578
+ None,
579
+ TransportOptions::default(),
580
+ )?;
581
+
582
+ let client_details = InitializeRequestParams {
583
+ capabilities: ClientCapabilities::default(),
584
+ client_info: Implementation {
585
+ name: "test-client".into(),
586
+ version: "0.1.0".into(),
587
+ },
588
+ protocol_version: LATEST_PROTOCOL_VERSION.into(),
589
+ };
590
+
591
+ let client = client_runtime::create_client(client_details, transport, TestClientHandler);
592
+
593
+ client.clone().start().await?;
594
+
595
+ let result = client
596
+ .call_tool(CallToolRequestParams {
597
+ name: "create_message_error".into(),
598
+ arguments: None,
599
+ })
600
+ .await?;
601
+
602
+ assert!(result.is_error.unwrap_or(false));
603
+ let text = result.content[0].as_text_content()?.text.clone();
604
+ assert!(text.contains("missing field"));
605
+
606
+ Ok(())
607
+ }
608
+ }
@@ -1,5 +1,5 @@
1
- use std::{ffi::c_void, mem::MaybeUninit, ptr::null_mut};
2
1
  use rb_sys::{rb_thread_call_with_gvl, rb_thread_call_without_gvl};
2
+ use std::{ffi::c_void, mem::MaybeUninit, ptr::null_mut};
3
3
 
4
4
  unsafe extern "C" fn call_without_gvl<F, R>(arg: *mut c_void) -> *mut c_void
5
5
  where