Skip to main content

gaze_document/mcp/
mod.rs

1//! MCP tool adapters for `gaze-document`.
2//!
3//! Tools in this module are opt-in via the `mcp` feature and must be
4//! registered explicitly by adopters. Invocation still happens through
5//! `gaze_mcp_core::PiiEnvelope::dispatch`; this module only provides the
6//! tool bodies and catalog metadata.
7
8use 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
22/// Default `gaze_read_file` input cap: 25 MiB.
23pub const DEFAULT_MAX_FILE_SIZE: u64 = 25 * 1024 * 1024;
24
25/// Options used when registering `gaze-document` MCP tools.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[non_exhaustive]
28pub struct GazeReadOpts {
29    /// Maximum accepted input file size in bytes for `gaze_read_file`.
30    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
41/// Register `gaze_read_text` and `gaze_read_file` with their canonical names.
42pub 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/// MCP tool that tokenizes already-extracted text via the document recognizer set.
52#[derive(Debug)]
53#[non_exhaustive]
54pub struct GazeReadText {
55    descriptor: ToolDescriptor,
56}
57
58impl GazeReadText {
59    /// Construct a `gaze_read_text` tool.
60    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/// MCP tool that OCRs an image/PDF file and tokenizes text before model use.
111#[derive(Debug)]
112#[non_exhaustive]
113pub struct GazeReadFile {
114    descriptor: ToolDescriptor,
115    max_file_size: u64,
116}
117
118impl GazeReadFile {
119    /// Construct a `gaze_read_file` tool with the default 25 MiB input cap.
120    pub fn new() -> Self {
121        Self::with_max_file_size(DEFAULT_MAX_FILE_SIZE)
122    }
123
124    /// Construct a `gaze_read_file` tool with a caller-supplied input cap.
125    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        // Restore only inside the trusted tool, after protected manifest args
167        // were recorded. Opening the token spelling can select a different file.
168        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    // The envelope may have already protected arguments. Preserve the session's
231    // exact owned tokens while applying the document-specific primary graph.
232    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        // A distinct literal-token file must never replace the owner's target.
600        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            &registry,
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}