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(not(feature = "ocr-tesseract"))]
288fn read_file_response(
289 _path: &PathBuf,
290 _ctx: &ToolCtx<'_>,
291) -> Result<DocumentToolResponse, ToolError> {
292 Err(ToolError::BackendUnavailable(
293 "rebuild gaze-document with `--features ocr-tesseract` to enable `gaze_read_file`"
294 .to_string(),
295 ))
296}
297
298#[cfg(feature = "ocr-tesseract")]
299fn source_kind(kind: InputKind) -> &'static str {
300 match crate::bundle::kind_label(kind) {
301 "png" | "jpeg" => "image",
302 "pdf" => "pdf",
303 other => other,
304 }
305}
306
307#[cfg(feature = "ocr-tesseract")]
308fn map_document_error(err: DocumentError) -> ToolError {
309 match err {
310 DocumentError::TesseractNotFound(hint) | DocumentError::PdfiumNotFound(hint) => {
311 ToolError::BackendUnavailable(hint)
312 }
313 DocumentError::TesseractFailed { status, stderr } => {
314 ToolError::BackendFailure(format!("tesseract exited with status {status}: {stderr}"))
315 }
316 DocumentError::PdfRasterFailed(detail) => ToolError::BackendFailure(detail),
317 DocumentError::UnsupportedInput { path, reason } => {
318 ToolError::InvalidArgs(format!("unsupported input `{}`: {reason}", path.display()))
319 }
320 other => ToolError::internal(other),
321 }
322}
323
324#[cfg(not(feature = "ocr-tesseract"))]
325fn map_document_error(err: crate::DocumentError) -> ToolError {
326 ToolError::internal(err)
327}
328
329fn format_text_markdown(text: &str) -> String {
330 let mut out = String::new();
331 out.push_str("# gaze-document safe text\n\n");
332 out.push_str("Source kind: `text`\n\n");
333 out.push_str("---\n\n");
334 out.push_str(text);
335 if !text.ends_with('\n') {
336 out.push('\n');
337 }
338 out
339}
340
341fn response_schema() -> serde_json::Value {
342 json!({
343 "type": "object",
344 "properties": {
345 "clean_markdown": { "type": "string" },
346 "manifest_id": { "type": "string" },
347 "file_metadata": {
348 "type": "object",
349 "properties": {
350 "source_kind": { "type": "string" },
351 "ocr_mean_confidence": { "type": ["number", "null"] },
352 "bundle_version": { "type": "integer" },
353 "page_count": { "type": ["integer", "null"] }
354 },
355 "required": [
356 "source_kind",
357 "ocr_mean_confidence",
358 "bundle_version",
359 "page_count"
360 ]
361 }
362 },
363 "required": ["clean_markdown", "manifest_id", "file_metadata"]
364 })
365}
366
367#[cfg(test)]
368mod tests {
369 use std::sync::atomic::{AtomicUsize, Ordering};
370 use std::sync::Arc;
371
372 use async_trait::async_trait;
373 use gaze_mcp_core::{
374 AuthError, AuthHook, DispatchError, ManifestStore, PiiEnvelope, Principal, SessionIdPolicy,
375 };
376 use gaze_mcp_core::{BeginCallContext, CallHandle, FailureReason, ManifestError, SnapshotRef};
377 use serde_json::json;
378
379 use super::*;
380
381 struct AllowAllAuth;
382
383 #[async_trait]
384 impl AuthHook for AllowAllAuth {
385 async fn authorize_agent(
386 &self,
387 _principal: &Principal,
388 _tool_name: &str,
389 ) -> Result<(), AuthError> {
390 Ok(())
391 }
392
393 async fn authorize_operator(
394 &self,
395 _principal: &Principal,
396 _tool_name: &str,
397 ) -> Result<(), AuthError> {
398 Err(AuthError::Denied("operator tier disabled in test".into()))
399 }
400 }
401
402 struct RecordingManifest {
403 begins: AtomicUsize,
404 finishes: AtomicUsize,
405 failures: AtomicUsize,
406 args: std::sync::Mutex<Vec<serde_json::Value>>,
407 }
408
409 impl RecordingManifest {
410 fn new() -> Self {
411 Self {
412 begins: AtomicUsize::new(0),
413 finishes: AtomicUsize::new(0),
414 failures: AtomicUsize::new(0),
415 args: Default::default(),
416 }
417 }
418 }
419
420 #[async_trait]
421 impl ManifestStore for RecordingManifest {
422 async fn begin_call(&self, ctx: BeginCallContext<'_>) -> Result<CallHandle, ManifestError> {
423 self.begins.fetch_add(1, Ordering::SeqCst);
424 self.args.lock().unwrap().push(ctx.redacted_args.clone());
425 Ok(CallHandle::new(ctx.call_id))
426 }
427
428 async fn finish_call(
429 &self,
430 _handle: CallHandle,
431 _snapshot: SnapshotRef,
432 ) -> Result<(), ManifestError> {
433 self.finishes.fetch_add(1, Ordering::SeqCst);
434 Ok(())
435 }
436
437 async fn fail_call(
438 &self,
439 _handle: CallHandle,
440 _reason: FailureReason,
441 ) -> Result<(), ManifestError> {
442 self.failures.fetch_add(1, Ordering::SeqCst);
443 Ok(())
444 }
445 }
446
447 struct Harness {
448 registry: ToolRegistry,
449 auth: AllowAllAuth,
450 manifest: Arc<RecordingManifest>,
451 pipeline: gaze::Pipeline,
452 session: gaze::Session,
453 session_id_policy: SessionIdPolicy,
454 }
455
456 impl Harness {
457 fn new() -> Self {
458 let mut registry = ToolRegistry::new();
459 register_tools(&mut registry, GazeReadOpts::default()).expect("register tools");
460 Self {
461 registry,
462 auth: AllowAllAuth,
463 manifest: Arc::new(RecordingManifest::new()),
464 pipeline: crate::bundle::build_document_pipeline().expect("pipeline"),
465 session: gaze::Session::new(gaze::Scope::Ephemeral).expect("session"),
466 session_id_policy: SessionIdPolicy::default_strict(),
467 }
468 }
469
470 async fn dispatch(
471 &self,
472 tool_name: &str,
473 args: serde_json::Value,
474 ) -> Result<serde_json::Value, DispatchError> {
475 let envelope = PiiEnvelope::new(
476 &self.registry,
477 &self.auth,
478 self.manifest.as_ref(),
479 &self.pipeline,
480 &self.session,
481 &[gaze::LocaleTag::Global],
482 &self.session_id_policy,
483 );
484 envelope
485 .dispatch(&Principal::new("unit-test"), tool_name, args, None)
486 .await
487 .map(|response| response.payload)
488 }
489 }
490
491 fn assert_no_raw_fixture_values(clean_markdown: &str) {
492 assert!(!clean_markdown.contains("Jane Doe"), "{clean_markdown}");
493 assert!(
494 !clean_markdown.contains("@example.invalid"),
495 "{clean_markdown}"
496 );
497 assert!(!clean_markdown.contains("555-0142"), "{clean_markdown}");
498 }
499
500 #[tokio::test]
501 async fn read_text_dispatch_returns_clean_markdown_and_manifest_id() {
502 let harness = Harness::new();
503 let payload = harness
504 .dispatch(
505 "gaze_read_text",
506 json!({
507 "text": "Bill to: Jane Doe\nEmail: jane.doe@example.invalid\nPhone: +1-555-0142"
508 }),
509 )
510 .await
511 .expect("dispatch succeeds");
512
513 let clean_markdown = payload["clean_markdown"].as_str().expect("clean markdown");
514 assert!(clean_markdown.contains(":Email_"), "{clean_markdown}");
515 assert!(clean_markdown.contains(":Name_"), "{clean_markdown}");
516 assert!(
517 clean_markdown.contains(":Custom:phone_"),
518 "{clean_markdown}"
519 );
520 assert_no_raw_fixture_values(clean_markdown);
521 assert!(!payload["manifest_id"].as_str().unwrap().is_empty());
522 assert_eq!(payload["file_metadata"]["source_kind"], "text");
523 assert_eq!(
524 payload["file_metadata"]["ocr_mean_confidence"],
525 serde_json::Value::Null
526 );
527 assert_eq!(harness.manifest.begins.load(Ordering::SeqCst), 1);
528 assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 1);
529 }
530
531 #[tokio::test]
532 async fn read_file_missing_path_fails_closed_as_not_found() {
533 let harness = Harness::new();
534 let err = harness
535 .dispatch(
536 "gaze_read_file",
537 json!({ "path": "testdata/does-not-exist.png" }),
538 )
539 .await
540 .expect_err("missing file fails");
541
542 match err {
543 DispatchError::ToolError(ToolError::NotFound(message)) => {
544 assert!(message.contains("not found"));
545 }
546 other => panic!("unexpected error: {other:?}"),
547 }
548 assert_eq!(harness.manifest.failures.load(Ordering::SeqCst), 1);
549 }
550
551 #[tokio::test]
552 async fn read_file_limit_fails_closed_before_ocr() {
553 let mut registry = ToolRegistry::new();
554 registry
555 .register(GazeReadFile::with_max_file_size(1))
556 .expect("register file tool");
557 let harness = Harness {
558 registry,
559 auth: AllowAllAuth,
560 manifest: Arc::new(RecordingManifest::new()),
561 pipeline: crate::bundle::build_document_pipeline().expect("pipeline"),
562 session: gaze::Session::new(gaze::Scope::Ephemeral).expect("session"),
563 session_id_policy: SessionIdPolicy::default_strict(),
564 };
565 let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
566 .join("testdata")
567 .join("synthetic_image.png");
568 let err = harness
569 .dispatch("gaze_read_file", json!({ "path": fixture }))
570 .await
571 .expect_err("oversized file fails");
572
573 match err {
574 DispatchError::ToolError(ToolError::LimitExceeded(message)) => {
575 assert!(message.contains("configured cap is 1 bytes"));
576 }
577 other => panic!("unexpected error: {other:?}"),
578 }
579 }
580
581 #[tokio::test]
582 async fn configured_dispatch_restores_owner_path_before_file_validation() {
583 let core = gaze_assembly::CorePipelineConfig::new().build().unwrap();
584 let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
585 let directory = tempfile::tempdir().unwrap();
586 let raw_path = directory
587 .path()
588 .join("alice@example.invalid")
589 .join("input.png");
590 std::fs::create_dir_all(raw_path.parent().unwrap()).unwrap();
591 std::fs::write(&raw_path, b"oversized").unwrap();
592 let gaze::CleanDocument::Text(protected_path) = core
593 .pseudonymize_text(&session, raw_path.to_str().unwrap())
594 .unwrap()
595 else {
596 panic!("text expected")
597 };
598 assert_ne!(protected_path, raw_path.to_str().unwrap());
599 let literal_path = PathBuf::from(&protected_path);
601 assert!(literal_path.starts_with(directory.path()));
602 std::fs::create_dir_all(literal_path.parent().unwrap()).unwrap();
603 std::fs::write(&literal_path, b"").unwrap();
604 let mut registry = ToolRegistry::new();
605 registry
606 .register(GazeReadFile::with_max_file_size(1))
607 .unwrap();
608 let manifest = RecordingManifest::new();
609 let policy = SessionIdPolicy::default_strict();
610 let envelope = PiiEnvelope::new(
611 ®istry,
612 &AllowAllAuth,
613 &manifest,
614 core.pipeline(),
615 &session,
616 core.locale_chain().as_slice(),
617 &policy,
618 );
619 for path in [raw_path.to_str().unwrap(), &protected_path] {
620 let err = envelope
621 .dispatch(
622 &Principal::new("unit-test"),
623 "gaze_read_file",
624 json!({"path": path}),
625 None,
626 )
627 .await
628 .unwrap_err();
629 assert!(
630 matches!(err, DispatchError::ToolError(ToolError::LimitExceeded(_))),
631 "{err:?}"
632 );
633 }
634 for args in manifest.args.lock().unwrap().iter() {
635 let path = args["path"].as_str().unwrap();
636 assert!(!path.contains("alice@example.invalid"));
637 assert_eq!(
638 session.restore_strict_text(path).unwrap(),
639 raw_path.to_str().unwrap()
640 );
641 }
642 let unknown_path = directory
643 .path()
644 .join("<deadbeef:Email_999>")
645 .join("input.png");
646 std::fs::create_dir_all(unknown_path.parent().unwrap()).unwrap();
647 std::fs::write(&unknown_path, b"oversized").unwrap();
648 let err = envelope
649 .dispatch(
650 &Principal::new("unit-test"),
651 "gaze_read_file",
652 json!({"path": unknown_path}),
653 None,
654 )
655 .await
656 .unwrap_err();
657 assert!(
658 matches!(err, DispatchError::ToolError(ToolError::InvalidArgs(_))),
659 "{err:?}"
660 );
661 assert_eq!(manifest.finishes.load(Ordering::SeqCst), 0);
662 }
663
664 #[cfg(feature = "ocr-tesseract")]
665 #[tokio::test]
666 async fn read_file_dispatch_returns_clean_markdown_for_fixture_when_backend_available() {
667 let harness = Harness::new();
668 let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
669 .join("testdata")
670 .join("synthetic_image.png");
671 let payload = match harness
672 .dispatch("gaze_read_file", json!({ "path": fixture }))
673 .await
674 {
675 Ok(payload) => payload,
676 Err(DispatchError::ToolError(ToolError::BackendUnavailable(message))) => {
677 eprintln!("SKIP: document backend unavailable: {message}");
678 return;
679 }
680 Err(other) => panic!("unexpected dispatch error: {other:?}"),
681 };
682
683 let clean_markdown = payload["clean_markdown"].as_str().expect("clean markdown");
684 assert!(clean_markdown.contains(":Email_"), "{clean_markdown}");
685 assert!(clean_markdown.contains(":Name_"), "{clean_markdown}");
686 assert!(
687 clean_markdown.contains(":Custom:phone_"),
688 "{clean_markdown}"
689 );
690 assert_no_raw_fixture_values(clean_markdown);
691 assert_eq!(payload["file_metadata"]["source_kind"], "image");
692 assert!(!payload["manifest_id"].as_str().unwrap().is_empty());
693 }
694}