From 0edf72ef469204ee960049074aa8c2e287c8fa52 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Mon, 3 Mar 2025 21:42:16 -0800 Subject: [PATCH 01/22] Chat demo --- .../MCPClientChat.xcodeproj/project.pbxproj | 606 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 50 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 58 ++ .../Assets.xcassets/Contents.json | 6 + .../Chat/Models/ChatManager.swift | 16 + .../Chat/Models/ChatMessage.swift | 28 + .../Chat/Models/ChatNonStreamManager.swift | 182 ++++++ .../MCPClientChat/Chat/UI/ChatInputView.swift | 81 +++ .../Chat/UI/ChatMessageView.swift | 86 +++ .../MCPClientChat/Chat/UI/ChatView.swift | 56 ++ .../MCPClientChat/ContentView.swift | 26 + .../MCP/Clients/GithubMCPClient.swift | 38 ++ .../MCP/Interface/MCPLLMClient.swift | 112 ++++ ...MCPInterfaceTool+MessagePrameterTool.swift | 428 +++++++++++++ .../MCPClientChat/MCPClientChat.entitlements | 10 + .../MCPClientChat/MCPClientChatApp.swift | 31 + .../Preview Assets.xcassets/Contents.json | 6 + .../MCPClientChatTests.swift | 17 + .../MCPClientChatUITests.swift | 43 ++ .../MCPClientChatUITestsLaunchTests.swift | 33 + 22 files changed, 1931 insertions(+) create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.pbxproj create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/Assets.xcassets/Contents.json create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatMessage.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatNonStreamManager.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatInputView.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/ContentView.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Interface/MCPLLMClient.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/MCPInterfaceTool+MessagePrameterTool.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChat.entitlements create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChatTests/MCPClientChatTests.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITests.swift create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITestsLaunchTests.swift diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.pbxproj b/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4c43723 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.pbxproj @@ -0,0 +1,606 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 7BDD62DB2D764B3D00E18088 /* SwiftAnthropic in Frameworks */ = {isa = PBXBuildFile; productRef = 7BDD62DA2D764B3D00E18088 /* SwiftAnthropic */; }; + 7BDD62ED2D764B7C00E18088 /* MCPClient in Frameworks */ = {isa = PBXBuildFile; productRef = 7BDD62EC2D764B7C00E18088 /* MCPClient */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 7BDD62BB2D764A4C00E18088 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7BDD62A12D764A4A00E18088 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7BDD62A82D764A4A00E18088; + remoteInfo = MCPClientChat; + }; + 7BDD62C52D764A4C00E18088 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7BDD62A12D764A4A00E18088 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7BDD62A82D764A4A00E18088; + remoteInfo = MCPClientChat; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 7BDD62A92D764A4A00E18088 /* MCPClientChat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MCPClientChat.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7BDD62BA2D764A4C00E18088 /* MCPClientChatTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MCPClientChatTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7BDD62C42D764A4C00E18088 /* MCPClientChatUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MCPClientChatUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 7BDD62AB2D764A4A00E18088 /* MCPClientChat */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = MCPClientChat; + sourceTree = ""; + }; + 7BDD62BD2D764A4C00E18088 /* MCPClientChatTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = MCPClientChatTests; + sourceTree = ""; + }; + 7BDD62C72D764A4C00E18088 /* MCPClientChatUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = MCPClientChatUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7BDD62A62D764A4A00E18088 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7BDD62ED2D764B7C00E18088 /* MCPClient in Frameworks */, + 7BDD62DB2D764B3D00E18088 /* SwiftAnthropic in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7BDD62B72D764A4C00E18088 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7BDD62C12D764A4C00E18088 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 7BDD62A02D764A4A00E18088 = { + isa = PBXGroup; + children = ( + 7BDD62AB2D764A4A00E18088 /* MCPClientChat */, + 7BDD62BD2D764A4C00E18088 /* MCPClientChatTests */, + 7BDD62C72D764A4C00E18088 /* MCPClientChatUITests */, + 7BDD62D92D764B3D00E18088 /* Frameworks */, + 7BDD62AA2D764A4A00E18088 /* Products */, + ); + sourceTree = ""; + }; + 7BDD62AA2D764A4A00E18088 /* Products */ = { + isa = PBXGroup; + children = ( + 7BDD62A92D764A4A00E18088 /* MCPClientChat.app */, + 7BDD62BA2D764A4C00E18088 /* MCPClientChatTests.xctest */, + 7BDD62C42D764A4C00E18088 /* MCPClientChatUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 7BDD62D92D764B3D00E18088 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7BDD62A82D764A4A00E18088 /* MCPClientChat */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7BDD62CE2D764A4C00E18088 /* Build configuration list for PBXNativeTarget "MCPClientChat" */; + buildPhases = ( + 7BDD62A52D764A4A00E18088 /* Sources */, + 7BDD62A62D764A4A00E18088 /* Frameworks */, + 7BDD62A72D764A4A00E18088 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7BDD62AB2D764A4A00E18088 /* MCPClientChat */, + ); + name = MCPClientChat; + packageProductDependencies = ( + 7BDD62DA2D764B3D00E18088 /* SwiftAnthropic */, + 7BDD62EC2D764B7C00E18088 /* MCPClient */, + ); + productName = MCPClientChat; + productReference = 7BDD62A92D764A4A00E18088 /* MCPClientChat.app */; + productType = "com.apple.product-type.application"; + }; + 7BDD62B92D764A4C00E18088 /* MCPClientChatTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7BDD62D12D764A4C00E18088 /* Build configuration list for PBXNativeTarget "MCPClientChatTests" */; + buildPhases = ( + 7BDD62B62D764A4C00E18088 /* Sources */, + 7BDD62B72D764A4C00E18088 /* Frameworks */, + 7BDD62B82D764A4C00E18088 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7BDD62BC2D764A4C00E18088 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 7BDD62BD2D764A4C00E18088 /* MCPClientChatTests */, + ); + name = MCPClientChatTests; + packageProductDependencies = ( + ); + productName = MCPClientChatTests; + productReference = 7BDD62BA2D764A4C00E18088 /* MCPClientChatTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 7BDD62C32D764A4C00E18088 /* MCPClientChatUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7BDD62D42D764A4C00E18088 /* Build configuration list for PBXNativeTarget "MCPClientChatUITests" */; + buildPhases = ( + 7BDD62C02D764A4C00E18088 /* Sources */, + 7BDD62C12D764A4C00E18088 /* Frameworks */, + 7BDD62C22D764A4C00E18088 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7BDD62C62D764A4C00E18088 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 7BDD62C72D764A4C00E18088 /* MCPClientChatUITests */, + ); + name = MCPClientChatUITests; + packageProductDependencies = ( + ); + productName = MCPClientChatUITests; + productReference = 7BDD62C42D764A4C00E18088 /* MCPClientChatUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7BDD62A12D764A4A00E18088 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + 7BDD62A82D764A4A00E18088 = { + CreatedOnToolsVersion = 16.0; + }; + 7BDD62B92D764A4C00E18088 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = 7BDD62A82D764A4A00E18088; + }; + 7BDD62C32D764A4C00E18088 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = 7BDD62A82D764A4A00E18088; + }; + }; + }; + buildConfigurationList = 7BDD62A42D764A4A00E18088 /* Build configuration list for PBXProject "MCPClientChat" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7BDD62A02D764A4A00E18088; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 7BDD62D72D764ADA00E18088 /* XCRemoteSwiftPackageReference "SwiftAnthropic" */, + 7BDD62D82D764B1800E18088 /* XCLocalSwiftPackageReference "../../../mcp-swift-sdk" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 7BDD62AA2D764A4A00E18088 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7BDD62A82D764A4A00E18088 /* MCPClientChat */, + 7BDD62B92D764A4C00E18088 /* MCPClientChatTests */, + 7BDD62C32D764A4C00E18088 /* MCPClientChatUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7BDD62A72D764A4A00E18088 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7BDD62B82D764A4C00E18088 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7BDD62C22D764A4C00E18088 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7BDD62A52D764A4A00E18088 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7BDD62B62D764A4C00E18088 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7BDD62C02D764A4C00E18088 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 7BDD62BC2D764A4C00E18088 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7BDD62A82D764A4A00E18088 /* MCPClientChat */; + targetProxy = 7BDD62BB2D764A4C00E18088 /* PBXContainerItemProxy */; + }; + 7BDD62C62D764A4C00E18088 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7BDD62A82D764A4A00E18088 /* MCPClientChat */; + targetProxy = 7BDD62C52D764A4C00E18088 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 7BDD62CC2D764A4C00E18088 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 7BDD62CD2D764A4C00E18088 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 7BDD62CF2D764A4C00E18088 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = MCPClientChat/MCPClientChat.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"MCPClientChat/Preview Content\""; + DEVELOPMENT_TEAM = CQ45U4X9K3; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = jamesRochabrun.MCPClientChat; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 7BDD62D02D764A4C00E18088 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = MCPClientChat/MCPClientChat.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"MCPClientChat/Preview Content\""; + DEVELOPMENT_TEAM = CQ45U4X9K3; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = jamesRochabrun.MCPClientChat; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 7BDD62D22D764A4C00E18088 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = CQ45U4X9K3; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = jamesRochabrun.MCPClientChatTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MCPClientChat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MCPClientChat"; + }; + name = Debug; + }; + 7BDD62D32D764A4C00E18088 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = CQ45U4X9K3; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = jamesRochabrun.MCPClientChatTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MCPClientChat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MCPClientChat"; + }; + name = Release; + }; + 7BDD62D52D764A4C00E18088 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = CQ45U4X9K3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = jamesRochabrun.MCPClientChatUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = MCPClientChat; + }; + name = Debug; + }; + 7BDD62D62D764A4C00E18088 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = CQ45U4X9K3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = jamesRochabrun.MCPClientChatUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = MCPClientChat; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7BDD62A42D764A4A00E18088 /* Build configuration list for PBXProject "MCPClientChat" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7BDD62CC2D764A4C00E18088 /* Debug */, + 7BDD62CD2D764A4C00E18088 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7BDD62CE2D764A4C00E18088 /* Build configuration list for PBXNativeTarget "MCPClientChat" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7BDD62CF2D764A4C00E18088 /* Debug */, + 7BDD62D02D764A4C00E18088 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7BDD62D12D764A4C00E18088 /* Build configuration list for PBXNativeTarget "MCPClientChatTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7BDD62D22D764A4C00E18088 /* Debug */, + 7BDD62D32D764A4C00E18088 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7BDD62D42D764A4C00E18088 /* Build configuration list for PBXNativeTarget "MCPClientChatUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7BDD62D52D764A4C00E18088 /* Debug */, + 7BDD62D62D764A4C00E18088 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 7BDD62D82D764B1800E18088 /* XCLocalSwiftPackageReference "../../../mcp-swift-sdk" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../mcp-swift-sdk"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 7BDD62D72D764ADA00E18088 /* XCRemoteSwiftPackageReference "SwiftAnthropic" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jamesrochabrun/SwiftAnthropic"; + requirement = { + branch = main; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 7BDD62DA2D764B3D00E18088 /* SwiftAnthropic */ = { + isa = XCSwiftPackageProductDependency; + package = 7BDD62D72D764ADA00E18088 /* XCRemoteSwiftPackageReference "SwiftAnthropic" */; + productName = SwiftAnthropic; + }; + 7BDD62EC2D764B7C00E18088 /* MCPClient */ = { + isa = XCSwiftPackageProductDependency; + package = 7BDD62D82D764B1800E18088 /* XCLocalSwiftPackageReference "../../../mcp-swift-sdk" */; + productName = MCPClient; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 7BDD62A12D764A4A00E18088 /* Project object */; +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..633f190 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,50 @@ +{ + "originHash" : "c14633a92d8709e70ab430e279f2e2d6f4a5285ccfc4da7998de3ef25a2721e7", + "pins" : [ + { + "identity" : "jsonrpc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/JSONRPC", + "state" : { + "revision" : "ef61a695bafa0e07080dadac65a0c59b37880548" + } + }, + { + "identity" : "swift-json-schema", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ajevans99/swift-json-schema", + "state" : { + "revision" : "707fc4c2d53138e8fbb4ee9e6669b050ba4b2239", + "version" : "0.3.2" + } + }, + { + "identity" : "swift-memberwise-init-macro", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gohanlon/swift-memberwise-init-macro", + "state" : { + "revision" : "21ca6d8c8f9e4ce27e92f82da294e0d91953b8b6", + "version" : "0.5.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swiftanthropic", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jamesrochabrun/SwiftAnthropic", + "state" : { + "branch" : "main", + "revision" : "e71ff4df0a2ab4e85fb492f02322980858a8157c" + } + } + ], + "version" : 3 +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Assets.xcassets/AccentColor.colorset/Contents.json b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Assets.xcassets/AppIcon.appiconset/Contents.json b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Assets.xcassets/Contents.json b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift new file mode 100644 index 0000000..cd478e4 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift @@ -0,0 +1,16 @@ +// +// ChatManager.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/3/25. +// + +import Foundation + +@MainActor +protocol ChatManager { + var messages: [ChatMessage] { get set } + var isProcessing: Bool { get } + func stop() + func send(message: ChatMessage) +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatMessage.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatMessage.swift new file mode 100644 index 0000000..3d9718d --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatMessage.swift @@ -0,0 +1,28 @@ +// +// ChatMessage.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/3/25. +// + +import Foundation + +/// Data model to represent a chat message +struct ChatMessage: Identifiable, Equatable { + /// Unique identifier + let id = UUID() + + /// The body of the chat message + var text: String + + /// The role of the message + let role: Role + + /// Indicates that we are waiting for the first bit of message content from OpenAI + var isWaitingForFirstText = false + + enum Role { + case user + case assistant + } +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatNonStreamManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatNonStreamManager.swift new file mode 100644 index 0000000..d5a3897 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatNonStreamManager.swift @@ -0,0 +1,182 @@ +// +// ChatNonStreamModel.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/3/25. +// + +import Foundation +import SwiftUI +import SwiftAnthropic + +@MainActor +@Observable +// Handle a chat conversation without stream. +final class ChatNonStreamManager: ChatManager { + + /// Messages sent from the user or received from Claude + var messages = [ChatMessage]() + + /// Service to communicate with Anthropic API + private let service: AnthropicService + + /// Message history for Claude's context + private var anthropicMessages: [MessageParameter.Message] = [] + + /// Current task handling Claude API request + private var task: Task? = nil + + /// Error message if something goes wrong + var errorMessage: String = "" + + /// Loading state indicator + var isLoading = false + + /// Web research client for tool use + private let mcpLLMClient: MCPLLMClient + + init( + service: AnthropicService, + mcpLLMClient: MCPLLMClient) + { + self.service = service + self.mcpLLMClient = mcpLLMClient + } + + /// Returns true if Claude is still processing a response + var isProcessing: Bool { + return isLoading + } + + /// Send a new message to Claude and get the complete response + func send(message: ChatMessage) { + self.messages.append(message) + self.processUserMessage(prompt: message.text) + } + + /// Cancel the current processing task + func stop() { + self.task?.cancel() + self.task = nil + self.isLoading = false + } + + private func processUserMessage(prompt: String) { + // Add a placeholder for Claude's response + self.messages.append(ChatMessage(text: "", role: .assistant, isWaitingForFirstText: true)) + + // Add user message to history + anthropicMessages.append(MessageParameter.Message( + role: .user, + content: .text(prompt) + )) + + task = Task { + do { + isLoading = true + + // Get available tools from MCP + let tools = try await mcpLLMClient.tools() + + // Send request and process response + try await continueConversation(tools: tools) + + isLoading = false + } catch { + errorMessage = "\(error)" + + // Update UI to show error + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = "Sorry, there was an error: \(error.localizedDescription)" + messages.append(last) + } + + isLoading = false + } + } + } + + private func continueConversation(tools: [MessageParameter.Tool]) async throws { + let parameters = MessageParameter( + model: .claude37Sonnet, + messages: anthropicMessages, + maxTokens: 10000, + tools: tools + ) + + // Make non-streaming request to Claude + let message = try await service.createMessage(parameters) + + // Process all content elements with a for loop + for contentItem in message.content { + switch contentItem { + case .text(let text, _): + // Update the UI with the response + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = text + messages.append(last) + } + + // Add assistant response to history + anthropicMessages.append(MessageParameter.Message( + role: .assistant, + content: .text(text) + )) + + case .toolUse(let tool): + print("Tool use detected - Name: \(tool.name), ID: \(tool.id)") + + // Update UI to show tool use + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text += "\n Using tool: \(tool.name)..." + messages.append(last) + } + + // Add the assistant message with tool use to message history + anthropicMessages.append(MessageParameter.Message( + role: .assistant, + content: .list([.toolUse(tool.id, tool.name, tool.input)]) + )) + + // Call tool via MCP + let toolResponse = await mcpLLMClient.callTool(name: tool.name, input: tool.input, debug: true) + print("Tool response: \(String(describing: toolResponse))") + + // Add tool result to conversation + if let toolResult = toolResponse { + // Add the assistant message with tool result + anthropicMessages.append(MessageParameter.Message( + role: .user, + content: .list([.toolResult(tool.id, toolResult)]) + )) + + // Now get a new response with the tool result + try await continueConversation(tools: tools) + } else { + // Handle tool failure + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = "There was an error using the tool \(tool.name)." + messages.append(last) + } + } + + case .thinking(_): + break + } + } + } + + /// Clear the conversation + func clearConversation() { + messages.removeAll() + anthropicMessages.removeAll() + errorMessage = "" + isLoading = false + task?.cancel() + task = nil + } +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatInputView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatInputView.swift new file mode 100644 index 0000000..fdb092d --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatInputView.swift @@ -0,0 +1,81 @@ +// +// ChatInputView.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/3/25. +// + +import SwiftUI + +/// A view for the user to enter chat messages +struct ChatInputView: View { + + private enum FocusedField { + case newMessageText + } + + /// Is a streaming chat response in progress + let isStreamingResponse: Bool + + /// Callback invoked when the user taps the submit button or presses return + var didSubmit: (String) -> Void + + /// Callback invoked when the user taps on the stop button + var didTapStop: () -> Void + + /// State to collect new text messages + @State private var newMessageText: String = "" + @FocusState private var focusedField: FocusedField? + + var body: some View { + HStack(spacing:0){ + chatInputTextField + actionButton + } + .padding(8) + } + + private var chatInputTextField: some View { + TextField("Type a message", text: $newMessageText, axis: .vertical) + .focused($focusedField, equals: .newMessageText) + .scrollContentBackground(.hidden) + .lineLimit(5) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius:30) + .stroke(.separator) + ) + .onAppear { + focusedField = .newMessageText + } + .onSubmit { + didSubmit(newMessageText) + newMessageText = "" + } + } + + private var actionButton: some View { + Button { + if isStreamingResponse { + didTapStop() + } else { + didSubmit(newMessageText) + newMessageText = "" + } + } label:{ + Image(systemName: isStreamingResponse ? "stop.circle.fill" : "arrow.up.circle.fill") + .font(.title) + .foregroundColor((isStreamingResponse || !newMessageText.isEmpty) ? .primary : .secondary) + .frame(width:40, height:40) + } + .buttonStyle(.plain) + .contentTransition(.symbolEffect(.replace)) + .padding(.horizontal, 8) + } +} + +#Preview { + ChatInputView(isStreamingResponse: false, didSubmit: { _ in }, didTapStop: { }) +} + diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift new file mode 100644 index 0000000..548716a --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift @@ -0,0 +1,86 @@ +// +// ChatMessageView.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/3/25. +// + +import Foundation +import SwiftUI + +struct ChatMessageView: View { + + /// The message to display + let message: ChatMessage + + /// Whether to animate in the chat bubble + let animateIn: Bool + + /// State used to animate in the chat bubble if `animateIn` is true + @State private var animationTrigger = false + + var body: some View { + HStack(alignment: .top, spacing: 12) { + chatIcon + VStack(alignment: .leading) { + chatName + chatBody + } + } + .opacity(bubbleOpacity) + .animation(.easeIn(duration: 0.75), value: animationTrigger) + .onAppear { + adjustAnimationTriggerIfNecessary() + } + } + + private var bubbleOpacity: Double { + guard animateIn else { + return 1 + } + return animationTrigger ? 1 : 0 + } + + private func adjustAnimationTriggerIfNecessary() { + guard animateIn else { + return + } + animationTrigger = true + } + + private var chatIcon: some View { + Image(systemName: message.role == .user ? "person.circle.fill" : "lightbulb.circle") + .font(.title2) + .frame(width:24, height:24) + .foregroundColor(message.role == .user ? .primary : .orange) + } + + private var chatName: some View { + Text(message.role == .user ? "You" : "Claude") + .fontWeight(.bold) + .frame(maxWidth: .infinity, maxHeight:24, alignment: .leading) + } + + @ViewBuilder + private var chatBody: some View { + if message.role == .user { + Text(LocalizedStringKey(message.text)) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.primary) + } else { + if message.isWaitingForFirstText { + ProgressView() + } else { + Text(LocalizedStringKey(message.text)) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.primary) + } + } + } +} + +#Preview { + ChatMessageView(message: ChatMessage(text: "hello", role: .user), animateIn: false) + .frame(maxWidth:.infinity) + .padding() +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift new file mode 100644 index 0000000..0043b63 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift @@ -0,0 +1,56 @@ +// +// ChatView.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/3/25. +// + +import Foundation +import SwiftUI + +@MainActor +struct ChatView: View { + + let chatManager: ChatManager + + var body: some View { + List { + ChatMessagesView(chatMessages: chatManager.messages) + .padding([.top, .leading, .trailing, .bottom]) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .safeAreaInset(edge: .bottom) { + ChatInputView(isStreamingResponse: chatManager.isProcessing, + didSubmit: { sendMessage($0) }, + didTapStop: { chatManager.stop() }) + } + } + + private func sendMessage(_ message: String) { + guard !message.isEmpty else { return } + chatManager.send(message: ChatMessage(text: message, role: .user)) + } +} + +private struct ChatMessagesView: View { + /// Flags to prevent messages from animating in multiple times as dependencies that drive `body` change + @State private var shouldAnimateMessageIn = [UUID: Bool]() + let chatMessages: [ChatMessage] + + var body: some View { + VStack(alignment: .leading) { + ChatMessageView(message: ChatMessage(text: "How can I help you?", role: .assistant), animateIn: true) + .listRowSeparator(.hidden) + + ForEach(chatMessages) { message in + ChatMessageView(message: message, animateIn: shouldAnimateMessageIn[message.id] ?? true) + .listRowSeparator(.hidden) + .transition(.opacity) + .onAppear { + shouldAnimateMessageIn[message.id] = false + } + } + } + } +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/ContentView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/ContentView.swift new file mode 100644 index 0000000..70ee1dc --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/ContentView.swift @@ -0,0 +1,26 @@ +// +// ContentView.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/3/25. +// + +import SwiftUI +import SwiftAnthropic +import MCPClient + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift new file mode 100644 index 0000000..f52dd37 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift @@ -0,0 +1,38 @@ +// +// GithubMCPClient.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/3/25. +// + +import Foundation +import MCPClient +import SwiftUI + +final class GIthubMCPClient: MCPLLMClient { + + /// Intentionally force unwrapping for demo, if cleint is nil this demo does not have any purpose. + var client: MCPClient? + + init() { + Task { + do { + var customEnv = ProcessInfo.processInfo.environment + customEnv["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + (customEnv["PATH"] ?? "") + // customEnv["GITHUB_PERSONAL_ACCESS_TOKEN"] = "YOUR_GITHUB_PERSONAL_TOKEN" // Needed for write operations. + self.client = try await MCPClient( + info: .init(name: "GIthubMCPClient", version: "1.0.0"), + transport: .stdioProcess( + "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + env: customEnv, + verbose: true + ), + capabilities: .init() + ) + } catch { + print("Failed to initialize MCPClient: \(error)") + } + } + } +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Interface/MCPLLMClient.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Interface/MCPLLMClient.swift new file mode 100644 index 0000000..e5bf5d7 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Interface/MCPLLMClient.swift @@ -0,0 +1,112 @@ +// +// MCPLLMClient.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/3/25. +// + +import Foundation +import MCPClient +import MCPInterface +import SwiftAnthropic + +// TODO: James+Gui decide where this should live so it can be reused. + +/** + * Protocol that bridges the MCP (Multi-Client Protocol) framework with LLM providers library. + * + * This protocol provides methods to: + * 1. Retrieve available tools from an MCP client and convert them to Anthropic's format + * 2. Execute tools with provided parameters and handle their responses + */ +protocol MCPLLMClient { + + /// The underlying MCP client that processes requests + var client: MCPClient? { get } + /** + * Retrieves available tools from the MCP client and converts them to Anthropic's tool format. + * + * - Returns: An array of Anthropic-compatible tools + * - Throws: Errors from the underlying MCP client or during conversion process + */ + func tools() async throws -> [MessageParameter.Tool] + /** + * Executes a tool with the specified name and input parameters. + * + * - Parameters: + * - name: The identifier of the tool to call + * - input: Dictionary of parameters to pass to the tool + * - debug: Flag to enable verbose logging during execution + * - Returns: A string containing the tool's response, or `nil` if execution failed + */ + func callTool(name: String, input: [String: Any], debug: Bool) async -> String? +} + +/** + * Extension providing default implementations of the MCPSwiftAnthropicClient protocol. + */ +extension MCPLLMClient { + + func tools() async throws -> [MessageParameter.Tool] { + guard let client else { return [] } + let tools = await client.tools + return try tools.value.get().map { $0.toAnthropicTool() } + } + + func callTool( + name: String, + input: [String: Any], + debug: Bool) + async -> String? { + + guard let client else { return nil } + + do { + if debug { + print("🔧 Calling tool '\(name)'...") + } + + // Convert DynamicContent values to basic types + var serializableInput: [String: Any] = [:] + for (key, value) in input { + if let dynamicContent = value as? MessageResponse.Content.DynamicContent { + serializableInput[key] = dynamicContent.extractValue() + } else { + serializableInput[key] = value + } + } + + let inputData = try JSONSerialization.data(withJSONObject: serializableInput) + let inputJSON = try JSONDecoder().decode(JSON.self, from: inputData) + + let result = try await client.callTool(named: name, arguments: inputJSON) + + if result.isError != true { + if let content = result.content.first?.text?.text { + if debug { + print("✅ Tool execution successful") + } + return content + } else { + if debug { + print("⚠️ Tool returned no text content") + } + return nil + } + } else { + print("❌ Tool returned an error") + if let errorText = result.content.first?.text?.text { + if debug { + print(" Error: \(errorText)") + } + } + return nil + } + } catch { + if debug { + print("⛔️ Error calling tool: \(error)") + } + return nil + } + } +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/MCPInterfaceTool+MessagePrameterTool.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/MCPInterfaceTool+MessagePrameterTool.swift new file mode 100644 index 0000000..fc1a0ef --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/MCPInterfaceTool+MessagePrameterTool.swift @@ -0,0 +1,428 @@ +// +// MCPInterfaceTool+MessagePrameterTool.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/3/25. +// + +import Foundation +import MCPClient +import MCPInterface +import SwiftAnthropic + +// TODO: James+Gui decide where this should live so it can be reused. + +extension MCPInterface.Tool { + + /** + * Converts an MCP interface tool to SwiftAnthropic's tool format. + * + * This function transforms the tool's metadata and schema structure from + * the MCP format to the format expected by the Anthropic API, ensuring + * compatibility between the two systems. + * + * - Returns: A `SwiftAnthropic.MessageParameter.Tool` representing the same + * functionality as the original MCP tool. + */ + public func toAnthropicTool() -> SwiftAnthropic.MessageParameter.Tool { + // Convert the JSON to SwiftAnthropic.JSONSchema + let anthropicInputSchema: SwiftAnthropic.MessageParameter.Tool.JSONSchema? + + switch self.inputSchema { + case .object(let value): + anthropicInputSchema = convertToAnthropicJSONSchema(from: value) + case .array(_): + // Arrays are not directly supported in the schema root + anthropicInputSchema = nil + } + + return SwiftAnthropic.MessageParameter.Tool( + name: self.name, + description: self.description, + inputSchema: anthropicInputSchema, + cacheControl: nil + ) + } + + /** + * Converts MCP JSON object to SwiftAnthropic JSONSchema format. + * + * This helper function transforms a JSON schema object from MCP format to the + * corresponding Anthropic format, handling the root schema properties. + * + * - Parameter jsonObject: Dictionary containing MCP JSON schema properties + * - Returns: An equivalent SwiftAnthropic JSONSchema object, or nil if conversion fails + */ + private func convertToAnthropicJSONSchema(from jsonObject: [String: MCPInterface.JSON.Value]) -> SwiftAnthropic.MessageParameter.Tool.JSONSchema? { + guard let typeValue = jsonObject["type"], + case .string(let typeString) = typeValue, + let jsonType = SwiftAnthropic.MessageParameter.Tool.JSONSchema.JSONType(rawValue: typeString) else { + return nil + } + + // Extract properties + var properties: [String: SwiftAnthropic.MessageParameter.Tool.JSONSchema.Property]? = nil + if let propertiesValue = jsonObject["properties"], + case .object(let propertiesObject) = propertiesValue { + properties = [:] + for (key, value) in propertiesObject { + if case .object(let propertyObject) = value, + let property = convertToAnthropicProperty(from: propertyObject) { + properties?[key] = property + } + } + } + + // Extract required fields + var required: [String]? = nil + if let requiredValue = jsonObject["required"], + case .array(let requiredArray) = requiredValue { + required = [] + for item in requiredArray { + if case .string(let field) = item { + required?.append(field) + } + } + } + + // Extract pattern + var pattern: String? = nil + if let patternValue = jsonObject["pattern"], + case .string(let patternString) = patternValue { + pattern = patternString + } + + // Extract const + var constValue: String? = nil + if let constVal = jsonObject["const"], + case .string(let constString) = constVal { + constValue = constString + } + + // Extract enum values + var enumValues: [String]? = nil + if let enumValue = jsonObject["enum"], + case .array(let enumArray) = enumValue { + enumValues = [] + for item in enumArray { + if case .string(let value) = item { + enumValues?.append(value) + } + } + } + + // Extract multipleOf + var multipleOf: Int? = nil + if let multipleOfValue = jsonObject["multipleOf"], + case .number(let multipleOfDouble) = multipleOfValue { + multipleOf = Int(multipleOfDouble) + } + + // Extract minimum + var minimum: Int? = nil + if let minimumValue = jsonObject["minimum"], + case .number(let minimumDouble) = minimumValue { + minimum = Int(minimumDouble) + } + + // Extract maximum + var maximum: Int? = nil + if let maximumValue = jsonObject["maximum"], + case .number(let maximumDouble) = maximumValue { + maximum = Int(maximumDouble) + } + + return SwiftAnthropic.MessageParameter.Tool.JSONSchema( + type: jsonType, + properties: properties, + required: required, + pattern: pattern, + const: constValue, + enumValues: enumValues, + multipleOf: multipleOf, + minimum: minimum, + maximum: maximum + ) + } + + /** + * Converts MCP property object to SwiftAnthropic Property format. + * + * This helper function transforms a property definition from MCP format to the + * corresponding Anthropic format, preserving all relevant attributes. + * + * - Parameter propertyObject: Dictionary containing MCP property schema + * - Returns: An equivalent SwiftAnthropic Property object, or nil if conversion fails + */ + private func convertToAnthropicProperty(from propertyObject: [String: MCPInterface.JSON.Value]) -> SwiftAnthropic.MessageParameter.Tool.JSONSchema.Property? { + guard let typeValue = propertyObject["type"], + case .string(let typeString) = typeValue, + let jsonType = SwiftAnthropic.MessageParameter.Tool.JSONSchema.JSONType(rawValue: typeString) else { + return nil + } + + // Extract description + var description: String? = nil + if let descValue = propertyObject["description"], + case .string(let descString) = descValue { + description = descString + } + + // Extract format + var format: String? = nil + if let formatValue = propertyObject["format"], + case .string(let formatString) = formatValue { + format = formatString + } + + // Extract items + var items: SwiftAnthropic.MessageParameter.Tool.JSONSchema.Items? = nil + if let itemsValue = propertyObject["items"], + case .object(let itemsObject) = itemsValue { + items = convertToAnthropicItems(from: itemsObject) + } + + // Extract required fields + var required: [String]? = nil + if let requiredValue = propertyObject["required"], + case .array(let requiredArray) = requiredValue { + required = [] + for item in requiredArray { + if case .string(let field) = item { + required?.append(field) + } + } + } + + // Extract pattern + var pattern: String? = nil + if let patternValue = propertyObject["pattern"], + case .string(let patternString) = patternValue { + pattern = patternString + } + + // Extract const + var constValue: String? = nil + if let constVal = propertyObject["const"], + case .string(let constString) = constVal { + constValue = constString + } + + // Extract enum values + var enumValues: [String]? = nil + if let enumValue = propertyObject["enum"], + case .array(let enumArray) = enumValue { + enumValues = [] + for item in enumArray { + if case .string(let value) = item { + enumValues?.append(value) + } + } + } + + // Extract multipleOf + var multipleOf: Int? = nil + if let multipleOfValue = propertyObject["multipleOf"], + case .number(let multipleOfDouble) = multipleOfValue { + multipleOf = Int(multipleOfDouble) + } + + // Extract minimum + var minimum: Double? = nil + if let minimumValue = propertyObject["minimum"], + case .number(let minimumDouble) = minimumValue { + minimum = minimumDouble + } + + // Extract maximum + var maximum: Double? = nil + if let maximumValue = propertyObject["maximum"], + case .number(let maximumDouble) = maximumValue { + maximum = maximumDouble + } + + // Extract minItems + var minItems: Int? = nil + if let minItemsValue = propertyObject["minItems"], + case .number(let minItemsDouble) = minItemsValue { + minItems = Int(minItemsDouble) + } + + // Extract maxItems + var maxItems: Int? = nil + if let maxItemsValue = propertyObject["maxItems"], + case .number(let maxItemsDouble) = maxItemsValue { + maxItems = Int(maxItemsDouble) + } + + // Extract uniqueItems + var uniqueItems: Bool? = nil + if let uniqueItemsValue = propertyObject["uniqueItems"], + case .bool(let uniqueItemsBool) = uniqueItemsValue { + uniqueItems = uniqueItemsBool + } + + return SwiftAnthropic.MessageParameter.Tool.JSONSchema.Property( + type: jsonType, + description: description, + format: format, + items: items, + required: required, + pattern: pattern, + const: constValue, + enumValues: enumValues, + multipleOf: multipleOf, + minimum: minimum, + maximum: maximum, + minItems: minItems, + maxItems: maxItems, + uniqueItems: uniqueItems + ) + } + + /** + * Converts MCP items object to SwiftAnthropic Items format. + * + * This helper function transforms an items definition from MCP format to the + * corresponding Anthropic format, used primarily for array type properties. + * + * - Parameter itemsObject: Dictionary containing MCP items schema + * - Returns: An equivalent SwiftAnthropic Items object, or nil if conversion fails + */ + private func convertToAnthropicItems(from itemsObject: [String: MCPInterface.JSON.Value]) -> SwiftAnthropic.MessageParameter.Tool.JSONSchema.Items? { + guard let typeValue = itemsObject["type"], + case .string(let typeString) = typeValue, + let jsonType = SwiftAnthropic.MessageParameter.Tool.JSONSchema.JSONType(rawValue: typeString) else { + return nil + } + + // Extract properties + var properties: [String: SwiftAnthropic.MessageParameter.Tool.JSONSchema.Property]? = nil + if let propertiesValue = itemsObject["properties"], + case .object(let propertiesObject) = propertiesValue { + properties = [:] + for (key, value) in propertiesObject { + if case .object(let propertyObject) = value, + let property = convertToAnthropicProperty(from: propertyObject) { + properties?[key] = property + } + } + } + + // Extract pattern + var pattern: String? = nil + if let patternValue = itemsObject["pattern"], + case .string(let patternString) = patternValue { + pattern = patternString + } + + // Extract const + var constValue: String? = nil + if let constVal = itemsObject["const"], + case .string(let constString) = constVal { + constValue = constString + } + + // Extract enum values + var enumValues: [String]? = nil + if let enumValue = itemsObject["enum"], + case .array(let enumArray) = enumValue { + enumValues = [] + for item in enumArray { + if case .string(let value) = item { + enumValues?.append(value) + } + } + } + + // Extract multipleOf + var multipleOf: Int? = nil + if let multipleOfValue = itemsObject["multipleOf"], + case .number(let multipleOfDouble) = multipleOfValue { + multipleOf = Int(multipleOfDouble) + } + + // Extract minimum + var minimum: Double? = nil + if let minimumValue = itemsObject["minimum"], + case .number(let minimumDouble) = minimumValue { + minimum = minimumDouble + } + + // Extract maximum + var maximum: Double? = nil + if let maximumValue = itemsObject["maximum"], + case .number(let maximumDouble) = maximumValue { + maximum = maximumDouble + } + + // Extract minItems + var minItems: Int? = nil + if let minItemsValue = itemsObject["minItems"], + case .number(let minItemsDouble) = minItemsValue { + minItems = Int(minItemsDouble) + } + + // Extract maxItems + var maxItems: Int? = nil + if let maxItemsValue = itemsObject["maxItems"], + case .number(let maxItemsDouble) = maxItemsValue { + maxItems = Int(maxItemsDouble) + } + + // Extract uniqueItems + var uniqueItems: Bool? = nil + if let uniqueItemsValue = itemsObject["uniqueItems"], + case .bool(let uniqueItemsBool) = uniqueItemsValue { + uniqueItems = uniqueItemsBool + } + + return SwiftAnthropic.MessageParameter.Tool.JSONSchema.Items( + type: jsonType, + properties: properties, + pattern: pattern, + const: constValue, + enumValues: enumValues, + multipleOf: multipleOf, + minimum: minimum, + maximum: maximum, + minItems: minItems, + maxItems: maxItems, + uniqueItems: uniqueItems + ) + } +} + +/** + * Extension for extracting primitive values from MessageResponse.Content.DynamicContent. + * This enables proper serialization of dynamic content to JSON for tool calls. + */ +extension MessageResponse.Content.DynamicContent { + /** + * Extracts the underlying primitive value from DynamicContent for JSON serialization. + * + * This method recursively unwraps dictionary and array values to ensure all + * nested dynamic content is properly converted to basic types that can be + * serialized to JSON. + * + * - Returns: An equivalent value using only basic Swift types (String, Int, Double, etc.) + */ + func extractValue() -> Any { + switch self { + case .string(let value): + return value + case .integer(let value): + return value + case .double(let value): + return value + case .dictionary(let value): + return value.mapValues { $0.extractValue() } + case .array(let value): + return value.map { $0.extractValue() } + case .bool(let value): + return value + case .null: + return NSNull() + } + } +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChat.entitlements b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChat.entitlements new file mode 100644 index 0000000..311b32b --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChat.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift new file mode 100644 index 0000000..c9a297c --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift @@ -0,0 +1,31 @@ +// +// MCPClientChatApp.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/3/25. +// + +import SwiftUI +import SwiftAnthropic + +@main +struct MCPClientChatApp: App { + + @State private var chatManager = ChatNonStreamManager( + service: AnthropicServiceFactory.service(apiKey: "YOUR_API_KEY", betaHeaders: nil, debugEnabled: true), + mcpLLMClient: GIthubMCPClient()) + + var body: some Scene { + WindowGroup { + ChatView(chatManager: chatManager) + .toolbar(removing: .title) + .containerBackground( + .thinMaterial, for: .window + ) + .toolbarBackgroundVisibility( + .hidden, for: .windowToolbar + ) + } + .windowStyle(.hiddenTitleBar) + } +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Preview Content/Preview Assets.xcassets/Contents.json b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChatTests/MCPClientChatTests.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChatTests/MCPClientChatTests.swift new file mode 100644 index 0000000..47ce04c --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChatTests/MCPClientChatTests.swift @@ -0,0 +1,17 @@ +// +// MCPClientChatTests.swift +// MCPClientChatTests +// +// Created by James Rochabrun on 3/3/25. +// + +import Testing +@testable import MCPClientChat + +struct MCPClientChatTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITests.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITests.swift new file mode 100644 index 0000000..fb48749 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITests.swift @@ -0,0 +1,43 @@ +// +// MCPClientChatUITests.swift +// MCPClientChatUITests +// +// Created by James Rochabrun on 3/3/25. +// + +import XCTest + +final class MCPClientChatUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITestsLaunchTests.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITestsLaunchTests.swift new file mode 100644 index 0000000..e3c1b66 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// MCPClientChatUITestsLaunchTests.swift +// MCPClientChatUITests +// +// Created by James Rochabrun on 3/3/25. +// + +import XCTest + +final class MCPClientChatUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} From 02364a2197459713050c59d95cd9bdc30cb4b17f Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Mon, 3 Mar 2025 21:55:35 -0800 Subject: [PATCH 02/22] Typo --- .../MCPClientChat/MCP/Clients/GithubMCPClient.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift index f52dd37..392e905 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift @@ -11,7 +11,6 @@ import SwiftUI final class GIthubMCPClient: MCPLLMClient { - /// Intentionally force unwrapping for demo, if cleint is nil this demo does not have any purpose. var client: MCPClient? init() { From faa0d0fec470f88e6ed29d2ecdc910f53aa6d153 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Mon, 3 Mar 2025 21:58:33 -0800 Subject: [PATCH 03/22] Comments --- .../MCPClientChat/MCP/Clients/GithubMCPClient.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift index 392e905..178183d 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift @@ -16,15 +16,16 @@ final class GIthubMCPClient: MCPLLMClient { init() { Task { do { - var customEnv = ProcessInfo.processInfo.environment - customEnv["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + (customEnv["PATH"] ?? "") + /// Need to define manually the `env` to be able to initialize client!!! + var env = ProcessInfo.processInfo.environment + env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + (env["PATH"] ?? "") // customEnv["GITHUB_PERSONAL_ACCESS_TOKEN"] = "YOUR_GITHUB_PERSONAL_TOKEN" // Needed for write operations. self.client = try await MCPClient( info: .init(name: "GIthubMCPClient", version: "1.0.0"), transport: .stdioProcess( "npx", args: ["-y", "@modelcontextprotocol/server-github"], - env: customEnv, + env: env, verbose: true ), capabilities: .init() From e73cc48bdf57ad3c0fe4781c4905380726c4f1d7 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Tue, 4 Mar 2025 23:47:07 -0800 Subject: [PATCH 04/22] Adding openAi dependency --- .../MCPClientChat.xcodeproj/project.pbxproj | 17 ++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 11 +++++++- .../MCPClientChat/ContentView.swift | 26 ------------------- 3 files changed, 27 insertions(+), 27 deletions(-) delete mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/ContentView.swift diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.pbxproj b/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.pbxproj index 4c43723..99d7087 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.pbxproj +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 7BDD62DB2D764B3D00E18088 /* SwiftAnthropic in Frameworks */ = {isa = PBXBuildFile; productRef = 7BDD62DA2D764B3D00E18088 /* SwiftAnthropic */; }; 7BDD62ED2D764B7C00E18088 /* MCPClient in Frameworks */ = {isa = PBXBuildFile; productRef = 7BDD62EC2D764B7C00E18088 /* MCPClient */; }; + 7BDD644F2D7811BA00E18088 /* SwiftOpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = 7BDD644E2D7811BA00E18088 /* SwiftOpenAI */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -58,6 +59,7 @@ buildActionMask = 2147483647; files = ( 7BDD62ED2D764B7C00E18088 /* MCPClient in Frameworks */, + 7BDD644F2D7811BA00E18088 /* SwiftOpenAI in Frameworks */, 7BDD62DB2D764B3D00E18088 /* SwiftAnthropic in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -129,6 +131,7 @@ packageProductDependencies = ( 7BDD62DA2D764B3D00E18088 /* SwiftAnthropic */, 7BDD62EC2D764B7C00E18088 /* MCPClient */, + 7BDD644E2D7811BA00E18088 /* SwiftOpenAI */, ); productName = MCPClientChat; productReference = 7BDD62A92D764A4A00E18088 /* MCPClientChat.app */; @@ -215,6 +218,7 @@ packageReferences = ( 7BDD62D72D764ADA00E18088 /* XCRemoteSwiftPackageReference "SwiftAnthropic" */, 7BDD62D82D764B1800E18088 /* XCLocalSwiftPackageReference "../../../mcp-swift-sdk" */, + 7BDD644D2D7811B300E18088 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */, ); preferredProjectObjectVersion = 77; productRefGroup = 7BDD62AA2D764A4A00E18088 /* Products */; @@ -587,6 +591,14 @@ kind = branch; }; }; + 7BDD644D2D7811B300E18088 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jamesrochabrun/SwiftOpenAI"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -600,6 +612,11 @@ package = 7BDD62D82D764B1800E18088 /* XCLocalSwiftPackageReference "../../../mcp-swift-sdk" */; productName = MCPClient; }; + 7BDD644E2D7811BA00E18088 /* SwiftOpenAI */ = { + isa = XCSwiftPackageProductDependency; + package = 7BDD644D2D7811B300E18088 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */; + productName = SwiftOpenAI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 7BDD62A12D764A4A00E18088 /* Project object */; diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 633f190..ffdaaf7 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c14633a92d8709e70ab430e279f2e2d6f4a5285ccfc4da7998de3ef25a2721e7", + "originHash" : "ea89bc09a647584f246cc2ccc1334b5e579f0d3a53f72aa83011d8c436a3060f", "pins" : [ { "identity" : "jsonrpc", @@ -44,6 +44,15 @@ "branch" : "main", "revision" : "e71ff4df0a2ab4e85fb492f02322980858a8157c" } + }, + { + "identity" : "swiftopenai", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jamesrochabrun/SwiftOpenAI", + "state" : { + "branch" : "main", + "revision" : "8bb0ffc621b27f7cb40d49bb1166ee19862224b4" + } } ], "version" : 3 diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/ContentView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/ContentView.swift deleted file mode 100644 index 70ee1dc..0000000 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/ContentView.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// ContentView.swift -// MCPClientChat -// -// Created by James Rochabrun on 3/3/25. -// - -import SwiftUI -import SwiftAnthropic -import MCPClient - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} From 5ade3eae0a990f9b43fa5fb9a74728c3840caffa Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Tue, 4 Mar 2025 23:58:41 -0800 Subject: [PATCH 05/22] Getting ready for openai changes --- ....swift => AnthropicNonStreamManager.swift} | 23 +++++++---- .../Chat/Models/ChatManager.swift | 3 ++ .../MCP/Clients/GithubMCPClient.swift | 19 ++++++++-- .../MCPClient+LLMTools.swift} | 38 +++++++------------ ...MCPInterfaceTool+MessagePrameterTool.swift | 0 .../MCPClientChat/MCPClientChatApp.swift | 18 +++++++-- 6 files changed, 62 insertions(+), 39 deletions(-) rename MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/{ChatNonStreamManager.swift => AnthropicNonStreamManager.swift} (91%) rename MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/{Interface/MCPLLMClient.swift => Tools/MCPClient+LLMTools.swift} (80%) rename MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/{ => Tools}/MCPInterfaceTool+MessagePrameterTool.swift (100%) diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatNonStreamManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift similarity index 91% rename from MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatNonStreamManager.swift rename to MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift index d5a3897..f73672f 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatNonStreamManager.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift @@ -6,13 +6,15 @@ // import Foundation +import MCPInterface +import MCPClient import SwiftUI import SwiftAnthropic @MainActor @Observable // Handle a chat conversation without stream. -final class ChatNonStreamManager: ChatManager { +final class AnthropicNonStreamManager: ChatManager { /// Messages sent from the user or received from Claude var messages = [ChatMessage]() @@ -33,14 +35,11 @@ final class ChatNonStreamManager: ChatManager { var isLoading = false /// Web research client for tool use - private let mcpLLMClient: MCPLLMClient + private var mcpClient: MCPClient? - init( - service: AnthropicService, - mcpLLMClient: MCPLLMClient) + init(service: AnthropicService) { self.service = service - self.mcpLLMClient = mcpLLMClient } /// Returns true if Claude is still processing a response @@ -48,6 +47,10 @@ final class ChatNonStreamManager: ChatManager { return isLoading } + func updateClient(_ client: MCPClient) { + mcpClient = client + } + /// Send a new message to Claude and get the complete response func send(message: ChatMessage) { self.messages.append(message) @@ -62,6 +65,10 @@ final class ChatNonStreamManager: ChatManager { } private func processUserMessage(prompt: String) { + + guard let mcpClient else { + fatalError("Client not initialized") + } // Add a placeholder for Claude's response self.messages.append(ChatMessage(text: "", role: .assistant, isWaitingForFirstText: true)) @@ -76,7 +83,7 @@ final class ChatNonStreamManager: ChatManager { isLoading = true // Get available tools from MCP - let tools = try await mcpLLMClient.tools() + let tools = try await mcpClient.anthropicTools() // Send request and process response try await continueConversation(tools: tools) @@ -142,7 +149,7 @@ final class ChatNonStreamManager: ChatManager { )) // Call tool via MCP - let toolResponse = await mcpLLMClient.callTool(name: tool.name, input: tool.input, debug: true) + let toolResponse = await mcpClient?.anthropicCallTool(name: tool.name, input: tool.input, debug: true) print("Tool response: \(String(describing: toolResponse))") // Add tool result to conversation diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift index cd478e4..cd28533 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift @@ -6,6 +6,8 @@ // import Foundation +import MCPInterface +import MCPClient @MainActor protocol ChatManager { @@ -13,4 +15,5 @@ protocol ChatManager { var isProcessing: Bool { get } func stop() func send(message: ChatMessage) + func updateClient(_ client: MCPClient) } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift index 178183d..67559aa 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift @@ -9,17 +9,16 @@ import Foundation import MCPClient import SwiftUI -final class GIthubMCPClient: MCPLLMClient { +final class GIthubMCPClient { - var client: MCPClient? + private var client: MCPClient? + private let clientInitialized = AsyncStream.makeStream(of: MCPClient?.self) init() { Task { do { - /// Need to define manually the `env` to be able to initialize client!!! var env = ProcessInfo.processInfo.environment env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + (env["PATH"] ?? "") - // customEnv["GITHUB_PERSONAL_ACCESS_TOKEN"] = "YOUR_GITHUB_PERSONAL_TOKEN" // Needed for write operations. self.client = try await MCPClient( info: .init(name: "GIthubMCPClient", version: "1.0.0"), transport: .stdioProcess( @@ -30,9 +29,21 @@ final class GIthubMCPClient: MCPLLMClient { ), capabilities: .init() ) + clientInitialized.continuation.yield(self.client) + clientInitialized.continuation.finish() } catch { print("Failed to initialize MCPClient: \(error)") + clientInitialized.continuation.yield(nil) + clientInitialized.continuation.finish() } } } + + // Modern async/await approach + func getClientAsync() async throws -> MCPClient? { + for await client in clientInitialized.stream { + return client + } + return nil // Stream completed without a client + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Interface/MCPLLMClient.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift similarity index 80% rename from MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Interface/MCPLLMClient.swift rename to MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift index e5bf5d7..025618b 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Interface/MCPLLMClient.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift @@ -9,27 +9,32 @@ import Foundation import MCPClient import MCPInterface import SwiftAnthropic +import SwiftOpenAI // TODO: James+Gui decide where this should live so it can be reused. +// MARK: Anthropic + /** - * Protocol that bridges the MCP (Multi-Client Protocol) framework with LLM providers library. + * Protocol that bridges the MCP (Multi-Client Protocol) framework with SwiftAnthropic library. * * This protocol provides methods to: * 1. Retrieve available tools from an MCP client and convert them to Anthropic's format * 2. Execute tools with provided parameters and handle their responses */ -protocol MCPLLMClient { +extension MCPClient { - /// The underlying MCP client that processes requests - var client: MCPClient? { get } /** * Retrieves available tools from the MCP client and converts them to Anthropic's tool format. * * - Returns: An array of Anthropic-compatible tools * - Throws: Errors from the underlying MCP client or during conversion process */ - func tools() async throws -> [MessageParameter.Tool] + func anthropicTools() async throws -> [SwiftAnthropic.MessageParameter.Tool] { + let tools = await tools + return try tools.value.get().map { $0.toAnthropicTool() } + } + /** * Executes a tool with the specified name and input parameters. * @@ -39,28 +44,11 @@ protocol MCPLLMClient { * - debug: Flag to enable verbose logging during execution * - Returns: A string containing the tool's response, or `nil` if execution failed */ - func callTool(name: String, input: [String: Any], debug: Bool) async -> String? -} - -/** - * Extension providing default implementations of the MCPSwiftAnthropicClient protocol. - */ -extension MCPLLMClient { - - func tools() async throws -> [MessageParameter.Tool] { - guard let client else { return [] } - let tools = await client.tools - return try tools.value.get().map { $0.toAnthropicTool() } - } - - func callTool( + func anthropicCallTool( name: String, input: [String: Any], debug: Bool) async -> String? { - - guard let client else { return nil } - do { if debug { print("🔧 Calling tool '\(name)'...") @@ -79,7 +67,7 @@ extension MCPLLMClient { let inputData = try JSONSerialization.data(withJSONObject: serializableInput) let inputJSON = try JSONDecoder().decode(JSON.self, from: inputData) - let result = try await client.callTool(named: name, arguments: inputJSON) + let result = try await callTool(named: name, arguments: inputJSON) if result.isError != true { if let content = result.content.first?.text?.text { @@ -110,3 +98,5 @@ extension MCPLLMClient { } } } + +// MARK: OpenAI diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/MCPInterfaceTool+MessagePrameterTool.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPInterfaceTool+MessagePrameterTool.swift similarity index 100% rename from MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/MCPInterfaceTool+MessagePrameterTool.swift rename to MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPInterfaceTool+MessagePrameterTool.swift diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift index c9a297c..c924f45 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift @@ -10,10 +10,17 @@ import SwiftAnthropic @main struct MCPClientChatApp: App { + + @State private var chatManager: ChatManager + private let githubClient = GIthubMCPClient() + + init() { + let service = AnthropicServiceFactory.service(apiKey: "", betaHeaders: nil) - @State private var chatManager = ChatNonStreamManager( - service: AnthropicServiceFactory.service(apiKey: "YOUR_API_KEY", betaHeaders: nil, debugEnabled: true), - mcpLLMClient: GIthubMCPClient()) + let initialManager = AnthropicNonStreamManager(service: service) + + _chatManager = State(initialValue: initialManager) + } var body: some Scene { WindowGroup { @@ -25,6 +32,11 @@ struct MCPClientChatApp: App { .toolbarBackgroundVisibility( .hidden, for: .windowToolbar ) + .task { + if let client = try? await githubClient.getClientAsync() { + chatManager.updateClient(client) + } + } } .windowStyle(.hiddenTitleBar) } From 1183f8e8bab65a61f99db804652080f363215c73 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Wed, 5 Mar 2025 00:02:53 -0800 Subject: [PATCH 06/22] OpenAI tools --- ...Tool.swift => MCPTool+AnthropicTool.swift} | 0 .../MCP/Tools/MCPTool+OpenAITool.swift | 264 ++++++++++++++++++ 2 files changed, 264 insertions(+) rename MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/{MCPInterfaceTool+MessagePrameterTool.swift => MCPTool+AnthropicTool.swift} (100%) create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPInterfaceTool+MessagePrameterTool.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+AnthropicTool.swift similarity index 100% rename from MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPInterfaceTool+MessagePrameterTool.swift rename to MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+AnthropicTool.swift diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift new file mode 100644 index 0000000..4ee52b5 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift @@ -0,0 +1,264 @@ +// +// MCPTool+OpenAITool.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/5/25. +// + +import Foundation +import MCPClient +import MCPInterface +import SwiftOpenAI + +extension MCPInterface.Tool { + + /** + * Converts an MCP interface tool to SwiftOpenAI's tool format. + * + * This function transforms the tool's metadata and schema structure from + * the MCP format to the format expected by the OpenAI API, ensuring + * compatibility between the two systems. + * + * - Returns: A `SwiftOpenAI.Tool` representing the same + * functionality as the original MCP tool. + */ + public func toOpenAITool() -> SwiftOpenAI.ChatCompletionParameters.Tool { + // Convert the JSON to SwiftOpenAI.JSONSchema + let openAIParameters: SwiftOpenAI.JSONSchema? + + switch self.inputSchema { + case .object(let value): + openAIParameters = convertToOpenAIJSONSchema(from: value) + case .array(_): + // Arrays are not directly supported in the schema root + openAIParameters = nil + } + + let chatFunction = SwiftOpenAI.ChatCompletionParameters.ChatFunction( + name: self.name, + strict: true, // Set strict to true for consistent behavior + description: self.description, + parameters: openAIParameters + ) + + return SwiftOpenAI.ChatCompletionParameters.Tool( + type: "function", // Currently only "function" is supported + function: chatFunction + ) + } + + /** + * Converts MCP JSON object to SwiftOpenAI JSONSchema format. + * + * This helper function transforms a JSON schema object from MCP format to the + * corresponding OpenAI format, handling the root schema properties. + * + * - Parameter jsonObject: Dictionary containing MCP JSON schema properties + * - Returns: An equivalent SwiftOpenAI JSONSchema object, or nil if conversion fails + */ + private func convertToOpenAIJSONSchema(from jsonObject: [String: MCPInterface.JSON.Value]) -> SwiftOpenAI.JSONSchema? { + // Extract type + let type: JSONSchemaType? + if let typeValue = jsonObject["type"] { + switch typeValue { + case .string(let typeString): + switch typeString { + case "string": type = .string + case "number": type = .number + case "integer": type = .integer + case "boolean": type = .boolean + case "object": type = .object + case "array": type = .array + case "null": type = .null + default: type = nil + } + case .array(let typeArray): + // Handle union types + var types: [JSONSchemaType] = [] + for item in typeArray { + if case .string(let typeString) = item { + switch typeString { + case "string": types.append(.string) + case "number": types.append(.number) + case "integer": types.append(.integer) + case "boolean": types.append(.boolean) + case "object": types.append(.object) + case "array": types.append(.array) + case "null": types.append(.null) + default: continue + } + } + } + if !types.isEmpty { + type = .union(types) + } else { + type = nil + } + default: + type = nil + } + } else { + type = nil + } + + // Extract description + var description: String? = nil + if let descValue = jsonObject["description"], + case .string(let descString) = descValue { + description = descString + } + + // Extract properties + var properties: [String: SwiftOpenAI.JSONSchema]? = nil + if let propertiesValue = jsonObject["properties"], + case .object(let propertiesObject) = propertiesValue { + properties = [:] + for (key, value) in propertiesObject { + if case .object(let propertyObject) = value, + let property = convertToOpenAIJSONSchema(from: propertyObject) { + properties?[key] = property + } + } + } + + // Extract items for array types + var items: SwiftOpenAI.JSONSchema? = nil + if let itemsValue = jsonObject["items"] { + switch itemsValue { + case .object(let itemsObject): + items = convertToOpenAIJSONSchema(from: itemsObject) + case .array(let itemsArray): + // Handle array of schemas for tuples + if let firstItem = itemsArray.first, + case .object(let firstItemObject) = firstItem { + items = convertToOpenAIJSONSchema(from: firstItemObject) + } + default: + break + } + } + + // Extract required fields + var required: [String]? = nil + if let requiredValue = jsonObject["required"], + case .array(let requiredArray) = requiredValue { + required = [] + for item in requiredArray { + if case .string(let field) = item { + required?.append(field) + } + } + } + + // Fix for OpenAI's requirement: for strict schemas, include all property keys in required array + // If we're dealing with an object type and have properties + if type == .object && properties != nil { + // Initialize the set of all property keys + var allPropertyKeys = Set(properties!.keys) + + // If we already have some required fields, merge them with our property keys + if let existingRequired = required { + let requiredSet = Set(existingRequired) + allPropertyKeys = allPropertyKeys.union(requiredSet) + } + + // Use the complete set of properties as our required fields + required = Array(allPropertyKeys) + } + + // Extract additional properties + var additionalProperties: Bool = false + if let addPropsValue = jsonObject["additionalProperties"] { + switch addPropsValue { + case .bool(let addPropsBool): + additionalProperties = addPropsBool + case .object(_): + // If additionalProperties is an object schema, treat it as true + additionalProperties = true + default: + additionalProperties = false + } + } + + // Extract enum values + var enumValues: [String]? = nil + if let enumValue = jsonObject["enum"], + case .array(let enumArray) = enumValue { + enumValues = [] + for item in enumArray { + switch item { + case .string(let value): + enumValues?.append(value) + case .number(let value): + enumValues?.append(String(value)) + case .bool(let value): + enumValues?.append(value ? "true" : "false") + default: + continue + } + } + } + + // Extract ref + var ref: String? = nil + if let refValue = jsonObject["$ref"], + case .string(let refString) = refValue { + ref = refString + } + + // Create and return the JSON schema with only the supported parameters + return SwiftOpenAI.JSONSchema( + type: type, + description: description, + properties: properties, + items: items, + required: required, + additionalProperties: additionalProperties, + enum: enumValues, + ref: ref + ) + } + + /** + * Extracts primitive value from JSON.Value for use in OpenAI schema properties. + * + * - Parameter value: The JSON.Value to extract from + * - Returns: The primitive Swift type corresponding to the JSON value + */ + private func extractPrimitiveValue(from value: MCPInterface.JSON.Value) -> Any? { + switch value { + case .string(let stringValue): + return stringValue + case .number(let numberValue): + return numberValue + case .bool(let boolValue): + return boolValue + case .null: + return NSNull() + case .array(let arrayValue): + return arrayValue.compactMap { extractPrimitiveValue(from: $0) } + case .object(let objectValue): + var result: [String: Any] = [:] + for (key, value) in objectValue { + if let extractedValue = extractPrimitiveValue(from: value) { + result[key] = extractedValue + } + } + return result + } + } +} + +/** + * Extension for batch conversion of multiple MCP tools to OpenAI tools. + */ +extension Array where Element == MCPInterface.Tool { + /** + * Converts an array of MCP interface tools to an array of SwiftOpenAI tools. + * + * - Returns: An array of SwiftOpenAI.Tool objects + */ + public func toOpenAITools() -> [SwiftOpenAI.ChatCompletionParameters.Tool] { + return self.map { $0.toOpenAITool() } + } +} From 3df5d973a91c744e202ae9a5b5ca1f2dca96e162 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Wed, 5 Mar 2025 00:06:33 -0800 Subject: [PATCH 07/22] Adding final tools conversion for openai --- .../MCP/Tools/MCPClient+LLMTools.swift | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift index 025618b..55e32ec 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift @@ -16,9 +16,9 @@ import SwiftOpenAI // MARK: Anthropic /** - * Protocol that bridges the MCP (Multi-Client Protocol) framework with SwiftAnthropic library. + * Extension that bridges the MCP (Multi-Client Protocol) framework with [SwiftAnthropic](https://github.com/jamesrochabrun/SwiftAnthropic) library. * - * This protocol provides methods to: + * This Extension provides methods to: * 1. Retrieve available tools from an MCP client and convert them to Anthropic's format * 2. Execute tools with provided parameters and handle their responses */ @@ -100,3 +100,71 @@ extension MCPClient { } // MARK: OpenAI + +/** + * Extension that bridges the MCP (Multi-Client Protocol) framework with [SwiftOpenAI](https://github.com/jamesrochabrun/SwiftOpenAI) library. + * + * This Extension provides methods to: + * 1. Retrieve available tools from an MCP client and convert them to Anthropic's format + * 2. Execute tools with provided parameters and handle their responses + */ +extension MCPClient { + + func tools() async throws -> [SwiftOpenAI.ChatCompletionParameters.Tool] { + let tools = await tools + return try tools.value.get().map { $0.toOpenAITool() } + } + + func callTool( + name: String, + input: [String: Any], + debug: Bool) + async -> String? { + + do { + if debug { + print("🔧 Calling tool '\(name)'...") + } + + // Convert OpenAI function call parameters to serializable format + var serializableInput: [String: Any] = [:] + for (key, value) in input { + // Handle any special OpenAI types that might need conversion + // This will depend on what types OpenAI uses in their response + serializableInput[key] = value + } + + let inputData = try JSONSerialization.data(withJSONObject: serializableInput) + let inputJSON = try JSONDecoder().decode(JSON.self, from: inputData) + + let result = try await callTool(named: name, arguments: inputJSON) + + if result.isError != true { + if let content = result.content.first?.text?.text { + if debug { + print("✅ Tool execution successful") + } + return content + } else { + if debug { + print("⚠️ Tool returned no text content") + } + return nil + } + } else { + print("❌ Tool returned an error") + if let errorText = result.content.first?.text?.text { + if debug { + print(" Error: \(errorText)") + } + } + return nil + } + } catch { + if debug { + print("⛔️ Error calling tool: \(error)") + } + return nil + } + } +} From e97f89a166a693789cc04fe8e7e7362ba50b50e3 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Wed, 5 Mar 2025 00:15:47 -0800 Subject: [PATCH 08/22] Adding openai demo --- .../Models/AnthropicNonStreamManager.swift | 3 +- .../Models/OpenAIChatNonStreamManager.swift | 242 ++++++++++++++++++ 2 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift index f73672f..a2f36e1 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift @@ -37,8 +37,7 @@ final class AnthropicNonStreamManager: ChatManager { /// Web research client for tool use private var mcpClient: MCPClient? - init(service: AnthropicService) - { + init(service: AnthropicService) { self.service = service } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift new file mode 100644 index 0000000..ad252b5 --- /dev/null +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift @@ -0,0 +1,242 @@ +// +// OpenAIChatNonStreamModel.swift +// MCPClientChat +// +// Created by James Rochabrun on 3/3/25. +// + +import Foundation +import MCPInterface +import MCPClient +import SwiftUI +import SwiftOpenAI + +@MainActor +@Observable +// Handle a chat conversation without stream for OpenAI. +final class OpenAIChatNonStreamManager: ChatManager { + + /// Messages sent from the user or received from OpenAI + var messages = [ChatMessage]() + + /// Service to communicate with OpenAI API + private let service: OpenAIService + + /// Message history for OpenAI's context + private var openAIMessages: [SwiftOpenAI.ChatCompletionParameters.Message] = [] + + /// Current task handling OpenAI API request + private var task: Task? = nil + + /// Error message if something goes wrong + var errorMessage: String = "" + + /// Loading state indicator + var isLoading = false + + private var mcpClient: MCPClient? + + init(service: OpenAIService) { + self.service = service + } + + /// Returns true if OpenAI is still processing a response + var isProcessing: Bool { + return isLoading + } + + func updateClient(_ client: MCPClient) { + mcpClient = client + } + + /// Send a new message to OpenAI and get the complete response + func send(message: ChatMessage) { + self.messages.append(message) + self.processUserMessage(prompt: message.text) + } + + /// Cancel the current processing task + func stop() { + self.task?.cancel() + self.task = nil + self.isLoading = false + } + + private func processUserMessage(prompt: String) { + // Add a placeholder for OpenAI's response + self.messages.append(ChatMessage(text: "", role: .assistant, isWaitingForFirstText: true)) + + // Add user message to history + openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( + role: .user, + content: .text(prompt) + )) + + task = Task { + do { + isLoading = true + + guard let mcpClient else { + throw NSError(domain: "OpenAIChat", code: 1, userInfo: [NSLocalizedDescriptionKey: "mcpClient is nil"]) + } + // Get available tools from MCP + let tools = try await mcpClient.tools() + + // Send request and process response + try await continueConversation(tools: tools) + + isLoading = false + } catch { + errorMessage = "\(error)" + + // Update UI to show error + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = "Sorry, there was an error: \(error.localizedDescription)" + messages.append(last) + } + + isLoading = false + } + } + } + + private func continueConversation(tools: [SwiftOpenAI.ChatCompletionParameters.Tool]) async throws { + guard let mcpClient else { + throw NSError(domain: "OpenAIChat", code: 1, userInfo: [NSLocalizedDescriptionKey: "mcpClient is nil"]) + } + + let parameters = SwiftOpenAI.ChatCompletionParameters( + messages: openAIMessages, + model: .gpt4o, + toolChoice: .auto, + tools: tools + ) + + // Make non-streaming request to OpenAI + let response = try await service.startChat(parameters: parameters) + + guard let choices = response.choices, + let firstChoice = choices.first, + let message = firstChoice.message else { + throw NSError(domain: "OpenAIChat", code: 1, userInfo: [NSLocalizedDescriptionKey: "No message in response"]) + } + + // Process the regular text content + if let messageContent = message.content, !messageContent.isEmpty { + // Update the UI with the response + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = messageContent + messages.append(last) + } + + // Add assistant response to history + openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( + role: .assistant, + content: .text(messageContent) + )) + } + + // Process tool calls if any + if let toolCalls = message.toolCalls, !toolCalls.isEmpty { + for toolCall in toolCalls { + + let function = toolCall.function + guard let id = toolCall.id, + let name = function.name, + let argumentsData = function.arguments.data(using: .utf8) else { + continue + } + + let toolId = id + let toolName = name + let argumentsString = function.arguments + + // Parse arguments from string to dictionary + let arguments: [String: Any] + do { + guard let parsedArgs = try JSONSerialization.jsonObject(with: argumentsData) as? [String: Any] else { + continue + } + arguments = parsedArgs + } catch { + print("Error parsing tool arguments: \(error)") + continue + } + + print("Tool use detected - Name: \(toolName), ID: \(toolId)") + + // Update UI to show tool use + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text += "\n Using tool: \(toolName)..." + messages.append(last) + } + + // Add the assistant message with tool call to message history + let toolCallObject = SwiftOpenAI.ToolCall( + id: toolId, + function: SwiftOpenAI.FunctionCall( + arguments: argumentsString, + name: toolName + ) + ) + + openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( + role: .assistant, + content: .text(""), // Content is null when using tool calls + toolCalls: [toolCallObject] + )) + + // Call tool via MCP + let toolResponse = await mcpClient.callTool(name: toolName, input: arguments, debug: true) + print("Tool response: \(String(describing: toolResponse))") + + // Add tool result to conversation + if let toolResult = toolResponse { + // Add the tool result as a tool message + openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( + role: .tool, + content: .text(toolResult), + toolCallID: toolId + )) + + // Now get a new response with the tool result + try await continueConversation(tools: tools) + } else { + // Handle tool failure + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = "There was an error using the tool \(toolName)." + messages.append(last) + } + + // Add error response as tool message + openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( + role: .tool, + content: .text("Error: Tool execution failed"), + toolCallID: toolId + )) + } + } + } + } + + /// Clear the conversation + func clearConversation() { + messages.removeAll() + openAIMessages.removeAll() + errorMessage = "" + isLoading = false + task?.cancel() + task = nil + } +} + +// Helper extension to convert Data to String +extension Data { + var asString: String? { + return String(data: self, encoding: .utf8) + } +} From db485e1626458c3d1767753cf5be6dd14abdca08 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Wed, 5 Mar 2025 00:23:18 -0800 Subject: [PATCH 09/22] Adding hint code --- .../MCPClientChat/Chat/UI/ChatMessageView.swift | 2 +- .../MCPClientChat/MCPClientChatApp.swift | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift index 548716a..af640bb 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift @@ -56,7 +56,7 @@ struct ChatMessageView: View { } private var chatName: some View { - Text(message.role == .user ? "You" : "Claude") + Text(message.role == .user ? "You" : "Assistant") .fontWeight(.bold) .frame(maxWidth: .infinity, maxHeight:24, alignment: .leading) } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift index c924f45..8ade5bd 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift @@ -7,6 +7,7 @@ import SwiftUI import SwiftAnthropic +import SwiftOpenAI @main struct MCPClientChatApp: App { @@ -15,11 +16,19 @@ struct MCPClientChatApp: App { private let githubClient = GIthubMCPClient() init() { - let service = AnthropicServiceFactory.service(apiKey: "", betaHeaders: nil) + let service = AnthropicServiceFactory.service(apiKey: "", betaHeaders: nil, debugEnabled: true) let initialManager = AnthropicNonStreamManager(service: service) _chatManager = State(initialValue: initialManager) + + // Uncomment this and comment the above for OpenAI Demo + + // let openAIService = OpenAIServiceFactory.service(apiKey: "", debugEnabled: true) + // + // let openAIChatNonStreamManager = OpenAIChatNonStreamManager(service: openAIService) + // + // _chatManager = State(initialValue: openAIChatNonStreamManager) } var body: some Scene { From bb3631b1c5c3816742b36deb01e02d48702fb25f Mon Sep 17 00:00:00 2001 From: Gui Sabran Date: Wed, 5 Mar 2025 09:47:30 -0800 Subject: [PATCH 10/22] lint, mostly --- .../Chat/Models/ChatManager.swift | 8 +- .../Chat/Models/ChatMessage.swift | 26 +- .../Chat/Models/ChatNonStreamManager.swift | 335 +++---- .../MCPClientChat/Chat/UI/ChatInputView.swift | 130 +-- .../Chat/UI/ChatMessageView.swift | 140 +-- .../MCPClientChat/Chat/UI/ChatView.swift | 87 +- .../MCPClientChat/ContentView.swift | 22 +- .../MCP/Clients/GithubMCPClient.swift | 54 +- .../MCP/Interface/MCPLLMClient.swift | 179 ++-- ...MCPInterfaceTool+MessagePrameterTool.swift | 884 ++++++++++-------- .../MCPClientChat/MCPClientChatApp.swift | 39 +- .../MCPClientChatTests.swift | 7 +- .../MCPClientChatUITests.swift | 60 +- .../MCPClientChatUITestsLaunchTests.swift | 34 +- 14 files changed, 1045 insertions(+), 960 deletions(-) diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift index cd478e4..370cecf 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift @@ -9,8 +9,8 @@ import Foundation @MainActor protocol ChatManager { - var messages: [ChatMessage] { get set } - var isProcessing: Bool { get } - func stop() - func send(message: ChatMessage) + var messages: [ChatMessage] { get set } + var isProcessing: Bool { get } + func stop() + func send(message: ChatMessage) } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatMessage.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatMessage.swift index 3d9718d..7af58c6 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatMessage.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatMessage.swift @@ -9,20 +9,20 @@ import Foundation /// Data model to represent a chat message struct ChatMessage: Identifiable, Equatable { - /// Unique identifier - let id = UUID() + /// Unique identifier + let id = UUID() - /// The body of the chat message - var text: String + /// The body of the chat message + var text: String - /// The role of the message - let role: Role + /// The role of the message + let role: Role - /// Indicates that we are waiting for the first bit of message content from OpenAI - var isWaitingForFirstText = false - - enum Role { - case user - case assistant - } + /// Indicates that we are waiting for the first bit of message content from OpenAI + var isWaitingForFirstText = false + + enum Role { + case user + case assistant + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatNonStreamManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatNonStreamManager.swift index d5a3897..308c449 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatNonStreamManager.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatNonStreamManager.swift @@ -1,3 +1,5 @@ +// swiftlint:disable no_direct_standard_out_logs + // // ChatNonStreamModel.swift // MCPClientChat @@ -6,177 +8,178 @@ // import Foundation -import SwiftUI import SwiftAnthropic +import SwiftUI @MainActor @Observable -// Handle a chat conversation without stream. +/// Handle a chat conversation without stream. final class ChatNonStreamManager: ChatManager { - - /// Messages sent from the user or received from Claude - var messages = [ChatMessage]() - - /// Service to communicate with Anthropic API - private let service: AnthropicService - - /// Message history for Claude's context - private var anthropicMessages: [MessageParameter.Message] = [] - - /// Current task handling Claude API request - private var task: Task? = nil - - /// Error message if something goes wrong - var errorMessage: String = "" - - /// Loading state indicator - var isLoading = false - - /// Web research client for tool use - private let mcpLLMClient: MCPLLMClient - - init( - service: AnthropicService, - mcpLLMClient: MCPLLMClient) - { - self.service = service - self.mcpLLMClient = mcpLLMClient - } - - /// Returns true if Claude is still processing a response - var isProcessing: Bool { - return isLoading - } - - /// Send a new message to Claude and get the complete response - func send(message: ChatMessage) { - self.messages.append(message) - self.processUserMessage(prompt: message.text) - } - - /// Cancel the current processing task - func stop() { - self.task?.cancel() - self.task = nil - self.isLoading = false - } - - private func processUserMessage(prompt: String) { - // Add a placeholder for Claude's response - self.messages.append(ChatMessage(text: "", role: .assistant, isWaitingForFirstText: true)) - - // Add user message to history - anthropicMessages.append(MessageParameter.Message( - role: .user, - content: .text(prompt) - )) - - task = Task { - do { - isLoading = true - - // Get available tools from MCP - let tools = try await mcpLLMClient.tools() - - // Send request and process response - try await continueConversation(tools: tools) - - isLoading = false - } catch { - errorMessage = "\(error)" - - // Update UI to show error - if var last = messages.popLast() { - last.isWaitingForFirstText = false - last.text = "Sorry, there was an error: \(error.localizedDescription)" - messages.append(last) - } - - isLoading = false - } + + // MARK: Lifecycle + + init( + service: AnthropicService, + mcpLLMClient: MCPLLMClient) + { + self.service = service + self.mcpLLMClient = mcpLLMClient + } + + // MARK: Internal + + /// Messages sent from the user or received from Claude + var messages = [ChatMessage]() + + /// Error message if something goes wrong + var errorMessage = "" + + /// Loading state indicator + var isLoading = false + + /// Returns true if Claude is still processing a response + var isProcessing: Bool { + isLoading + } + + /// Send a new message to Claude and get the complete response + func send(message: ChatMessage) { + messages.append(message) + processUserMessage(prompt: message.text) + } + + /// Cancel the current processing task + func stop() { + task?.cancel() + task = nil + isLoading = false + } + + /// Clear the conversation + func clearConversation() { + messages.removeAll() + anthropicMessages.removeAll() + errorMessage = "" + isLoading = false + task?.cancel() + task = nil + } + + // MARK: Private + + /// Service to communicate with Anthropic API + private let service: AnthropicService + + /// Message history for Claude's context + private var anthropicMessages: [MessageParameter.Message] = [] + + /// Current task handling Claude API request + private var task: Task? = nil + + /// Web research client for tool use + private let mcpLLMClient: MCPLLMClient + + private func processUserMessage(prompt: String) { + // Add a placeholder for Claude's response + messages.append(ChatMessage(text: "", role: .assistant, isWaitingForFirstText: true)) + + // Add user message to history + anthropicMessages.append(MessageParameter.Message( + role: .user, + content: .text(prompt))) + + task = Task { + do { + isLoading = true + + // Get available tools from MCP + let tools = try await mcpLLMClient.tools() + + // Send request and process response + try await continueConversation(tools: tools) + + isLoading = false + } catch { + errorMessage = "\(error)" + + // Update UI to show error + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = "Sorry, there was an error: \(error.localizedDescription)" + messages.append(last) + } + + isLoading = false } - } - - private func continueConversation(tools: [MessageParameter.Tool]) async throws { - let parameters = MessageParameter( - model: .claude37Sonnet, - messages: anthropicMessages, - maxTokens: 10000, - tools: tools - ) - - // Make non-streaming request to Claude - let message = try await service.createMessage(parameters) - - // Process all content elements with a for loop - for contentItem in message.content { - switch contentItem { - case .text(let text, _): - // Update the UI with the response - if var last = messages.popLast() { - last.isWaitingForFirstText = false - last.text = text - messages.append(last) - } - - // Add assistant response to history - anthropicMessages.append(MessageParameter.Message( - role: .assistant, - content: .text(text) - )) - - case .toolUse(let tool): - print("Tool use detected - Name: \(tool.name), ID: \(tool.id)") - - // Update UI to show tool use - if var last = messages.popLast() { - last.isWaitingForFirstText = false - last.text += "\n Using tool: \(tool.name)..." - messages.append(last) - } - - // Add the assistant message with tool use to message history - anthropicMessages.append(MessageParameter.Message( - role: .assistant, - content: .list([.toolUse(tool.id, tool.name, tool.input)]) - )) - - // Call tool via MCP - let toolResponse = await mcpLLMClient.callTool(name: tool.name, input: tool.input, debug: true) - print("Tool response: \(String(describing: toolResponse))") - - // Add tool result to conversation - if let toolResult = toolResponse { - // Add the assistant message with tool result - anthropicMessages.append(MessageParameter.Message( - role: .user, - content: .list([.toolResult(tool.id, toolResult)]) - )) - - // Now get a new response with the tool result - try await continueConversation(tools: tools) - } else { - // Handle tool failure - if var last = messages.popLast() { - last.isWaitingForFirstText = false - last.text = "There was an error using the tool \(tool.name)." - messages.append(last) - } - } - - case .thinking(_): - break - } + } + } + + private func continueConversation(tools: [MessageParameter.Tool]) async throws { + let parameters = MessageParameter( + model: .claude37Sonnet, + messages: anthropicMessages, + maxTokens: 10000, + tools: tools) + + // Make non-streaming request to Claude + let message = try await service.createMessage(parameters) + + // Process all content elements with a for loop + for contentItem in message.content { + switch contentItem { + case .text(let text, _): + // Update the UI with the response + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = text + messages.append(last) + } + + // Add assistant response to history + anthropicMessages.append(MessageParameter.Message( + role: .assistant, + content: .text(text))) + + case .toolUse(let tool): + print("Tool use detected - Name: \(tool.name), ID: \(tool.id)") + + // Update UI to show tool use + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text += "\n Using tool: \(tool.name)..." + messages.append(last) + } + + // Add the assistant message with tool use to message history + anthropicMessages.append(MessageParameter.Message( + role: .assistant, + content: .list([.toolUse(tool.id, tool.name, tool.input)]))) + + // Call tool via MCP + let toolResponse = await mcpLLMClient.callTool(name: tool.name, input: tool.input, debug: true) + print("Tool response: \(String(describing: toolResponse))") + + // Add tool result to conversation + if let toolResult = toolResponse { + // Add the assistant message with tool result + anthropicMessages.append(MessageParameter.Message( + role: .user, + content: .list([.toolResult(tool.id, toolResult)]))) + + // Now get a new response with the tool result + try await continueConversation(tools: tools) + } else { + // Handle tool failure + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = "There was an error using the tool \(tool.name)." + messages.append(last) + } + } + + case .thinking: + break } - } - - /// Clear the conversation - func clearConversation() { - messages.removeAll() - anthropicMessages.removeAll() - errorMessage = "" - isLoading = false - task?.cancel() - task = nil - } + } + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatInputView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatInputView.swift index fdb092d..02469d7 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatInputView.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatInputView.swift @@ -9,73 +9,75 @@ import SwiftUI /// A view for the user to enter chat messages struct ChatInputView: View { - - private enum FocusedField { - case newMessageText - } - - /// Is a streaming chat response in progress - let isStreamingResponse: Bool - - /// Callback invoked when the user taps the submit button or presses return - var didSubmit: (String) -> Void - - /// Callback invoked when the user taps on the stop button - var didTapStop: () -> Void - - /// State to collect new text messages - @State private var newMessageText: String = "" - @FocusState private var focusedField: FocusedField? - - var body: some View { - HStack(spacing:0){ - chatInputTextField - actionButton + + // MARK: Internal + + /// Is a streaming chat response in progress + let isStreamingResponse: Bool + + /// Callback invoked when the user taps the submit button or presses return + var didSubmit: (String) -> Void + + /// Callback invoked when the user taps on the stop button + var didTapStop: () -> Void + + var body: some View { + HStack(spacing: 0) { + chatInputTextField + actionButton + } + .padding(8) + } + + // MARK: Private + + private enum FocusedField { + case newMessageText + } + + /// State to collect new text messages + @State private var newMessageText = "" + @FocusState private var focusedField: FocusedField? + + private var chatInputTextField: some View { + TextField("Type a message", text: $newMessageText, axis: .vertical) + .focused($focusedField, equals: .newMessageText) + .scrollContentBackground(.hidden) + .lineLimit(5) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 30) + .stroke(.separator)) + .onAppear { + focusedField = .newMessageText } - .padding(8) - } - - private var chatInputTextField: some View { - TextField("Type a message", text: $newMessageText, axis: .vertical) - .focused($focusedField, equals: .newMessageText) - .scrollContentBackground(.hidden) - .lineLimit(5) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius:30) - .stroke(.separator) - ) - .onAppear { - focusedField = .newMessageText - } - .onSubmit { - didSubmit(newMessageText) - newMessageText = "" - } - } - - private var actionButton: some View { - Button { - if isStreamingResponse { - didTapStop() - } else { - didSubmit(newMessageText) - newMessageText = "" - } - } label:{ - Image(systemName: isStreamingResponse ? "stop.circle.fill" : "arrow.up.circle.fill") - .font(.title) - .foregroundColor((isStreamingResponse || !newMessageText.isEmpty) ? .primary : .secondary) - .frame(width:40, height:40) + .onSubmit { + didSubmit(newMessageText) + newMessageText = "" } - .buttonStyle(.plain) - .contentTransition(.symbolEffect(.replace)) - .padding(.horizontal, 8) - } + } + + private var actionButton: some View { + Button { + if isStreamingResponse { + didTapStop() + } else { + didSubmit(newMessageText) + newMessageText = "" + } + } label: { + Image(systemName: isStreamingResponse ? "stop.circle.fill" : "arrow.up.circle.fill") + .font(.title) + .foregroundColor((isStreamingResponse || !newMessageText.isEmpty) ? .primary : .secondary) + .frame(width: 40, height: 40) + } + .buttonStyle(.plain) + .contentTransition(.symbolEffect(.replace)) + .padding(.horizontal, 8) + } } #Preview { - ChatInputView(isStreamingResponse: false, didSubmit: { _ in }, didTapStop: { }) + ChatInputView(isStreamingResponse: false, didSubmit: { _ in }, didTapStop: { }) } - diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift index 548716a..1cc9551 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift @@ -9,78 +9,82 @@ import Foundation import SwiftUI struct ChatMessageView: View { - - /// The message to display - let message: ChatMessage - - /// Whether to animate in the chat bubble - let animateIn: Bool - - /// State used to animate in the chat bubble if `animateIn` is true - @State private var animationTrigger = false - - var body: some View { - HStack(alignment: .top, spacing: 12) { - chatIcon - VStack(alignment: .leading) { - chatName - chatBody - } - } - .opacity(bubbleOpacity) - .animation(.easeIn(duration: 0.75), value: animationTrigger) - .onAppear { - adjustAnimationTriggerIfNecessary() - } - } - - private var bubbleOpacity: Double { - guard animateIn else { - return 1 - } - return animationTrigger ? 1 : 0 - } - - private func adjustAnimationTriggerIfNecessary() { - guard animateIn else { - return + + // MARK: Internal + + /// The message to display + let message: ChatMessage + + /// Whether to animate in the chat bubble + let animateIn: Bool + + var body: some View { + HStack(alignment: .top, spacing: 12) { + chatIcon + VStack(alignment: .leading) { + chatName + chatBody } - animationTrigger = true - } - - private var chatIcon: some View { - Image(systemName: message.role == .user ? "person.circle.fill" : "lightbulb.circle") - .font(.title2) - .frame(width:24, height:24) - .foregroundColor(message.role == .user ? .primary : .orange) - } - - private var chatName: some View { - Text(message.role == .user ? "You" : "Claude") - .fontWeight(.bold) - .frame(maxWidth: .infinity, maxHeight:24, alignment: .leading) - } - - @ViewBuilder - private var chatBody: some View { - if message.role == .user { - Text(LocalizedStringKey(message.text)) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.primary) + } + .opacity(bubbleOpacity) + .animation(.easeIn(duration: 0.75), value: animationTrigger) + .onAppear { + adjustAnimationTriggerIfNecessary() + } + } + + // MARK: Private + + /// State used to animate in the chat bubble if `animateIn` is true + @State private var animationTrigger = false + + private var bubbleOpacity: Double { + guard animateIn else { + return 1 + } + return animationTrigger ? 1 : 0 + } + + private var chatIcon: some View { + Image(systemName: message.role == .user ? "person.circle.fill" : "lightbulb.circle") + .font(.title2) + .frame(width: 24, height: 24) + .foregroundColor(message.role == .user ? .primary : .orange) + } + + private var chatName: some View { + Text(message.role == .user ? "You" : "Claude") + .fontWeight(.bold) + .frame(maxWidth: .infinity, maxHeight: 24, alignment: .leading) + } + + @ViewBuilder + private var chatBody: some View { + if message.role == .user { + Text(LocalizedStringKey(message.text)) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.primary) + } else { + if message.isWaitingForFirstText { + ProgressView() } else { - if message.isWaitingForFirstText { - ProgressView() - } else { - Text(LocalizedStringKey(message.text)) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.primary) - } + Text(LocalizedStringKey(message.text)) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.primary) } - } + } + } + + private func adjustAnimationTriggerIfNecessary() { + guard animateIn else { + return + } + animationTrigger = true + } } #Preview { - ChatMessageView(message: ChatMessage(text: "hello", role: .user), animateIn: false) - .frame(maxWidth:.infinity) - .padding() + ChatMessageView(message: ChatMessage(text: "hello", role: .user), animateIn: false) + .frame(maxWidth: .infinity) + .padding() } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift index 0043b63..2f7b9d7 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift @@ -8,49 +8,58 @@ import Foundation import SwiftUI +// MARK: - ChatView + @MainActor struct ChatView: View { - - let chatManager: ChatManager - - var body: some View { - List { - ChatMessagesView(chatMessages: chatManager.messages) - .padding([.top, .leading, .trailing, .bottom]) - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .safeAreaInset(edge: .bottom) { - ChatInputView(isStreamingResponse: chatManager.isProcessing, - didSubmit: { sendMessage($0) }, - didTapStop: { chatManager.stop() }) - } - } - - private func sendMessage(_ message: String) { - guard !message.isEmpty else { return } - chatManager.send(message: ChatMessage(text: message, role: .user)) - } + + // MARK: Internal + + let chatManager: ChatManager + + var body: some View { + List { + ChatMessagesView(chatMessages: chatManager.messages) + .padding([.top, .leading, .trailing, .bottom]) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .safeAreaInset(edge: .bottom) { + ChatInputView( + isStreamingResponse: chatManager.isProcessing, + didSubmit: { sendMessage($0) }, + didTapStop: { chatManager.stop() }) + } + } + + // MARK: Private + + private func sendMessage(_ message: String) { + guard !message.isEmpty else { return } + chatManager.send(message: ChatMessage(text: message, role: .user)) + } } +// MARK: - ChatMessagesView + private struct ChatMessagesView: View { - /// Flags to prevent messages from animating in multiple times as dependencies that drive `body` change - @State private var shouldAnimateMessageIn = [UUID: Bool]() - let chatMessages: [ChatMessage] - - var body: some View { - VStack(alignment: .leading) { - ChatMessageView(message: ChatMessage(text: "How can I help you?", role: .assistant), animateIn: true) - .listRowSeparator(.hidden) - - ForEach(chatMessages) { message in - ChatMessageView(message: message, animateIn: shouldAnimateMessageIn[message.id] ?? true) - .listRowSeparator(.hidden) - .transition(.opacity) - .onAppear { - shouldAnimateMessageIn[message.id] = false - } - } + /// Flags to prevent messages from animating in multiple times as dependencies that drive `body` change + @State private var shouldAnimateMessageIn = [UUID: Bool]() + let chatMessages: [ChatMessage] + + var body: some View { + VStack(alignment: .leading) { + ChatMessageView(message: ChatMessage(text: "How can I help you?", role: .assistant), animateIn: true) + .listRowSeparator(.hidden) + + ForEach(chatMessages) { message in + ChatMessageView(message: message, animateIn: shouldAnimateMessageIn[message.id] ?? true) + .listRowSeparator(.hidden) + .transition(.opacity) + .onAppear { + shouldAnimateMessageIn[message.id] = false + } } - } + } + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/ContentView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/ContentView.swift index 70ee1dc..95d2af8 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/ContentView.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/ContentView.swift @@ -5,22 +5,22 @@ // Created by James Rochabrun on 3/3/25. // -import SwiftUI -import SwiftAnthropic import MCPClient +import SwiftAnthropic +import SwiftUI struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") } + .padding() + } } #Preview { - ContentView() + ContentView() } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift index 178183d..5aa745f 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift @@ -1,3 +1,5 @@ +// swiftlint:disable no_direct_standard_out_logs + // // GithubMCPClient.swift // MCPClientChat @@ -9,30 +11,32 @@ import Foundation import MCPClient import SwiftUI -final class GIthubMCPClient: MCPLLMClient { - - var client: MCPClient? - - init() { - Task { - do { - /// Need to define manually the `env` to be able to initialize client!!! - var env = ProcessInfo.processInfo.environment - env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + (env["PATH"] ?? "") - // customEnv["GITHUB_PERSONAL_ACCESS_TOKEN"] = "YOUR_GITHUB_PERSONAL_TOKEN" // Needed for write operations. - self.client = try await MCPClient( - info: .init(name: "GIthubMCPClient", version: "1.0.0"), - transport: .stdioProcess( - "npx", - args: ["-y", "@modelcontextprotocol/server-github"], - env: env, - verbose: true - ), - capabilities: .init() - ) - } catch { - print("Failed to initialize MCPClient: \(error)") - } +final class GithubMCPClient: MCPLLMClient { + + // MARK: Lifecycle + + init() { + Task { + do { + /// Need to define manually the `env` to be able to initialize client!!! +// var env = ProcessInfo.processInfo.environment +// env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + (env["PATH"] ?? "") + // customEnv["GITHUB_PERSONAL_ACCESS_TOKEN"] = "YOUR_GITHUB_PERSONAL_TOKEN" // Needed for write operations. + self.client = try await MCPClient( + info: .init(name: "GithubMCPClient", version: "1.0.0"), + transport: .stdioProcess( + "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + // env: env, + verbose: true), + capabilities: .init()) + } catch { + print("Failed to initialize MCPClient: \(error)") } - } + } + } + + // MARK: Internal + + var client: MCPClient? } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Interface/MCPLLMClient.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Interface/MCPLLMClient.swift index e5bf5d7..61286d3 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Interface/MCPLLMClient.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Interface/MCPLLMClient.swift @@ -1,4 +1,5 @@ -// +// swiftlint:disable no_direct_standard_out_logs + // MCPLLMClient.swift // MCPClientChat // @@ -10,103 +11,97 @@ import MCPClient import MCPInterface import SwiftAnthropic +// MARK: - MCPLLMClient + // TODO: James+Gui decide where this should live so it can be reused. -/** - * Protocol that bridges the MCP (Multi-Client Protocol) framework with LLM providers library. - * - * This protocol provides methods to: - * 1. Retrieve available tools from an MCP client and convert them to Anthropic's format - * 2. Execute tools with provided parameters and handle their responses - */ +/// Protocol that bridges the MCP (Multi-Client Protocol) framework with LLM providers library. +/// +/// This protocol provides methods to: +/// 1. Retrieve available tools from an MCP client and convert them to Anthropic's format +/// 2. Execute tools with provided parameters and handle their responses protocol MCPLLMClient { - - /// The underlying MCP client that processes requests - var client: MCPClient? { get } - /** - * Retrieves available tools from the MCP client and converts them to Anthropic's tool format. - * - * - Returns: An array of Anthropic-compatible tools - * - Throws: Errors from the underlying MCP client or during conversion process - */ - func tools() async throws -> [MessageParameter.Tool] - /** - * Executes a tool with the specified name and input parameters. - * - * - Parameters: - * - name: The identifier of the tool to call - * - input: Dictionary of parameters to pass to the tool - * - debug: Flag to enable verbose logging during execution - * - Returns: A string containing the tool's response, or `nil` if execution failed - */ - func callTool(name: String, input: [String: Any], debug: Bool) async -> String? + + /// The underlying MCP client that processes requests + var client: MCPClient? { get } + /// Retrieves available tools from the MCP client and converts them to Anthropic's tool format. + /// + /// - Returns: An array of Anthropic-compatible tools + /// - Throws: Errors from the underlying MCP client or during conversion process + func tools() async throws -> [MessageParameter.Tool] + /// Executes a tool with the specified name and input parameters. + /// + /// - Parameters: + /// - name: The identifier of the tool to call + /// - input: Dictionary of parameters to pass to the tool + /// - debug: Flag to enable verbose logging during execution + /// - Returns: A string containing the tool's response, or `nil` if execution failed + func callTool(name: String, input: [String: Any], debug: Bool) async -> String? } -/** - * Extension providing default implementations of the MCPSwiftAnthropicClient protocol. - */ +/// Extension providing default implementations of the MCPSwiftAnthropicClient protocol. extension MCPLLMClient { - - func tools() async throws -> [MessageParameter.Tool] { - guard let client else { return [] } - let tools = await client.tools - return try tools.value.get().map { $0.toAnthropicTool() } - } - - func callTool( - name: String, - input: [String: Any], - debug: Bool) - async -> String? { - - guard let client else { return nil } - do { - if debug { - print("🔧 Calling tool '\(name)'...") - } - - // Convert DynamicContent values to basic types - var serializableInput: [String: Any] = [:] - for (key, value) in input { - if let dynamicContent = value as? MessageResponse.Content.DynamicContent { - serializableInput[key] = dynamicContent.extractValue() - } else { - serializableInput[key] = value - } - } - - let inputData = try JSONSerialization.data(withJSONObject: serializableInput) - let inputJSON = try JSONDecoder().decode(JSON.self, from: inputData) - - let result = try await client.callTool(named: name, arguments: inputJSON) - - if result.isError != true { - if let content = result.content.first?.text?.text { - if debug { - print("✅ Tool execution successful") - } - return content - } else { - if debug { - print("⚠️ Tool returned no text content") - } - return nil - } - } else { - print("❌ Tool returned an error") - if let errorText = result.content.first?.text?.text { - if debug { - print(" Error: \(errorText)") - } - } - return nil - } - } catch { - if debug { - print("⛔️ Error calling tool: \(error)") - } - return nil + func tools() async throws -> [MessageParameter.Tool] { + guard let client else { return [] } + let tools = await client.tools + return try tools.value.get().map { $0.toAnthropicTool() } + } + + func callTool( + name: String, + input: [String: Any], + debug: Bool) + async -> String? + { + guard let client else { return nil } + + do { + if debug { + print("🔧 Calling tool '\(name)'...") + } + + // Convert DynamicContent values to basic types + var serializableInput: [String: Any] = [:] + for (key, value) in input { + if let dynamicContent = value as? MessageResponse.Content.DynamicContent { + serializableInput[key] = dynamicContent.extractValue() + } else { + serializableInput[key] = value + } + } + + let inputData = try JSONSerialization.data(withJSONObject: serializableInput) + let inputJSON = try JSONDecoder().decode(JSON.self, from: inputData) + + let result = try await client.callTool(named: name, arguments: inputJSON) + + if result.isError != true { + if let content = result.content.first?.text?.text { + if debug { + print("✅ Tool execution successful") + } + return content + } else { + if debug { + print("⚠️ Tool returned no text content") + } + return nil + } + } else { + print("❌ Tool returned an error") + if let errorText = result.content.first?.text?.text { + if debug { + print(" Error: \(errorText)") + } + } + return nil + } + } catch { + if debug { + print("⛔️ Error calling tool: \(error)") } - } + return nil + } + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/MCPInterfaceTool+MessagePrameterTool.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/MCPInterfaceTool+MessagePrameterTool.swift index fc1a0ef..7c5e7bf 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/MCPInterfaceTool+MessagePrameterTool.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/MCPInterfaceTool+MessagePrameterTool.swift @@ -13,416 +13,482 @@ import SwiftAnthropic // TODO: James+Gui decide where this should live so it can be reused. extension MCPInterface.Tool { - - /** - * Converts an MCP interface tool to SwiftAnthropic's tool format. - * - * This function transforms the tool's metadata and schema structure from - * the MCP format to the format expected by the Anthropic API, ensuring - * compatibility between the two systems. - * - * - Returns: A `SwiftAnthropic.MessageParameter.Tool` representing the same - * functionality as the original MCP tool. - */ - public func toAnthropicTool() -> SwiftAnthropic.MessageParameter.Tool { - // Convert the JSON to SwiftAnthropic.JSONSchema - let anthropicInputSchema: SwiftAnthropic.MessageParameter.Tool.JSONSchema? - - switch self.inputSchema { - case .object(let value): - anthropicInputSchema = convertToAnthropicJSONSchema(from: value) - case .array(_): - // Arrays are not directly supported in the schema root - anthropicInputSchema = nil - } - - return SwiftAnthropic.MessageParameter.Tool( - name: self.name, - description: self.description, - inputSchema: anthropicInputSchema, - cacheControl: nil - ) - } - - /** - * Converts MCP JSON object to SwiftAnthropic JSONSchema format. - * - * This helper function transforms a JSON schema object from MCP format to the - * corresponding Anthropic format, handling the root schema properties. - * - * - Parameter jsonObject: Dictionary containing MCP JSON schema properties - * - Returns: An equivalent SwiftAnthropic JSONSchema object, or nil if conversion fails - */ - private func convertToAnthropicJSONSchema(from jsonObject: [String: MCPInterface.JSON.Value]) -> SwiftAnthropic.MessageParameter.Tool.JSONSchema? { - guard let typeValue = jsonObject["type"], - case .string(let typeString) = typeValue, - let jsonType = SwiftAnthropic.MessageParameter.Tool.JSONSchema.JSONType(rawValue: typeString) else { - return nil - } - - // Extract properties - var properties: [String: SwiftAnthropic.MessageParameter.Tool.JSONSchema.Property]? = nil - if let propertiesValue = jsonObject["properties"], - case .object(let propertiesObject) = propertiesValue { - properties = [:] - for (key, value) in propertiesObject { - if case .object(let propertyObject) = value, - let property = convertToAnthropicProperty(from: propertyObject) { - properties?[key] = property - } - } - } - - // Extract required fields - var required: [String]? = nil - if let requiredValue = jsonObject["required"], - case .array(let requiredArray) = requiredValue { - required = [] - for item in requiredArray { - if case .string(let field) = item { - required?.append(field) - } - } - } - - // Extract pattern - var pattern: String? = nil - if let patternValue = jsonObject["pattern"], - case .string(let patternString) = patternValue { - pattern = patternString - } - - // Extract const - var constValue: String? = nil - if let constVal = jsonObject["const"], - case .string(let constString) = constVal { - constValue = constString - } - - // Extract enum values - var enumValues: [String]? = nil - if let enumValue = jsonObject["enum"], - case .array(let enumArray) = enumValue { - enumValues = [] - for item in enumArray { - if case .string(let value) = item { - enumValues?.append(value) - } - } - } - - // Extract multipleOf - var multipleOf: Int? = nil - if let multipleOfValue = jsonObject["multipleOf"], - case .number(let multipleOfDouble) = multipleOfValue { - multipleOf = Int(multipleOfDouble) - } - - // Extract minimum - var minimum: Int? = nil - if let minimumValue = jsonObject["minimum"], - case .number(let minimumDouble) = minimumValue { - minimum = Int(minimumDouble) - } - - // Extract maximum - var maximum: Int? = nil - if let maximumValue = jsonObject["maximum"], - case .number(let maximumDouble) = maximumValue { - maximum = Int(maximumDouble) - } - - return SwiftAnthropic.MessageParameter.Tool.JSONSchema( - type: jsonType, - properties: properties, - required: required, - pattern: pattern, - const: constValue, - enumValues: enumValues, - multipleOf: multipleOf, - minimum: minimum, - maximum: maximum - ) - } - - /** - * Converts MCP property object to SwiftAnthropic Property format. - * - * This helper function transforms a property definition from MCP format to the - * corresponding Anthropic format, preserving all relevant attributes. - * - * - Parameter propertyObject: Dictionary containing MCP property schema - * - Returns: An equivalent SwiftAnthropic Property object, or nil if conversion fails - */ - private func convertToAnthropicProperty(from propertyObject: [String: MCPInterface.JSON.Value]) -> SwiftAnthropic.MessageParameter.Tool.JSONSchema.Property? { - guard let typeValue = propertyObject["type"], - case .string(let typeString) = typeValue, - let jsonType = SwiftAnthropic.MessageParameter.Tool.JSONSchema.JSONType(rawValue: typeString) else { - return nil - } - - // Extract description - var description: String? = nil - if let descValue = propertyObject["description"], - case .string(let descString) = descValue { - description = descString - } - - // Extract format - var format: String? = nil - if let formatValue = propertyObject["format"], - case .string(let formatString) = formatValue { - format = formatString - } - - // Extract items - var items: SwiftAnthropic.MessageParameter.Tool.JSONSchema.Items? = nil - if let itemsValue = propertyObject["items"], - case .object(let itemsObject) = itemsValue { - items = convertToAnthropicItems(from: itemsObject) - } - - // Extract required fields - var required: [String]? = nil - if let requiredValue = propertyObject["required"], - case .array(let requiredArray) = requiredValue { - required = [] - for item in requiredArray { - if case .string(let field) = item { - required?.append(field) - } - } - } - - // Extract pattern - var pattern: String? = nil - if let patternValue = propertyObject["pattern"], - case .string(let patternString) = patternValue { - pattern = patternString - } - - // Extract const - var constValue: String? = nil - if let constVal = propertyObject["const"], - case .string(let constString) = constVal { - constValue = constString - } - - // Extract enum values - var enumValues: [String]? = nil - if let enumValue = propertyObject["enum"], - case .array(let enumArray) = enumValue { - enumValues = [] - for item in enumArray { - if case .string(let value) = item { - enumValues?.append(value) - } - } - } - - // Extract multipleOf - var multipleOf: Int? = nil - if let multipleOfValue = propertyObject["multipleOf"], - case .number(let multipleOfDouble) = multipleOfValue { - multipleOf = Int(multipleOfDouble) - } - - // Extract minimum - var minimum: Double? = nil - if let minimumValue = propertyObject["minimum"], - case .number(let minimumDouble) = minimumValue { - minimum = minimumDouble - } - - // Extract maximum - var maximum: Double? = nil - if let maximumValue = propertyObject["maximum"], - case .number(let maximumDouble) = maximumValue { - maximum = maximumDouble - } - - // Extract minItems - var minItems: Int? = nil - if let minItemsValue = propertyObject["minItems"], - case .number(let minItemsDouble) = minItemsValue { - minItems = Int(minItemsDouble) - } - - // Extract maxItems - var maxItems: Int? = nil - if let maxItemsValue = propertyObject["maxItems"], - case .number(let maxItemsDouble) = maxItemsValue { - maxItems = Int(maxItemsDouble) - } - - // Extract uniqueItems - var uniqueItems: Bool? = nil - if let uniqueItemsValue = propertyObject["uniqueItems"], - case .bool(let uniqueItemsBool) = uniqueItemsValue { - uniqueItems = uniqueItemsBool - } - - return SwiftAnthropic.MessageParameter.Tool.JSONSchema.Property( - type: jsonType, - description: description, - format: format, - items: items, - required: required, - pattern: pattern, - const: constValue, - enumValues: enumValues, - multipleOf: multipleOf, - minimum: minimum, - maximum: maximum, - minItems: minItems, - maxItems: maxItems, - uniqueItems: uniqueItems - ) - } - - /** - * Converts MCP items object to SwiftAnthropic Items format. - * - * This helper function transforms an items definition from MCP format to the - * corresponding Anthropic format, used primarily for array type properties. - * - * - Parameter itemsObject: Dictionary containing MCP items schema - * - Returns: An equivalent SwiftAnthropic Items object, or nil if conversion fails - */ - private func convertToAnthropicItems(from itemsObject: [String: MCPInterface.JSON.Value]) -> SwiftAnthropic.MessageParameter.Tool.JSONSchema.Items? { - guard let typeValue = itemsObject["type"], - case .string(let typeString) = typeValue, - let jsonType = SwiftAnthropic.MessageParameter.Tool.JSONSchema.JSONType(rawValue: typeString) else { - return nil - } - - // Extract properties - var properties: [String: SwiftAnthropic.MessageParameter.Tool.JSONSchema.Property]? = nil - if let propertiesValue = itemsObject["properties"], - case .object(let propertiesObject) = propertiesValue { - properties = [:] - for (key, value) in propertiesObject { - if case .object(let propertyObject) = value, - let property = convertToAnthropicProperty(from: propertyObject) { - properties?[key] = property - } - } - } - - // Extract pattern - var pattern: String? = nil - if let patternValue = itemsObject["pattern"], - case .string(let patternString) = patternValue { - pattern = patternString - } - - // Extract const - var constValue: String? = nil - if let constVal = itemsObject["const"], - case .string(let constString) = constVal { - constValue = constString - } - - // Extract enum values - var enumValues: [String]? = nil - if let enumValue = itemsObject["enum"], - case .array(let enumArray) = enumValue { - enumValues = [] - for item in enumArray { - if case .string(let value) = item { - enumValues?.append(value) - } - } - } - - // Extract multipleOf - var multipleOf: Int? = nil - if let multipleOfValue = itemsObject["multipleOf"], - case .number(let multipleOfDouble) = multipleOfValue { - multipleOf = Int(multipleOfDouble) - } - - // Extract minimum - var minimum: Double? = nil - if let minimumValue = itemsObject["minimum"], - case .number(let minimumDouble) = minimumValue { - minimum = minimumDouble - } - - // Extract maximum - var maximum: Double? = nil - if let maximumValue = itemsObject["maximum"], - case .number(let maximumDouble) = maximumValue { - maximum = maximumDouble - } - - // Extract minItems - var minItems: Int? = nil - if let minItemsValue = itemsObject["minItems"], - case .number(let minItemsDouble) = minItemsValue { - minItems = Int(minItemsDouble) - } - - // Extract maxItems - var maxItems: Int? = nil - if let maxItemsValue = itemsObject["maxItems"], - case .number(let maxItemsDouble) = maxItemsValue { - maxItems = Int(maxItemsDouble) - } - - // Extract uniqueItems - var uniqueItems: Bool? = nil - if let uniqueItemsValue = itemsObject["uniqueItems"], - case .bool(let uniqueItemsBool) = uniqueItemsValue { - uniqueItems = uniqueItemsBool - } - - return SwiftAnthropic.MessageParameter.Tool.JSONSchema.Items( - type: jsonType, - properties: properties, - pattern: pattern, - const: constValue, - enumValues: enumValues, - multipleOf: multipleOf, - minimum: minimum, - maximum: maximum, - minItems: minItems, - maxItems: maxItems, - uniqueItems: uniqueItems - ) - } + + // MARK: Public + + /// Converts an MCP interface tool to SwiftAnthropic's tool format. + /// + /// This function transforms the tool's metadata and schema structure from + /// the MCP format to the format expected by the Anthropic API, ensuring + /// compatibility between the two systems. + /// + /// - Returns: A `SwiftAnthropic.MessageParameter.Tool` representing the same + /// functionality as the original MCP tool. + public func toAnthropicTool() -> SwiftAnthropic.MessageParameter.Tool { + // Convert the JSON to SwiftAnthropic.JSONSchema + let anthropicInputSchema: SwiftAnthropic.MessageParameter.Tool.JSONSchema? + + switch inputSchema { + case .object(let value): + anthropicInputSchema = convertToAnthropicJSONSchema(from: value) + case .array: + // Arrays are not directly supported in the schema root + anthropicInputSchema = nil + } + + return SwiftAnthropic.MessageParameter.Tool( + name: name, + description: description, + inputSchema: anthropicInputSchema, + cacheControl: nil) + } + + // MARK: Private + + /// Converts MCP JSON object to SwiftAnthropic JSONSchema format. + /// + /// This helper function transforms a JSON schema object from MCP format to the + /// corresponding Anthropic format, handling the root schema properties. + /// + /// - Parameter jsonObject: Dictionary containing MCP JSON schema properties + /// - Returns: An equivalent SwiftAnthropic JSONSchema object, or nil if conversion fails + private func convertToAnthropicJSONSchema(from jsonObject: [String: MCPInterface.JSON.Value]) -> SwiftAnthropic + .MessageParameter.Tool.JSONSchema? + { + guard + let typeValue = jsonObject["type"], + case .string(let typeString) = typeValue, + let jsonType = SwiftAnthropic.MessageParameter.Tool.JSONSchema.JSONType(rawValue: typeString) + else { + return nil + } + + // Extract properties + var properties: [String: SwiftAnthropic.MessageParameter.Tool.JSONSchema.Property]? = nil + if + let propertiesValue = jsonObject["properties"], + case .object(let propertiesObject) = propertiesValue + { + properties = [:] + for (key, value) in propertiesObject { + if + case .object(let propertyObject) = value, + let property = convertToAnthropicProperty(from: propertyObject) + { + properties?[key] = property + } + } + } + + // Extract required fields + var required: [String]? = nil + if + let requiredValue = jsonObject["required"], + case .array(let requiredArray) = requiredValue + { + required = [] + for item in requiredArray { + if case .string(let field) = item { + required?.append(field) + } + } + } + + // Extract pattern + var pattern: String? = nil + if + let patternValue = jsonObject["pattern"], + case .string(let patternString) = patternValue + { + pattern = patternString + } + + // Extract const + var constValue: String? = nil + if + let constVal = jsonObject["const"], + case .string(let constString) = constVal + { + constValue = constString + } + + // Extract enum values + var enumValues: [String]? = nil + if + let enumValue = jsonObject["enum"], + case .array(let enumArray) = enumValue + { + enumValues = [] + for item in enumArray { + if case .string(let value) = item { + enumValues?.append(value) + } + } + } + + // Extract multipleOf + var multipleOf: Int? = nil + if + let multipleOfValue = jsonObject["multipleOf"], + case .number(let multipleOfDouble) = multipleOfValue + { + multipleOf = Int(multipleOfDouble) + } + + // Extract minimum + var minimum: Int? = nil + if + let minimumValue = jsonObject["minimum"], + case .number(let minimumDouble) = minimumValue + { + minimum = Int(minimumDouble) + } + + // Extract maximum + var maximum: Int? = nil + if + let maximumValue = jsonObject["maximum"], + case .number(let maximumDouble) = maximumValue + { + maximum = Int(maximumDouble) + } + + return SwiftAnthropic.MessageParameter.Tool.JSONSchema( + type: jsonType, + properties: properties, + required: required, + pattern: pattern, + const: constValue, + enumValues: enumValues, + multipleOf: multipleOf, + minimum: minimum, + maximum: maximum) + } + + /// Converts MCP property object to SwiftAnthropic Property format. + /// + /// This helper function transforms a property definition from MCP format to the + /// corresponding Anthropic format, preserving all relevant attributes. + /// + /// - Parameter propertyObject: Dictionary containing MCP property schema + /// - Returns: An equivalent SwiftAnthropic Property object, or nil if conversion fails + private func convertToAnthropicProperty(from propertyObject: [String: MCPInterface.JSON.Value]) -> SwiftAnthropic + .MessageParameter.Tool.JSONSchema.Property? + { + guard + let typeValue = propertyObject["type"], + case .string(let typeString) = typeValue, + let jsonType = SwiftAnthropic.MessageParameter.Tool.JSONSchema.JSONType(rawValue: typeString) + else { + return nil + } + + // Extract description + var description: String? = nil + if + let descValue = propertyObject["description"], + case .string(let descString) = descValue + { + description = descString + } + + // Extract format + var format: String? = nil + if + let formatValue = propertyObject["format"], + case .string(let formatString) = formatValue + { + format = formatString + } + + // Extract items + var items: SwiftAnthropic.MessageParameter.Tool.JSONSchema.Items? = nil + if + let itemsValue = propertyObject["items"], + case .object(let itemsObject) = itemsValue + { + items = convertToAnthropicItems(from: itemsObject) + } + + // Extract required fields + var required: [String]? = nil + if + let requiredValue = propertyObject["required"], + case .array(let requiredArray) = requiredValue + { + required = [] + for item in requiredArray { + if case .string(let field) = item { + required?.append(field) + } + } + } + + // Extract pattern + var pattern: String? = nil + if + let patternValue = propertyObject["pattern"], + case .string(let patternString) = patternValue + { + pattern = patternString + } + + // Extract const + var constValue: String? = nil + if + let constVal = propertyObject["const"], + case .string(let constString) = constVal + { + constValue = constString + } + + // Extract enum values + var enumValues: [String]? = nil + if + let enumValue = propertyObject["enum"], + case .array(let enumArray) = enumValue + { + enumValues = [] + for item in enumArray { + if case .string(let value) = item { + enumValues?.append(value) + } + } + } + + // Extract multipleOf + var multipleOf: Int? = nil + if + let multipleOfValue = propertyObject["multipleOf"], + case .number(let multipleOfDouble) = multipleOfValue + { + multipleOf = Int(multipleOfDouble) + } + + // Extract minimum + var minimum: Double? = nil + if + let minimumValue = propertyObject["minimum"], + case .number(let minimumDouble) = minimumValue + { + minimum = minimumDouble + } + + // Extract maximum + var maximum: Double? = nil + if + let maximumValue = propertyObject["maximum"], + case .number(let maximumDouble) = maximumValue + { + maximum = maximumDouble + } + + // Extract minItems + var minItems: Int? = nil + if + let minItemsValue = propertyObject["minItems"], + case .number(let minItemsDouble) = minItemsValue + { + minItems = Int(minItemsDouble) + } + + // Extract maxItems + var maxItems: Int? = nil + if + let maxItemsValue = propertyObject["maxItems"], + case .number(let maxItemsDouble) = maxItemsValue + { + maxItems = Int(maxItemsDouble) + } + + // Extract uniqueItems + var uniqueItems: Bool? = nil + if + let uniqueItemsValue = propertyObject["uniqueItems"], + case .bool(let uniqueItemsBool) = uniqueItemsValue + { + uniqueItems = uniqueItemsBool + } + + return SwiftAnthropic.MessageParameter.Tool.JSONSchema.Property( + type: jsonType, + description: description, + format: format, + items: items, + required: required, + pattern: pattern, + const: constValue, + enumValues: enumValues, + multipleOf: multipleOf, + minimum: minimum, + maximum: maximum, + minItems: minItems, + maxItems: maxItems, + uniqueItems: uniqueItems) + } + + /// Converts MCP items object to SwiftAnthropic Items format. + /// + /// This helper function transforms an items definition from MCP format to the + /// corresponding Anthropic format, used primarily for array type properties. + /// + /// - Parameter itemsObject: Dictionary containing MCP items schema + /// - Returns: An equivalent SwiftAnthropic Items object, or nil if conversion fails + private func convertToAnthropicItems(from itemsObject: [String: MCPInterface.JSON.Value]) -> SwiftAnthropic.MessageParameter + .Tool.JSONSchema.Items? + { + guard + let typeValue = itemsObject["type"], + case .string(let typeString) = typeValue, + let jsonType = SwiftAnthropic.MessageParameter.Tool.JSONSchema.JSONType(rawValue: typeString) + else { + return nil + } + + // Extract properties + var properties: [String: SwiftAnthropic.MessageParameter.Tool.JSONSchema.Property]? = nil + if + let propertiesValue = itemsObject["properties"], + case .object(let propertiesObject) = propertiesValue + { + properties = [:] + for (key, value) in propertiesObject { + if + case .object(let propertyObject) = value, + let property = convertToAnthropicProperty(from: propertyObject) + { + properties?[key] = property + } + } + } + + // Extract pattern + var pattern: String? = nil + if + let patternValue = itemsObject["pattern"], + case .string(let patternString) = patternValue + { + pattern = patternString + } + + // Extract const + var constValue: String? = nil + if + let constVal = itemsObject["const"], + case .string(let constString) = constVal + { + constValue = constString + } + + // Extract enum values + var enumValues: [String]? = nil + if + let enumValue = itemsObject["enum"], + case .array(let enumArray) = enumValue + { + enumValues = [] + for item in enumArray { + if case .string(let value) = item { + enumValues?.append(value) + } + } + } + + // Extract multipleOf + var multipleOf: Int? = nil + if + let multipleOfValue = itemsObject["multipleOf"], + case .number(let multipleOfDouble) = multipleOfValue + { + multipleOf = Int(multipleOfDouble) + } + + // Extract minimum + var minimum: Double? = nil + if + let minimumValue = itemsObject["minimum"], + case .number(let minimumDouble) = minimumValue + { + minimum = minimumDouble + } + + // Extract maximum + var maximum: Double? = nil + if + let maximumValue = itemsObject["maximum"], + case .number(let maximumDouble) = maximumValue + { + maximum = maximumDouble + } + + // Extract minItems + var minItems: Int? = nil + if + let minItemsValue = itemsObject["minItems"], + case .number(let minItemsDouble) = minItemsValue + { + minItems = Int(minItemsDouble) + } + + // Extract maxItems + var maxItems: Int? = nil + if + let maxItemsValue = itemsObject["maxItems"], + case .number(let maxItemsDouble) = maxItemsValue + { + maxItems = Int(maxItemsDouble) + } + + // Extract uniqueItems + var uniqueItems: Bool? = nil + if + let uniqueItemsValue = itemsObject["uniqueItems"], + case .bool(let uniqueItemsBool) = uniqueItemsValue + { + uniqueItems = uniqueItemsBool + } + + return SwiftAnthropic.MessageParameter.Tool.JSONSchema.Items( + type: jsonType, + properties: properties, + pattern: pattern, + const: constValue, + enumValues: enumValues, + multipleOf: multipleOf, + minimum: minimum, + maximum: maximum, + minItems: minItems, + maxItems: maxItems, + uniqueItems: uniqueItems) + } } -/** - * Extension for extracting primitive values from MessageResponse.Content.DynamicContent. - * This enables proper serialization of dynamic content to JSON for tool calls. - */ +/// Extension for extracting primitive values from MessageResponse.Content.DynamicContent. +/// This enables proper serialization of dynamic content to JSON for tool calls. extension MessageResponse.Content.DynamicContent { - /** - * Extracts the underlying primitive value from DynamicContent for JSON serialization. - * - * This method recursively unwraps dictionary and array values to ensure all - * nested dynamic content is properly converted to basic types that can be - * serialized to JSON. - * - * - Returns: An equivalent value using only basic Swift types (String, Int, Double, etc.) - */ - func extractValue() -> Any { - switch self { - case .string(let value): - return value - case .integer(let value): - return value - case .double(let value): - return value - case .dictionary(let value): - return value.mapValues { $0.extractValue() } - case .array(let value): - return value.map { $0.extractValue() } - case .bool(let value): - return value - case .null: - return NSNull() - } - } + /// Extracts the underlying primitive value from DynamicContent for JSON serialization. + /// + /// This method recursively unwraps dictionary and array values to ensure all + /// nested dynamic content is properly converted to basic types that can be + /// serialized to JSON. + /// + /// - Returns: An equivalent value using only basic Swift types (String, Int, Double, etc.) + func extractValue() -> Any { + switch self { + case .string(let value): + return value + case .integer(let value): + return value + case .double(let value): + return value + case .dictionary(let value): + return value.mapValues { $0.extractValue() } + case .array(let value): + return value.map { $0.extractValue() } + case .bool(let value): + return value + case .null: + return NSNull() + } + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift index c9a297c..220dd57 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift @@ -5,27 +5,28 @@ // Created by James Rochabrun on 3/3/25. // -import SwiftUI import SwiftAnthropic +import SwiftUI @main struct MCPClientChatApp: App { - - @State private var chatManager = ChatNonStreamManager( - service: AnthropicServiceFactory.service(apiKey: "YOUR_API_KEY", betaHeaders: nil, debugEnabled: true), - mcpLLMClient: GIthubMCPClient()) - - var body: some Scene { - WindowGroup { - ChatView(chatManager: chatManager) - .toolbar(removing: .title) - .containerBackground( - .thinMaterial, for: .window - ) - .toolbarBackgroundVisibility( - .hidden, for: .windowToolbar - ) - } - .windowStyle(.hiddenTitleBar) - } + + @State private var chatManager = ChatNonStreamManager( + service: AnthropicServiceFactory.service( + apiKey: "YOUR_API_KEY", + betaHeaders: nil, + debugEnabled: true), + mcpLLMClient: GithubMCPClient()) + + var body: some Scene { + WindowGroup { + ChatView(chatManager: chatManager) + .toolbar(removing: .title) + .containerBackground( + .thinMaterial, for: .window) + .toolbarBackgroundVisibility( + .hidden, for: .windowToolbar) + } + .windowStyle(.hiddenTitleBar) + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChatTests/MCPClientChatTests.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChatTests/MCPClientChatTests.swift index 47ce04c..359d9a8 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChatTests/MCPClientChatTests.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChatTests/MCPClientChatTests.swift @@ -10,8 +10,9 @@ import Testing struct MCPClientChatTests { - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } + @Test + func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITests.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITests.swift index fb48749..dff73bf 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITests.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITests.swift @@ -9,35 +9,35 @@ import XCTest final class MCPClientChatUITests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - @MainActor - func testLaunchPerformance() throws { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } } + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITestsLaunchTests.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITestsLaunchTests.swift index e3c1b66..56e4095 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITestsLaunchTests.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChatUITests/MCPClientChatUITestsLaunchTests.swift @@ -9,25 +9,25 @@ import XCTest final class MCPClientChatUITestsLaunchTests: XCTestCase { - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } - override func setUpWithError() throws { - continueAfterFailure = false - } + override func setUpWithError() throws { + continueAfterFailure = false + } - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } } From 6a13f1371dfd27fd8c1c035ee81eb5b32f080ab8 Mon Sep 17 00:00:00 2001 From: Gui Sabran Date: Wed, 5 Mar 2025 09:47:55 -0800 Subject: [PATCH 11/22] fix json data being spread over several chunks received from stdio --- .../DataChannel+StdioProcess.swift | 26 ++- .../Sources/helpers/JSON+Streaming.swift | 137 ++++++++++++ .../Tests/helpers/JSON+streaming.swift | 201 ++++++++++++++++++ ...erializationDeserializationTestUtils.swift | 9 +- MCPServer/Sources/DataChannel+stdio.swift | 3 +- Package.swift | 1 + 6 files changed, 365 insertions(+), 12 deletions(-) rename MCPClient/Sources/{ => stdioTransport}/DataChannel+StdioProcess.swift (91%) create mode 100644 MCPInterface/Sources/helpers/JSON+Streaming.swift create mode 100644 MCPInterface/Tests/helpers/JSON+streaming.swift diff --git a/MCPClient/Sources/DataChannel+StdioProcess.swift b/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift similarity index 91% rename from MCPClient/Sources/DataChannel+StdioProcess.swift rename to MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift index d9ef9d3..4cd4a1d 100644 --- a/MCPClient/Sources/DataChannel+StdioProcess.swift +++ b/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift @@ -1,6 +1,7 @@ import Foundation import JSONRPC +import MCPInterface import OSLog private let logger = Logger( @@ -42,17 +43,18 @@ extension JSONRPCSetupError: LocalizedError { } } -extension DataChannel { +extension Transport { // MARK: Public + /// Creates a new `Transport` by launching the given executable with the specified arguments and attaching to its standard IO. public static func stdioProcess( _ executable: String, args: [String] = [], cwd: String? = nil, env: [String: String]? = nil, verbose: Bool = false) - throws -> DataChannel + throws -> Transport { if verbose { let command = "\(executable) \(args.joined(separator: " "))" @@ -103,10 +105,11 @@ extension DataChannel { return try stdioProcess(unlaunchedProcess: process, verbose: verbose) } + /// Creates a new `Transport` by launching the given process and attaching to its standard IO. public static func stdioProcess( unlaunchedProcess process: Process, verbose: Bool = false) - throws -> DataChannel + throws -> Transport { guard let stdin = process.standardInput as? Pipe, @@ -119,9 +122,10 @@ extension DataChannel { // Run the process var stdoutData = Data() var stderrData = Data() - let outStream: AsyncStream if verbose { + var truncatedData = Data() + // As we are both reading stdout here in this function, and want to make the stream readable to the caller, // we read the data from the process's stdout, process it and then re-yield it to the caller to a new stream. // This is because an AsyncStream can have only one reader. @@ -131,7 +135,7 @@ extension DataChannel { } Task { - for await data in stdout.fileHandleForReading.dataStream { + for await data in stdout.fileHandleForReading.dataStream.jsonStream { stdoutData.append(data) outContinuation?.yield(data) @@ -143,17 +147,19 @@ extension DataChannel { if stdout.fileHandleForReading.fileDescriptor != stderr.fileHandleForReading.fileDescriptor { Task { for await data in stderr.fileHandleForReading.dataStream { - logger.log("Received error:\n\(String(data: data, encoding: .utf8) ?? "nil")") + if verbose { + logger.log("Received error:\n\(String(data: data, encoding: .utf8) ?? "nil")") + } stderrData.append(data) } } } } else { // If we are not in verbose mode, we are not reading from stdout internally, so we can just return the stream directly. - outStream = stdout.fileHandleForReading.dataStream + outStream = stdout.fileHandleForReading.dataStream.jsonStream } - // Ensures that the process is terminated when the DataChannel is de-referenced. + // Ensures that the process is terminated when the Transport is de-referenced. let lifetime = Lifetime { if process.isRunning { process.terminate() @@ -177,7 +183,7 @@ extension DataChannel { throw error } - let writeHandler: DataChannel.WriteHandler = { [lifetime] data in + let writeHandler: Transport.WriteHandler = { [lifetime] data in _ = lifetime if verbose { logger.log("Sending data:\n\(String(data: data, encoding: .utf8) ?? "nil")") @@ -188,7 +194,7 @@ extension DataChannel { stdin.fileHandleForWriting.write(Data("\n".utf8)) } - return DataChannel(writeHandler: writeHandler, dataSequence: outStream) + return Transport(writeHandler: writeHandler, dataSequence: outStream) } // MARK: Private diff --git a/MCPInterface/Sources/helpers/JSON+Streaming.swift b/MCPInterface/Sources/helpers/JSON+Streaming.swift new file mode 100644 index 0000000..6a2e118 --- /dev/null +++ b/MCPInterface/Sources/helpers/JSON+Streaming.swift @@ -0,0 +1,137 @@ +import Foundation + +extension AsyncStream { + /// Given a stream of Data that represents valid JSON objects, but that might be received over several chunks, + /// or concatenated within the same chunk, return a stream of Data objects, each representing a valid JSON object. + public var jsonStream: AsyncStream { + let (stream, continuation) = AsyncStream.makeStream() + Task { + var truncatedData = Data() + for await data in self { + truncatedData.append(data) + let (jsonObjects, newTruncatedData) = truncatedData.parseJSONObjects() + truncatedData = newTruncatedData ?? Data() + + for jsonObject in jsonObjects { + continuation.yield(jsonObject) + } + } + continuation.finish() + } + return stream + } +} + +extension Data { + + // MARK: Internal + + /// Given a Data object that represents one or several valid JSON objects concatenated together, with the last one possibly truncated, + /// return a list of Data objects, each representing a valid JSON object, as well as the optional truncated data. + func parseJSONObjects() -> (objects: [Data], truncatedData: Data?) { + var objects = [Data]() + var isEscaping = false + var isInString = false + + var openBraceCount = 0 + var currentChunkStartIndex: Int? = 0 + + for (idx, byte) in enumerated() { + if isEscaping { + isEscaping = false + continue + } + + if byte == Self.escape { + isEscaping = true + continue + } + + if byte == Self.quote { + isInString = !isInString + continue + } + + if !isInString { + if byte == Self.openBrace { + if openBraceCount == 0 { + currentChunkStartIndex = idx + } + openBraceCount += 1 + } else if byte == Self.closeBrace { + openBraceCount -= 1 + + if openBraceCount == 0, let startIndex = currentChunkStartIndex { + let object = self[self.startIndex.advanced(by: startIndex) ..< self.startIndex.advanced(by: idx + 1)] + objects.append(object) + currentChunkStartIndex = nil + } + } + } + } + + let truncatedData: Data? + if let lastChunkStartIndex = currentChunkStartIndex { + truncatedData = self[startIndex.advanced(by: lastChunkStartIndex) ..< startIndex.advanced(by: count)] + } else { + truncatedData = nil + } + + return (objects: objects, truncatedData: truncatedData) + } + + // MARK: Private + + private static let openBrace = UInt8(ascii: "{") + private static let closeBrace = UInt8(ascii: "}") + private static let quote = UInt8(ascii: "\"") + private static let escape = UInt8(ascii: "\\") + + /// Given a Data object that represents one or several valid JSON objects concatenated together, with the last one possibly unfinished, + /// return a list of Data objects, each representing a valid JSON object. + private var jsonObjects: [Data] { + var chunks = [Data]() + var isEscaping = false + var isInString = false + + var openBraceCount = 0 + var currentChunkStartIndex: Int? = 0 + + for (idx, byte) in enumerated() { + if isEscaping { + isEscaping = false + continue + } + + if byte == Self.escape { + isEscaping = true + continue + } + + if byte == Self.quote { + isInString = !isInString + continue + } + + if !isInString { + if byte == Self.openBrace { + if openBraceCount == 0 { + currentChunkStartIndex = idx + } + openBraceCount += 1 + } else if byte == Self.closeBrace { + openBraceCount -= 1 + + if openBraceCount == 0, let startIndex = currentChunkStartIndex { + let chunk = self[startIndex ..< idx + 1] + chunks.append(chunk) + currentChunkStartIndex = nil + } + } + } + } + + return chunks + } + +} diff --git a/MCPInterface/Tests/helpers/JSON+streaming.swift b/MCPInterface/Tests/helpers/JSON+streaming.swift new file mode 100644 index 0000000..eac28e1 --- /dev/null +++ b/MCPInterface/Tests/helpers/JSON+streaming.swift @@ -0,0 +1,201 @@ +import Foundation +import SwiftTestingUtils +import Testing +@testable import MCPInterface + +// MARK: - DataExtensionTests + +enum DataExtensionTests { + struct JSONObjects { + + @Test("Single JSON value") + func decodeSingleJSONValue() throws { + try validateObjectDecoding( + "{\"key\": \"value\"}", + [["key": .string("value")]]) + } + + @Test("Single JSON value with nested object") + func decodeNestedJSONValue() throws { + try validateObjectDecoding( + "{\"key\": \"value\", \"nested\": {\"key\": \"value\"}}", + [[ + "key": .string("value"), + "nested": ["key": .string("value")], + ]]) + } + + @Test("Several JSON value") + func decodeSeveralJSONValue() throws { + try validateObjectDecoding( + "{\"key\": \"value\"}{\"key\": \"value\", \"nested\": {\"key\": \"value\"}}", + [ + ["key": .string("value")], + [ + "key": .string("value"), + "nested": ["key": .string("value")], + ], + ]) + } + + @Test("Several JSON value with spacing") + func decodeSeveralJSONValueWithSpacing() throws { + try validateObjectDecoding( + """ + {"key": "value"} + + {"key": "value", "nested": {"key": "value"}} + """, + [ + ["key": .string("value")], + [ + "key": .string("value"), + "nested": ["key": .string("value")], + ], + ]) + } + + @Test("Several JSON value with spacing and escaped characters") + func decodeSeveralJSONValueWithSpacingAndEscapedCharacters() throws { + try validateObjectDecoding( + """ + {"key": "val{u}e"} + + {"key": "value", "nested": {"key": "val\\"{u}e"}} + """, + [ + ["key": .string("val{u}e")], + [ + "key": .string("value"), + "nested": ["key": .string("val\"{u}e")], + ], + ]) + } + } + + struct JSONObjectsWithTruncatedData { + + @Test("Partial JSON value") + func decodePartialJSONValue() throws { + try validateObjectDecoding( + "{\"key\": \"val", + [], + truncatedData: "{\"key\": \"val") + } + + @Test("Single JSON value") + func decodeSingleJSONValue() throws { + try validateObjectDecoding( + "{\"key\": \"value\"} {\"key\": \"valu", + [["key": .string("value")]], + truncatedData: "{\"key\": \"valu") + } + + @Test("Several JSON value with spacing") + func decodeSeveralJSONValueWithSpacing() throws { + try validateObjectDecoding( + """ + {"key": "value"} + + {"key": "value", "nested": {"key": "value"}} + + {\"key\": \"valu + """, + [ + ["key": .string("value")], + [ + "key": .string("value"), + "nested": ["key": .string("value")], + ], + ], + truncatedData: "{\"key\": \"valu") + } + + @Test("Several JSON value with spacing and escaped characters") + func decodeSeveralJSONValueWithSpacingAndEscapedCharacters() throws { + try validateObjectDecoding( + """ + {"key": "val{u}e"} + + {"key": "value", "nested": {"key": "val\\"{u}e"}} + {"key": "value", "nested": {"key": "val\\"{u + """, + [ + ["key": .string("val{u}e")], + [ + "key": .string("value"), + "nested": ["key": .string("val\"{u}e")], + ], + ], + truncatedData: """ + {"key": "value", "nested": {"key": "val\\"{u + """) + } + } +} + +/// Validate that extracting JSON objects from `jsonsRepresentation` yields the expected values, +/// and that the truncated data also match the expectation. +private func validateObjectDecoding( + _ jsonsRepresentation: String, + _ expectedJSONObjects: [JSON], + truncatedData expectedTruncatedData: String = "") + throws +{ + guard let data = jsonsRepresentation.data(using: .utf8) else { + throw NSError(domain: "Invalid JSON string", code: 1, userInfo: nil) + } + let (objects, truncatedData) = data.parseJSONObjects() + let jsons = try objects.map { object in + try object.jsonString() + } + + let expectedJSONs = try expectedJSONObjects.map { jsonObject in + try jsonObject.asJSONData().jsonString() + } + #expect(jsons.count == expectedJSONs.count) + for (json, expectedJSON) in zip(jsons, expectedJSONs) { + #expect(json == expectedJSON) + } + + let truncatedDataStr = (truncatedData.map { String(data: $0, encoding: .utf8) } ?? nil) ?? "" + #expect( + expectedTruncatedData.trimmingCharacters(in: .whitespacesAndNewlines) == + truncatedDataStr.trimmingCharacters(in: .whitespacesAndNewlines)) +} + +// MARK: - DataStreamTests + +struct DataStreamTests { + @Test + func receivesValidJSON() async throws { + let firstObjectReceived = expectation(description: "first object received") + let secondObjectReceived = expectation(description: "second object received") + + let (rawDataStream, continuation) = AsyncStream.makeStream() + let jsonStream = rawDataStream.jsonStream + + func send(_ data: String) { + let data = data.data(using: .utf8)! + continuation.yield(data) + } + + var receivedObjects: [Data] = [] + Task { + for try await object in jsonStream { + receivedObjects.append(object) + if receivedObjects.count == 1 { + firstObjectReceived.fulfill() + } else if receivedObjects.count == 2 { + secondObjectReceived.fulfill() + } + } + } + + send(#"{"key": "va"#) + send(#"lue"}{"key": "value", "nes"#) + try await fulfillment(of: firstObjectReceived) + send(#"ted": {"key": "value"}}"#) + try await fulfillment(of: secondObjectReceived) + } +} diff --git a/MCPInterface/Tests/interface/SerializationDeserializationTestUtils.swift b/MCPInterface/Tests/interface/SerializationDeserializationTestUtils.swift index c5606b5..c98e866 100644 --- a/MCPInterface/Tests/interface/SerializationDeserializationTestUtils.swift +++ b/MCPInterface/Tests/interface/SerializationDeserializationTestUtils.swift @@ -6,10 +6,17 @@ extension Data { func jsonString() throws -> String { let object = try JSONSerialization.jsonObject(with: self, options: []) let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) - return String(data: data, encoding: .utf8)! + guard let str = String(data: data, encoding: .utf8) else { + throw JSONError() + } + return str } } +// MARK: - JSONError + +private struct JSONError: Error { } + /// Test decoding the Json data to the given type, encoding it back to Json, and comparing the results. func testDecodingEncodingOf(_ json: String, with _: T.Type) throws { let jsonData = json.data(using: .utf8)! diff --git a/MCPServer/Sources/DataChannel+stdio.swift b/MCPServer/Sources/DataChannel+stdio.swift index f1be9f3..642891a 100644 --- a/MCPServer/Sources/DataChannel+stdio.swift +++ b/MCPServer/Sources/DataChannel+stdio.swift @@ -1,5 +1,6 @@ import Foundation import JSONRPC +import MCPInterface extension DataChannel { @@ -14,6 +15,6 @@ extension DataChannel { FileHandle.standardOutput.write(data) } - return DataChannel(writeHandler: writeHandler, dataSequence: FileHandle.standardInput.dataStream) + return DataChannel(writeHandler: writeHandler, dataSequence: FileHandle.standardInput.dataStream.jsonStream) } } diff --git a/Package.swift b/Package.swift index d8f0831..dc6232f 100644 --- a/Package.swift +++ b/Package.swift @@ -110,6 +110,7 @@ let package = Package( dependencies: [ .product(name: "JSONRPC", package: "JSONRPC"), .target(name: "MCPInterface"), + .target(name: "SwiftTestingUtils"), ], path: "MCPInterface/Tests"), .testTarget( From 23dd13f99bf10f69c13a28b0c4c6ecb30d4b8707 Mon Sep 17 00:00:00 2001 From: Gui Sabran Date: Wed, 5 Mar 2025 09:51:36 -0800 Subject: [PATCH 12/22] update readme for sandbox --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 14a2c4a..96e83a8 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ And then add the product that you need to all targets that use the dependency: ## Quick Start +⚠️ When using stdio servers in a MacOS app, you need to disable sandboxing. This is because the app will need to run the processes for each server. + ### Creating a Server ```swift import MCPServer From 7d6116bc985bfdb27588831a59ec83c11cc40c10 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Wed, 5 Mar 2025 10:56:21 -0800 Subject: [PATCH 13/22] updating branch with stream changes --- .../Chat/Models/OpenAIChatNonStreamManager.swift | 7 ------- .../MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift | 3 ++- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift index ad252b5..2a049ed 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift @@ -233,10 +233,3 @@ final class OpenAIChatNonStreamManager: ChatManager { task = nil } } - -// Helper extension to convert Data to String -extension Data { - var asString: String? { - return String(data: self, encoding: .utf8) - } -} diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift index 2f7b9d7..19fa472 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatView.swift @@ -20,7 +20,6 @@ struct ChatView: View { var body: some View { List { ChatMessagesView(chatMessages: chatManager.messages) - .padding([.top, .leading, .trailing, .bottom]) } .listStyle(.plain) .scrollContentBackground(.hidden) @@ -59,7 +58,9 @@ private struct ChatMessagesView: View { .onAppear { shouldAnimateMessageIn[message.id] = false } + .padding(.bottom, 4) } } + .padding() } } From b9fde08d54967cb18ca3fa0e9909753c22eeb994 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Wed, 5 Mar 2025 12:48:45 -0800 Subject: [PATCH 14/22] Solving problem for env --- .../Sources/stdioTransport/DataChannel+StdioProcess.swift | 4 +++- .../MCPClientChat/MCP/Clients/GithubMCPClient.swift | 3 --- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift b/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift index 4cd4a1d..0c0ca53 100644 --- a/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift +++ b/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift @@ -219,7 +219,9 @@ extension Transport { private static func loadZshEnvironment() throws -> [String: String] { let process = Process() process.launchPath = "/bin/zsh" - process.arguments = ["-c", "source ~/.zshrc && printenv"] + // Those are loaded for interactive login shell by zsh: + // https://www.freecodecamp.org/news/how-do-zsh-configuration-files-work/ + process.arguments = ["-c", "source ~/.zshenv; source ~/.zprofile; source ~/.zshrc; source ~/.zshrc; printenv"] let env = try getProcessStdout(process: process) if let path = env?.split(separator: "\n").filter({ $0.starts(with: "PATH=") }).first { diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift index 474948c..f5c0b13 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift @@ -19,14 +19,11 @@ final class GIthubMCPClient { init() { Task { do { - var env = ProcessInfo.processInfo.environment - env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + (env["PATH"] ?? "") self.client = try await MCPClient( info: .init(name: "GIthubMCPClient", version: "1.0.0"), transport: .stdioProcess( "npx", args: ["-y", "@modelcontextprotocol/server-github"], - env: env, verbose: true ), capabilities: .init() From a86e6e7fa99c7f56fc2d45f5de106a0e12508056 Mon Sep 17 00:00:00 2001 From: James Rochabrun Date: Wed, 5 Mar 2025 13:29:35 -0800 Subject: [PATCH 15/22] Update MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift Co-authored-by: Guillaume Sabran --- .../Sources/stdioTransport/DataChannel+StdioProcess.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift b/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift index 0c0ca53..7f051ac 100644 --- a/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift +++ b/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift @@ -147,9 +147,7 @@ extension Transport { if stdout.fileHandleForReading.fileDescriptor != stderr.fileHandleForReading.fileDescriptor { Task { for await data in stderr.fileHandleForReading.dataStream { - if verbose { - logger.log("Received error:\n\(String(data: data, encoding: .utf8) ?? "nil")") - } + logger.log("Received error:\n\(String(data: data, encoding: .utf8) ?? "nil")") stderrData.append(data) } } From 058ea0a3d2660671c8341e0dbe26be0b9d9e0f1f Mon Sep 17 00:00:00 2001 From: James Rochabrun Date: Wed, 5 Mar 2025 13:29:44 -0800 Subject: [PATCH 16/22] Update MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift Co-authored-by: Guillaume Sabran --- MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift b/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift index 7f051ac..b678af8 100644 --- a/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift +++ b/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift @@ -124,8 +124,6 @@ extension Transport { var stderrData = Data() let outStream: AsyncStream if verbose { - var truncatedData = Data() - // As we are both reading stdout here in this function, and want to make the stream readable to the caller, // we read the data from the process's stdout, process it and then re-yield it to the caller to a new stream. // This is because an AsyncStream can have only one reader. From 6a5a8105b06bdb2e24170c6a1aed4159f1a3b53b Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Wed, 5 Mar 2025 13:30:49 -0800 Subject: [PATCH 17/22] pr feedback --- MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift b/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift index b678af8..85a7988 100644 --- a/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift +++ b/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift @@ -220,7 +220,7 @@ extension Transport { process.arguments = ["-c", "source ~/.zshenv; source ~/.zprofile; source ~/.zshrc; source ~/.zshrc; printenv"] let env = try getProcessStdout(process: process) - if let path = env?.split(separator: "\n").filter({ $0.starts(with: "PATH=") }).first { + if let path = env?.split(separator: "\n").filter({ $0.starts(with: "PATH=") }).last { return ["PATH": String(path.dropFirst("PATH=".count))] } else { return ProcessInfo.processInfo.environment From f1fa16e622f17efb7421072e42701f8a2eae3787 Mon Sep 17 00:00:00 2001 From: James Rochabrun Date: Wed, 5 Mar 2025 13:46:03 -0800 Subject: [PATCH 18/22] Update MCPInterface/Sources/helpers/JSON+Streaming.swift Co-authored-by: Guillaume Sabran --- .../Sources/helpers/JSON+Streaming.swift | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/MCPInterface/Sources/helpers/JSON+Streaming.swift b/MCPInterface/Sources/helpers/JSON+Streaming.swift index 6a2e118..203dbe9 100644 --- a/MCPInterface/Sources/helpers/JSON+Streaming.swift +++ b/MCPInterface/Sources/helpers/JSON+Streaming.swift @@ -87,51 +87,5 @@ extension Data { private static let quote = UInt8(ascii: "\"") private static let escape = UInt8(ascii: "\\") - /// Given a Data object that represents one or several valid JSON objects concatenated together, with the last one possibly unfinished, - /// return a list of Data objects, each representing a valid JSON object. - private var jsonObjects: [Data] { - var chunks = [Data]() - var isEscaping = false - var isInString = false - - var openBraceCount = 0 - var currentChunkStartIndex: Int? = 0 - - for (idx, byte) in enumerated() { - if isEscaping { - isEscaping = false - continue - } - - if byte == Self.escape { - isEscaping = true - continue - } - - if byte == Self.quote { - isInString = !isInString - continue - } - - if !isInString { - if byte == Self.openBrace { - if openBraceCount == 0 { - currentChunkStartIndex = idx - } - openBraceCount += 1 - } else if byte == Self.closeBrace { - openBraceCount -= 1 - - if openBraceCount == 0, let startIndex = currentChunkStartIndex { - let chunk = self[startIndex ..< idx + 1] - chunks.append(chunk) - currentChunkStartIndex = nil - } - } - } - } - - return chunks - } } From 64d7ddde2ccd66a5d0c50ab5285ec95f45dbc981 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Wed, 5 Mar 2025 15:35:01 -0800 Subject: [PATCH 19/22] lint --- .../MCPClientChat/Chat/UI/ChatMessageView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift index 087f801..1be989d 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift @@ -51,14 +51,14 @@ struct ChatMessageView: View { private var chatIcon: some View { Image(systemName: message.role == .user ? "person.circle.fill" : "lightbulb.circle") .font(.title2) - .frame(width:24, height:24) + .frame(width: 24, height: 24) .foregroundColor(message.role == .user ? .primary : .orange) } private var chatName: some View { Text(message.role == .user ? "You" : "Assistant") .fontWeight(.bold) - .frame(maxWidth: .infinity, maxHeight:24, alignment: .leading) + .frame(maxWidth: .infinity, maxHeight: 24, alignment: .leading) } @ViewBuilder @@ -81,6 +81,6 @@ struct ChatMessageView: View { #Preview { ChatMessageView(message: ChatMessage(text: "hello", role: .user), animateIn: false) - .frame(maxWidth:.infinity) + .frame(maxWidth: .infinity) .padding() } From c613382588aba219b52b4c0f100b5980193723c9 Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Wed, 5 Mar 2025 16:06:48 -0800 Subject: [PATCH 20/22] Avoding print rules --- .../MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift | 1 + .../MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift | 1 + .../MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift | 3 +-- .../MCPClientChat/MCP/Tools/MCPTool+AnthropicTool.swift | 2 -- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift index a2f36e1..ea6bf5c 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift @@ -1,3 +1,4 @@ +// swiftlint:disable no_direct_standard_out_logs // // ChatNonStreamModel.swift // MCPClientChat diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift index 2a049ed..9639172 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift @@ -1,3 +1,4 @@ +// swiftlint:disable no_direct_standard_out_logs // // OpenAIChatNonStreamModel.swift // MCPClientChat diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift index 55e32ec..cae76a2 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift @@ -1,3 +1,4 @@ +// swiftlint:disable no_direct_standard_out_logs // // MCPLLMClient.swift // MCPClientChat @@ -11,8 +12,6 @@ import MCPInterface import SwiftAnthropic import SwiftOpenAI -// TODO: James+Gui decide where this should live so it can be reused. - // MARK: Anthropic /** diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+AnthropicTool.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+AnthropicTool.swift index 7c5e7bf..454ec89 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+AnthropicTool.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+AnthropicTool.swift @@ -10,8 +10,6 @@ import MCPClient import MCPInterface import SwiftAnthropic -// TODO: James+Gui decide where this should live so it can be reused. - extension MCPInterface.Tool { // MARK: Public From 4721089c2218d6023aa0e12b64119eaf362a6d6d Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Wed, 5 Mar 2025 16:10:48 -0800 Subject: [PATCH 21/22] lint --- .../DataChannel+StdioProcess.swift | 2 +- .../Models/AnthropicNonStreamManager.swift | 342 ++++++------- .../Chat/Models/ChatManager.swift | 12 +- .../Models/OpenAIChatNonStreamManager.swift | 431 ++++++++-------- .../Chat/UI/ChatMessageView.swift | 123 ++--- .../MCP/Clients/GithubMCPClient.swift | 66 +-- .../MCP/Tools/MCPClient+LLMTools.swift | 281 +++++----- .../MCP/Tools/MCPTool+OpenAITool.swift | 483 +++++++++--------- .../MCPClientChat/MCPClientChatApp.swift | 82 +-- .../Sources/helpers/JSON+Streaming.swift | 1 - 10 files changed, 919 insertions(+), 904 deletions(-) diff --git a/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift b/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift index 85a7988..6e9f313 100644 --- a/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift +++ b/MCPClient/Sources/stdioTransport/DataChannel+StdioProcess.swift @@ -216,7 +216,7 @@ extension Transport { let process = Process() process.launchPath = "/bin/zsh" // Those are loaded for interactive login shell by zsh: - // https://www.freecodecamp.org/news/how-do-zsh-configuration-files-work/ + // https://www.freecodecamp.org/news/how-do-zsh-configuration-files-work/ process.arguments = ["-c", "source ~/.zshenv; source ~/.zprofile; source ~/.zshrc; source ~/.zshrc; printenv"] let env = try getProcessStdout(process: process) diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift index ea6bf5c..479ac2e 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/AnthropicNonStreamManager.swift @@ -7,183 +7,183 @@ // import Foundation -import MCPInterface import MCPClient -import SwiftUI +import MCPInterface import SwiftAnthropic +import SwiftUI @MainActor @Observable -// Handle a chat conversation without stream. +/// Handle a chat conversation without stream. final class AnthropicNonStreamManager: ChatManager { - - /// Messages sent from the user or received from Claude - var messages = [ChatMessage]() - - /// Service to communicate with Anthropic API - private let service: AnthropicService - - /// Message history for Claude's context - private var anthropicMessages: [MessageParameter.Message] = [] - - /// Current task handling Claude API request - private var task: Task? = nil - - /// Error message if something goes wrong - var errorMessage: String = "" - - /// Loading state indicator - var isLoading = false - - /// Web research client for tool use - private var mcpClient: MCPClient? - - init(service: AnthropicService) { - self.service = service - } - - /// Returns true if Claude is still processing a response - var isProcessing: Bool { - return isLoading - } - - func updateClient(_ client: MCPClient) { - mcpClient = client - } - - /// Send a new message to Claude and get the complete response - func send(message: ChatMessage) { - self.messages.append(message) - self.processUserMessage(prompt: message.text) - } - - /// Cancel the current processing task - func stop() { - self.task?.cancel() - self.task = nil - self.isLoading = false - } - - private func processUserMessage(prompt: String) { - - guard let mcpClient else { - fatalError("Client not initialized") - } - // Add a placeholder for Claude's response - self.messages.append(ChatMessage(text: "", role: .assistant, isWaitingForFirstText: true)) - - // Add user message to history - anthropicMessages.append(MessageParameter.Message( - role: .user, - content: .text(prompt) - )) - - task = Task { - do { - isLoading = true - - // Get available tools from MCP - let tools = try await mcpClient.anthropicTools() - - // Send request and process response - try await continueConversation(tools: tools) - - isLoading = false - } catch { - errorMessage = "\(error)" - - // Update UI to show error - if var last = messages.popLast() { - last.isWaitingForFirstText = false - last.text = "Sorry, there was an error: \(error.localizedDescription)" - messages.append(last) - } - - isLoading = false - } + + // MARK: Lifecycle + + init(service: AnthropicService) { + self.service = service + } + + // MARK: Internal + + /// Messages sent from the user or received from Claude + var messages = [ChatMessage]() + + /// Error message if something goes wrong + var errorMessage = "" + + /// Loading state indicator + var isLoading = false + + /// Returns true if Claude is still processing a response + var isProcessing: Bool { + isLoading + } + + func updateClient(_ client: MCPClient) { + mcpClient = client + } + + /// Send a new message to Claude and get the complete response + func send(message: ChatMessage) { + messages.append(message) + processUserMessage(prompt: message.text) + } + + /// Cancel the current processing task + func stop() { + task?.cancel() + task = nil + isLoading = false + } + + /// Clear the conversation + func clearConversation() { + messages.removeAll() + anthropicMessages.removeAll() + errorMessage = "" + isLoading = false + task?.cancel() + task = nil + } + + // MARK: Private + + /// Service to communicate with Anthropic API + private let service: AnthropicService + + /// Message history for Claude's context + private var anthropicMessages: [MessageParameter.Message] = [] + + /// Current task handling Claude API request + private var task: Task? = nil + + /// Web research client for tool use + private var mcpClient: MCPClient? + + private func processUserMessage(prompt: String) { + guard let mcpClient else { + fatalError("Client not initialized") + } + // Add a placeholder for Claude's response + messages.append(ChatMessage(text: "", role: .assistant, isWaitingForFirstText: true)) + + // Add user message to history + anthropicMessages.append(MessageParameter.Message( + role: .user, + content: .text(prompt))) + + task = Task { + do { + isLoading = true + + // Get available tools from MCP + let tools = try await mcpClient.anthropicTools() + + // Send request and process response + try await continueConversation(tools: tools) + + isLoading = false + } catch { + errorMessage = "\(error)" + + // Update UI to show error + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = "Sorry, there was an error: \(error.localizedDescription)" + messages.append(last) + } + + isLoading = false } - } - - private func continueConversation(tools: [MessageParameter.Tool]) async throws { - let parameters = MessageParameter( - model: .claude37Sonnet, - messages: anthropicMessages, - maxTokens: 10000, - tools: tools - ) - - // Make non-streaming request to Claude - let message = try await service.createMessage(parameters) - - // Process all content elements with a for loop - for contentItem in message.content { - switch contentItem { - case .text(let text, _): - // Update the UI with the response - if var last = messages.popLast() { - last.isWaitingForFirstText = false - last.text = text - messages.append(last) - } - - // Add assistant response to history - anthropicMessages.append(MessageParameter.Message( - role: .assistant, - content: .text(text) - )) - - case .toolUse(let tool): - print("Tool use detected - Name: \(tool.name), ID: \(tool.id)") - - // Update UI to show tool use - if var last = messages.popLast() { - last.isWaitingForFirstText = false - last.text += "\n Using tool: \(tool.name)..." - messages.append(last) - } - - // Add the assistant message with tool use to message history - anthropicMessages.append(MessageParameter.Message( - role: .assistant, - content: .list([.toolUse(tool.id, tool.name, tool.input)]) - )) - - // Call tool via MCP - let toolResponse = await mcpClient?.anthropicCallTool(name: tool.name, input: tool.input, debug: true) - print("Tool response: \(String(describing: toolResponse))") - - // Add tool result to conversation - if let toolResult = toolResponse { - // Add the assistant message with tool result - anthropicMessages.append(MessageParameter.Message( - role: .user, - content: .list([.toolResult(tool.id, toolResult)]) - )) - - // Now get a new response with the tool result - try await continueConversation(tools: tools) - } else { - // Handle tool failure - if var last = messages.popLast() { - last.isWaitingForFirstText = false - last.text = "There was an error using the tool \(tool.name)." - messages.append(last) - } - } - - case .thinking(_): - break - } + } + } + + private func continueConversation(tools: [MessageParameter.Tool]) async throws { + let parameters = MessageParameter( + model: .claude37Sonnet, + messages: anthropicMessages, + maxTokens: 10000, + tools: tools) + + // Make non-streaming request to Claude + let message = try await service.createMessage(parameters) + + // Process all content elements with a for loop + for contentItem in message.content { + switch contentItem { + case .text(let text, _): + // Update the UI with the response + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = text + messages.append(last) + } + + // Add assistant response to history + anthropicMessages.append(MessageParameter.Message( + role: .assistant, + content: .text(text))) + + case .toolUse(let tool): + print("Tool use detected - Name: \(tool.name), ID: \(tool.id)") + + // Update UI to show tool use + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text += "\n Using tool: \(tool.name)..." + messages.append(last) + } + + // Add the assistant message with tool use to message history + anthropicMessages.append(MessageParameter.Message( + role: .assistant, + content: .list([.toolUse(tool.id, tool.name, tool.input)]))) + + // Call tool via MCP + let toolResponse = await mcpClient?.anthropicCallTool(name: tool.name, input: tool.input, debug: true) + print("Tool response: \(String(describing: toolResponse))") + + // Add tool result to conversation + if let toolResult = toolResponse { + // Add the assistant message with tool result + anthropicMessages.append(MessageParameter.Message( + role: .user, + content: .list([.toolResult(tool.id, toolResult)]))) + + // Now get a new response with the tool result + try await continueConversation(tools: tools) + } else { + // Handle tool failure + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = "There was an error using the tool \(tool.name)." + messages.append(last) + } + } + + case .thinking: + break } - } - - /// Clear the conversation - func clearConversation() { - messages.removeAll() - anthropicMessages.removeAll() - errorMessage = "" - isLoading = false - task?.cancel() - task = nil - } + } + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift index cd28533..6357bfd 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/ChatManager.swift @@ -6,14 +6,14 @@ // import Foundation -import MCPInterface import MCPClient +import MCPInterface @MainActor protocol ChatManager { - var messages: [ChatMessage] { get set } - var isProcessing: Bool { get } - func stop() - func send(message: ChatMessage) - func updateClient(_ client: MCPClient) + var messages: [ChatMessage] { get set } + var isProcessing: Bool { get } + func stop() + func send(message: ChatMessage) + func updateClient(_ client: MCPClient) } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift index 9639172..16679dd 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/Models/OpenAIChatNonStreamManager.swift @@ -7,230 +7,231 @@ // import Foundation -import MCPInterface import MCPClient -import SwiftUI +import MCPInterface import SwiftOpenAI +import SwiftUI @MainActor @Observable -// Handle a chat conversation without stream for OpenAI. +/// Handle a chat conversation without stream for OpenAI. final class OpenAIChatNonStreamManager: ChatManager { - - /// Messages sent from the user or received from OpenAI - var messages = [ChatMessage]() - - /// Service to communicate with OpenAI API - private let service: OpenAIService - - /// Message history for OpenAI's context - private var openAIMessages: [SwiftOpenAI.ChatCompletionParameters.Message] = [] - - /// Current task handling OpenAI API request - private var task: Task? = nil - - /// Error message if something goes wrong - var errorMessage: String = "" - - /// Loading state indicator - var isLoading = false - - private var mcpClient: MCPClient? - - init(service: OpenAIService) { - self.service = service - } - - /// Returns true if OpenAI is still processing a response - var isProcessing: Bool { - return isLoading - } - - func updateClient(_ client: MCPClient) { - mcpClient = client - } - - /// Send a new message to OpenAI and get the complete response - func send(message: ChatMessage) { - self.messages.append(message) - self.processUserMessage(prompt: message.text) - } - - /// Cancel the current processing task - func stop() { - self.task?.cancel() - self.task = nil - self.isLoading = false - } - - private func processUserMessage(prompt: String) { - // Add a placeholder for OpenAI's response - self.messages.append(ChatMessage(text: "", role: .assistant, isWaitingForFirstText: true)) - - // Add user message to history - openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( - role: .user, - content: .text(prompt) - )) - - task = Task { - do { - isLoading = true - - guard let mcpClient else { - throw NSError(domain: "OpenAIChat", code: 1, userInfo: [NSLocalizedDescriptionKey: "mcpClient is nil"]) - } - // Get available tools from MCP - let tools = try await mcpClient.tools() - - // Send request and process response - try await continueConversation(tools: tools) - - isLoading = false - } catch { - errorMessage = "\(error)" - - // Update UI to show error - if var last = messages.popLast() { - last.isWaitingForFirstText = false - last.text = "Sorry, there was an error: \(error.localizedDescription)" - messages.append(last) - } - - isLoading = false - } - } - } - - private func continueConversation(tools: [SwiftOpenAI.ChatCompletionParameters.Tool]) async throws { - guard let mcpClient else { - throw NSError(domain: "OpenAIChat", code: 1, userInfo: [NSLocalizedDescriptionKey: "mcpClient is nil"]) + + // MARK: Lifecycle + + init(service: OpenAIService) { + self.service = service + } + + // MARK: Internal + + /// Messages sent from the user or received from OpenAI + var messages = [ChatMessage]() + + /// Error message if something goes wrong + var errorMessage = "" + + /// Loading state indicator + var isLoading = false + + /// Returns true if OpenAI is still processing a response + var isProcessing: Bool { + isLoading + } + + func updateClient(_ client: MCPClient) { + mcpClient = client + } + + /// Send a new message to OpenAI and get the complete response + func send(message: ChatMessage) { + messages.append(message) + processUserMessage(prompt: message.text) + } + + /// Cancel the current processing task + func stop() { + task?.cancel() + task = nil + isLoading = false + } + + /// Clear the conversation + func clearConversation() { + messages.removeAll() + openAIMessages.removeAll() + errorMessage = "" + isLoading = false + task?.cancel() + task = nil + } + + // MARK: Private + + /// Service to communicate with OpenAI API + private let service: OpenAIService + + /// Message history for OpenAI's context + private var openAIMessages: [SwiftOpenAI.ChatCompletionParameters.Message] = [] + + /// Current task handling OpenAI API request + private var task: Task? = nil + + private var mcpClient: MCPClient? + + private func processUserMessage(prompt: String) { + // Add a placeholder for OpenAI's response + messages.append(ChatMessage(text: "", role: .assistant, isWaitingForFirstText: true)) + + // Add user message to history + openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( + role: .user, + content: .text(prompt))) + + task = Task { + do { + isLoading = true + + guard let mcpClient else { + throw NSError(domain: "OpenAIChat", code: 1, userInfo: [NSLocalizedDescriptionKey: "mcpClient is nil"]) + } + // Get available tools from MCP + let tools = try await mcpClient.tools() + + // Send request and process response + try await continueConversation(tools: tools) + + isLoading = false + } catch { + errorMessage = "\(error)" + + // Update UI to show error + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = "Sorry, there was an error: \(error.localizedDescription)" + messages.append(last) + } + + isLoading = false } - - let parameters = SwiftOpenAI.ChatCompletionParameters( - messages: openAIMessages, - model: .gpt4o, - toolChoice: .auto, - tools: tools - ) - - // Make non-streaming request to OpenAI - let response = try await service.startChat(parameters: parameters) - - guard let choices = response.choices, - let firstChoice = choices.first, - let message = firstChoice.message else { - throw NSError(domain: "OpenAIChat", code: 1, userInfo: [NSLocalizedDescriptionKey: "No message in response"]) + } + } + + private func continueConversation(tools: [SwiftOpenAI.ChatCompletionParameters.Tool]) async throws { + guard let mcpClient else { + throw NSError(domain: "OpenAIChat", code: 1, userInfo: [NSLocalizedDescriptionKey: "mcpClient is nil"]) + } + + let parameters = SwiftOpenAI.ChatCompletionParameters( + messages: openAIMessages, + model: .gpt4o, + toolChoice: .auto, + tools: tools) + + // Make non-streaming request to OpenAI + let response = try await service.startChat(parameters: parameters) + + guard + let choices = response.choices, + let firstChoice = choices.first, + let message = firstChoice.message + else { + throw NSError(domain: "OpenAIChat", code: 1, userInfo: [NSLocalizedDescriptionKey: "No message in response"]) + } + + // Process the regular text content + if let messageContent = message.content, !messageContent.isEmpty { + // Update the UI with the response + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text = messageContent + messages.append(last) } - - // Process the regular text content - if let messageContent = message.content, !messageContent.isEmpty { - // Update the UI with the response - if var last = messages.popLast() { + + // Add assistant response to history + openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( + role: .assistant, + content: .text(messageContent))) + } + + // Process tool calls if any + if let toolCalls = message.toolCalls, !toolCalls.isEmpty { + for toolCall in toolCalls { + let function = toolCall.function + guard + let id = toolCall.id, + let name = function.name, + let argumentsData = function.arguments.data(using: .utf8) + else { + continue + } + + let toolId = id + let toolName = name + let argumentsString = function.arguments + + // Parse arguments from string to dictionary + let arguments: [String: Any] + do { + guard let parsedArgs = try JSONSerialization.jsonObject(with: argumentsData) as? [String: Any] else { + continue + } + arguments = parsedArgs + } catch { + print("Error parsing tool arguments: \(error)") + continue + } + + print("Tool use detected - Name: \(toolName), ID: \(toolId)") + + // Update UI to show tool use + if var last = messages.popLast() { + last.isWaitingForFirstText = false + last.text += "\n Using tool: \(toolName)..." + messages.append(last) + } + + // Add the assistant message with tool call to message history + let toolCallObject = SwiftOpenAI.ToolCall( + id: toolId, + function: SwiftOpenAI.FunctionCall( + arguments: argumentsString, + name: toolName)) + + openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( + role: .assistant, + content: .text(""), // Content is null when using tool calls + toolCalls: [toolCallObject])) + + // Call tool via MCP + let toolResponse = await mcpClient.callTool(name: toolName, input: arguments, debug: true) + print("Tool response: \(String(describing: toolResponse))") + + // Add tool result to conversation + if let toolResult = toolResponse { + // Add the tool result as a tool message + openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( + role: .tool, + content: .text(toolResult), + toolCallID: toolId)) + + // Now get a new response with the tool result + try await continueConversation(tools: tools) + } else { + // Handle tool failure + if var last = messages.popLast() { last.isWaitingForFirstText = false - last.text = messageContent + last.text = "There was an error using the tool \(toolName)." messages.append(last) - } - - // Add assistant response to history - openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( - role: .assistant, - content: .text(messageContent) - )) - } - - // Process tool calls if any - if let toolCalls = message.toolCalls, !toolCalls.isEmpty { - for toolCall in toolCalls { - - let function = toolCall.function - guard let id = toolCall.id, - let name = function.name, - let argumentsData = function.arguments.data(using: .utf8) else { - continue - } - - let toolId = id - let toolName = name - let argumentsString = function.arguments - - // Parse arguments from string to dictionary - let arguments: [String: Any] - do { - guard let parsedArgs = try JSONSerialization.jsonObject(with: argumentsData) as? [String: Any] else { - continue - } - arguments = parsedArgs - } catch { - print("Error parsing tool arguments: \(error)") - continue - } - - print("Tool use detected - Name: \(toolName), ID: \(toolId)") - - // Update UI to show tool use - if var last = messages.popLast() { - last.isWaitingForFirstText = false - last.text += "\n Using tool: \(toolName)..." - messages.append(last) - } - - // Add the assistant message with tool call to message history - let toolCallObject = SwiftOpenAI.ToolCall( - id: toolId, - function: SwiftOpenAI.FunctionCall( - arguments: argumentsString, - name: toolName - ) - ) - - openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( - role: .assistant, - content: .text(""), // Content is null when using tool calls - toolCalls: [toolCallObject] - )) - - // Call tool via MCP - let toolResponse = await mcpClient.callTool(name: toolName, input: arguments, debug: true) - print("Tool response: \(String(describing: toolResponse))") - - // Add tool result to conversation - if let toolResult = toolResponse { - // Add the tool result as a tool message - openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( - role: .tool, - content: .text(toolResult), - toolCallID: toolId - )) - - // Now get a new response with the tool result - try await continueConversation(tools: tools) - } else { - // Handle tool failure - if var last = messages.popLast() { - last.isWaitingForFirstText = false - last.text = "There was an error using the tool \(toolName)." - messages.append(last) - } - - // Add error response as tool message - openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( - role: .tool, - content: .text("Error: Tool execution failed"), - toolCallID: toolId - )) - } - } + } + + // Add error response as tool message + openAIMessages.append(SwiftOpenAI.ChatCompletionParameters.Message( + role: .tool, + content: .text("Error: Tool execution failed"), + toolCallID: toolId)) + } } - } - - /// Clear the conversation - func clearConversation() { - messages.removeAll() - openAIMessages.removeAll() - errorMessage = "" - isLoading = false - task?.cancel() - task = nil - } + } + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift index 1be989d..69672ca 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/Chat/UI/ChatMessageView.swift @@ -10,77 +10,82 @@ import SwiftUI struct ChatMessageView: View { - /// The message to display - let message: ChatMessage + // MARK: Internal - /// Whether to animate in the chat bubble - let animateIn: Bool + /// The message to display + let message: ChatMessage - /// State used to animate in the chat bubble if `animateIn` is true - @State private var animationTrigger = false + /// Whether to animate in the chat bubble + let animateIn: Bool - var body: some View { - HStack(alignment: .top, spacing: 12) { - chatIcon - VStack(alignment: .leading) { - chatName - chatBody - } + var body: some View { + HStack(alignment: .top, spacing: 12) { + chatIcon + VStack(alignment: .leading) { + chatName + chatBody } - .opacity(bubbleOpacity) - .animation(.easeIn(duration: 0.75), value: animationTrigger) - .onAppear { - adjustAnimationTriggerIfNecessary() - } - } + } + .opacity(bubbleOpacity) + .animation(.easeIn(duration: 0.75), value: animationTrigger) + .onAppear { + adjustAnimationTriggerIfNecessary() + } + } - private var bubbleOpacity: Double { - guard animateIn else { - return 1 - } - return animationTrigger ? 1 : 0 - } + // MARK: Private - private func adjustAnimationTriggerIfNecessary() { - guard animateIn else { - return - } - animationTrigger = true - } + /// State used to animate in the chat bubble if `animateIn` is true + @State private var animationTrigger = false - private var chatIcon: some View { - Image(systemName: message.role == .user ? "person.circle.fill" : "lightbulb.circle") - .font(.title2) - .frame(width: 24, height: 24) - .foregroundColor(message.role == .user ? .primary : .orange) - } + private var bubbleOpacity: Double { + guard animateIn else { + return 1 + } + return animationTrigger ? 1 : 0 + } - private var chatName: some View { - Text(message.role == .user ? "You" : "Assistant") - .fontWeight(.bold) - .frame(maxWidth: .infinity, maxHeight: 24, alignment: .leading) - } + private var chatIcon: some View { + Image(systemName: message.role == .user ? "person.circle.fill" : "lightbulb.circle") + .font(.title2) + .frame(width: 24, height: 24) + .foregroundColor(message.role == .user ? .primary : .orange) + } - @ViewBuilder - private var chatBody: some View { - if message.role == .user { - Text(LocalizedStringKey(message.text)) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.primary) + private var chatName: some View { + Text(message.role == .user ? "You" : "Assistant") + .fontWeight(.bold) + .frame(maxWidth: .infinity, maxHeight: 24, alignment: .leading) + } + + @ViewBuilder + private var chatBody: some View { + if message.role == .user { + Text(LocalizedStringKey(message.text)) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.primary) + } else { + if message.isWaitingForFirstText { + ProgressView() } else { - if message.isWaitingForFirstText { - ProgressView() - } else { - Text(LocalizedStringKey(message.text)) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.primary) - } + Text(LocalizedStringKey(message.text)) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.primary) } - } + } + } + + private func adjustAnimationTriggerIfNecessary() { + guard animateIn else { + return + } + animationTrigger = true + } + } #Preview { - ChatMessageView(message: ChatMessage(text: "hello", role: .user), animateIn: false) - .frame(maxWidth: .infinity) - .padding() + ChatMessageView(message: ChatMessage(text: "hello", role: .user), animateIn: false) + .frame(maxWidth: .infinity) + .padding() } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift index f5c0b13..a281f94 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Clients/GithubMCPClient.swift @@ -12,37 +12,41 @@ import MCPClient import SwiftUI final class GIthubMCPClient { - - private var client: MCPClient? - private let clientInitialized = AsyncStream.makeStream(of: MCPClient?.self) - - init() { - Task { - do { - self.client = try await MCPClient( - info: .init(name: "GIthubMCPClient", version: "1.0.0"), - transport: .stdioProcess( - "npx", - args: ["-y", "@modelcontextprotocol/server-github"], - verbose: true - ), - capabilities: .init() - ) - clientInitialized.continuation.yield(self.client) - clientInitialized.continuation.finish() - } catch { - print("Failed to initialize MCPClient: \(error)") - clientInitialized.continuation.yield(nil) - clientInitialized.continuation.finish() - } - } - } - // Modern async/await approach - func getClientAsync() async throws -> MCPClient? { - for await client in clientInitialized.stream { - return client + // MARK: Lifecycle + + init() { + Task { + do { + self.client = try await MCPClient( + info: .init(name: "GIthubMCPClient", version: "1.0.0"), + transport: .stdioProcess( + "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + verbose: true), + capabilities: .init()) + clientInitialized.continuation.yield(self.client) + clientInitialized.continuation.finish() + } catch { + print("Failed to initialize MCPClient: \(error)") + clientInitialized.continuation.yield(nil) + clientInitialized.continuation.finish() } - return nil // Stream completed without a client - } + } + } + + // MARK: Internal + + /// Modern async/await approach + func getClientAsync() async throws -> MCPClient? { + for await client in clientInitialized.stream { + return client + } + return nil // Stream completed without a client + } + + // MARK: Private + + private var client: MCPClient? + private let clientInitialized = AsyncStream.makeStream(of: MCPClient?.self) } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift index cae76a2..ac7c6ce 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift @@ -14,156 +14,149 @@ import SwiftOpenAI // MARK: Anthropic -/** - * Extension that bridges the MCP (Multi-Client Protocol) framework with [SwiftAnthropic](https://github.com/jamesrochabrun/SwiftAnthropic) library. - * - * This Extension provides methods to: - * 1. Retrieve available tools from an MCP client and convert them to Anthropic's format - * 2. Execute tools with provided parameters and handle their responses - */ +/// Extension that bridges the MCP (Multi-Client Protocol) framework with [SwiftAnthropic](https://github.com/jamesrochabrun/SwiftAnthropic) library. +/// +/// This Extension provides methods to: +/// 1. Retrieve available tools from an MCP client and convert them to Anthropic's format +/// 2. Execute tools with provided parameters and handle their responses extension MCPClient { - - /** - * Retrieves available tools from the MCP client and converts them to Anthropic's tool format. - * - * - Returns: An array of Anthropic-compatible tools - * - Throws: Errors from the underlying MCP client or during conversion process - */ - func anthropicTools() async throws -> [SwiftAnthropic.MessageParameter.Tool] { - let tools = await tools - return try tools.value.get().map { $0.toAnthropicTool() } - } - - /** - * Executes a tool with the specified name and input parameters. - * - * - Parameters: - * - name: The identifier of the tool to call - * - input: Dictionary of parameters to pass to the tool - * - debug: Flag to enable verbose logging during execution - * - Returns: A string containing the tool's response, or `nil` if execution failed - */ - func anthropicCallTool( - name: String, - input: [String: Any], - debug: Bool) - async -> String? { - do { - if debug { - print("🔧 Calling tool '\(name)'...") - } - - // Convert DynamicContent values to basic types - var serializableInput: [String: Any] = [:] - for (key, value) in input { - if let dynamicContent = value as? MessageResponse.Content.DynamicContent { - serializableInput[key] = dynamicContent.extractValue() - } else { - serializableInput[key] = value - } - } - - let inputData = try JSONSerialization.data(withJSONObject: serializableInput) - let inputJSON = try JSONDecoder().decode(JSON.self, from: inputData) - - let result = try await callTool(named: name, arguments: inputJSON) - - if result.isError != true { - if let content = result.content.first?.text?.text { - if debug { - print("✅ Tool execution successful") - } - return content - } else { - if debug { - print("⚠️ Tool returned no text content") - } - return nil - } - } else { - print("❌ Tool returned an error") - if let errorText = result.content.first?.text?.text { - if debug { - print(" Error: \(errorText)") - } - } - return nil - } - } catch { - if debug { - print("⛔️ Error calling tool: \(error)") - } - return nil + + /// Retrieves available tools from the MCP client and converts them to Anthropic's tool format. + /// + /// - Returns: An array of Anthropic-compatible tools + /// - Throws: Errors from the underlying MCP client or during conversion process + func anthropicTools() async throws -> [SwiftAnthropic.MessageParameter.Tool] { + let tools = await tools + return try tools.value.get().map { $0.toAnthropicTool() } + } + + /// Executes a tool with the specified name and input parameters. + /// + /// - Parameters: + /// - name: The identifier of the tool to call + /// - input: Dictionary of parameters to pass to the tool + /// - debug: Flag to enable verbose logging during execution + /// - Returns: A string containing the tool's response, or `nil` if execution failed + func anthropicCallTool( + name: String, + input: [String: Any], + debug: Bool) + async -> String? + { + do { + if debug { + print("🔧 Calling tool '\(name)'...") + } + + // Convert DynamicContent values to basic types + var serializableInput: [String: Any] = [:] + for (key, value) in input { + if let dynamicContent = value as? MessageResponse.Content.DynamicContent { + serializableInput[key] = dynamicContent.extractValue() + } else { + serializableInput[key] = value + } + } + + let inputData = try JSONSerialization.data(withJSONObject: serializableInput) + let inputJSON = try JSONDecoder().decode(JSON.self, from: inputData) + + let result = try await callTool(named: name, arguments: inputJSON) + + if result.isError != true { + if let content = result.content.first?.text?.text { + if debug { + print("✅ Tool execution successful") + } + return content + } else { + if debug { + print("⚠️ Tool returned no text content") + } + return nil + } + } else { + print("❌ Tool returned an error") + if let errorText = result.content.first?.text?.text { + if debug { + print(" Error: \(errorText)") + } + } + return nil + } + } catch { + if debug { + print("⛔️ Error calling tool: \(error)") } - } + return nil + } + } } // MARK: OpenAI -/** - * Extension that bridges the MCP (Multi-Client Protocol) framework with [SwiftOpenAI](https://github.com/jamesrochabrun/SwiftOpenAI) library. - * - * This Extension provides methods to: - * 1. Retrieve available tools from an MCP client and convert them to Anthropic's format - * 2. Execute tools with provided parameters and handle their responses - */ +/// Extension that bridges the MCP (Multi-Client Protocol) framework with [SwiftOpenAI](https://github.com/jamesrochabrun/SwiftOpenAI) library. +/// +/// This Extension provides methods to: +/// 1. Retrieve available tools from an MCP client and convert them to Anthropic's format +/// 2. Execute tools with provided parameters and handle their responses extension MCPClient { - - func tools() async throws -> [SwiftOpenAI.ChatCompletionParameters.Tool] { - let tools = await tools - return try tools.value.get().map { $0.toOpenAITool() } - } - - func callTool( - name: String, - input: [String: Any], - debug: Bool) - async -> String? { - - do { - if debug { - print("🔧 Calling tool '\(name)'...") - } - - // Convert OpenAI function call parameters to serializable format - var serializableInput: [String: Any] = [:] - for (key, value) in input { - // Handle any special OpenAI types that might need conversion - // This will depend on what types OpenAI uses in their response - serializableInput[key] = value - } - - let inputData = try JSONSerialization.data(withJSONObject: serializableInput) - let inputJSON = try JSONDecoder().decode(JSON.self, from: inputData) - - let result = try await callTool(named: name, arguments: inputJSON) - - if result.isError != true { - if let content = result.content.first?.text?.text { - if debug { - print("✅ Tool execution successful") - } - return content - } else { - if debug { - print("⚠️ Tool returned no text content") - } - return nil - } - } else { - print("❌ Tool returned an error") - if let errorText = result.content.first?.text?.text { - if debug { - print(" Error: \(errorText)") - } - } - return nil - } - } catch { - if debug { - print("⛔️ Error calling tool: \(error)") - } - return nil + + func tools() async throws -> [SwiftOpenAI.ChatCompletionParameters.Tool] { + let tools = await tools + return try tools.value.get().map { $0.toOpenAITool() } + } + + func callTool( + name: String, + input: [String: Any], + debug: Bool) + async -> String? + { + do { + if debug { + print("🔧 Calling tool '\(name)'...") + } + + // Convert OpenAI function call parameters to serializable format + var serializableInput: [String: Any] = [:] + for (key, value) in input { + // Handle any special OpenAI types that might need conversion + // This will depend on what types OpenAI uses in their response + serializableInput[key] = value + } + + let inputData = try JSONSerialization.data(withJSONObject: serializableInput) + let inputJSON = try JSONDecoder().decode(JSON.self, from: inputData) + + let result = try await callTool(named: name, arguments: inputJSON) + + if result.isError != true { + if let content = result.content.first?.text?.text { + if debug { + print("✅ Tool execution successful") + } + return content + } else { + if debug { + print("⚠️ Tool returned no text content") + } + return nil + } + } else { + print("❌ Tool returned an error") + if let errorText = result.content.first?.text?.text { + if debug { + print(" Error: \(errorText)") + } + } + return nil + } + } catch { + if debug { + print("⛔️ Error calling tool: \(error)") } - } + return nil + } + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift index 4ee52b5..f6e47eb 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift @@ -11,254 +11,263 @@ import MCPInterface import SwiftOpenAI extension MCPInterface.Tool { - - /** - * Converts an MCP interface tool to SwiftOpenAI's tool format. - * - * This function transforms the tool's metadata and schema structure from - * the MCP format to the format expected by the OpenAI API, ensuring - * compatibility between the two systems. - * - * - Returns: A `SwiftOpenAI.Tool` representing the same - * functionality as the original MCP tool. - */ - public func toOpenAITool() -> SwiftOpenAI.ChatCompletionParameters.Tool { - // Convert the JSON to SwiftOpenAI.JSONSchema - let openAIParameters: SwiftOpenAI.JSONSchema? - - switch self.inputSchema { - case .object(let value): - openAIParameters = convertToOpenAIJSONSchema(from: value) - case .array(_): - // Arrays are not directly supported in the schema root - openAIParameters = nil - } - - let chatFunction = SwiftOpenAI.ChatCompletionParameters.ChatFunction( - name: self.name, - strict: true, // Set strict to true for consistent behavior - description: self.description, - parameters: openAIParameters - ) - - return SwiftOpenAI.ChatCompletionParameters.Tool( - type: "function", // Currently only "function" is supported - function: chatFunction - ) - } - - /** - * Converts MCP JSON object to SwiftOpenAI JSONSchema format. - * - * This helper function transforms a JSON schema object from MCP format to the - * corresponding OpenAI format, handling the root schema properties. - * - * - Parameter jsonObject: Dictionary containing MCP JSON schema properties - * - Returns: An equivalent SwiftOpenAI JSONSchema object, or nil if conversion fails - */ - private func convertToOpenAIJSONSchema(from jsonObject: [String: MCPInterface.JSON.Value]) -> SwiftOpenAI.JSONSchema? { - // Extract type - let type: JSONSchemaType? - if let typeValue = jsonObject["type"] { - switch typeValue { - case .string(let typeString): + + // MARK: Public + + /// Converts an MCP interface tool to SwiftOpenAI's tool format. + /// + /// This function transforms the tool's metadata and schema structure from + /// the MCP format to the format expected by the OpenAI API, ensuring + /// compatibility between the two systems. + /// + /// - Returns: A `SwiftOpenAI.Tool` representing the same + /// functionality as the original MCP tool. + public func toOpenAITool() -> SwiftOpenAI.ChatCompletionParameters.Tool { + // Convert the JSON to SwiftOpenAI.JSONSchema + let openAIParameters: SwiftOpenAI.JSONSchema? + + switch inputSchema { + case .object(let value): + openAIParameters = convertToOpenAIJSONSchema(from: value) + case .array: + // Arrays are not directly supported in the schema root + openAIParameters = nil + } + + let chatFunction = SwiftOpenAI.ChatCompletionParameters.ChatFunction( + name: name, + strict: true, // Set strict to true for consistent behavior + description: description, + parameters: openAIParameters) + + return SwiftOpenAI.ChatCompletionParameters.Tool( + type: "function", // Currently only "function" is supported + function: chatFunction) + } + + // MARK: Private + + /// Converts MCP JSON object to SwiftOpenAI JSONSchema format. + /// + /// This helper function transforms a JSON schema object from MCP format to the + /// corresponding OpenAI format, handling the root schema properties. + /// + /// - Parameter jsonObject: Dictionary containing MCP JSON schema properties + /// - Returns: An equivalent SwiftOpenAI JSONSchema object, or nil if conversion fails + private func convertToOpenAIJSONSchema(from jsonObject: [String: MCPInterface.JSON.Value]) -> SwiftOpenAI.JSONSchema? { + // Extract type + let type: JSONSchemaType? + if let typeValue = jsonObject["type"] { + switch typeValue { + case .string(let typeString): + switch typeString { + case "string": type = .string + case "number": type = .number + case "integer": type = .integer + case "boolean": type = .boolean + case "object": type = .object + case "array": type = .array + case "null": type = .null + default: type = nil + } + + case .array(let typeArray): + // Handle union types + var types: [JSONSchemaType] = [] + for item in typeArray { + if case .string(let typeString) = item { switch typeString { - case "string": type = .string - case "number": type = .number - case "integer": type = .integer - case "boolean": type = .boolean - case "object": type = .object - case "array": type = .array - case "null": type = .null - default: type = nil + case "string": types.append(.string) + case "number": types.append(.number) + case "integer": types.append(.integer) + case "boolean": types.append(.boolean) + case "object": types.append(.object) + case "array": types.append(.array) + case "null": types.append(.null) + default: continue } - case .array(let typeArray): - // Handle union types - var types: [JSONSchemaType] = [] - for item in typeArray { - if case .string(let typeString) = item { - switch typeString { - case "string": types.append(.string) - case "number": types.append(.number) - case "integer": types.append(.integer) - case "boolean": types.append(.boolean) - case "object": types.append(.object) - case "array": types.append(.array) - case "null": types.append(.null) - default: continue - } - } - } - if !types.isEmpty { - type = .union(types) - } else { - type = nil - } - default: - type = nil - } - } else { - type = nil - } - - // Extract description - var description: String? = nil - if let descValue = jsonObject["description"], - case .string(let descString) = descValue { - description = descString - } - - // Extract properties - var properties: [String: SwiftOpenAI.JSONSchema]? = nil - if let propertiesValue = jsonObject["properties"], - case .object(let propertiesObject) = propertiesValue { - properties = [:] - for (key, value) in propertiesObject { - if case .object(let propertyObject) = value, - let property = convertToOpenAIJSONSchema(from: propertyObject) { - properties?[key] = property - } - } + } + } + if !types.isEmpty { + type = .union(types) + } else { + type = nil + } + + default: + type = nil } - - // Extract items for array types - var items: SwiftOpenAI.JSONSchema? = nil - if let itemsValue = jsonObject["items"] { - switch itemsValue { - case .object(let itemsObject): - items = convertToOpenAIJSONSchema(from: itemsObject) - case .array(let itemsArray): - // Handle array of schemas for tuples - if let firstItem = itemsArray.first, - case .object(let firstItemObject) = firstItem { - items = convertToOpenAIJSONSchema(from: firstItemObject) - } - default: - break - } + } else { + type = nil + } + + // Extract description + var description: String? = nil + if + let descValue = jsonObject["description"], + case .string(let descString) = descValue + { + description = descString + } + + // Extract properties + var properties: [String: SwiftOpenAI.JSONSchema]? = nil + if + let propertiesValue = jsonObject["properties"], + case .object(let propertiesObject) = propertiesValue + { + properties = [:] + for (key, value) in propertiesObject { + if + case .object(let propertyObject) = value, + let property = convertToOpenAIJSONSchema(from: propertyObject) + { + properties?[key] = property + } } - - // Extract required fields - var required: [String]? = nil - if let requiredValue = jsonObject["required"], - case .array(let requiredArray) = requiredValue { - required = [] - for item in requiredArray { - if case .string(let field) = item { - required?.append(field) - } - } + } + + // Extract items for array types + var items: SwiftOpenAI.JSONSchema? = nil + if let itemsValue = jsonObject["items"] { + switch itemsValue { + case .object(let itemsObject): + items = convertToOpenAIJSONSchema(from: itemsObject) + + case .array(let itemsArray): + // Handle array of schemas for tuples + if + let firstItem = itemsArray.first, + case .object(let firstItemObject) = firstItem + { + items = convertToOpenAIJSONSchema(from: firstItemObject) + } + + default: + break } - - // Fix for OpenAI's requirement: for strict schemas, include all property keys in required array - // If we're dealing with an object type and have properties - if type == .object && properties != nil { - // Initialize the set of all property keys - var allPropertyKeys = Set(properties!.keys) - - // If we already have some required fields, merge them with our property keys - if let existingRequired = required { - let requiredSet = Set(existingRequired) - allPropertyKeys = allPropertyKeys.union(requiredSet) - } - - // Use the complete set of properties as our required fields - required = Array(allPropertyKeys) + } + + // Extract required fields + var required: [String]? = nil + if + let requiredValue = jsonObject["required"], + case .array(let requiredArray) = requiredValue + { + required = [] + for item in requiredArray { + if case .string(let field) = item { + required?.append(field) + } } - - // Extract additional properties - var additionalProperties: Bool = false - if let addPropsValue = jsonObject["additionalProperties"] { - switch addPropsValue { - case .bool(let addPropsBool): - additionalProperties = addPropsBool - case .object(_): - // If additionalProperties is an object schema, treat it as true - additionalProperties = true - default: - additionalProperties = false - } + } + + // Fix for OpenAI's requirement: for strict schemas, include all property keys in required array + // If we're dealing with an object type and have properties + if type == .object && properties != nil { + // Initialize the set of all property keys + var allPropertyKeys = Set(properties!.keys) + + // If we already have some required fields, merge them with our property keys + if let existingRequired = required { + let requiredSet = Set(existingRequired) + allPropertyKeys = allPropertyKeys.union(requiredSet) } - - // Extract enum values - var enumValues: [String]? = nil - if let enumValue = jsonObject["enum"], - case .array(let enumArray) = enumValue { - enumValues = [] - for item in enumArray { - switch item { - case .string(let value): - enumValues?.append(value) - case .number(let value): - enumValues?.append(String(value)) - case .bool(let value): - enumValues?.append(value ? "true" : "false") - default: - continue - } - } + + // Use the complete set of properties as our required fields + required = Array(allPropertyKeys) + } + + // Extract additional properties + var additionalProperties = false + if let addPropsValue = jsonObject["additionalProperties"] { + switch addPropsValue { + case .bool(let addPropsBool): + additionalProperties = addPropsBool + case .object: + // If additionalProperties is an object schema, treat it as true + additionalProperties = true + default: + additionalProperties = false } - - // Extract ref - var ref: String? = nil - if let refValue = jsonObject["$ref"], - case .string(let refString) = refValue { - ref = refString + } + + // Extract enum values + var enumValues: [String]? = nil + if + let enumValue = jsonObject["enum"], + case .array(let enumArray) = enumValue + { + enumValues = [] + for item in enumArray { + switch item { + case .string(let value): + enumValues?.append(value) + case .number(let value): + enumValues?.append(String(value)) + case .bool(let value): + enumValues?.append(value ? "true" : "false") + default: + continue + } } - - // Create and return the JSON schema with only the supported parameters - return SwiftOpenAI.JSONSchema( - type: type, - description: description, - properties: properties, - items: items, - required: required, - additionalProperties: additionalProperties, - enum: enumValues, - ref: ref - ) - } - - /** - * Extracts primitive value from JSON.Value for use in OpenAI schema properties. - * - * - Parameter value: The JSON.Value to extract from - * - Returns: The primitive Swift type corresponding to the JSON value - */ - private func extractPrimitiveValue(from value: MCPInterface.JSON.Value) -> Any? { - switch value { - case .string(let stringValue): - return stringValue - case .number(let numberValue): - return numberValue - case .bool(let boolValue): - return boolValue - case .null: - return NSNull() - case .array(let arrayValue): - return arrayValue.compactMap { extractPrimitiveValue(from: $0) } - case .object(let objectValue): - var result: [String: Any] = [:] - for (key, value) in objectValue { - if let extractedValue = extractPrimitiveValue(from: value) { - result[key] = extractedValue - } - } - return result + } + + // Extract ref + var ref: String? = nil + if + let refValue = jsonObject["$ref"], + case .string(let refString) = refValue + { + ref = refString + } + + // Create and return the JSON schema with only the supported parameters + return SwiftOpenAI.JSONSchema( + type: type, + description: description, + properties: properties, + items: items, + required: required, + additionalProperties: additionalProperties, + enum: enumValues, + ref: ref) + } + + /// Extracts primitive value from JSON.Value for use in OpenAI schema properties. + /// + /// - Parameter value: The JSON.Value to extract from + /// - Returns: The primitive Swift type corresponding to the JSON value + private func extractPrimitiveValue(from value: MCPInterface.JSON.Value) -> Any? { + switch value { + case .string(let stringValue): + return stringValue + case .number(let numberValue): + return numberValue + case .bool(let boolValue): + return boolValue + case .null: + return NSNull() + case .array(let arrayValue): + return arrayValue.compactMap { extractPrimitiveValue(from: $0) } + case .object(let objectValue): + var result: [String: Any] = [:] + for (key, value) in objectValue { + if let extractedValue = extractPrimitiveValue(from: value) { + result[key] = extractedValue + } } - } + return result + } + } } -/** - * Extension for batch conversion of multiple MCP tools to OpenAI tools. - */ +/// Extension for batch conversion of multiple MCP tools to OpenAI tools. extension Array where Element == MCPInterface.Tool { - /** - * Converts an array of MCP interface tools to an array of SwiftOpenAI tools. - * - * - Returns: An array of SwiftOpenAI.Tool objects - */ - public func toOpenAITools() -> [SwiftOpenAI.ChatCompletionParameters.Tool] { - return self.map { $0.toOpenAITool() } - } + /// Converts an array of MCP interface tools to an array of SwiftOpenAI tools. + /// + /// - Returns: An array of SwiftOpenAI.Tool objects + public func toOpenAITools() -> [SwiftOpenAI.ChatCompletionParameters.Tool] { + map { $0.toOpenAITool() } + } } diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift index cd2ed0e..9ef5e60 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCPClientChatApp.swift @@ -5,48 +5,52 @@ // Created by James Rochabrun on 3/3/25. // -import SwiftUI import SwiftAnthropic import SwiftOpenAI +import SwiftUI @main struct MCPClientChatApp: App { - - @State private var chatManager: ChatManager - private let githubClient = GIthubMCPClient() - - init() { - let service = AnthropicServiceFactory.service(apiKey: "", betaHeaders: nil, debugEnabled: true) - - let initialManager = AnthropicNonStreamManager(service: service) - - _chatManager = State(initialValue: initialManager) - - // Uncomment this and comment the above for OpenAI Demo - - // let openAIService = OpenAIServiceFactory.service(apiKey: "", debugEnabled: true) - // - // let openAIChatNonStreamManager = OpenAIChatNonStreamManager(service: openAIService) - // - // _chatManager = State(initialValue: openAIChatNonStreamManager) - } - - var body: some Scene { - WindowGroup { - ChatView(chatManager: chatManager) - .toolbar(removing: .title) - .containerBackground( - .thinMaterial, for: .window - ) - .toolbarBackgroundVisibility( - .hidden, for: .windowToolbar - ) - .task { - if let client = try? await githubClient.getClientAsync() { - chatManager.updateClient(client) - } - } - } - .windowStyle(.hiddenTitleBar) - } + + // MARK: Lifecycle + + init() { + let service = AnthropicServiceFactory.service(apiKey: "", betaHeaders: nil, debugEnabled: true) + + let initialManager = AnthropicNonStreamManager(service: service) + + _chatManager = State(initialValue: initialManager) + + // Uncomment this and comment the above for OpenAI Demo + + // let openAIService = OpenAIServiceFactory.service(apiKey: "", debugEnabled: true) + // + // let openAIChatNonStreamManager = OpenAIChatNonStreamManager(service: openAIService) + // + // _chatManager = State(initialValue: openAIChatNonStreamManager) + } + + // MARK: Internal + + var body: some Scene { + WindowGroup { + ChatView(chatManager: chatManager) + .toolbar(removing: .title) + .containerBackground( + .thinMaterial, for: .window) + .toolbarBackgroundVisibility( + .hidden, for: .windowToolbar) + .task { + if let client = try? await githubClient.getClientAsync() { + chatManager.updateClient(client) + } + } + } + .windowStyle(.hiddenTitleBar) + } + + // MARK: Private + + @State private var chatManager: ChatManager + private let githubClient = GIthubMCPClient() } diff --git a/MCPInterface/Sources/helpers/JSON+Streaming.swift b/MCPInterface/Sources/helpers/JSON+Streaming.swift index 203dbe9..b748588 100644 --- a/MCPInterface/Sources/helpers/JSON+Streaming.swift +++ b/MCPInterface/Sources/helpers/JSON+Streaming.swift @@ -87,5 +87,4 @@ extension Data { private static let quote = UInt8(ascii: "\"") private static let escape = UInt8(ascii: "\\") - } From 05440ea57b630bc4459278ff6f8bd2ab84285b2d Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Wed, 5 Mar 2025 16:31:41 -0800 Subject: [PATCH 22/22] More linting --- .../MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift | 8 ++++---- .../MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift index ac7c6ce..e994ffc 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPClient+LLMTools.swift @@ -15,14 +15,14 @@ import SwiftOpenAI // MARK: Anthropic /// Extension that bridges the MCP (Multi-Client Protocol) framework with [SwiftAnthropic](https://github.com/jamesrochabrun/SwiftAnthropic) library. -/// +/// /// This Extension provides methods to: /// 1. Retrieve available tools from an MCP client and convert them to Anthropic's format /// 2. Execute tools with provided parameters and handle their responses extension MCPClient { /// Retrieves available tools from the MCP client and converts them to Anthropic's tool format. - /// + /// /// - Returns: An array of Anthropic-compatible tools /// - Throws: Errors from the underlying MCP client or during conversion process func anthropicTools() async throws -> [SwiftAnthropic.MessageParameter.Tool] { @@ -31,7 +31,7 @@ extension MCPClient { } /// Executes a tool with the specified name and input parameters. - /// + /// /// - Parameters: /// - name: The identifier of the tool to call /// - input: Dictionary of parameters to pass to the tool @@ -96,7 +96,7 @@ extension MCPClient { // MARK: OpenAI /// Extension that bridges the MCP (Multi-Client Protocol) framework with [SwiftOpenAI](https://github.com/jamesrochabrun/SwiftOpenAI) library. -/// +/// /// This Extension provides methods to: /// 1. Retrieve available tools from an MCP client and convert them to Anthropic's format /// 2. Execute tools with provided parameters and handle their responses diff --git a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift index f6e47eb..818f14e 100644 --- a/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift +++ b/MCPClientChatDemo/MCPClientChat/MCPClientChat/MCP/Tools/MCPTool+OpenAITool.swift @@ -15,11 +15,11 @@ extension MCPInterface.Tool { // MARK: Public /// Converts an MCP interface tool to SwiftOpenAI's tool format. - /// + /// /// This function transforms the tool's metadata and schema structure from /// the MCP format to the format expected by the OpenAI API, ensuring /// compatibility between the two systems. - /// + /// /// - Returns: A `SwiftOpenAI.Tool` representing the same /// functionality as the original MCP tool. public func toOpenAITool() -> SwiftOpenAI.ChatCompletionParameters.Tool { @@ -48,10 +48,10 @@ extension MCPInterface.Tool { // MARK: Private /// Converts MCP JSON object to SwiftOpenAI JSONSchema format. - /// + /// /// This helper function transforms a JSON schema object from MCP format to the /// corresponding OpenAI format, handling the root schema properties. - /// + /// /// - Parameter jsonObject: Dictionary containing MCP JSON schema properties /// - Returns: An equivalent SwiftOpenAI JSONSchema object, or nil if conversion fails private func convertToOpenAIJSONSchema(from jsonObject: [String: MCPInterface.JSON.Value]) -> SwiftOpenAI.JSONSchema? { @@ -235,7 +235,7 @@ extension MCPInterface.Tool { } /// Extracts primitive value from JSON.Value for use in OpenAI schema properties. - /// + /// /// - Parameter value: The JSON.Value to extract from /// - Returns: The primitive Swift type corresponding to the JSON value private func extractPrimitiveValue(from value: MCPInterface.JSON.Value) -> Any? { @@ -265,7 +265,7 @@ extension MCPInterface.Tool { /// Extension for batch conversion of multiple MCP tools to OpenAI tools. extension Array where Element == MCPInterface.Tool { /// Converts an array of MCP interface tools to an array of SwiftOpenAI tools. - /// + /// /// - Returns: An array of SwiftOpenAI.Tool objects public func toOpenAITools() -> [SwiftOpenAI.ChatCompletionParameters.Tool] { map { $0.toOpenAITool() }