1use std::path::{Path, PathBuf};
9
10use async_trait::async_trait;
11use gaze_mcp_core::{
12 Tool, ToolCtx, ToolDescriptor, ToolError, ToolRegistry, ToolRegistryError, ToolResponse,
13};
14use serde::Serialize;
15use serde_json::json;
16
17#[cfg(feature = "ocr-tesseract")]
18use crate::extract::InputKind;
19#[cfg(feature = "ocr-tesseract")]
20use crate::DocumentError;
21
22pub const DEFAULT_MAX_FILE_SIZE: u64 = 25 * 1024 * 1024;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[non_exhaustive]
28pub struct GazeReadOpts {
29 pub max_file_size: u64,
31}
32
33impl Default for GazeReadOpts {
34 fn default() -> Self {
35 Self {
36 max_file_size: DEFAULT_MAX_FILE_SIZE,
37 }
38 }
39}
40
41pub fn register_tools(
43 registry: &mut ToolRegistry,
44 opts: GazeReadOpts,
45) -> Result<(), ToolRegistryError> {
46 registry.register(GazeReadText::new())?;
47 registry.register(GazeReadFile::with_max_file_size(opts.max_file_size))?;
48 Ok(())
49}
50
51#[derive(Debug)]
53#[non_exhaustive]
54pub struct GazeReadText {
55 descriptor: ToolDescriptor,
56}
57
58impl GazeReadText {
59 pub fn new() -> Self {
61 Self {
62 descriptor: ToolDescriptor::agent(
63 "gaze_read_text",
64 json!({
65 "type": "object",
66 "properties": {
67 "text": {
68 "type": "string",
69 "description": "Already-extracted text to pseudonymize before model use."
70 }
71 },
72 "required": ["text"]
73 }),
74 )
75 .with_description("Pseudonymize already-extracted text before returning it to an MCP client.")
76 .with_output_schema(response_schema())
77 .with_carriers(gaze_mcp_core::CarrierDeclaration::text_fields(&["text"]), response_carriers()),
78 }
79 }
80}
81
82impl Default for GazeReadText {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88#[async_trait]
89impl Tool for GazeReadText {
90 fn descriptor(&self) -> &ToolDescriptor {
91 &self.descriptor
92 }
93
94 async fn invoke(&self, ctx: &ToolCtx<'_>) -> Result<ToolResponse, ToolError> {
95 let text = required_string(ctx.redacted_args(), "text")?;
96 let clean_text = redact_document_text(text, ctx)?;
97 Ok(ToolResponse::json(json!(DocumentToolResponse {
98 clean_markdown: format_text_markdown(&clean_text),
99 manifest_id: ctx.call_id().to_string(),
100 file_metadata: FileMetadata {
101 source_kind: "text".to_string(),
102 ocr_mean_confidence: None,
103 bundle_version: crate::BUNDLE_VERSION,
104 page_count: None,
105 },
106 })))
107 }
108}
109
110#[derive(Debug)]
112#[non_exhaustive]
113pub struct GazeReadFile {
114 descriptor: ToolDescriptor,
115 max_file_size: u64,
116}
117
118impl GazeReadFile {
119 pub fn new() -> Self {
121 Self::with_max_file_size(DEFAULT_MAX_FILE_SIZE)
122 }
123
124 pub fn with_max_file_size(max_file_size: u64) -> Self {
126 Self {
127 descriptor: ToolDescriptor::agent(
128 "gaze_read_file",
129 json!({
130 "type": "object",
131 "properties": {
132 "path": {
133 "type": "string",
134 "description": "Filesystem path to a PNG, JPG, or PDF document."
135 }
136 },
137 "required": ["path"]
138 }),
139 )
140 .with_description(
141 "Read an image or PDF through OCR and Gaze pseudonymization before MCP return.",
142 )
143 .with_output_schema(response_schema())
144 .with_carriers(
145 gaze_mcp_core::CarrierDeclaration::text_fields(&["path"]),
146 response_carriers(),
147 ),
148 max_file_size,
149 }
150 }
151}
152
153impl Default for GazeReadFile {
154 fn default() -> Self {
155 Self::new()
156 }
157}
158
159#[async_trait]
160impl Tool for GazeReadFile {
161 fn descriptor(&self) -> &ToolDescriptor {
162 &self.descriptor
163 }
164
165 async fn invoke(&self, ctx: &ToolCtx<'_>) -> Result<ToolResponse, ToolError> {
166 let protected_path = required_string(ctx.redacted_args(), "path")?;
169 let raw_path = ctx
170 .resources()
171 .session()
172 .restore_strict_text(protected_path)
173 .map_err(|_| ToolError::InvalidArgs("path restoration failed".into()))?;
174 let path = PathBuf::from(raw_path);
175 validate_file(&path, self.max_file_size)?;
176 read_file_response(&path, ctx).map(|response| ToolResponse::json(json!(response)))
177 }
178}
179
180#[derive(Serialize)]
181struct DocumentToolResponse {
182 clean_markdown: String,
183 manifest_id: String,
184 file_metadata: FileMetadata,
185}
186
187#[derive(Serialize)]
188struct FileMetadata {
189 source_kind: String,
190 ocr_mean_confidence: Option<f32>,
191 bundle_version: u32,
192 page_count: Option<u32>,
193}
194
195fn required_string<'a>(args: &'a serde_json::Value, field: &str) -> Result<&'a str, ToolError> {
196 args.get(field)
197 .and_then(|value| value.as_str())
198 .ok_or_else(|| ToolError::InvalidArgs(format!("missing required string field `{field}`")))
199}
200
201fn response_carriers() -> gaze_mcp_core::CarrierDeclaration {
202 use gaze_mcp_core::CarrierSegment::Member;
203 let path = |names: &[&str]| {
204 names
205 .iter()
206 .map(|name| Member((*name).into()))
207 .collect::<Vec<_>>()
208 };
209 let mut members = ["clean_markdown", "manifest_id", "file_metadata"]
210 .iter()
211 .map(|name| path(&[name]))
212 .collect::<Vec<_>>();
213 for name in [
214 "source_kind",
215 "ocr_mean_confidence",
216 "bundle_version",
217 "page_count",
218 ] {
219 members.push(path(&["file_metadata", name]));
220 }
221 let numbers = ["ocr_mean_confidence", "bundle_version", "page_count"]
222 .iter()
223 .map(|name| path(&["file_metadata", name]))
224 .collect();
225 gaze_mcp_core::CarrierDeclaration::new(members, numbers)
226}
227
228fn redact_document_text(text: &str, ctx: &ToolCtx<'_>) -> Result<String, ToolError> {
229 let pipeline = crate::bundle::build_document_pipeline().map_err(map_document_error)?;
230 let mut transaction = ctx.resources().session().begin_transaction();
233 let clean = pipeline
234 .protect_text_transaction(&mut transaction, text, ctx.resources().protection_context())
235 .map_err(ToolError::internal)?;
236 transaction.commit().map_err(ToolError::internal)?;
237 Ok(clean)
238}
239
240fn validate_file(path: &Path, max_file_size: u64) -> Result<(), ToolError> {
241 let metadata = std::fs::metadata(path).map_err(|err| map_file_metadata_error(path, err))?;
242 if !metadata.is_file() {
243 return Err(ToolError::InvalidArgs(format!(
244 "path `{}` is not a regular file",
245 path.display()
246 )));
247 }
248 if metadata.len() > max_file_size {
249 return Err(ToolError::LimitExceeded(format!(
250 "file `{}` is {} bytes; configured cap is {} bytes",
251 path.display(),
252 metadata.len(),
253 max_file_size
254 )));
255 }
256 Ok(())
257}
258
259fn map_file_metadata_error(path: &Path, err: std::io::Error) -> ToolError {
260 if err.kind() == std::io::ErrorKind::NotFound {
261 ToolError::NotFound(format!("file `{}` not found", path.display()))
262 } else {
263 ToolError::internal(err)
264 }
265}
266
267#[cfg(feature = "ocr-tesseract")]
268fn read_file_response(path: &Path, ctx: &ToolCtx<'_>) -> Result<DocumentToolResponse, ToolError> {
269 let kind = InputKind::detect(path).map_err(map_document_error)?;
270 let backend = crate::ocr::TesseractBackend::new();
271 let (ocr_result, pdf_page_count, _) =
272 crate::bundle::run_ocr(path, kind, &backend).map_err(map_document_error)?;
273 let normalized = crate::ocr::normalize_ocr_artifacts(&ocr_result.text);
274 let clean_text = redact_document_text(&normalized, ctx)?;
275 Ok(DocumentToolResponse {
276 clean_markdown: crate::bundle::format_clean_markdown(&clean_text, kind),
277 manifest_id: ctx.call_id().to_string(),
278 file_metadata: FileMetadata {
279 source_kind: source_kind(kind).to_string(),
280 ocr_mean_confidence: ocr_result.mean_confidence,
281 bundle_version: crate::BUNDLE_VERSION,
282 page_count: pdf_page_count.and_then(|count| u32::try_from(count).ok()),
283 },
284 })
285}
286
287#[cfg(feature = "ocr-tesseract")]
288fn source_kind(kind: InputKind) -> &'static str {
289 match crate::bundle::kind_label(kind) {
290 "png" | "jpeg" => "image",
291 "pdf" => "pdf",
292 other => other,
293 }
294}
295
296#[cfg(feature = "ocr-tesseract")]
297fn map_document_error(err: DocumentError) -> ToolError {
298 match err {
299 DocumentError::TesseractNotFound(hint) | DocumentError::PdfiumNotFound(hint) => {
300 ToolError::BackendUnavailable(hint)
301 }
302 DocumentError::TesseractFailed { status, stderr } => {
303 ToolError::BackendFailure(format!("tesseract exited with status {status}: {stderr}"))
304 }
305 DocumentError::PdfRasterFailed(detail) => ToolError::BackendFailure(detail),
306 DocumentError::UnsupportedInput { path, reason } => {
307 ToolError::InvalidArgs(format!("unsupported input `{}`: {reason}", path.display()))
308 }
309 other => ToolError::internal(other),
310 }
311}
312
313fn format_text_markdown(text: &str) -> String {
314 let mut out = String::new();
315 out.push_str("# gaze-document safe text\n\n");
316 out.push_str("Source kind: `text`\n\n");
317 out.push_str("---\n\n");
318 out.push_str(text);
319 if !text.ends_with('\n') {
320 out.push('\n');
321 }
322 out
323}
324
325fn response_schema() -> serde_json::Value {
326 json!({
327 "type": "object",
328 "properties": {
329 "clean_markdown": { "type": "string" },
330 "manifest_id": { "type": "string" },
331 "file_metadata": {
332 "type": "object",
333 "properties": {
334 "source_kind": { "type": "string" },
335 "ocr_mean_confidence": { "type": ["number", "null"] },
336 "bundle_version": { "type": "integer" },
337 "page_count": { "type": ["integer", "null"] }
338 },
339 "required": [
340 "source_kind",
341 "ocr_mean_confidence",
342 "bundle_version",
343 "page_count"
344 ]
345 }
346 },
347 "required": ["clean_markdown", "manifest_id", "file_metadata"]
348 })
349}
350
351#[cfg(test)]
352mod tests {
353 use std::sync::atomic::{AtomicUsize, Ordering};
354 use std::sync::Arc;
355
356 use async_trait::async_trait;
357 use gaze_mcp_core::{
358 AuthError, AuthHook, DispatchError, ManifestStore, PiiEnvelope, Principal, SessionIdPolicy,
359 };
360 use gaze_mcp_core::{BeginCallContext, CallHandle, FailureReason, ManifestError, SnapshotRef};
361 use serde_json::json;
362
363 use super::*;
364
365 struct AllowAllAuth;
366
367 #[async_trait]
368 impl AuthHook for AllowAllAuth {
369 async fn authorize_agent(
370 &self,
371 _principal: &Principal,
372 _tool_name: &str,
373 ) -> Result<(), AuthError> {
374 Ok(())
375 }
376
377 async fn authorize_operator(
378 &self,
379 _principal: &Principal,
380 _tool_name: &str,
381 ) -> Result<(), AuthError> {
382 Err(AuthError::Denied("operator tier disabled in test".into()))
383 }
384 }
385
386 struct RecordingManifest {
387 begins: AtomicUsize,
388 finishes: AtomicUsize,
389 failures: AtomicUsize,
390 args: std::sync::Mutex<Vec<serde_json::Value>>,
391 }
392
393 impl RecordingManifest {
394 fn new() -> Self {
395 Self {
396 begins: AtomicUsize::new(0),
397 finishes: AtomicUsize::new(0),
398 failures: AtomicUsize::new(0),
399 args: Default::default(),
400 }
401 }
402 }
403
404 #[async_trait]
405 impl ManifestStore for RecordingManifest {
406 async fn begin_call(&self, ctx: BeginCallContext<'_>) -> Result<CallHandle, ManifestError> {
407 self.begins.fetch_add(1, Ordering::SeqCst);
408 self.args.lock().unwrap().push(ctx.redacted_args.clone());
409 Ok(CallHandle::new(ctx.call_id))
410 }
411
412 async fn finish_call(
413 &self,
414 _handle: CallHandle,
415 _snapshot: SnapshotRef,
416 ) -> Result<(), ManifestError> {
417 self.finishes.fetch_add(1, Ordering::SeqCst);
418 Ok(())
419 }
420
421 async fn fail_call(
422 &self,
423 _handle: CallHandle,
424 _reason: FailureReason,
425 ) -> Result<(), ManifestError> {
426 self.failures.fetch_add(1, Ordering::SeqCst);
427 Ok(())
428 }
429 }
430
431 struct Harness {
432 registry: ToolRegistry,
433 auth: AllowAllAuth,
434 manifest: Arc<RecordingManifest>,
435 pipeline: gaze::Pipeline,
436 session: gaze::Session,
437 session_id_policy: SessionIdPolicy,
438 }
439
440 impl Harness {
441 fn new() -> Self {
442 let mut registry = ToolRegistry::new();
443 register_tools(&mut registry, GazeReadOpts::default()).expect("register tools");
444 Self {
445 registry,
446 auth: AllowAllAuth,
447 manifest: Arc::new(RecordingManifest::new()),
448 pipeline: crate::bundle::build_document_pipeline().expect("pipeline"),
449 session: gaze::Session::new(gaze::Scope::Ephemeral).expect("session"),
450 session_id_policy: SessionIdPolicy::default_strict(),
451 }
452 }
453
454 async fn dispatch(
455 &self,
456 tool_name: &str,
457 args: serde_json::Value,
458 ) -> Result<serde_json::Value, DispatchError> {
459 let envelope = PiiEnvelope::new(
460 &self.registry,
461 &self.auth,
462 self.manifest.as_ref(),
463 &self.pipeline,
464 &self.session,
465 &[gaze::LocaleTag::Global],
466 &self.session_id_policy,
467 );
468 envelope
469 .dispatch(&Principal::new("unit-test"), tool_name, args, None)
470 .await
471 .map(|response| response.payload)
472 }
473 }
474
475 fn assert_no_raw_fixture_values(clean_markdown: &str) {
476 assert!(!clean_markdown.contains("Jane Doe"), "{clean_markdown}");
477 assert!(
478 !clean_markdown.contains("@example.invalid"),
479 "{clean_markdown}"
480 );
481 assert!(!clean_markdown.contains("555-0142"), "{clean_markdown}");
482 }
483
484 #[tokio::test]
485 async fn read_text_dispatch_returns_clean_markdown_and_manifest_id() {
486 let harness = Harness::new();
487 let payload = harness
488 .dispatch(
489 "gaze_read_text",
490 json!({
491 "text": "Bill to: Jane Doe\nEmail: jane.doe@example.invalid\nPhone: +1-555-0142"
492 }),
493 )
494 .await
495 .expect("dispatch succeeds");
496
497 let clean_markdown = payload["clean_markdown"].as_str().expect("clean markdown");
498 assert!(clean_markdown.contains(":Email_"), "{clean_markdown}");
499 assert!(clean_markdown.contains(":Name_"), "{clean_markdown}");
500 assert!(
501 clean_markdown.contains(":Custom:phone_"),
502 "{clean_markdown}"
503 );
504 assert_no_raw_fixture_values(clean_markdown);
505 assert!(!payload["manifest_id"].as_str().unwrap().is_empty());
506 assert_eq!(payload["file_metadata"]["source_kind"], "text");
507 assert_eq!(
508 payload["file_metadata"]["ocr_mean_confidence"],
509 serde_json::Value::Null
510 );
511 assert_eq!(harness.manifest.begins.load(Ordering::SeqCst), 1);
512 assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 1);
513 }
514
515 #[tokio::test]
516 async fn read_file_missing_path_fails_closed_as_not_found() {
517 let harness = Harness::new();
518 let err = harness
519 .dispatch(
520 "gaze_read_file",
521 json!({ "path": "testdata/does-not-exist.png" }),
522 )
523 .await
524 .expect_err("missing file fails");
525
526 match err {
527 DispatchError::ToolError(ToolError::NotFound(message)) => {
528 assert!(message.contains("not found"));
529 }
530 other => panic!("unexpected error: {other:?}"),
531 }
532 assert_eq!(harness.manifest.failures.load(Ordering::SeqCst), 1);
533 }
534
535 #[tokio::test]
536 async fn read_file_limit_fails_closed_before_ocr() {
537 let mut registry = ToolRegistry::new();
538 registry
539 .register(GazeReadFile::with_max_file_size(1))
540 .expect("register file tool");
541 let harness = Harness {
542 registry,
543 auth: AllowAllAuth,
544 manifest: Arc::new(RecordingManifest::new()),
545 pipeline: crate::bundle::build_document_pipeline().expect("pipeline"),
546 session: gaze::Session::new(gaze::Scope::Ephemeral).expect("session"),
547 session_id_policy: SessionIdPolicy::default_strict(),
548 };
549 let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
550 .join("testdata")
551 .join("synthetic_image.png");
552 let err = harness
553 .dispatch("gaze_read_file", json!({ "path": fixture }))
554 .await
555 .expect_err("oversized file fails");
556
557 match err {
558 DispatchError::ToolError(ToolError::LimitExceeded(message)) => {
559 assert!(message.contains("configured cap is 1 bytes"));
560 }
561 other => panic!("unexpected error: {other:?}"),
562 }
563 }
564
565 #[tokio::test]
566 async fn configured_dispatch_restores_owner_path_before_file_validation() {
567 let core = gaze_assembly::CorePipelineConfig::new().build().unwrap();
568 let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
569 let directory = tempfile::tempdir().unwrap();
570 let raw_path = directory
571 .path()
572 .join("alice@example.invalid")
573 .join("input.png");
574 std::fs::create_dir_all(raw_path.parent().unwrap()).unwrap();
575 std::fs::write(&raw_path, b"oversized").unwrap();
576 let gaze::CleanDocument::Text(protected_path) = core
577 .pseudonymize_text(&session, raw_path.to_str().unwrap())
578 .unwrap()
579 else {
580 panic!("text expected")
581 };
582 assert_ne!(protected_path, raw_path.to_str().unwrap());
583 let literal_path = PathBuf::from(&protected_path);
585 assert!(literal_path.starts_with(directory.path()));
586 std::fs::create_dir_all(literal_path.parent().unwrap()).unwrap();
587 std::fs::write(&literal_path, b"").unwrap();
588 let mut registry = ToolRegistry::new();
589 registry
590 .register(GazeReadFile::with_max_file_size(1))
591 .unwrap();
592 let manifest = RecordingManifest::new();
593 let policy = SessionIdPolicy::default_strict();
594 let envelope = PiiEnvelope::new(
595 ®istry,
596 &AllowAllAuth,
597 &manifest,
598 core.pipeline(),
599 &session,
600 core.locale_chain().as_slice(),
601 &policy,
602 );
603 for path in [raw_path.to_str().unwrap(), &protected_path] {
604 let err = envelope
605 .dispatch(
606 &Principal::new("unit-test"),
607 "gaze_read_file",
608 json!({"path": path}),
609 None,
610 )
611 .await
612 .unwrap_err();
613 assert!(
614 matches!(err, DispatchError::ToolError(ToolError::LimitExceeded(_))),
615 "{err:?}"
616 );
617 }
618 for args in manifest.args.lock().unwrap().iter() {
619 let path = args["path"].as_str().unwrap();
620 assert!(!path.contains("alice@example.invalid"));
621 assert_eq!(
622 session.restore_strict_text(path).unwrap(),
623 raw_path.to_str().unwrap()
624 );
625 }
626 let unknown_path = directory
627 .path()
628 .join("<deadbeef:Email_999>")
629 .join("input.png");
630 std::fs::create_dir_all(unknown_path.parent().unwrap()).unwrap();
631 std::fs::write(&unknown_path, b"oversized").unwrap();
632 let err = envelope
633 .dispatch(
634 &Principal::new("unit-test"),
635 "gaze_read_file",
636 json!({"path": unknown_path}),
637 None,
638 )
639 .await
640 .unwrap_err();
641 assert!(
642 matches!(err, DispatchError::ToolError(ToolError::InvalidArgs(_))),
643 "{err:?}"
644 );
645 assert_eq!(manifest.finishes.load(Ordering::SeqCst), 0);
646 }
647
648 #[cfg(feature = "ocr-tesseract")]
649 #[tokio::test]
650 async fn read_file_dispatch_returns_clean_markdown_for_fixture_when_backend_available() {
651 let harness = Harness::new();
652 let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
653 .join("testdata")
654 .join("synthetic_image.png");
655 let payload = match harness
656 .dispatch("gaze_read_file", json!({ "path": fixture }))
657 .await
658 {
659 Ok(payload) => payload,
660 Err(DispatchError::ToolError(ToolError::BackendUnavailable(message))) => {
661 eprintln!("SKIP: document backend unavailable: {message}");
662 return;
663 }
664 Err(other) => panic!("unexpected dispatch error: {other:?}"),
665 };
666
667 let clean_markdown = payload["clean_markdown"].as_str().expect("clean markdown");
668 assert!(clean_markdown.contains(":Email_"), "{clean_markdown}");
669 assert!(clean_markdown.contains(":Name_"), "{clean_markdown}");
670 assert!(
671 clean_markdown.contains(":Custom:phone_"),
672 "{clean_markdown}"
673 );
674 assert_no_raw_fixture_values(clean_markdown);
675 assert_eq!(payload["file_metadata"]["source_kind"], "image");
676 assert!(!payload["manifest_id"].as_str().unwrap().is_empty());
677 }
678}