|
| 1 | +{-# LANGUAGE DuplicateRecordFields #-} |
| 2 | +{-# LANGUAGE NamedFieldPuns #-} |
| 3 | + |
| 4 | +{- | Pure query helpers backing the MCP tools. |
| 5 | +
|
| 6 | +MCP-independent by design: this module has no @dpella/mcp@ imports, only the |
| 7 | +in-repo URI scheme and the model query layer. Tools live next door in |
| 8 | +"Emanote.MCP.Tools" and adapt these helpers to MCP wire types. Mirrors the |
| 9 | +'Emanote.MCP.Catalog' / 'Emanote.MCP.Handlers' split phase 2 introduced for |
| 10 | +resources. |
| 11 | +-} |
| 12 | +module Emanote.MCP.ToolCatalog ( |
| 13 | + NoteMatch (..), |
| 14 | + ResolveResult (..), |
| 15 | + findNotes, |
| 16 | + getBacklinks, |
| 17 | + resolveWikilink, |
| 18 | +) where |
| 19 | + |
| 20 | +import Commonmark.Extensions.WikiLink qualified as WL |
| 21 | +import Data.Aeson (ToJSON (..), (.=)) |
| 22 | +import Data.Aeson qualified as Aeson |
| 23 | +import Data.IxSet.Typed qualified as Ix |
| 24 | +import Data.Text qualified as T |
| 25 | +import Emanote.MCP.Uri (noteUriPrefix) |
| 26 | +import Emanote.Model (Model) |
| 27 | +import Emanote.Model qualified as M |
| 28 | +import Emanote.Model.Graph qualified as G |
| 29 | +import Emanote.Model.Link.Rel qualified as Rel |
| 30 | +import Emanote.Model.Link.Resolve qualified as Resolve |
| 31 | +import Emanote.Model.Note qualified as N |
| 32 | +import Emanote.Model.StaticFile qualified as SF |
| 33 | +import Emanote.Model.Title qualified as Tit |
| 34 | +import Emanote.Route qualified as R |
| 35 | +import Network.URI.Slug qualified as Slug |
| 36 | +import Optics.Operators ((^.)) |
| 37 | +import Relude |
| 38 | + |
| 39 | +-- --------------------------------------------------------------------------- |
| 40 | +-- Result shapes |
| 41 | +-- --------------------------------------------------------------------------- |
| 42 | + |
| 43 | +-- | A single note hit returned by 'findNotes' and 'getBacklinks'. |
| 44 | +data NoteMatch = NoteMatch |
| 45 | + { path :: Text |
| 46 | + , title :: Text |
| 47 | + } |
| 48 | + deriving stock (Eq, Show, Generic) |
| 49 | + |
| 50 | +{- | The @uri@ field is derived from @path@ so there is no way for the two to |
| 51 | +diverge: drift in 'noteUriPrefix' propagates to every consumer through |
| 52 | +one place. |
| 53 | +-} |
| 54 | +instance ToJSON NoteMatch where |
| 55 | + toJSON NoteMatch {path, title} = |
| 56 | + Aeson.object |
| 57 | + [ "path" .= path |
| 58 | + , "title" .= title |
| 59 | + , "uri" .= (noteUriPrefix <> path) |
| 60 | + ] |
| 61 | + |
| 62 | +-- | Outcome of resolving a wikilink, mirroring 'Rel.ResolvedRelTarget'. |
| 63 | +data ResolveResult |
| 64 | + = ResolvedNote NoteMatch |
| 65 | + | ResolvedStatic Text |
| 66 | + | UnresolvedMissing |
| 67 | + | UnresolvedAmbiguous [Either NoteMatch Text] |
| 68 | + deriving stock (Eq, Show, Generic) |
| 69 | + |
| 70 | +instance ToJSON ResolveResult where |
| 71 | + toJSON = \case |
| 72 | + ResolvedNote nm -> |
| 73 | + Aeson.object ["result" .= ("found" :: Text), "kind" .= ("note" :: Text), "note" .= nm] |
| 74 | + ResolvedStatic p -> |
| 75 | + Aeson.object ["result" .= ("found" :: Text), "kind" .= ("static" :: Text), "path" .= p] |
| 76 | + UnresolvedMissing -> |
| 77 | + Aeson.object ["result" .= ("missing" :: Text)] |
| 78 | + UnresolvedAmbiguous cs -> |
| 79 | + Aeson.object |
| 80 | + [ "result" .= ("ambiguous" :: Text) |
| 81 | + , "candidates" .= (candidateValue <$> cs) |
| 82 | + ] |
| 83 | + where |
| 84 | + candidateValue = \case |
| 85 | + Left nm -> Aeson.object ["kind" .= ("note" :: Text), "note" .= nm] |
| 86 | + Right p -> Aeson.object ["kind" .= ("static" :: Text), "path" .= p] |
| 87 | + |
| 88 | +noteMatchOf :: N.Note -> NoteMatch |
| 89 | +noteMatchOf note = |
| 90 | + NoteMatch |
| 91 | + { path = toText $ R.lmlSourcePath (note ^. N.noteRoute) |
| 92 | + , title = Tit.toPlain (note ^. N.noteTitle) |
| 93 | + } |
| 94 | + |
| 95 | +{- | Build a 'NoteMatch' from a route. Falls back to a route-derived title |
| 96 | +when the note can't be looked up — used by callers that hold a route but |
| 97 | +not the 'N.Note' (e.g. backlink sources). |
| 98 | +-} |
| 99 | +noteMatchOfRoute :: Model -> R.LMLRoute -> NoteMatch |
| 100 | +noteMatchOfRoute model r = |
| 101 | + maybe fallback noteMatchOf (M.modelLookupNoteByRoute' r model) |
| 102 | + where |
| 103 | + fallback = |
| 104 | + NoteMatch |
| 105 | + { path = toText $ R.lmlSourcePath r |
| 106 | + , title = Tit.toPlain (Tit.fromRoute r) |
| 107 | + } |
| 108 | + |
| 109 | +-- --------------------------------------------------------------------------- |
| 110 | +-- Queries |
| 111 | +-- --------------------------------------------------------------------------- |
| 112 | + |
| 113 | +{- | Substring search (case-insensitive) over note titles and source paths. |
| 114 | +
|
| 115 | +Returns up to @limit@ matches in 'IxSet' iteration order; this is stable |
| 116 | +under a given model snapshot but not lexicographically sorted. Callers that |
| 117 | +want ordered output should sort downstream. |
| 118 | +-} |
| 119 | +findNotes :: Text -> Int -> Model -> [NoteMatch] |
| 120 | +findNotes query lim model = |
| 121 | + let q = T.toLower query |
| 122 | + hit note = |
| 123 | + let m = noteMatchOf note |
| 124 | + in if q `T.isInfixOf` T.toLower (title m) || q `T.isInfixOf` T.toLower (path m) |
| 125 | + then Just m |
| 126 | + else Nothing |
| 127 | + in take (max 0 lim) $ mapMaybe hit $ Ix.toList (model ^. M.modelNotes) |
| 128 | + |
| 129 | +{- | Backlinks for the note at the given source path. |
| 130 | +
|
| 131 | +Returns 'Left' if @path@ isn't a recognised LML source path |
| 132 | +(@guide/mcp.md@, @daily/2024-01-01.org@, …). An empty list is a valid |
| 133 | +'Right' result and means the note exists but no other note links to it. |
| 134 | +-} |
| 135 | +getBacklinks :: FilePath -> Model -> Either Text [NoteMatch] |
| 136 | +getBacklinks fp model = |
| 137 | + case R.mkLMLRouteFromMdOrOrgFilePath fp of |
| 138 | + Nothing -> Left $ "Not a recognised note path: " <> toText fp |
| 139 | + Just r -> |
| 140 | + Right $ noteMatchOfRoute model . fst <$> G.modelLookupBacklinks r model |
| 141 | + |
| 142 | +{- | Resolve a wikilink string (without brackets), optionally relative to a |
| 143 | +source note for ambiguity disambiguation. Defaults the @from@ context to |
| 144 | +the notebook index when unspecified. |
| 145 | +-} |
| 146 | +resolveWikilink :: Text -> Maybe FilePath -> Model -> Either Text ResolveResult |
| 147 | +resolveWikilink wlText mFromPath model = do |
| 148 | + wl <- maybeToRight ("Not a valid wikilink: " <> wlText) (parseWikiLinkText wlText) |
| 149 | + fromR <- case mFromPath of |
| 150 | + Nothing -> Right (M.modelIndexRoute model) |
| 151 | + Just p -> maybeToRight ("Not a recognised note path: " <> toText p) (R.mkLMLRouteFromMdOrOrgFilePath p) |
| 152 | + Right $ case Resolve.resolveWikiLinkMustExist model fromR wl of |
| 153 | + Rel.RRTFound (Left (_, note)) -> ResolvedNote (noteMatchOf note) |
| 154 | + Rel.RRTFound (Right sf) -> ResolvedStatic (staticFilePath sf) |
| 155 | + Rel.RRTMissing -> UnresolvedMissing |
| 156 | + Rel.RRTAmbiguous cs -> UnresolvedAmbiguous $ toList $ candidate <$> cs |
| 157 | + where |
| 158 | + candidate = \case |
| 159 | + Left (_, note) -> Left (noteMatchOf note) |
| 160 | + Right sf -> Right (staticFilePath sf) |
| 161 | + staticFilePath sf = toText $ R.encodeRoute (sf ^. SF.staticFileRoute) |
| 162 | + |
| 163 | +-- --------------------------------------------------------------------------- |
| 164 | +-- Internal helpers |
| 165 | +-- --------------------------------------------------------------------------- |
| 166 | + |
| 167 | +-- | Parse a slash-separated wikilink target (e.g. "foo/bar") into a 'WL.WikiLink'. |
| 168 | +parseWikiLinkText :: Text -> Maybe WL.WikiLink |
| 169 | +parseWikiLinkText s |
| 170 | + | T.null s = Nothing |
| 171 | + | otherwise = viaNonEmpty WL.mkWikiLinkFromSlugs (Slug.decodeSlug <$> T.splitOn "/" s) |
0 commit comments