packages feed

mcp-hoogle-0.2.0: src/McpHoogle/Tools.hs

-- | MCP tool definitions and their handlers.
--
-- Each constructor of 'HoogleTool' becomes an MCP tool that Claude (or any
-- MCP client) can invoke. The @mcp-server@ library's Template Haskell
-- derivation turns the ADT into a tool list + dispatcher automatically.
--
-- The handler reads from an 'IORef' holding a 'Maybe' 'Database'. The ref
-- starts as 'Nothing' when no database exists on disk and is populated
-- by 'RegenerateDatabase' or 'ReloadDatabase'.
--
-- 'RegenerateDatabase' runs asynchronously via 'forkIO' so the MCP response
-- returns immediately. Use 'RegenerationStatus' to poll progress.
module McpHoogle.Tools
  ( HoogleTool(..)
  , SearchParams(..)
  , SearchTypeParams(..)
  , LookupModuleParams(..)
  , RegenerateDatabaseParams(..)
  , ReloadDatabaseParams(..)
  , RegenState(..)
  , handleTool
  , toolDescriptions
  )
where

import Data.Text (Text)
import Data.Text qualified as Text
import Data.IORef (IORef, readIORef, writeIORef)
import Data.Time.Clock (UTCTime, getCurrentTime, diffUTCTime)
import Control.Concurrent (forkIO)
import Control.Concurrent.STM (TVar, readTVarIO, atomically, writeTVar)
import Control.Exception (bracket, SomeException, try)
import GHC.IO.Handle (hDuplicate, hDuplicateTo)
import Hoogle (Database, searchDatabase, withDatabase, hoogle, defaultDatabaseLocation)
import McpHoogle.Format (formatTargets)
import System.Environment (setEnv, lookupEnv)
import System.IO (stdout, stderr, hFlush)

-- | Parameters for a general search (name, keyword, or type signature).
data SearchParams = SearchParams
  { query :: Text
  }

-- | Parameters for a type-signature-specific search.
data SearchTypeParams = SearchTypeParams
  { typeSignature :: Text
  }

-- | Parameters for a module-name lookup.
data LookupModuleParams = LookupModuleParams
  { moduleName :: Text
  }

-- | Parameters for database regeneration.
--
-- Requires @ghcBinPath@ so that @ghc-pkg@ can be found even when the
-- MCP server was started outside a nix-shell.
data RegenerateDatabaseParams = RegenerateDatabaseParams
  { ghcBinPath :: Text
  }

-- | Parameters for reloading the database from disk without regenerating.
data ReloadDatabaseParams = ReloadDatabaseParams
  { reloadPath :: Text
  }

-- | Tracks the state of an asynchronous database regeneration.
--
-- Stored in a 'TVar' for thread-safe access between the MCP handler
-- thread and the background regeneration thread.
data RegenState
  = RegenIdle
  | RegenRunning UTCTime     -- ^ Started at this time
  | RegenDone UTCTime Text   -- ^ Finished at, database path
  | RegenFailed UTCTime Text -- ^ Finished at, error message

-- | The set of MCP tools this server exposes.
--
-- Each constructor maps to one callable tool. The nested parameter records
-- provide named arguments (avoiding partial record selectors) which the
-- TH derivation exposes as the tool's input schema.
data HoogleTool
  = Search SearchParams
  | SearchType SearchTypeParams
  | LookupModule LookupModuleParams
  | RegenerateDatabase RegenerateDatabaseParams
  | ReloadDatabase ReloadDatabaseParams
  | RegenerationStatus

-- | Human-readable descriptions for each tool and its arguments.
-- Fed to 'deriveToolHandlerWithDescription' so MCP clients know what
-- each tool does and what arguments to pass.
toolDescriptions :: [(String, String)]
toolDescriptions =
  [ ("Search", "Search Hoogle by function name, type signature, or keyword. Returns matching functions with their types, packages, and documentation.")
  , ("SearchType", "Search Hoogle specifically by type signature. Example: 'a -> [a]' or '[a] -> Int'")
  , ("LookupModule", "Search for all exports of a given module name. Example: 'Data.Map'")
  , ("RegenerateDatabase", "Regenerate the Hoogle database asynchronously. Returns immediately — use regeneration_status to poll progress. Requires ghcBinPath so ghc-pkg can be found. Find it with: nix-shell --run 'dirname $(which ghc-pkg)'")
  , ("ReloadDatabase", "Reload the Hoogle database from disk without regenerating. Use after running 'mcp-hoogle generate' externally via a bash command.")
  , ("RegenerationStatus", "Check the status of an asynchronous database regeneration. Reports idle, in-progress (with elapsed time), completed, or failed.")
  , ("query", "The search query: a function name, keyword, or type signature")
  , ("typeSignature", "A Haskell type signature to search for, e.g. '[a] -> Int' or 'Map k v -> [(k,v)]'")
  , ("moduleName", "A Haskell module name to look up, e.g. 'Data.Map' or 'Control.Monad'")
  , ("ghcBinPath", "Path to GHC's bin directory containing ghc-pkg. Find with: nix-shell --run 'dirname $(which ghc-pkg)'")
  , ("reloadPath", "Path to the .hoo database file to reload. Use empty string for default (~/.hoogle/default-haskell-5.0.18.hoo).")
  ]

-- | Dispatch a tool call to the appropriate Hoogle operation.
--
-- Reads the current database from the 'IORef'. For 'RegenerateDatabase',
-- spawns a background thread and returns immediately — poll with
-- 'RegenerationStatus'.
handleTool :: IORef (Maybe Database) -> TVar RegenState -> HoogleTool -> IO Text
handleTool databaseRef _regenStateVar (Search (SearchParams searchQuery)) = do
  mDatabase <- readIORef databaseRef
  case mDatabase of
    Nothing -> pure "No database loaded. Call regenerate_database with your project's ghcBinPath first."
    Just database -> do
      let results = take 20 (searchDatabase database (Text.unpack searchQuery))
      pure (formatTargets results)
handleTool databaseRef _regenStateVar (SearchType (SearchTypeParams sig)) = do
  mDatabase <- readIORef databaseRef
  case mDatabase of
    Nothing -> pure "No database loaded. Call regenerate_database with your project's ghcBinPath first."
    Just database -> do
      let results = take 20 (searchDatabase database (Text.unpack sig))
      pure (formatTargets results)
handleTool databaseRef _regenStateVar (LookupModule (LookupModuleParams modName)) = do
  mDatabase <- readIORef databaseRef
  case mDatabase of
    Nothing -> pure "No database loaded. Call regenerate_database with your project's ghcBinPath first."
    Just database -> do
      let queryString = "module:" <> Text.unpack modName
          results = take 30 (searchDatabase database queryString)
      -- If module: prefix doesn't work well, fall back to plain search
      let finalResults = if null results
            then take 30 (searchDatabase database (Text.unpack modName))
            else results
      pure (formatTargets finalResults)
handleTool databaseRef regenStateVar (RegenerateDatabase (RegenerateDatabaseParams ghcBin)) = do
  currentState <- readTVarIO regenStateVar
  case currentState of
    RegenRunning _ -> pure "Database regeneration already in progress. Use regeneration_status to check progress."
    _ -> do
      startTime <- getCurrentTime
      atomically (writeTVar regenStateVar (RegenRunning startTime))
      dbPath <- defaultDatabaseLocation
      let ghcBinStr = Text.unpack ghcBin
      _ <- forkIO $ do
        result <- try $ do
          withPrependedPath ghcBinStr $
            withSilencedStdout $
              hoogle ["generate", "--local", "--database=" <> dbPath]
          withDatabase dbPath $ \newDb ->
            writeIORef databaseRef (Just newDb)
        finishTime <- getCurrentTime
        case result of
          Right () ->
            atomically (writeTVar regenStateVar (RegenDone finishTime (Text.pack dbPath)))
          Left someException ->
            atomically (writeTVar regenStateVar (RegenFailed finishTime (Text.pack (show (someException :: SomeException)))))
      pure "Database regeneration started. Use regeneration_status to check progress."
handleTool databaseRef _regenStateVar (ReloadDatabase (ReloadDatabaseParams path)) = do
  dbPath <- if Text.null path
    then defaultDatabaseLocation
    else pure (Text.unpack path)
  withDatabase dbPath $ \newDb -> do
    writeIORef databaseRef (Just newDb)
    pure (Text.pack ("Database reloaded from: " <> dbPath))
handleTool _databaseRef regenStateVar RegenerationStatus = do
  currentState <- readTVarIO regenStateVar
  now <- getCurrentTime
  case currentState of
    RegenIdle -> pure "No regeneration in progress or completed."
    RegenRunning startTime ->
      let elapsed = floor (diffUTCTime now startTime) :: Int
      in pure (Text.pack ("Regeneration in progress (started " <> show elapsed <> "s ago)."))
    RegenDone finishTime dbPathText ->
      let ago = floor (diffUTCTime now finishTime) :: Int
      in pure (Text.pack ("Regeneration completed " <> show ago <> "s ago. Database loaded from: " <> Text.unpack dbPathText))
    RegenFailed finishTime errorText ->
      let ago = floor (diffUTCTime now finishTime) :: Int
      in pure (Text.pack ("Regeneration failed " <> show ago <> "s ago: " <> Text.unpack errorText))

-- | Temporarily prepend a directory to PATH, run an action, then restore.
withPrependedPath :: FilePath -> IO a -> IO a
withPrependedPath dir action = do
  oldPath <- lookupEnv "PATH"
  let newPath = case oldPath of
        Nothing -> dir
        Just p  -> dir <> ":" <> p
  bracket
    (setEnv "PATH" newPath)
    (\_ -> maybe (setEnv "PATH" "") (setEnv "PATH") oldPath)
    (\_ -> action)

-- | Redirect stdout to \/dev\/null during an action.
--
-- Hoogle's library API writes progress text to stdout which would corrupt
-- the MCP JSON-RPC stream. We redirect stdout to stderr (so it's still
-- visible for debugging) and restore it after.
withSilencedStdout :: IO a -> IO a
withSilencedStdout action = do
  hFlush stdout
  savedStdout <- hDuplicate stdout
  hDuplicateTo stderr stdout
  result <- action
  hFlush stdout
  hDuplicateTo savedStdout stdout
  pure result