From 10e0ab3e3f8852d8d4fa390e8ac1896459f211bc Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 20 Oct 2025 11:02:31 -0700 Subject: [PATCH 1/9] Add update mcp-tool --- unison-cli/src/Unison/MCP/Tools.hs | 22 ++++++++++++++++++++++ unison-cli/src/Unison/MCP/Types.hs | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/unison-cli/src/Unison/MCP/Tools.hs b/unison-cli/src/Unison/MCP/Tools.hs index ae5051bfea..7a2f4f8f2c 100644 --- a/unison-cli/src/Unison/MCP/Tools.hs +++ b/unison-cli/src/Unison/MCP/Tools.hs @@ -50,6 +50,7 @@ tools = listProjectLibrariesTool, listLibraryDefinitionsTool, viewDefinitionsTool, + updateTool, listLocalProjectsTool, listProjectBranchesTool, getCurrentProjectContextTool, @@ -309,6 +310,27 @@ viewDefinitionsTool = pure $ textToolResult outputJSON } +updateTool :: Tool MCP +updateTool = + Tool + { toolName = toToolName ViewDefinitionsTool, + toolDescription = "Update definitions in the codebase to the provided code.", + toolAnnotations = + ToolAnnotations + { title = Just "Update Definitions", + readOnlyHint = Just False, + destructiveHint = Just True, + idempotentHint = Just True, + openWorldHint = Just False + }, + toolArgType = Proxy, + toolHandler = \(UpdateDefinitionsToolArguments {projectContext}) -> handleToolError $ do + definitions <- handleInputMCP projectContext [Right $ Input.Update2I] + let outputJSON = Text.decodeUtf8 . BL.toStrict $ Aeson.encode definitions + pure $ textToolResult outputJSON + } + + listLocalProjectsTool :: Tool MCP listLocalProjectsTool = Tool diff --git a/unison-cli/src/Unison/MCP/Types.hs b/unison-cli/src/Unison/MCP/Types.hs index 341ac768d0..69b2f0a75f 100644 --- a/unison-cli/src/Unison/MCP/Types.hs +++ b/unison-cli/src/Unison/MCP/Types.hs @@ -12,6 +12,7 @@ module Unison.MCP.Types ShareProjectReadmeToolArguments (..), ListLibraryDefinitionsToolArguments (..), ViewDefinitionsToolArguments (..), + UpdateDefinitionsToolArguments (..), SearchDefinitionsToolArguments (..), SearchByTypeToolArguments (..), DocsToolArguments (..), @@ -72,6 +73,7 @@ data ToolKind | ListProjectLibrariesTool | ListLibraryDefinitionsTool | ViewDefinitionsTool + | UpdateDefinitionsTool | SearchDefinitionsTool | SearchByTypeTool | ListLocalProjectsTool @@ -95,6 +97,7 @@ kindNameMapping = (ListProjectLibrariesTool, "list-project-libraries"), (ListLibraryDefinitionsTool, "list-library-definitions"), (ViewDefinitionsTool, "view-definitions"), + (UpdateDefinitionsTool, "update-definitions"), (SearchDefinitionsTool, "search-definitions-by-name"), (SearchByTypeTool, "search-by-type"), (ListLocalProjectsTool, "list-local-projects"), @@ -269,6 +272,27 @@ instance FromJSON ViewDefinitionsToolArguments where names <- fmap Name.unsafeParseText <$> o .: "names" pure $ ViewDefinitionsToolArguments {projectContext, names} +data UpdateDefinitionsToolArguments = UpdateDefinitionsToolArguments + { projectContext :: ProjectContext + } + deriving (Eq, Show) + +instance HasInputSchema UpdateDefinitionsToolArguments where + toInputSchema _ = + object + [ "type" .= ("object" :: Text), + "properties" + .= object + [ "projectContext" .= toInputSchema (Proxy :: Proxy ProjectContext) + ], + "required" .= ["projectContext" :: Text] + ] + +instance FromJSON UpdateDefinitionsToolArguments where + parseJSON = withObject "UpdateDefinitionsToolArguments" $ \o -> do + projectContext <- o .: "projectContext" + pure $ UpdateDefinitionsToolArguments {projectContext} + data ListLibraryDefinitionsToolArguments = ListLibraryDefinitionsToolArguments { projectContext :: ProjectContext, libName :: Text From 70a13da29de689197ca2943e763775b4a5643c88 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 20 Oct 2025 12:08:40 -0700 Subject: [PATCH 2/9] Allow passing code to 'update' in MCP --- unison-cli/src/Unison/MCP/Cli.hs | 33 +++++++++++++++----- unison-cli/src/Unison/MCP/Tools.hs | 28 +++++++++++------ unison-cli/src/Unison/MCP/Types.hs | 50 +++++++++++++++++++++++++++--- 3 files changed, 89 insertions(+), 22 deletions(-) diff --git a/unison-cli/src/Unison/MCP/Cli.hs b/unison-cli/src/Unison/MCP/Cli.hs index b9ef30e5b3..f1739a1b72 100644 --- a/unison-cli/src/Unison/MCP/Cli.hs +++ b/unison-cli/src/Unison/MCP/Cli.hs @@ -19,6 +19,7 @@ import Unison.Cli.Monad qualified as Cli import Unison.Codebase qualified as Codebase import Unison.Codebase.Editor.HandleInput qualified as HandleInput import Unison.Codebase.Editor.Input (Event, Input) +import Unison.Codebase.Editor.Output qualified as Output import Unison.Codebase.Path qualified as Path import Unison.Codebase.ProjectPath qualified as PP import Unison.CommandLine.OutputMessages qualified as Output @@ -33,22 +34,24 @@ import Prelude hiding (readFile, writeFile) data CliOutput = CliOutput { sourceCodeUpdates :: [Text], - outputMessages :: [Text] + outputMessages :: [Text], + errorMessages :: [Text] } deriving (Eq, Show) instance Semigroup CliOutput where - CliOutput src1 out1 <> CliOutput src2 out2 = - CliOutput (src1 <> src2) (out1 <> out2) + CliOutput src1 out1 errs1 <> CliOutput src2 out2 errs2 = + CliOutput (src1 <> src2) (out1 <> out2) (errs1 <> errs2) instance Monoid CliOutput where - mempty = CliOutput [] [] + mempty = CliOutput [] [] [] instance ToJSON CliOutput where - toJSON (CliOutput sourceCodeUpdates outputMessages) = + toJSON (CliOutput sourceCodeUpdates outputMessages errorMessages) = object [ "sourceCodeUpdates" .= sourceCodeUpdates, - "outputMessages" .= outputMessages + "outputMessages" .= outputMessages, + "errorMessages" .= errorMessages ] ppForProjectContext :: ProjectContext -> ExceptT Text Transaction PP.ProjectPath @@ -67,6 +70,9 @@ handleInputMCP projectContext input = do case input of (inp : rest) -> do (_, cliOutput) <- cliToMCP projectContext (HandleInput.loop inp) + case cliOutput.errorMessages of + [] -> pure () + errs -> throwError $ Text.unlines ("Errors:" : errs) (cliOutput <>) <$> handleInputMCP projectContext rest [] -> pure mempty @@ -80,10 +86,15 @@ cliToMCP projCtx cli = do tokenProvider = AuthN.newTokenProvider credMan authenticatedHTTPClient <- AuthN.newAuthenticatedHTTPClient tokenProvider ucmVersion outputVar <- newTVarIO Seq.empty + errorsVar <- newTVarIO Seq.empty sourceCodeUpdatesVar <- newTVarIO Seq.empty let notify output = do pretty <- Output.notifyUser workDir Output.fetchIssueFromGitHub output - atomically $ modifyTVar outputVar (<> Seq.singleton pretty) + if (Output.isFailure output) + then do + atomically $ modifyTVar errorsVar (<> Seq.singleton pretty) + else do + atomically $ modifyTVar outputVar (<> Seq.singleton pretty) let notifyNumbered output = do let (pretty, nargs) = Output.notifyNumbered output atomically $ modifyTVar outputVar (<> Seq.singleton pretty) @@ -124,15 +135,21 @@ cliToMCP projCtx cli = do -- flush the output buffer since it should now be filled. cliOut <- atomically $ do msgs <- readTVar outputVar + errs <- readTVar errorsVar sourceCodeUpdates <- toList <$> readTVar sourceCodeUpdatesVar let outputMessages = msgs & fmap (Pretty.toPlain 0) & toList + let errorMessages = + errs + & fmap (Text.pack . Pretty.toPlain 0) + & toList pure $ ( CliOutput { sourceCodeUpdates, - outputMessages + outputMessages, + errorMessages } ) case cliResult of diff --git a/unison-cli/src/Unison/MCP/Tools.hs b/unison-cli/src/Unison/MCP/Tools.hs index 7a2f4f8f2c..c80fd5fff3 100644 --- a/unison-cli/src/Unison/MCP/Tools.hs +++ b/unison-cli/src/Unison/MCP/Tools.hs @@ -118,6 +118,16 @@ shareProjectSearchTool = pure $ errorToolResult errorMsg } +-- | Load and typecheck the provided code, THEN run the provided inputs within that scratchfile context. +withCode :: Either FilePath Text -> [Input] -> ProjectContext -> EMCP CallToolResult +withCode code inputs projectContext = do + source <- case code of + Left filePath -> liftIO $ readUtf8 filePath + Right codeSnippet -> pure codeSnippet + output <- handleInputMCP projectContext ([Left $ UnisonFileChanged "scratch.u" source] <> (Right <$> inputs)) + let outputJSON = Text.decodeUtf8 . BL.toStrict $ Aeson.encode output + pure $ textToolResult outputJSON + typecheckCodeTool :: Tool MCP typecheckCodeTool = Tool @@ -163,12 +173,8 @@ typecheckCodeTool = }, toolArgType = Proxy, toolHandler = \(TypecheckCodeToolArguments {code, projectContext}) -> handleToolError do - source <- case code of - Left filePath -> liftIO $ readUtf8 filePath - Right codeSnippet -> pure codeSnippet - output <- handleInputMCP projectContext [Left $ UnisonFileChanged "scratch.u" source] - let outputJSON = Text.decodeUtf8 . BL.toStrict $ Aeson.encode output - pure $ textToolResult outputJSON + -- Just load the code, nothing more + withCode code [] projectContext } docsTool :: Tool MCP @@ -324,10 +330,12 @@ updateTool = openWorldHint = Just False }, toolArgType = Proxy, - toolHandler = \(UpdateDefinitionsToolArguments {projectContext}) -> handleToolError $ do - definitions <- handleInputMCP projectContext [Right $ Input.Update2I] - let outputJSON = Text.decodeUtf8 . BL.toStrict $ Aeson.encode definitions - pure $ textToolResult outputJSON + toolHandler = \(UpdateDefinitionsToolArguments {projectContext, code}) -> handleToolError $ do + Env {isEditable} <- ask + when (not $ isEditable projectContext) $ + let example = "--mcp-editable-branches=" <> into @Text projectContext.projectName <> "/" <> into @Text projectContext.branchName + in throwError $ "The provided project context is not editable. Please ask the user to allow edits to this project in their MCP configuration. E.g. by adding `" <> example <> "`" + withCode code [Input.Update2I] projectContext } diff --git a/unison-cli/src/Unison/MCP/Types.hs b/unison-cli/src/Unison/MCP/Types.hs index 69b2f0a75f..472135a3bc 100644 --- a/unison-cli/src/Unison/MCP/Types.hs +++ b/unison-cli/src/Unison/MCP/Types.hs @@ -273,7 +273,8 @@ instance FromJSON ViewDefinitionsToolArguments where pure $ ViewDefinitionsToolArguments {projectContext, names} data UpdateDefinitionsToolArguments = UpdateDefinitionsToolArguments - { projectContext :: ProjectContext + { projectContext :: ProjectContext, + code :: Either FilePath Text } deriving (Eq, Show) @@ -283,15 +284,56 @@ instance HasInputSchema UpdateDefinitionsToolArguments where [ "type" .= ("object" :: Text), "properties" .= object - [ "projectContext" .= toInputSchema (Proxy :: Proxy ProjectContext) + [ "projectContext" .= toInputSchema (Proxy :: Proxy ProjectContext), + "code" + .= object + [ "description" .= ("The source code to update definitions to. If a string, it is the source code itself. If a file path, it is the path to a file containing the source code." :: Text), + "oneOf" + .= [ object + [ "description" .= ("The file path to the source code." :: Text), + "type" .= ("object" :: Text), + "properties" + .= object + [ "filePath" + .= object + [ "type" .= ("string" :: Text), + "description" .= ("An absolute file path to the source code." :: Text) + ] + ], + "required" .= ["filePath" :: Text], + "additionalProperties" .= False + ], + object + [ "description" .= ("The source code to use." :: Text), + "type" .= ("object" :: Text), + "properties" + .= object + [ "text" + .= object + [ "type" .= ("string" :: Text), + "description" .= ("The source code to use." :: Text) + ] + ], + "required" .= ["text" :: Text], + "additionalProperties" .= False + ] + ] + ] ], - "required" .= ["projectContext" :: Text] + "required" .= ["projectContext", "code" :: Text] ] instance FromJSON UpdateDefinitionsToolArguments where parseJSON = withObject "UpdateDefinitionsToolArguments" $ \o -> do projectContext <- o .: "projectContext" - pure $ UpdateDefinitionsToolArguments {projectContext} + source <- o .: "code" + code <- + source .:? "filePath" >>= \case + Just filePath -> pure $ Left filePath + Nothing -> do + text <- source .: "text" + pure $ Right text + pure $ UpdateDefinitionsToolArguments {projectContext, code} data ListLibraryDefinitionsToolArguments = ListLibraryDefinitionsToolArguments { projectContext :: ProjectContext, From d753c05e58eb5a76a5120acc328c020c8c22e556 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 20 Oct 2025 12:11:56 -0700 Subject: [PATCH 3/9] Fix update --- unison-cli/src/Unison/MCP/Cli.hs | 28 ++++++++++++++++++---------- unison-cli/src/Unison/MCP/Tools.hs | 13 ++++++++----- unison-cli/src/Unison/Main.hs | 2 +- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/unison-cli/src/Unison/MCP/Cli.hs b/unison-cli/src/Unison/MCP/Cli.hs index f1739a1b72..82f52720bd 100644 --- a/unison-cli/src/Unison/MCP/Cli.hs +++ b/unison-cli/src/Unison/MCP/Cli.hs @@ -67,17 +67,24 @@ ppForProjectContext ProjectContext {projectName, branchName} = do handleInputMCP :: ProjectContext -> [Either Event Input] -> ExceptT Text MCP CliOutput handleInputMCP projectContext input = do - case input of - (inp : rest) -> do - (_, cliOutput) <- cliToMCP projectContext (HandleInput.loop inp) - case cliOutput.errorMessages of - [] -> pure () - errs -> throwError $ Text.unlines ("Errors:" : errs) - (cliOutput <>) <$> handleInputMCP projectContext rest - [] -> pure mempty + hasErroredVar <- newTVarIO False + let onErr _errMsg = atomically $ writeTVar hasErroredVar True + result <- cliToMCP projectContext onErr do + Cli.labelE \fail' -> do + for_ input \inp -> do + HandleInput.loop inp + readTVarIO hasErroredVar >>= \case + False -> pure () + True -> fail' "An error occurred during input handling." + case result of + (Nothing, cliOut) -> pure cliOut + (Just (Left err), cliOutput) -> + pure $ cliOutput <> mempty {errorMessages = [err]} + (Just (Right ()), cliOutput) -> + pure cliOutput -cliToMCP :: ProjectContext -> Cli.Cli a -> ExceptT Text MCP (Maybe a, CliOutput) -cliToMCP projCtx cli = do +cliToMCP :: ProjectContext -> (Text -> IO ()) -> Cli.Cli a -> ExceptT Text MCP (Maybe a, CliOutput) +cliToMCP projCtx onError cli = do MCP.Env {ucmVersion, codebase, runtime, workDir} <- ask initialPP <- ExceptT . liftIO $ Codebase.runTransactionExceptT codebase $ do ppForProjectContext projCtx @@ -93,6 +100,7 @@ cliToMCP projCtx cli = do if (Output.isFailure output) then do atomically $ modifyTVar errorsVar (<> Seq.singleton pretty) + liftIO $ onError (Text.pack (Pretty.toPlain 0 pretty)) else do atomically $ modifyTVar outputVar (<> Seq.singleton pretty) let notifyNumbered output = do diff --git a/unison-cli/src/Unison/MCP/Tools.hs b/unison-cli/src/Unison/MCP/Tools.hs index c80fd5fff3..2c46601f05 100644 --- a/unison-cli/src/Unison/MCP/Tools.hs +++ b/unison-cli/src/Unison/MCP/Tools.hs @@ -86,7 +86,7 @@ installLibTool = }, toolArgType = Proxy, toolHandler = \(LibInstallToolArguments {projectContext, libProjectName, libBranchName}) -> handleToolError $ do - (_r, output) <- cliToMCP projectContext $ do + (_r, output) <- cliToMCP projectContext (const $ pure ()) $ do handleInstallLib False (ProjectAndBranch (UnsafeProjectName libProjectName) (ProjectBranchNameOrLatestRelease'Name . UnsafeProjectBranchName <$> libBranchName)) let outputJSON = Text.decodeUtf8 . BL.toStrict $ Aeson.encode output pure $ textToolResult outputJSON @@ -162,6 +162,8 @@ typecheckCodeTool = m = Random.natIn 0 1000 ensureEqual (n + m) (m + n) ``` + + If you intend to update code, you may call the Update Definitions tool directly instead, it will typecheck and update in one step. |], toolAnnotations = ToolAnnotations @@ -237,8 +239,9 @@ listProjectDefinitionsTool = }, toolArgType = Proxy, toolHandler = \(ProjectContextArgument projectContext) -> handleToolError $ do + let noop _ = pure () output <- - cliToMCP projectContext Cli.getCurrentBranch0 >>= \case + cliToMCP projectContext noop Cli.getCurrentBranch0 >>= \case (Just b, _output) -> do let noLibBranch = Branch.deleteLibdeps b if (R.null $ Branch.deepTerms noLibBranch) && (R.null $ Branch.deepTypes noLibBranch) @@ -319,8 +322,8 @@ viewDefinitionsTool = updateTool :: Tool MCP updateTool = Tool - { toolName = toToolName ViewDefinitionsTool, - toolDescription = "Update definitions in the codebase to the provided code.", + { toolName = toToolName UpdateDefinitionsTool, + toolDescription = "Typecheck, then update definitions in the codebase to the provided code.", toolAnnotations = ToolAnnotations { title = Just "Update Definitions", @@ -385,7 +388,7 @@ getCurrentProjectContextTool :: Tool MCP getCurrentProjectContextTool = Tool { toolName = toToolName GetCurrentProjectContextTool, - toolDescription = "Get the current project context.", + toolDescription = "Get the current project context. This is useful for determining the user's working branch, but all commands take an explicit project context, so it's unnecessary if you already know which context is desired.", toolAnnotations = ToolAnnotations { title = Just "Get Current Project Context", diff --git a/unison-cli/src/Unison/Main.hs b/unison-cli/src/Unison/Main.hs index 37467c83b7..6334a3cc50 100644 --- a/unison-cli/src/Unison/Main.hs +++ b/unison-cli/src/Unison/Main.hs @@ -140,7 +140,7 @@ main version = do progName <- getProgName -- hSetBuffering stdout NoBuffering -- cool (renderUsageInfo, globalOptions, command) <- parseCLIArgs progName (Text.unpack (Version.gitDescribeWithDate version)) - let GlobalOptions {codebasePathOption = mCodePathOption, exitOption, lspFormattingConfig} = globalOptions + let GlobalOptions {codebasePathOption = mCodePathOption, exitOption, lspFormattingConfig, mcpEditableBranches} = globalOptions currentDir <- getCurrentDirectory case command of PrintVersion -> From cc25c2ae8903873dc0d74c3be03542e11bb8d925 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 20 Oct 2025 15:22:51 -0700 Subject: [PATCH 4/9] Better error message in update mcp --- unison-cli/src/Unison/MCP/Tools.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unison-cli/src/Unison/MCP/Tools.hs b/unison-cli/src/Unison/MCP/Tools.hs index 2c46601f05..820ea12a40 100644 --- a/unison-cli/src/Unison/MCP/Tools.hs +++ b/unison-cli/src/Unison/MCP/Tools.hs @@ -337,7 +337,7 @@ updateTool = Env {isEditable} <- ask when (not $ isEditable projectContext) $ let example = "--mcp-editable-branches=" <> into @Text projectContext.projectName <> "/" <> into @Text projectContext.branchName - in throwError $ "The provided project context is not editable. Please ask the user to allow edits to this project in their MCP configuration. E.g. by adding `" <> example <> "`" + in throwError $ "The provided project-branch is not editable.\nPlease ask the user to allow edits to this project in their MCP configuration by adding `" <> example <> "` to the invocation of the Unison mcp within their agent's mcp configuration." withCode code [Input.Update2I] projectContext } From 1f42af85ec61fad58352fbd709092d34603ed308 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 20 Oct 2025 15:24:43 -0700 Subject: [PATCH 5/9] Add update mcp transcripts --- unison-src/transcripts/idempotent/mcp.md | 102 ++++++++++++++++++++--- 1 file changed, 92 insertions(+), 10 deletions(-) diff --git a/unison-src/transcripts/idempotent/mcp.md b/unison-src/transcripts/idempotent/mcp.md index e8c1f48159..631d1293f8 100644 --- a/unison-src/transcripts/idempotent/mcp.md +++ b/unison-src/transcripts/idempotent/mcp.md @@ -50,7 +50,7 @@ RESPONSE: "result": { "content": [ { - "text": "{\"outputMessages\":[\" Branch Remote branch\\n1. main \"],\"sourceCodeUpdates\":[]}", + "text": "{\"errorMessages\":[],\"outputMessages\":[\" Branch Remote branch\\n1. main \"],\"sourceCodeUpdates\":[]}", "type": "text" } ], @@ -87,7 +87,7 @@ RESPONSE: "result": { "content": [ { - "text": "{\"outputMessages\":[\"Loading changes detected in scratch.u.\",\"No changes found.\",\" 1 | > x = 1 + 2\\n ⧩\\n 3\"],\"sourceCodeUpdates\":[]}", + "text": "{\"errorMessages\":[],\"outputMessages\":[\"Loading changes detected in scratch.u.\",\"No changes found.\",\" 1 | > x = 1 + 2\\n ⧩\\n 3\"],\"sourceCodeUpdates\":[]}", "type": "text" } ], @@ -124,7 +124,7 @@ RESPONSE: "result": { "content": [ { - "text": "{\"outputMessages\":[\"This is a scratch project for testing tools in MCP.\\n\\n\\n\"],\"sourceCodeUpdates\":[]}", + "text": "{\"errorMessages\":[],\"outputMessages\":[\"This is a scratch project for testing tools in MCP.\\n\\n\\n\"],\"sourceCodeUpdates\":[]}", "type": "text" } ], @@ -161,7 +161,7 @@ RESPONSE: "result": { "content": [ { - "text": "{\"content\":[{\"text\":\"{\\\"outputMessages\\\":[\\\"1. myFailingTest : [Result]\\\\n2. myPassingTest : [Result]\\\\n3. myTerm : Nat\\\\n4. type MyType\\\\n5. MyType.MyConstructor : MyType\\\\n6. README : Doc2\\\\n\\\"],\\\"sourceCodeUpdates\\\":[]}\",\"type\":\"text\"}],\"isError\":false}", + "text": "{\"content\":[{\"text\":\"{\\\"errorMessages\\\":[],\\\"outputMessages\\\":[\\\"1. myFailingTest : [Result]\\\\n2. myPassingTest : [Result]\\\\n3. myTerm : Nat\\\\n4. type MyType\\\\n5. MyType.MyConstructor : MyType\\\\n6. README : Doc2\\\\n\\\"],\\\"sourceCodeUpdates\\\":[]}\",\"type\":\"text\"}],\"isError\":false}", "type": "text" } ], @@ -198,7 +198,7 @@ RESPONSE: "result": { "content": [ { - "text": "{\"outputMessages\":[\"1. builtins. (840 terms, 121 types)\"],\"sourceCodeUpdates\":[]}", + "text": "{\"errorMessages\":[],\"outputMessages\":[\"1. builtins. (840 terms, 121 types)\"],\"sourceCodeUpdates\":[]}", "type": "text" } ], @@ -232,7 +232,7 @@ RESPONSE: "result": { "content": [ { - "text": "{\"outputMessages\":[\" Branch Remote branch\\n1. main \"],\"sourceCodeUpdates\":[]}", + "text": "{\"errorMessages\":[],\"outputMessages\":[\" Branch Remote branch\\n1. main \"],\"sourceCodeUpdates\":[]}", "type": "text" } ], @@ -269,7 +269,7 @@ RESPONSE: "result": { "content": [ { - "text": "{\"outputMessages\":[\"type MyType = MyConstructor\\n\\nmyTerm : Nat\\nmyTerm = 99\"],\"sourceCodeUpdates\":[]}", + "text": "{\"errorMessages\":[],\"outputMessages\":[\"type MyType = MyConstructor\\n\\nmyTerm : Nat\\nmyTerm = 99\"],\"sourceCodeUpdates\":[]}", "type": "text" } ], @@ -306,7 +306,7 @@ RESPONSE: "result": { "content": [ { - "text": "{\"outputMessages\":[\"1. myFailingTest : [Result]\\n2. myPassingTest : [Result]\\n3. myTerm : Nat\\n4. type MyType\\n5. MyType.MyConstructor : MyType\\n\"],\"sourceCodeUpdates\":[]}", + "text": "{\"errorMessages\":[],\"outputMessages\":[\"1. myFailingTest : [Result]\\n2. myPassingTest : [Result]\\n3. myTerm : Nat\\n4. type MyType\\n5. MyType.MyConstructor : MyType\\n\"],\"sourceCodeUpdates\":[]}", "type": "text" } ], @@ -343,7 +343,7 @@ RESPONSE: "result": { "content": [ { - "text": "{\"outputMessages\":[\"1. myTerm : Nat\\n\"],\"sourceCodeUpdates\":[]}", + "text": "{\"errorMessages\":[],\"outputMessages\":[\"1. myTerm : Nat\\n\"],\"sourceCodeUpdates\":[]}", "type": "text" } ], @@ -412,7 +412,89 @@ RESPONSE: "result": { "content": [ { - "text": "{\"outputMessages\":[\"Cached test results (`help testcache` to learn more)\\n\\n 1. myPassingTest ◉ passing\\n\\n 2. myFailingTest ✗ failing\\n\\n🚫 1 test(s) failing, ✅ 1 test(s) passing\\n\\nTip: Use view 1 to view the source of a test.\"],\"sourceCodeUpdates\":[]}", + "text": "{\"errorMessages\":[],\"outputMessages\":[\"Cached test results (`help testcache` to learn more)\\n\\n 1. myPassingTest ◉ passing\\n\\n 2. myFailingTest ✗ failing\\n\\n🚫 1 test(s) failing, ✅ 1 test(s) passing\\n\\nTip: Use view 1 to view the source of a test.\"],\"sourceCodeUpdates\":[]}", + "type": "text" + } + ], + "isError": false + } + } + +``` + +## update-definitions + +MCP can't edit branches unless they are marked as editable. + +``` api +POST /mcp +BODY: + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "update-definitions", + "arguments": { + "projectContext": { + "projectName": "scratch", + "branchName": "uneditable" + }, "code": {"text": "myTerm = 100"} + } + } + } + +RESPONSE: + { + "id": 1, + "jsonrpc": "2.0", + "result": { + "content": [ + { + "text": "The provided project-branch is not editable.\nPlease ask the user to allow edits to this project in their MCP configuration by adding `--mcp-editable-branches=scratch/uneditable` to the invocation of the Unison mcp within their agent's mcp configuration.", + "type": "text" + } + ], + "isError": true + } + } + +``` + +Transcripts allow mcp-editing on `agent-*` branches. + +``` ucm +scratch/agent-foo> builtins.merge lib.builtins + + Done. +``` + +``` api +POST /mcp +BODY: + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "update-definitions", + "arguments": { + "projectContext": { + "projectName": "scratch", + "branchName": "agent-foo" + }, "code": {"text": "myTerm = 100"} + } + } + } + +RESPONSE: + { + "id": 1, + "jsonrpc": "2.0", + "result": { + "content": [ + { + "text": "{\"errorMessages\":[],\"outputMessages\":[\"Loading changes detected in scratch.u.\",\"+ myTerm : Nat\\n\\nRun `update` to apply these changes to your codebase.\",\"Done.\"],\"sourceCodeUpdates\":[]}", "type": "text" } ], From e52424043093a65a4a4da3ce9ef4ecab2336b844 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Tue, 21 Oct 2025 15:44:15 -0700 Subject: [PATCH 6/9] Allow update to write to the a source file --- unison-cli/src/Unison/CommandLine.hs | 33 +++++++++++++++++++++++ unison-cli/src/Unison/CommandLine/Main.hs | 31 +++------------------ unison-cli/src/Unison/MCP/Cli.hs | 22 ++++++++++----- unison-cli/src/Unison/MCP/Tools.hs | 10 +++---- unison-src/transcripts/idempotent/mcp.md | 4 +-- 5 files changed, 58 insertions(+), 42 deletions(-) diff --git a/unison-cli/src/Unison/CommandLine.hs b/unison-cli/src/Unison/CommandLine.hs index 7385036eb2..63eac74469 100644 --- a/unison-cli/src/Unison/CommandLine.hs +++ b/unison-cli/src/Unison/CommandLine.hs @@ -10,6 +10,10 @@ module Unison.CommandLine parseInput, prompt, reportParseFailure, + + -- * Shared Helpers + defaultLoadSourceFile, + defaultWriteSourceFile, ) where @@ -22,9 +26,12 @@ import Data.Map qualified as Map import Data.Text qualified as Text import Data.Vector qualified as Vector import System.FilePath (takeFileName) +import System.IO.Error (isDoesNotExistError) import Text.Numeral (defaultInflection) import Text.Numeral.Language.ENG qualified as Numeral import Text.Regex.TDFA ((=~)) +import Unison.Cli.Monad (LoadSourceResult) +import Unison.Cli.Monad qualified as Cli import Unison.Codebase (Codebase) import Unison.Codebase.Branch (Branch0) import Unison.Codebase.Branch qualified as Branch @@ -43,6 +50,8 @@ import Unison.Prelude import Unison.PrettyTerminal qualified as PrettyTerm import Unison.Symbol (Symbol) import Unison.Util.Pretty qualified as P +import UnliftIO (catch) +import UnliftIO.Directory qualified as Directory import Prelude hiding (readFile, writeFile) allow :: FilePath -> Bool @@ -51,6 +60,30 @@ allow p = not (".#" `isPrefixOf` takeFileName p) && (isSuffixOf ".u" p || isSuffixOf ".uu" p) +defaultWriteSourceFile :: Text -> Text -> Bool -> IO () +defaultWriteSourceFile fp contents addFold = do + path <- Directory.canonicalizePath (Text.unpack fp) + prependUtf8 + path + if addFold + then contents <> "\n\n---- Anything below this line is ignored by Unison.\n\n" + else contents <> "\n\n" + +defaultLoadSourceFile :: Text -> IO LoadSourceResult +defaultLoadSourceFile fname = + if allow $ Text.unpack fname + then + let handle :: IOException -> IO LoadSourceResult + handle e = + case e of + _ | isDoesNotExistError e -> return Cli.InvalidSourceNameError + _ -> return Cli.LoadError + go = do + contents <- readUtf8 $ Text.unpack fname + return $ Cli.LoadSuccess contents + in catch go handle + else return Cli.InvalidSourceNameError + data ExpansionFailure = TooManyArguments (NonEmpty InputPattern.Argument) | UnexpectedStructuredArgument StructuredArgument diff --git a/unison-cli/src/Unison/CommandLine/Main.hs b/unison-cli/src/Unison/CommandLine/Main.hs index aa9e635697..38949b70de 100644 --- a/unison-cli/src/Unison/CommandLine/Main.hs +++ b/unison-cli/src/Unison/CommandLine/Main.hs @@ -4,7 +4,7 @@ module Unison.CommandLine.Main where import Compat (withInterruptHandler) -import Control.Exception (catch, displayException, mask) +import Control.Exception (displayException, mask) import Control.Lens ((?~)) import Control.Lens.Lens import Crypto.Random qualified as Random @@ -21,7 +21,6 @@ import System.Console.Haskeline qualified as Line import System.Console.Haskeline.History qualified as Line import System.FSNotify qualified as FSNotify import System.IO (hGetEcho, hPutStrLn, hSetEcho, stderr, stdin) -import System.IO.Error (isDoesNotExistError) import U.Codebase.Sqlite.Queries qualified as Queries import Unison.Auth.CredentialManager qualified as AuthN import Unison.Auth.HTTPClient (AuthenticatedHttpClient) @@ -57,7 +56,6 @@ import Unison.Symbol (Symbol) import Unison.Syntax.Parser qualified as Parser import Unison.Util.Pretty qualified as P import UnliftIO qualified -import UnliftIO.Directory qualified as Directory import UnliftIO.STM getUserInput :: @@ -235,20 +233,6 @@ main dir welcome ppIds initialInputs runtime sbRuntime codebase serverBaseUrl uc pp getProjectRoot (loopState ^. #numberedArgs) - let loadSourceFile :: Text -> IO Cli.LoadSourceResult - loadSourceFile fname = - if allow $ Text.unpack fname - then - let handle :: IOException -> IO Cli.LoadSourceResult - handle e = - case e of - _ | isDoesNotExistError e -> return Cli.InvalidSourceNameError - _ -> return Cli.LoadError - go = do - contents <- readUtf8 $ Text.unpack fname - return $ Cli.LoadSuccess contents - in catch go handle - else return Cli.InvalidSourceNameError let notify :: Output -> IO () notify = notifyUser (pure dir) fetchIssueFromGitHub @@ -282,23 +266,14 @@ main dir welcome ppIds initialInputs runtime sbRuntime codebase serverBaseUrl uc ] action - let writeSource :: Text -> Text -> Bool -> IO () - writeSource fp contents addFold = do - path <- Directory.canonicalizePath (Text.unpack fp) - prependUtf8 - path - if addFold - then contents <> "\n\n---- Anything below this line is ignored by Unison.\n\n" - else contents <> "\n\n" - let env = Cli.Env { authHTTPClient, codebase, credentialManager, - loadSource = loadSourceFile, + loadSource = defaultLoadSourceFile, lspCheckForChanges, - writeSource, + writeSource = defaultWriteSourceFile, generateUniqueName = Parser.uniqueBase32Namegen <$> Random.getSystemDRG, notify, notifyNumbered = \o -> diff --git a/unison-cli/src/Unison/MCP/Cli.hs b/unison-cli/src/Unison/MCP/Cli.hs index 82f52720bd..21cce0f29f 100644 --- a/unison-cli/src/Unison/MCP/Cli.hs +++ b/unison-cli/src/Unison/MCP/Cli.hs @@ -2,6 +2,7 @@ module Unison.MCP.Cli ( handleInputMCP, ppForProjectContext, cliToMCP, + virtualSourceName, ) where @@ -22,6 +23,7 @@ import Unison.Codebase.Editor.Input (Event, Input) import Unison.Codebase.Editor.Output qualified as Output import Unison.Codebase.Path qualified as Path import Unison.Codebase.ProjectPath qualified as PP +import Unison.CommandLine (defaultLoadSourceFile, defaultWriteSourceFile) import Unison.CommandLine.OutputMessages qualified as Output import Unison.MCP.Types import Unison.MCP.Types qualified as MCP @@ -32,6 +34,9 @@ import Unison.Util.Pretty qualified as Pretty import UnliftIO.STM import Prelude hiding (readFile, writeFile) +virtualSourceName :: Text +virtualSourceName = "" + data CliOutput = CliOutput { sourceCodeUpdates :: [Text], outputMessages :: [Text], @@ -108,13 +113,16 @@ cliToMCP projCtx onError cli = do atomically $ modifyTVar outputVar (<> Seq.singleton pretty) pure nargs - let loadSource = error "loadSource is not implemented for the MCP server." - let writeSource _sourceName content replace = do - if replace - then do - atomically $ writeTVar sourceCodeUpdatesVar (Seq.singleton content) + let writeSource sourceName content replace = do + if sourceName == virtualSourceName + then + if replace + then do + atomically $ writeTVar sourceCodeUpdatesVar (Seq.singleton content) + else do + atomically $ modifyTVar sourceCodeUpdatesVar (<> Seq.singleton content) else do - atomically $ modifyTVar sourceCodeUpdatesVar (<> Seq.singleton content) + defaultWriteSourceFile sourceName content replace seedRef <- liftIO $ newIORef (0 :: Int) let cliEnv = @@ -125,7 +133,7 @@ cliToMCP projCtx onError cli = do generateUniqueName = do i <- atomicModifyIORef' seedRef \i -> let !i' = i + 1 in (i', i) pure (Parser.uniqueBase32Namegen (Random.drgNewSeed (Random.seedFromInteger (fromIntegral i)))), - loadSource, + loadSource = defaultLoadSourceFile, lspCheckForChanges = \_ -> pure (), writeSource, notify, diff --git a/unison-cli/src/Unison/MCP/Tools.hs b/unison-cli/src/Unison/MCP/Tools.hs index 820ea12a40..54e08a0f5f 100644 --- a/unison-cli/src/Unison/MCP/Tools.hs +++ b/unison-cli/src/Unison/MCP/Tools.hs @@ -21,7 +21,7 @@ import Unison.Codebase.Path qualified as Path import Unison.Codebase.ProjectPath import Unison.Core.Project (ProjectBranchName (..), ProjectName (..)) import Unison.HashQualified qualified as HQ -import Unison.MCP.Cli (cliToMCP, handleInputMCP) +import Unison.MCP.Cli (cliToMCP, handleInputMCP, virtualSourceName) import Unison.MCP.Share.API (ReadmeResponse (..)) import Unison.MCP.Share.API qualified as Share import Unison.MCP.Types @@ -121,10 +121,10 @@ shareProjectSearchTool = -- | Load and typecheck the provided code, THEN run the provided inputs within that scratchfile context. withCode :: Either FilePath Text -> [Input] -> ProjectContext -> EMCP CallToolResult withCode code inputs projectContext = do - source <- case code of - Left filePath -> liftIO $ readUtf8 filePath - Right codeSnippet -> pure codeSnippet - output <- handleInputMCP projectContext ([Left $ UnisonFileChanged "scratch.u" source] <> (Right <$> inputs)) + (filePath, source) <- case code of + Left filePath -> (Text.pack filePath,) <$> liftIO (readUtf8 filePath) + Right codeSnippet -> pure (virtualSourceName, codeSnippet) + output <- handleInputMCP projectContext ([Left $ UnisonFileChanged filePath source] <> (Right <$> inputs)) let outputJSON = Text.decodeUtf8 . BL.toStrict $ Aeson.encode output pure $ textToolResult outputJSON diff --git a/unison-src/transcripts/idempotent/mcp.md b/unison-src/transcripts/idempotent/mcp.md index 631d1293f8..746a737d0e 100644 --- a/unison-src/transcripts/idempotent/mcp.md +++ b/unison-src/transcripts/idempotent/mcp.md @@ -87,7 +87,7 @@ RESPONSE: "result": { "content": [ { - "text": "{\"errorMessages\":[],\"outputMessages\":[\"Loading changes detected in scratch.u.\",\"No changes found.\",\" 1 | > x = 1 + 2\\n ⧩\\n 3\"],\"sourceCodeUpdates\":[]}", + "text": "{\"errorMessages\":[],\"outputMessages\":[\"Loading changes detected in .\",\"No changes found.\",\" 1 | > x = 1 + 2\\n ⧩\\n 3\"],\"sourceCodeUpdates\":[]}", "type": "text" } ], @@ -494,7 +494,7 @@ RESPONSE: "result": { "content": [ { - "text": "{\"errorMessages\":[],\"outputMessages\":[\"Loading changes detected in scratch.u.\",\"+ myTerm : Nat\\n\\nRun `update` to apply these changes to your codebase.\",\"Done.\"],\"sourceCodeUpdates\":[]}", + "text": "{\"errorMessages\":[],\"outputMessages\":[\"Loading changes detected in .\",\"+ myTerm : Nat\\n\\nRun `update` to apply these changes to your codebase.\",\"Done.\"],\"sourceCodeUpdates\":[]}", "type": "text" } ], From cda78b2fb3cc9bf06ad5184b525490680836dd65 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Fri, 24 Oct 2025 11:24:11 -0700 Subject: [PATCH 7/9] Cleanup from removing mcp-editable flags --- unison-cli/src/Unison/MCP/Cli.hs | 4 ++-- unison-cli/src/Unison/MCP/Tools.hs | 4 ---- unison-cli/src/Unison/Main.hs | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/unison-cli/src/Unison/MCP/Cli.hs b/unison-cli/src/Unison/MCP/Cli.hs index 21cce0f29f..885e752222 100644 --- a/unison-cli/src/Unison/MCP/Cli.hs +++ b/unison-cli/src/Unison/MCP/Cli.hs @@ -105,7 +105,7 @@ cliToMCP projCtx onError cli = do if (Output.isFailure output) then do atomically $ modifyTVar errorsVar (<> Seq.singleton pretty) - liftIO $ onError (Text.pack (Pretty.toPlain 0 pretty)) + liftIO $ onError (Pretty.toPlain 0 pretty) else do atomically $ modifyTVar outputVar (<> Seq.singleton pretty) let notifyNumbered output = do @@ -159,7 +159,7 @@ cliToMCP projCtx onError cli = do & toList let errorMessages = errs - & fmap (Text.pack . Pretty.toPlain 0) + & fmap (Pretty.toPlain 0) & toList pure $ ( CliOutput diff --git a/unison-cli/src/Unison/MCP/Tools.hs b/unison-cli/src/Unison/MCP/Tools.hs index 54e08a0f5f..3d66809835 100644 --- a/unison-cli/src/Unison/MCP/Tools.hs +++ b/unison-cli/src/Unison/MCP/Tools.hs @@ -334,10 +334,6 @@ updateTool = }, toolArgType = Proxy, toolHandler = \(UpdateDefinitionsToolArguments {projectContext, code}) -> handleToolError $ do - Env {isEditable} <- ask - when (not $ isEditable projectContext) $ - let example = "--mcp-editable-branches=" <> into @Text projectContext.projectName <> "/" <> into @Text projectContext.branchName - in throwError $ "The provided project-branch is not editable.\nPlease ask the user to allow edits to this project in their MCP configuration by adding `" <> example <> "` to the invocation of the Unison mcp within their agent's mcp configuration." withCode code [Input.Update2I] projectContext } diff --git a/unison-cli/src/Unison/Main.hs b/unison-cli/src/Unison/Main.hs index 6334a3cc50..37467c83b7 100644 --- a/unison-cli/src/Unison/Main.hs +++ b/unison-cli/src/Unison/Main.hs @@ -140,7 +140,7 @@ main version = do progName <- getProgName -- hSetBuffering stdout NoBuffering -- cool (renderUsageInfo, globalOptions, command) <- parseCLIArgs progName (Text.unpack (Version.gitDescribeWithDate version)) - let GlobalOptions {codebasePathOption = mCodePathOption, exitOption, lspFormattingConfig, mcpEditableBranches} = globalOptions + let GlobalOptions {codebasePathOption = mCodePathOption, exitOption, lspFormattingConfig} = globalOptions currentDir <- getCurrentDirectory case command of PrintVersion -> From c234652b9ada32ca13502370e938f7f48cf2db6c Mon Sep 17 00:00:00 2001 From: ChrisPenner <6439644+ChrisPenner@users.noreply.github.com> Date: Fri, 24 Oct 2025 18:27:44 +0000 Subject: [PATCH 8/9] automatically run ormolu --- unison-cli/src/Unison/MCP/Tools.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/unison-cli/src/Unison/MCP/Tools.hs b/unison-cli/src/Unison/MCP/Tools.hs index 3d66809835..26f41bf998 100644 --- a/unison-cli/src/Unison/MCP/Tools.hs +++ b/unison-cli/src/Unison/MCP/Tools.hs @@ -337,7 +337,6 @@ updateTool = withCode code [Input.Update2I] projectContext } - listLocalProjectsTool :: Tool MCP listLocalProjectsTool = Tool From 93f7a43a69ad80cbb9d1c05cbea7aea83a3cdadb Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Fri, 24 Oct 2025 11:27:07 -0700 Subject: [PATCH 9/9] Update transcripts --- unison-src/transcripts/idempotent/mcp.md | 43 ++---------------------- 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/unison-src/transcripts/idempotent/mcp.md b/unison-src/transcripts/idempotent/mcp.md index 746a737d0e..4ae3accdd2 100644 --- a/unison-src/transcripts/idempotent/mcp.md +++ b/unison-src/transcripts/idempotent/mcp.md @@ -424,47 +424,8 @@ RESPONSE: ## update-definitions -MCP can't edit branches unless they are marked as editable. - -``` api -POST /mcp -BODY: - { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "update-definitions", - "arguments": { - "projectContext": { - "projectName": "scratch", - "branchName": "uneditable" - }, "code": {"text": "myTerm = 100"} - } - } - } - -RESPONSE: - { - "id": 1, - "jsonrpc": "2.0", - "result": { - "content": [ - { - "text": "The provided project-branch is not editable.\nPlease ask the user to allow edits to this project in their MCP configuration by adding `--mcp-editable-branches=scratch/uneditable` to the invocation of the Unison mcp within their agent's mcp configuration.", - "type": "text" - } - ], - "isError": true - } - } - -``` - -Transcripts allow mcp-editing on `agent-*` branches. - ``` ucm -scratch/agent-foo> builtins.merge lib.builtins +scratch/foo> builtins.merge lib.builtins Done. ``` @@ -481,7 +442,7 @@ BODY: "arguments": { "projectContext": { "projectName": "scratch", - "branchName": "agent-foo" + "branchName": "foo" }, "code": {"text": "myTerm = 100"} } }