Initial implementation
This commit is contained in:
parent
cc603d35fe
commit
be1e9583f8
0 changed files with 4304 additions and 884 deletions
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["swiftlang.swift-vscode"]
|
||||
}
|
||||
8
.vscode/settings.json
vendored
Normal file
8
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "swiftlang.swift-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
}
|
||||
201
LICENSE
Normal file
201
LICENSE
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 Loopwork Limited
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
24
Package.resolved
Normal file
24
Package.resolved
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"originHash" : "cce36cb33302c2f1c28458e19b8439f736fc28106e4c6ea95d7992c74594c242",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91",
|
||||
"version" : "1.6.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "c8a44d836fe7913603e246acab7c528c2e780168",
|
||||
"version" : "1.4.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// swift-tools-version:5.7
|
||||
// swift-tools-version:6.0
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
|
@ -6,26 +6,34 @@ import PackageDescription
|
|||
let package = Package(
|
||||
name: "mcp-swift-sdk",
|
||||
platforms: [
|
||||
.macOS(.v12),
|
||||
.iOS(.v15),
|
||||
.tvOS(.v15),
|
||||
.watchOS(.v8)
|
||||
.macOS(.v14),
|
||||
.iOS(.v17),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
.library(
|
||||
name: "MCP",
|
||||
targets: ["MCP"]),
|
||||
targets: ["MCP"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-system.git", from: "1.0.0"),
|
||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.target(
|
||||
name: "MCP",
|
||||
dependencies: []),
|
||||
dependencies: [
|
||||
.product(name: "SystemPackage", package: "swift-system"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "MCPTests",
|
||||
dependencies: ["MCP"]),
|
||||
dependencies: [
|
||||
"MCP",
|
||||
.product(name: "SystemPackage", package: "swift-system"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
|
|
|||
145
README.md
145
README.md
|
|
@ -1,23 +1,12 @@
|
|||
# MCP Swift SDK
|
||||
|
||||
A Swift implementation of the Model Context Protocol (MCP) client. This SDK provides a modern, Swift-native way to interact with MCP servers.
|
||||
|
||||
## Features
|
||||
|
||||
- Full async/await support
|
||||
- Type-safe API
|
||||
- Resource management
|
||||
- Tool integration
|
||||
- Configurable transport layer
|
||||
- Swift concurrency with actor-based design
|
||||
Swift implementation of the [Model Context Protocol][mcp] (MCP).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Swift 5.7+
|
||||
- macOS 12.0+
|
||||
- iOS 15.0+
|
||||
- tvOS 15.0+
|
||||
- watchOS 8.0+
|
||||
- Swift 6.0+ / Xcode 16+
|
||||
- macOS 14.0+ (Sonoma)
|
||||
- iOS 17.0+
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -27,45 +16,65 @@ Add the following to your `Package.swift` file:
|
|||
|
||||
```swift
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/yourusername/mcp-swift-sdk.git", from: "1.0.0")
|
||||
.package(url: "https://github.com/loopwork-ai/mcp-swift-sdk.git", from: "0.1.0")
|
||||
]
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Setup
|
||||
### Basic Client Setup
|
||||
|
||||
```swift
|
||||
import MCP
|
||||
|
||||
// Create a transport (e.g., StdioTransport)
|
||||
let transport = StdioTransport(command: "/path/to/server")
|
||||
|
||||
// Create client info
|
||||
let clientInfo = Implementation(name: "MyApp", version: "1.0.0")
|
||||
|
||||
// Initialize the client
|
||||
let client = MCPClient(transport: transport, clientInfo: clientInfo)
|
||||
let client = Client(name: "MyApp", version: "1.0.0")
|
||||
|
||||
// Connect and initialize
|
||||
try await client.connect()
|
||||
// Create a transport and connect
|
||||
let transport = StdioTransport()
|
||||
try await client.connect(transport: transport)
|
||||
|
||||
// Initialize the connection
|
||||
let result = try await client.initialize()
|
||||
```
|
||||
|
||||
### Working with Resources
|
||||
### Basic Server Setup
|
||||
|
||||
```swift
|
||||
// List available resources
|
||||
let resources = try await client.listResources()
|
||||
import MCP
|
||||
|
||||
// Read a resource
|
||||
let resource = try await client.readResource("resource://example")
|
||||
// Initialize the server with capabilities
|
||||
let server = Server(
|
||||
name: "MyServer",
|
||||
version: "1.0.0",
|
||||
capabilities: .init(
|
||||
resources: .init(
|
||||
list: true,
|
||||
read: true,
|
||||
subscribe: true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Subscribe to resource updates
|
||||
try await client.subscribeResource("resource://example")
|
||||
// Create transport and start server
|
||||
let transport = StdioTransport()
|
||||
try await server.start(transport: transport)
|
||||
|
||||
// Unsubscribe from resource updates
|
||||
try await client.unsubscribeResource("resource://example")
|
||||
// Register method handlers
|
||||
server.withMethodHandler(ReadResource.self) { params in
|
||||
// Handle resource read request
|
||||
let uri = params.uri
|
||||
let content = [Resource.Content.text("Example content")]
|
||||
return .init(contents: content)
|
||||
}
|
||||
|
||||
// Register notification handlers
|
||||
server.onNotification(ResourceUpdatedNotification.self) { message in
|
||||
// Handle resource update notification
|
||||
}
|
||||
|
||||
// Stop the server when done
|
||||
await server.stop()
|
||||
```
|
||||
|
||||
### Working with Tools
|
||||
|
|
@ -75,41 +84,57 @@ try await client.unsubscribeResource("resource://example")
|
|||
let tools = try await client.listTools()
|
||||
|
||||
// Call a tool
|
||||
let result = try await client.callTool(name: "example-tool", arguments: ["key": "value"])
|
||||
```
|
||||
let (content, isError) = try await client.callTool(
|
||||
name: "example-tool",
|
||||
arguments: ["key": "value"]
|
||||
)
|
||||
|
||||
### Custom Transport
|
||||
|
||||
You can create your own transport by implementing the `MCPTransport` protocol:
|
||||
|
||||
```swift
|
||||
public protocol MCPTransport {
|
||||
func connect() async throws
|
||||
func disconnect() async
|
||||
func sendRequest(_ data: Data) async throws -> Data
|
||||
func sendNotification(_ data: Data) async throws
|
||||
// Handle tool content
|
||||
for item in content {
|
||||
switch item {
|
||||
case .text(let text):
|
||||
print(text)
|
||||
case .image(let data, let mimeType, let metadata):
|
||||
// Handle image data
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The SDK uses the `MCPError` type for error handling:
|
||||
### Working with Resources
|
||||
|
||||
```swift
|
||||
public enum MCPError: LocalizedError {
|
||||
case connectionClosed
|
||||
case invalidResponse
|
||||
case invalidParams(String)
|
||||
case serverError(String)
|
||||
case protocolError(String)
|
||||
case transportError(Error)
|
||||
// List available resources
|
||||
let (resources, nextCursor) = try await client.listResources()
|
||||
|
||||
// Read a resource
|
||||
let contents = try await client.readResource(uri: "resource://example")
|
||||
|
||||
// Subscribe to resource updates
|
||||
try await client.subscribeToResource(uri: "resource://example")
|
||||
|
||||
// Handle resource updates
|
||||
await client.onNotification(ResourceUpdatedNotification.self) { message in
|
||||
let uri = message.params.uri
|
||||
let content = message.params.content
|
||||
// Handle the update
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
### Working with Prompts
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
```swift
|
||||
// List available prompts
|
||||
let (prompts, nextCursor) = try await client.listPrompts()
|
||||
|
||||
// Get a prompt with arguments
|
||||
let (description, messages) = try await client.getPrompt(
|
||||
name: "example-prompt",
|
||||
arguments: ["key": "value"]
|
||||
)
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
This project is licensed under the Apache License, Version 2.0.
|
||||
|
||||
[mcp]: https://modelcontextprotocol.io
|
||||
212
Sources/MCP/Base/Error.swift
Normal file
212
Sources/MCP/Base/Error.swift
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import Foundation
|
||||
|
||||
/// A model context protocol error.
|
||||
public enum Error: Sendable {
|
||||
// Standard JSON-RPC 2.0 errors (-32700 to -32603)
|
||||
case parseError(String?) // -32700
|
||||
case invalidRequest(String?) // -32600
|
||||
case methodNotFound(String?) // -32601
|
||||
case invalidParams(String?) // -32602
|
||||
case internalError(String?) // -32603
|
||||
|
||||
// Server errors (-32000 to -32099)
|
||||
case serverError(code: Int, message: String)
|
||||
|
||||
// Transport specific errors
|
||||
case connectionClosed
|
||||
case transportError(Swift.Error)
|
||||
|
||||
/// The JSON-RPC 2.0 error code
|
||||
public var code: Int {
|
||||
switch self {
|
||||
case .parseError: return -32700
|
||||
case .invalidRequest: return -32600
|
||||
case .methodNotFound: return -32601
|
||||
case .invalidParams: return -32602
|
||||
case .internalError: return -32603
|
||||
case .serverError(let code, _): return code
|
||||
case .connectionClosed: return -32000
|
||||
case .transportError: return -32001
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: LocalizedError
|
||||
|
||||
extension Error: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .parseError(let detail):
|
||||
return "Parse error: Invalid JSON" + (detail.map { ": \($0)" } ?? "")
|
||||
case .invalidRequest(let detail):
|
||||
return "Invalid Request" + (detail.map { ": \($0)" } ?? "")
|
||||
case .methodNotFound(let detail):
|
||||
return "Method not found" + (detail.map { ": \($0)" } ?? "")
|
||||
case .invalidParams(let detail):
|
||||
return "Invalid params" + (detail.map { ": \($0)" } ?? "")
|
||||
case .internalError(let detail):
|
||||
return "Internal error" + (detail.map { ": \($0)" } ?? "")
|
||||
case .serverError(_, let message):
|
||||
return "Server error: \(message)"
|
||||
case .connectionClosed:
|
||||
return "Connection closed"
|
||||
case .transportError(let error):
|
||||
return "Transport error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
public var failureReason: String? {
|
||||
switch self {
|
||||
case .parseError:
|
||||
return "The server received invalid JSON that could not be parsed"
|
||||
case .invalidRequest:
|
||||
return "The JSON sent is not a valid Request object"
|
||||
case .methodNotFound:
|
||||
return "The method does not exist or is not available"
|
||||
case .invalidParams:
|
||||
return "Invalid method parameter(s)"
|
||||
case .internalError:
|
||||
return "Internal JSON-RPC error"
|
||||
case .serverError:
|
||||
return "Server-defined error occurred"
|
||||
case .connectionClosed:
|
||||
return "The connection to the server was closed"
|
||||
case .transportError(let error):
|
||||
return (error as? LocalizedError)?.failureReason ?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
public var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .parseError:
|
||||
return "Verify that the JSON being sent is valid and well-formed"
|
||||
case .invalidRequest:
|
||||
return "Ensure the request follows the JSON-RPC 2.0 specification format"
|
||||
case .methodNotFound:
|
||||
return "Check the method name and ensure it is supported by the server"
|
||||
case .invalidParams:
|
||||
return "Verify the parameters match the method's expected parameters"
|
||||
case .connectionClosed:
|
||||
return "Try reconnecting to the server"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: CustomDebugStringConvertible
|
||||
|
||||
extension Error: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
switch self {
|
||||
case .transportError(let error):
|
||||
return
|
||||
"[\(code)] \(errorDescription ?? "") (Underlying error: \(String(reflecting: error)))"
|
||||
default:
|
||||
return "[\(code)] \(errorDescription ?? "")"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Codable
|
||||
|
||||
extension Error: Codable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case code, message, data
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(code, forKey: .code)
|
||||
try container.encode(errorDescription ?? "Unknown error", forKey: .message)
|
||||
|
||||
// Encode additional data if available
|
||||
switch self {
|
||||
case .parseError(let detail),
|
||||
.invalidRequest(let detail),
|
||||
.methodNotFound(let detail),
|
||||
.invalidParams(let detail),
|
||||
.internalError(let detail):
|
||||
if let detail = detail {
|
||||
try container.encode(["detail": detail], forKey: .data)
|
||||
}
|
||||
case .serverError(_, _):
|
||||
// No additional data for server errors
|
||||
break
|
||||
case .connectionClosed:
|
||||
break
|
||||
case .transportError(let error):
|
||||
try container.encode(
|
||||
["error": error.localizedDescription],
|
||||
forKey: .data
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let code = try container.decode(Int.self, forKey: .code)
|
||||
let message = try container.decode(String.self, forKey: .message)
|
||||
let data = try container.decodeIfPresent([String: Value].self, forKey: .data)
|
||||
|
||||
switch code {
|
||||
case -32700:
|
||||
self = .parseError(data?["detail"] as? String ?? message)
|
||||
case -32600:
|
||||
self = .invalidRequest(data?["detail"] as? String ?? message)
|
||||
case -32601:
|
||||
self = .methodNotFound(data?["detail"] as? String ?? message)
|
||||
case -32602:
|
||||
self = .invalidParams(data?["detail"] as? String ?? message)
|
||||
case -32603:
|
||||
self = .internalError(data?["detail"] as? String ?? message)
|
||||
case -32000:
|
||||
self = .connectionClosed
|
||||
case -32001:
|
||||
self = .transportError(
|
||||
NSError(
|
||||
domain: "org.jsonrpc.error",
|
||||
code: code,
|
||||
userInfo: [NSLocalizedDescriptionKey: message]
|
||||
)
|
||||
)
|
||||
default:
|
||||
self = .serverError(code: code, message: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Equatable
|
||||
|
||||
extension Error: Equatable {
|
||||
public static func == (lhs: Error, rhs: Error) -> Bool {
|
||||
lhs.code == rhs.code
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Hashable
|
||||
|
||||
extension Error: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(code)
|
||||
switch self {
|
||||
case .parseError(let detail):
|
||||
hasher.combine(detail)
|
||||
case .invalidRequest(let detail):
|
||||
hasher.combine(detail)
|
||||
case .methodNotFound(let detail):
|
||||
hasher.combine(detail)
|
||||
case .invalidParams(let detail):
|
||||
hasher.combine(detail)
|
||||
case .internalError(let detail):
|
||||
hasher.combine(detail)
|
||||
case .serverError(_, let message):
|
||||
hasher.combine(message)
|
||||
case .connectionClosed:
|
||||
break
|
||||
case .transportError(let error):
|
||||
hasher.combine(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
69
Sources/MCP/Base/ID.swift
Normal file
69
Sources/MCP/Base/ID.swift
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import struct Foundation.UUID
|
||||
|
||||
/// A unique identifier for a request.
|
||||
public enum ID: Hashable, Sendable {
|
||||
/// A string ID.
|
||||
case string(String)
|
||||
|
||||
/// A number ID.
|
||||
case number(Int)
|
||||
|
||||
/// Generates a random string ID.
|
||||
public static var random: ID {
|
||||
return .string(UUID().uuidString)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExpressibleByStringLiteral
|
||||
|
||||
extension ID: ExpressibleByStringLiteral {
|
||||
public init(stringLiteral value: String) {
|
||||
self = .string(value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExpressibleByIntegerLiteral
|
||||
|
||||
extension ID: ExpressibleByIntegerLiteral {
|
||||
public init(integerLiteral value: Int) {
|
||||
self = .number(value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CustomStringConvertible
|
||||
|
||||
extension ID: CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .string(let str): return str
|
||||
case .number(let num): return String(num)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
extension ID: Codable {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let string = try? container.decode(String.self) {
|
||||
self = .string(string)
|
||||
} else if let number = try? container.decode(Int.self) {
|
||||
self = .number(number)
|
||||
} else if container.decodeNil() {
|
||||
// Handle unspecified/null IDs as empty string
|
||||
self = .string("")
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container, debugDescription: "ID must be string or number")
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .string(let str): try container.encode(str)
|
||||
case .number(let num): try container.encode(num)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
Sources/MCP/Base/Lifecycle.swift
Normal file
56
Sources/MCP/Base/Lifecycle.swift
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/// The initialization phase MUST be the first interaction between client and server.
|
||||
/// During this phase, the client and server:
|
||||
/// - Establish protocol version compatibility
|
||||
/// - Exchange and negotiate capabilities
|
||||
/// - Share implementation details
|
||||
///
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle/#initialization
|
||||
public enum Initialize: Method {
|
||||
public static let name: String = "initialize"
|
||||
|
||||
public struct Parameters: Hashable, Codable, Sendable {
|
||||
public let protocolVersion: String
|
||||
public let capabilities: Client.Capabilities
|
||||
public let clientInfo: Client.Info
|
||||
|
||||
public init(
|
||||
protocolVersion: String = Version.latest,
|
||||
capabilities: Client.Capabilities,
|
||||
clientInfo: Client.Info
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.capabilities = capabilities
|
||||
self.clientInfo = clientInfo
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case protocolVersion, capabilities, clientInfo
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
protocolVersion =
|
||||
try container.decodeIfPresent(String.self, forKey: .protocolVersion)
|
||||
?? Version.latest
|
||||
capabilities =
|
||||
try container.decodeIfPresent(Client.Capabilities.self, forKey: .capabilities)
|
||||
?? .init()
|
||||
clientInfo =
|
||||
try container.decodeIfPresent(Client.Info.self, forKey: .clientInfo)
|
||||
?? .init(name: "unknown", version: "0.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
public struct Result: Hashable, Codable, Sendable {
|
||||
public let protocolVersion: String
|
||||
public let capabilities: Server.Capabilities
|
||||
public let serverInfo: Server.Info
|
||||
public let instructions: String?
|
||||
}
|
||||
}
|
||||
|
||||
/// After successful initialization, the client MUST send an initialized notification to indicate it is ready to begin normal operations.
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle/#initialization
|
||||
public struct InitializedNotification: Notification {
|
||||
public static let name: String = "notifications/initialized"
|
||||
}
|
||||
332
Sources/MCP/Base/Messages.swift
Normal file
332
Sources/MCP/Base/Messages.swift
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import Foundation
|
||||
|
||||
private let jsonrpc = "2.0"
|
||||
|
||||
public struct Empty: Hashable, Codable, Sendable {}
|
||||
|
||||
// MARK: -
|
||||
|
||||
/// A method that can be used to send requests and receive responses.
|
||||
public protocol Method {
|
||||
/// The parameters of the method.
|
||||
associatedtype Parameters: Codable, Hashable, Sendable = Empty
|
||||
/// The result of the method.
|
||||
associatedtype Result: Codable, Hashable, Sendable = Empty
|
||||
/// The name of the method.
|
||||
static var name: String { get }
|
||||
}
|
||||
|
||||
/// Type-erased method for request/response handling
|
||||
struct AnyMethod: Method {
|
||||
static var name: String { "" }
|
||||
typealias Parameters = Value
|
||||
typealias Result = Value
|
||||
}
|
||||
|
||||
extension Method where Parameters == Empty {
|
||||
public static func request(id: ID = .random) -> Request<Self> {
|
||||
Request(id: id, method: name, params: Empty())
|
||||
}
|
||||
}
|
||||
|
||||
extension Method where Result == Empty {
|
||||
public static func response(id: ID) -> Response<Self> {
|
||||
Response(id: id, result: Empty())
|
||||
}
|
||||
}
|
||||
|
||||
extension Method {
|
||||
/// Create a request with the given parameters.
|
||||
public static func request(id: ID = .random, _ parameters: Self.Parameters) -> Request<Self> {
|
||||
Request(id: id, method: name, params: parameters)
|
||||
}
|
||||
|
||||
/// Create a response with the given result.
|
||||
public static func response(id: ID, result: Self.Result) -> Response<Self> {
|
||||
Response(id: id, result: result)
|
||||
}
|
||||
|
||||
/// Create a response with the given error.
|
||||
public static func response(id: ID, error: Error) -> Response<Self> {
|
||||
Response(id: id, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
/// A request message.
|
||||
public struct Request<M: Method>: Hashable, Identifiable, Codable, Sendable {
|
||||
/// The request ID.
|
||||
public let id: ID
|
||||
/// The method name.
|
||||
public let method: String
|
||||
/// The request parameters.
|
||||
public let params: M.Parameters
|
||||
|
||||
init(id: ID = .random, method: String, params: M.Parameters) {
|
||||
self.id = id
|
||||
self.method = method
|
||||
self.params = params
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case jsonrpc, id, method, params
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(jsonrpc, forKey: .jsonrpc)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(method, forKey: .method)
|
||||
if M.Parameters.self != Empty.self {
|
||||
try container.encode(params, forKey: .params)
|
||||
} else {
|
||||
// Encode empty object for Empty parameters
|
||||
try container.encode(Empty(), forKey: .params)
|
||||
}
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let version = try container.decode(String.self, forKey: .jsonrpc)
|
||||
guard version == jsonrpc else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .jsonrpc, in: container, debugDescription: "Invalid JSON-RPC version")
|
||||
}
|
||||
id = try container.decode(ID.self, forKey: .id)
|
||||
method = try container.decode(String.self, forKey: .method)
|
||||
if M.Parameters.self == Empty.self {
|
||||
if (try? container.decodeNil(forKey: .params)) != nil {
|
||||
params = Empty() as! M.Parameters
|
||||
} else if (try? container.decode(Empty.self, forKey: .params)) != nil {
|
||||
params = Empty() as! M.Parameters
|
||||
} else {
|
||||
// If params field is missing, use Empty
|
||||
params = Empty() as! M.Parameters
|
||||
}
|
||||
} else {
|
||||
params = try container.decode(M.Parameters.self, forKey: .params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A type-erased request for request/response handling
|
||||
typealias AnyRequest = Request<AnyMethod>
|
||||
|
||||
/// A box for request handlers that can be type-erased
|
||||
class RequestHandlerBox: @unchecked Sendable {
|
||||
func callAsFunction(_ request: Request<AnyMethod>) async throws -> Response<AnyMethod> {
|
||||
fatalError("Must override")
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed request handler that can be used to handle requests of a specific type
|
||||
final class TypedRequestHandler<M: Method>: RequestHandlerBox, @unchecked Sendable {
|
||||
private let _handle: @Sendable (Request<M>) async throws -> Response<M>
|
||||
|
||||
init(_ handler: @escaping @Sendable (Request<M>) async throws -> Response<M>) {
|
||||
self._handle = handler
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func callAsFunction(_ request: Request<AnyMethod>) async throws -> Response<AnyMethod>
|
||||
{
|
||||
// Create a concrete request from the type-erased one
|
||||
let data = try JSONEncoder().encode(request.params)
|
||||
let params = try JSONDecoder().decode(M.Parameters.self, from: data)
|
||||
let typedRequest = Request<M>(id: request.id, method: M.name, params: params)
|
||||
|
||||
// Handle with concrete type
|
||||
let response = try await _handle(typedRequest)
|
||||
|
||||
// Convert result to AnyMethod response
|
||||
switch response.result {
|
||||
case .success(let result):
|
||||
let resultData = try JSONEncoder().encode(result)
|
||||
let resultValue = try JSONDecoder().decode(Value.self, from: resultData)
|
||||
return Response(id: response.id, result: resultValue)
|
||||
case .failure(let error):
|
||||
return Response(id: response.id, error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
/// A response message.
|
||||
public struct Response<M: Method>: Hashable, Identifiable, Codable, Sendable {
|
||||
/// The response ID.
|
||||
public let id: ID
|
||||
/// The response result.
|
||||
public let result: Swift.Result<M.Result, Error>
|
||||
|
||||
public init(id: ID, result: M.Result) {
|
||||
self.id = id
|
||||
self.result = .success(result)
|
||||
}
|
||||
|
||||
public init(id: ID, error: Error) {
|
||||
self.id = id
|
||||
self.result = .failure(error)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case jsonrpc, id, result, error
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(jsonrpc, forKey: .jsonrpc)
|
||||
try container.encode(id, forKey: .id)
|
||||
switch result {
|
||||
case .success(let result):
|
||||
try container.encode(result, forKey: .result)
|
||||
case .failure(let error):
|
||||
try container.encode(error, forKey: .error)
|
||||
}
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let version = try container.decode(String.self, forKey: .jsonrpc)
|
||||
guard version == jsonrpc else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .jsonrpc, in: container, debugDescription: "Invalid JSON-RPC version")
|
||||
}
|
||||
id = try container.decode(ID.self, forKey: .id)
|
||||
if let result = try? container.decode(M.Result.self, forKey: .result) {
|
||||
self.result = .success(result)
|
||||
} else if let error = try? container.decode(Error.self, forKey: .error) {
|
||||
self.result = .failure(error)
|
||||
} else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: container.codingPath,
|
||||
debugDescription: "Invalid response"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A type-erased response for request/response handling
|
||||
typealias AnyResponse = Response<AnyMethod>
|
||||
|
||||
// MARK: -
|
||||
|
||||
/// A notification message.
|
||||
public protocol Notification {
|
||||
/// The parameters of the notification.
|
||||
associatedtype Parameters: Hashable, Codable, Sendable = Empty
|
||||
/// The name of the notification.
|
||||
static var name: String { get }
|
||||
}
|
||||
|
||||
/// A type-erased notification for message handling
|
||||
struct AnyNotification: Notification {
|
||||
static var name: String { "" }
|
||||
typealias Parameters = Empty
|
||||
}
|
||||
|
||||
/// A message that can be used to send notifications.
|
||||
public struct Message<N: Notification>: Hashable, Codable, Sendable {
|
||||
/// The method name.
|
||||
public let method: String
|
||||
/// The notification parameters.
|
||||
public let params: N.Parameters
|
||||
|
||||
public init(method: String, params: N.Parameters) {
|
||||
self.method = method
|
||||
self.params = params
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case jsonrpc, method, params
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(jsonrpc, forKey: .jsonrpc)
|
||||
try container.encode(method, forKey: .method)
|
||||
if N.Parameters.self != Empty.self {
|
||||
try container.encode(params, forKey: .params)
|
||||
}
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let version = try container.decode(String.self, forKey: .jsonrpc)
|
||||
guard version == jsonrpc else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .jsonrpc, in: container, debugDescription: "Invalid JSON-RPC version")
|
||||
}
|
||||
method = try container.decode(String.self, forKey: .method)
|
||||
|
||||
// Handle params field being optional
|
||||
if N.Parameters.self == Empty.self {
|
||||
// For Empty parameters, use Empty() if params is missing or null
|
||||
params = Empty() as! N.Parameters
|
||||
} else if let value = try? container.decode(N.Parameters.self, forKey: .params) {
|
||||
// If params exists and can be decoded, use it
|
||||
params = value
|
||||
} else if !container.contains(.params)
|
||||
|| (try? container.decodeNil(forKey: .params)) == true
|
||||
{
|
||||
// If params is missing or explicitly null, use Empty for Empty parameters
|
||||
// or throw for non-Empty parameters
|
||||
if N.Parameters.self == Empty.self {
|
||||
params = Empty() as! N.Parameters
|
||||
} else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: container.codingPath,
|
||||
debugDescription: "Missing required params field"))
|
||||
}
|
||||
} else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: container.codingPath,
|
||||
debugDescription: "Invalid params field"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A type-erased message for message handling
|
||||
typealias AnyMessage = Message<AnyNotification>
|
||||
|
||||
extension Notification where Parameters == Empty {
|
||||
/// Create a message with empty parameters.
|
||||
public static func message() -> Message<Self> {
|
||||
Message(method: name, params: Empty())
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification {
|
||||
/// Create a message with the given parameters.
|
||||
public static func message(_ parameters: Parameters) -> Message<Self> {
|
||||
Message(method: name, params: parameters)
|
||||
}
|
||||
}
|
||||
|
||||
/// A box for notification handlers that can be type-erased
|
||||
class NotificationHandlerBox: @unchecked Sendable {
|
||||
func callAsFunction(_ notification: Message<AnyNotification>) async throws {}
|
||||
}
|
||||
|
||||
/// A typed notification handler that can be used to handle notifications of a specific type
|
||||
final class TypedNotificationHandler<N: Notification>: NotificationHandlerBox,
|
||||
@unchecked Sendable
|
||||
{
|
||||
private let _handle: @Sendable (Message<N>) async throws -> Void
|
||||
|
||||
init(_ handler: @escaping @Sendable (Message<N>) async throws -> Void) {
|
||||
self._handle = handler
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func callAsFunction(_ notification: Message<AnyNotification>) async throws {
|
||||
// Create a concrete notification from the type-erased one
|
||||
let data = try JSONEncoder().encode(notification)
|
||||
let typedNotification = try JSONDecoder().decode(Message<N>.self, from: data)
|
||||
|
||||
try await _handle(typedNotification)
|
||||
}
|
||||
}
|
||||
172
Sources/MCP/Base/Transports.swift
Normal file
172
Sources/MCP/Base/Transports.swift
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import Darwin
|
||||
import Logging
|
||||
import SystemPackage
|
||||
|
||||
import struct Foundation.Data
|
||||
import struct Foundation.Date
|
||||
import class Foundation.JSONDecoder
|
||||
import class Foundation.JSONEncoder
|
||||
|
||||
/// Protocol defining the transport layer for MCP communication
|
||||
public protocol Transport: Actor {
|
||||
var logger: Logger { get }
|
||||
|
||||
/// Establishes connection with the transport
|
||||
func connect() async throws
|
||||
|
||||
/// Disconnects from the transport
|
||||
func disconnect() async
|
||||
|
||||
/// Sends a message string
|
||||
func send(_ message: String) async throws
|
||||
|
||||
/// Receives message strings as an async sequence
|
||||
func receive() -> AsyncThrowingStream<String, Swift.Error>
|
||||
}
|
||||
|
||||
/// Standard input/output transport implementation
|
||||
public actor StdioTransport: Transport {
|
||||
private let input: FileDescriptor
|
||||
private let output: FileDescriptor
|
||||
public nonisolated let logger: Logger
|
||||
|
||||
private var isConnected = false
|
||||
private let messageStream: AsyncStream<String>
|
||||
private let messageContinuation: AsyncStream<String>.Continuation
|
||||
|
||||
public init(
|
||||
input: FileDescriptor = FileDescriptor.standardInput,
|
||||
output: FileDescriptor = FileDescriptor.standardOutput,
|
||||
logger: Logger? = nil
|
||||
) {
|
||||
self.input = input
|
||||
self.output = output
|
||||
self.logger =
|
||||
logger
|
||||
?? Logger(
|
||||
label: "mcp.transport.stdio",
|
||||
factory: { _ in SwiftLogNoOpLogHandler() })
|
||||
|
||||
// Create message stream
|
||||
var continuation: AsyncStream<String>.Continuation!
|
||||
self.messageStream = AsyncStream { continuation = $0 }
|
||||
self.messageContinuation = continuation
|
||||
}
|
||||
|
||||
public func connect() async throws {
|
||||
guard !isConnected else { return }
|
||||
|
||||
// Set non-blocking mode
|
||||
try setNonBlocking(fileDescriptor: input)
|
||||
try setNonBlocking(fileDescriptor: output)
|
||||
|
||||
isConnected = true
|
||||
logger.info("Transport connected successfully")
|
||||
|
||||
// Start reading loop in background
|
||||
Task {
|
||||
await readLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private func setNonBlocking(fileDescriptor: FileDescriptor) throws {
|
||||
let flags = fcntl(fileDescriptor.rawValue, F_GETFL)
|
||||
guard flags >= 0 else {
|
||||
throw Error.transportError(Errno.badFileDescriptor)
|
||||
}
|
||||
let result = fcntl(fileDescriptor.rawValue, F_SETFL, flags | O_NONBLOCK)
|
||||
guard result >= 0 else {
|
||||
throw Error.transportError(Errno.badFileDescriptor)
|
||||
}
|
||||
}
|
||||
|
||||
private func readLoop() async {
|
||||
let bufferSize = 4096
|
||||
var buffer = [UInt8](repeating: 0, count: bufferSize)
|
||||
var pendingData = Data()
|
||||
|
||||
while isConnected && !Task.isCancelled {
|
||||
do {
|
||||
let bytesRead = try buffer.withUnsafeMutableBufferPointer { pointer in
|
||||
try input.read(into: UnsafeMutableRawBufferPointer(pointer))
|
||||
}
|
||||
|
||||
if bytesRead == 0 {
|
||||
logger.notice("EOF received")
|
||||
break
|
||||
}
|
||||
|
||||
pendingData.append(Data(buffer[..<bytesRead]))
|
||||
|
||||
// Process complete messages
|
||||
while let newlineIndex = pendingData.firstIndex(of: UInt8(ascii: "\n")) {
|
||||
let messageData = pendingData[..<newlineIndex]
|
||||
pendingData = pendingData[(newlineIndex + 1)...]
|
||||
|
||||
if let message = String(data: messageData, encoding: .utf8),
|
||||
!message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
logger.debug("Message received", metadata: ["message": "\(message)"])
|
||||
messageContinuation.yield(message)
|
||||
}
|
||||
}
|
||||
} catch let error as Errno where error == .resourceTemporarilyUnavailable {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms backoff
|
||||
continue
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
logger.error("Read error occurred", metadata: ["error": "\(error)"])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
messageContinuation.finish()
|
||||
}
|
||||
|
||||
public func disconnect() async {
|
||||
guard isConnected else { return }
|
||||
isConnected = false
|
||||
messageContinuation.finish()
|
||||
logger.info("Transport disconnected")
|
||||
}
|
||||
|
||||
public func send(_ message: String) async throws {
|
||||
guard isConnected else {
|
||||
throw Error.transportError(Errno.socketNotConnected)
|
||||
}
|
||||
|
||||
let message = message + "\n"
|
||||
guard let data = message.data(using: .utf8) else {
|
||||
throw Error.transportError(Errno.invalidArgument)
|
||||
}
|
||||
|
||||
var remaining = data
|
||||
while !remaining.isEmpty {
|
||||
do {
|
||||
let written = try remaining.withUnsafeBytes { buffer in
|
||||
try output.write(UnsafeRawBufferPointer(buffer))
|
||||
}
|
||||
if written > 0 {
|
||||
remaining = remaining.dropFirst(written)
|
||||
}
|
||||
} catch let error as Errno where error == .resourceTemporarilyUnavailable {
|
||||
try await Task.sleep(nanoseconds: 10_000_000) // 10ms backoff
|
||||
continue
|
||||
} catch {
|
||||
throw Error.transportError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func receive() -> AsyncThrowingStream<String, Swift.Error> {
|
||||
return AsyncThrowingStream { continuation in
|
||||
Task {
|
||||
for await message in messageStream {
|
||||
continuation.yield(message)
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
Sources/MCP/Base/Utilities/Ping.swift
Normal file
5
Sources/MCP/Base/Utilities/Ping.swift
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/// The Model Context Protocol includes an optional ping mechanism that allows either party to verify that their counterpart is still responsive and the connection is alive.
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/utilities/ping
|
||||
public enum Ping: Method {
|
||||
public static let name: String = "ping"
|
||||
}
|
||||
402
Sources/MCP/Base/Value.swift
Normal file
402
Sources/MCP/Base/Value.swift
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
import struct Foundation.Data
|
||||
import class Foundation.JSONDecoder
|
||||
import class Foundation.JSONEncoder
|
||||
|
||||
/// A codable value.
|
||||
public enum Value: Hashable, Sendable {
|
||||
case null
|
||||
case bool(Bool)
|
||||
case int(Int)
|
||||
case double(Double)
|
||||
case string(String)
|
||||
case data(mimeType: String? = nil, Data)
|
||||
case array([Value])
|
||||
case object([String: Value])
|
||||
|
||||
/// Create a `Value` from a `Codable` value.
|
||||
/// - Parameter value: The codable value
|
||||
/// - Returns: A value
|
||||
public init<T: Codable>(_ value: T) throws {
|
||||
if let valueAsValue = value as? Value {
|
||||
self = valueAsValue
|
||||
} else {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
self = try JSONDecoder().decode(Value.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the value is `null`.
|
||||
public var isNull: Bool {
|
||||
return self == .null
|
||||
}
|
||||
|
||||
/// Returns the `Bool` value if the value is a `bool`,
|
||||
/// otherwise returns `nil`.
|
||||
public var boolValue: Bool? {
|
||||
guard case let .bool(value) = self else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
/// Returns the `Int` value if the value is an `integer`,
|
||||
/// otherwise returns `nil`.
|
||||
public var intValue: Int? {
|
||||
guard case let .int(value) = self else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
/// Returns the `Double` value if the value is a `double`,
|
||||
/// otherwise returns `nil`.
|
||||
public var doubleValue: Double? {
|
||||
guard case let .double(value) = self else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
/// Returns the `String` value if the value is a `string`,
|
||||
/// otherwise returns `nil`.
|
||||
public var stringValue: String? {
|
||||
guard case let .string(value) = self else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
/// Returns the data value and optional MIME type if the value is `data`,
|
||||
/// otherwise returns `nil`.
|
||||
public var dataValue: (mimeType: String?, Data)? {
|
||||
guard case let .data(mimeType: mimeType, data) = self else { return nil }
|
||||
return (mimeType: mimeType, data)
|
||||
}
|
||||
|
||||
/// Returns the `[Value]` value if the value is an `array`,
|
||||
/// otherwise returns `nil`.
|
||||
public var arrayValue: [Value]? {
|
||||
guard case let .array(value) = self else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
/// Returns the `[String: Value]` value if the value is an `object`,
|
||||
/// otherwise returns `nil`.
|
||||
public var objectValue: [String: Value]? {
|
||||
guard case let .object(value) = self else { return nil }
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
extension Value: Codable {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(value)
|
||||
} else if let value = try? container.decode(Int.self) {
|
||||
self = .int(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .double(value)
|
||||
} else if let value = try? container.decode(String.self) {
|
||||
if Data.isDataURL(string: value),
|
||||
case let (mimeType, data)? = Data.parseDataURL(value)
|
||||
{
|
||||
self = .data(mimeType: mimeType, data)
|
||||
} else {
|
||||
self = .string(value)
|
||||
}
|
||||
} else if let value = try? container.decode([Value].self) {
|
||||
self = .array(value)
|
||||
} else if let value = try? container.decode([String: Value].self) {
|
||||
self = .object(value)
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container, debugDescription: "Value type not found")
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
|
||||
switch self {
|
||||
case .null:
|
||||
try container.encodeNil()
|
||||
case .bool(let value):
|
||||
try container.encode(value)
|
||||
case .int(let value):
|
||||
try container.encode(value)
|
||||
case .double(let value):
|
||||
try container.encode(value)
|
||||
case .string(let value):
|
||||
try container.encode(value)
|
||||
case let .data(mimeType, value):
|
||||
try container.encode(value.dataURLEncoded(mimeType: mimeType))
|
||||
case .array(let value):
|
||||
try container.encode(value)
|
||||
case .object(let value):
|
||||
try container.encode(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Value: CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .null:
|
||||
return ""
|
||||
case .bool(let value):
|
||||
return value.description
|
||||
case .int(let value):
|
||||
return value.description
|
||||
case .double(let value):
|
||||
return value.description
|
||||
case .string(let value):
|
||||
return value.description
|
||||
case let .data(mimeType, value):
|
||||
return value.dataURLEncoded(mimeType: mimeType)
|
||||
case .array(let value):
|
||||
return value.description
|
||||
case .object(let value):
|
||||
return value.description
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExpressibleByNilLiteral
|
||||
|
||||
extension Value: ExpressibleByNilLiteral {
|
||||
public init(nilLiteral: ()) {
|
||||
self = .null
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExpressibleByBooleanLiteral
|
||||
|
||||
extension Value: ExpressibleByBooleanLiteral {
|
||||
public init(booleanLiteral value: Bool) {
|
||||
self = .bool(value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExpressibleByIntegerLiteral
|
||||
|
||||
extension Value: ExpressibleByIntegerLiteral {
|
||||
public init(integerLiteral value: Int) {
|
||||
self = .int(value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExpressibleByFloatLiteral
|
||||
|
||||
extension Value: ExpressibleByFloatLiteral {
|
||||
public init(floatLiteral value: Double) {
|
||||
self = .double(value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExpressibleByStringLiteral
|
||||
|
||||
extension Value: ExpressibleByStringLiteral {
|
||||
public init(stringLiteral value: String) {
|
||||
self = .string(value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExpressibleByArrayLiteral
|
||||
|
||||
extension Value: ExpressibleByArrayLiteral {
|
||||
public init(arrayLiteral elements: Value...) {
|
||||
self = .array(elements)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExpressibleByDictionaryLiteral
|
||||
|
||||
extension Value: ExpressibleByDictionaryLiteral {
|
||||
public init(dictionaryLiteral elements: (String, Value)...) {
|
||||
var dictionary: [String: Value] = [:]
|
||||
for (key, value) in elements {
|
||||
dictionary[key] = value
|
||||
}
|
||||
self = .object(dictionary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExpressibleByStringInterpolation
|
||||
|
||||
extension Value: ExpressibleByStringInterpolation {
|
||||
public struct StringInterpolation: StringInterpolationProtocol {
|
||||
var stringValue: String
|
||||
|
||||
public init(literalCapacity: Int, interpolationCount: Int) {
|
||||
self.stringValue = ""
|
||||
self.stringValue.reserveCapacity(literalCapacity + interpolationCount)
|
||||
}
|
||||
|
||||
public mutating func appendLiteral(_ literal: String) {
|
||||
self.stringValue.append(literal)
|
||||
}
|
||||
|
||||
public mutating func appendInterpolation<T: CustomStringConvertible>(_ value: T) {
|
||||
self.stringValue.append(value.description)
|
||||
}
|
||||
}
|
||||
|
||||
public init(stringInterpolation: StringInterpolation) {
|
||||
self = .string(stringInterpolation.stringValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Standard Library Type Extensions
|
||||
|
||||
extension Bool {
|
||||
/// Creates a boolean value from a `Value` instance.
|
||||
///
|
||||
/// In strict mode, only `.bool` values are converted. In non-strict mode, the following conversions are supported:
|
||||
/// - Integers: `1` is `true`, `0` is `false`
|
||||
/// - Doubles: `1.0` is `true`, `0.0` is `false`
|
||||
/// - Strings (lowercase only):
|
||||
/// - `true`: "true", "t", "yes", "y", "on", "1"
|
||||
/// - `false`: "false", "f", "no", "n", "off", "0"
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - value: The `Value` to convert
|
||||
/// - strict: When `true`, only converts from `.bool` values. Defaults to `true`
|
||||
/// - Returns: A boolean value if conversion is possible, `nil` otherwise
|
||||
///
|
||||
/// - Example:
|
||||
/// ```swift
|
||||
/// Bool(Value.bool(true)) // Returns true
|
||||
/// Bool(Value.int(1), strict: false) // Returns true
|
||||
/// Bool(Value.string("yes"), strict: false) // Returns true
|
||||
/// ```
|
||||
public init?(_ value: Value, strict: Bool = true) {
|
||||
switch value {
|
||||
case .bool(let b):
|
||||
self = b
|
||||
case .int(let i) where !strict:
|
||||
switch i {
|
||||
case 0: self = false
|
||||
case 1: self = true
|
||||
default: return nil
|
||||
}
|
||||
case .double(let d) where !strict:
|
||||
switch d {
|
||||
case 0.0: self = false
|
||||
case 1.0: self = true
|
||||
default: return nil
|
||||
}
|
||||
case .string(let s) where !strict:
|
||||
switch s {
|
||||
case "true", "t", "yes", "y", "on", "1":
|
||||
self = true
|
||||
case "false", "f", "no", "n", "off", "0":
|
||||
self = false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Int {
|
||||
/// Creates an integer value from a `Value` instance.
|
||||
///
|
||||
/// In strict mode, only `.int` values are converted. In non-strict mode, the following conversions are supported:
|
||||
/// - Doubles: Converted if they can be represented exactly as integers
|
||||
/// - Strings: Parsed if they contain a valid integer representation
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - value: The `Value` to convert
|
||||
/// - strict: When `true`, only converts from `.int` values. Defaults to `true`
|
||||
/// - Returns: An integer value if conversion is possible, `nil` otherwise
|
||||
///
|
||||
/// - Example:
|
||||
/// ```swift
|
||||
/// Int(Value.int(42)) // Returns 42
|
||||
/// Int(Value.double(42.0), strict: false) // Returns 42
|
||||
/// Int(Value.string("42"), strict: false) // Returns 42
|
||||
/// Int(Value.double(42.5), strict: false) // Returns nil
|
||||
/// ```
|
||||
public init?(_ value: Value, strict: Bool = true) {
|
||||
switch value {
|
||||
case .int(let i):
|
||||
self = i
|
||||
case .double(let d) where !strict:
|
||||
guard let intValue = Int(exactly: d) else { return nil }
|
||||
self = intValue
|
||||
case .string(let s) where !strict:
|
||||
guard let intValue = Int(s) else { return nil }
|
||||
self = intValue
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Double {
|
||||
/// Creates a double value from a `Value` instance.
|
||||
///
|
||||
/// In strict mode, converts from `.double` and `.int` values. In non-strict mode, the following conversions are supported:
|
||||
/// - Integers: Converted to their double representation
|
||||
/// - Strings: Parsed if they contain a valid floating-point representation
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - value: The `Value` to convert
|
||||
/// - strict: When `true`, only converts from `.double` and `.int` values. Defaults to `true`
|
||||
/// - Returns: A double value if conversion is possible, `nil` otherwise
|
||||
///
|
||||
/// - Example:
|
||||
/// ```swift
|
||||
/// Double(Value.double(42.5)) // Returns 42.5
|
||||
/// Double(Value.int(42)) // Returns 42.0
|
||||
/// Double(Value.string("42.5"), strict: false) // Returns 42.5
|
||||
/// ```
|
||||
public init?(_ value: Value, strict: Bool = true) {
|
||||
switch value {
|
||||
case .double(let d):
|
||||
self = d
|
||||
case .int(let i):
|
||||
self = Double(i)
|
||||
case .string(let s) where !strict:
|
||||
guard let doubleValue = Double(s) else { return nil }
|
||||
self = doubleValue
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
/// Creates a string value from a `Value` instance.
|
||||
///
|
||||
/// In strict mode, only `.string` values are converted. In non-strict mode, the following conversions are supported:
|
||||
/// - Integers: Converted to their string representation
|
||||
/// - Doubles: Converted to their string representation
|
||||
/// - Booleans: Converted to "true" or "false"
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - value: The `Value` to convert
|
||||
/// - strict: When `true`, only converts from `.string` values. Defaults to `true`
|
||||
/// - Returns: A string value if conversion is possible, `nil` otherwise
|
||||
///
|
||||
/// - Example:
|
||||
/// ```swift
|
||||
/// String(Value.string("hello")) // Returns "hello"
|
||||
/// String(Value.int(42), strict: false) // Returns "42"
|
||||
/// String(Value.bool(true), strict: false) // Returns "true"
|
||||
/// ```
|
||||
public init?(_ value: Value, strict: Bool = true) {
|
||||
switch value {
|
||||
case .string(let s):
|
||||
self = s
|
||||
case .int(let i) where !strict:
|
||||
self = String(i)
|
||||
case .double(let d) where !strict:
|
||||
self = String(d)
|
||||
case .bool(let b) where !strict:
|
||||
self = String(b)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Sources/MCP/Base/Versioning.swift
Normal file
11
Sources/MCP/Base/Versioning.swift
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import Foundation
|
||||
|
||||
/// The Model Context Protocol uses string-based version identifiers
|
||||
/// following the format YYYY-MM-DD, to indicate
|
||||
/// the last date backwards incompatible changes were made.
|
||||
///
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/versioning/
|
||||
public enum Version {
|
||||
/// The current protocol version.
|
||||
public static let latest = "2024-11-05"
|
||||
}
|
||||
377
Sources/MCP/Client/Client.swift
Normal file
377
Sources/MCP/Client/Client.swift
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
import Logging
|
||||
import SystemPackage
|
||||
|
||||
import struct Foundation.Data
|
||||
import struct Foundation.Date
|
||||
import class Foundation.JSONDecoder
|
||||
import class Foundation.JSONEncoder
|
||||
|
||||
/// Model Context Protocol client
|
||||
public actor Client {
|
||||
/// Implementation information
|
||||
public struct Info: Hashable, Codable, Sendable {
|
||||
/// The client name
|
||||
public var name: String
|
||||
/// The client version
|
||||
public var version: String
|
||||
|
||||
public init(name: String, version: String) {
|
||||
self.name = name
|
||||
self.version = version
|
||||
}
|
||||
}
|
||||
|
||||
/// The client capabilities
|
||||
public struct Capabilities: Hashable, Codable, Sendable {
|
||||
/// The roots capabilities
|
||||
public struct Roots: Hashable, Codable, Sendable {
|
||||
/// Whether the list of roots has changed
|
||||
public var listChanged: Bool?
|
||||
|
||||
public init(listChanged: Bool? = nil) {
|
||||
self.listChanged = listChanged
|
||||
}
|
||||
}
|
||||
|
||||
/// The sampling capabilities
|
||||
public struct Sampling: Hashable, Codable, Sendable {
|
||||
public init() {}
|
||||
}
|
||||
|
||||
/// Whether the client supports sampling
|
||||
public var sampling: Sampling?
|
||||
/// Experimental features supported by the client
|
||||
public var experimental: [String: String]?
|
||||
/// Whether the client supports roots
|
||||
public var roots: Capabilities.Roots?
|
||||
|
||||
public init(
|
||||
sampling: Sampling? = nil,
|
||||
experimental: [String: String]? = nil,
|
||||
roots: Capabilities.Roots? = nil
|
||||
) {
|
||||
self.sampling = sampling
|
||||
self.experimental = experimental
|
||||
self.roots = roots
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection to the server
|
||||
private var connection: (any Transport)?
|
||||
/// The logger for the client
|
||||
private var logger: Logger? {
|
||||
get async {
|
||||
await connection?.logger
|
||||
}
|
||||
}
|
||||
|
||||
/// The client information
|
||||
private let clientInfo: Client.Info
|
||||
/// The client name
|
||||
public nonisolated var name: String { clientInfo.name }
|
||||
/// The client version
|
||||
public nonisolated var version: String { clientInfo.version }
|
||||
|
||||
/// The client capabilities
|
||||
public var capabilities: Client.Capabilities
|
||||
|
||||
/// The server capabilities
|
||||
private var serverCapabilities: Server.Capabilities?
|
||||
/// The server version
|
||||
private var serverVersion: String?
|
||||
/// The server instructions
|
||||
private var instructions: String?
|
||||
|
||||
/// A dictionary of type-erased notification handlers, keyed by method name
|
||||
private var notificationHandlers: [String: [NotificationHandlerBox]] = [:]
|
||||
/// The task for the message handling loop
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
/// A pending request with a continuation for the result
|
||||
private struct PendingRequest<T> {
|
||||
let continuation: CheckedContinuation<T, Swift.Error>
|
||||
}
|
||||
/// A dictionary of type-erased pending requests, keyed by request ID
|
||||
private var pendingRequests: [ID: Any] = [:]
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
version: String
|
||||
) {
|
||||
self.clientInfo = Client.Info(name: name, version: version)
|
||||
self.capabilities = Capabilities()
|
||||
}
|
||||
|
||||
/// Connect to the server using the given transport
|
||||
public func connect(transport: any Transport) async throws {
|
||||
self.connection = transport
|
||||
try await self.connection?.connect()
|
||||
|
||||
await logger?.info(
|
||||
"Client connected", metadata: ["name": "\(name)", "version": "\(version)"])
|
||||
|
||||
// Start message handling loop
|
||||
task = Task {
|
||||
guard let connection = self.connection else { return }
|
||||
repeat {
|
||||
// Check for cancellation before starting the iteration
|
||||
if Task.isCancelled { break }
|
||||
|
||||
do {
|
||||
let stream = await connection.receive()
|
||||
for try await string in stream {
|
||||
if Task.isCancelled { break } // Check inside loop too
|
||||
|
||||
// Decode and handle incoming message
|
||||
guard let data = string.data(using: .utf8) else {
|
||||
throw Error.parseError("Invalid UTF-8 data")
|
||||
}
|
||||
|
||||
// Attempt to decode string data as AnyResponse or AnyMessage
|
||||
let decoder = JSONDecoder()
|
||||
if let response = try? decoder.decode(AnyResponse.self, from: data) {
|
||||
await handleResponse(response, for: response)
|
||||
} else if let message = try? decoder.decode(AnyMessage.self, from: data) {
|
||||
await handleMessage(message)
|
||||
} else {
|
||||
var metadata: Logger.Metadata = [:]
|
||||
if let string = String(data: data, encoding: .utf8) {
|
||||
metadata["message"] = .string(string)
|
||||
}
|
||||
await logger?.warning(
|
||||
"Unexpected message received by client", metadata: metadata)
|
||||
}
|
||||
}
|
||||
} catch let error as Errno where error == .resourceTemporarilyUnavailable {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
||||
continue
|
||||
} catch {
|
||||
await logger?.error(
|
||||
"Error in message handling loop", metadata: ["error": "\(error)"])
|
||||
break
|
||||
}
|
||||
} while true
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect the client and cancel all pending requests
|
||||
public func disconnect() async {
|
||||
// Cancel all pending requests
|
||||
for (id, request) in pendingRequests {
|
||||
// We know this cast is safe because we only store PendingRequest values
|
||||
if let typedRequest = request as? PendingRequest<Any> {
|
||||
typedRequest.continuation.resume(
|
||||
throwing: Error.internalError("Client disconnected"))
|
||||
}
|
||||
pendingRequests.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
task?.cancel()
|
||||
task = nil
|
||||
if let connection = connection {
|
||||
await connection.disconnect()
|
||||
}
|
||||
connection = nil
|
||||
}
|
||||
|
||||
// MARK: - Registration
|
||||
|
||||
/// Register a handler for a notification
|
||||
@discardableResult
|
||||
public func onNotification<N: Notification>(
|
||||
_ type: N.Type,
|
||||
handler: @escaping @Sendable (Message<N>) async throws -> Void
|
||||
) async -> Self {
|
||||
let handlers = notificationHandlers[N.name, default: []]
|
||||
notificationHandlers[N.name] = handlers + [TypedNotificationHandler(handler)]
|
||||
return self
|
||||
}
|
||||
|
||||
// MARK: - Requests
|
||||
|
||||
/// Send a request and receive its response
|
||||
public func send<M: Method>(_ request: Request<M>) async throws -> M.Result {
|
||||
guard let connection = connection else {
|
||||
throw Error.internalError("Client connection not initialized")
|
||||
}
|
||||
|
||||
let requestData = try JSONEncoder().encode(request)
|
||||
guard let requestString = String(data: requestData, encoding: .utf8) else {
|
||||
throw Error.internalError("Failed to encode request")
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
// Store the pending request first
|
||||
Task {
|
||||
self.addPendingRequest(
|
||||
id: request.id,
|
||||
continuation: continuation,
|
||||
type: M.Result.self
|
||||
)
|
||||
|
||||
// Send the request
|
||||
do {
|
||||
try await connection.send(requestString)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
self.removePendingRequest(id: request.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addPendingRequest<T>(
|
||||
id: ID,
|
||||
continuation: CheckedContinuation<T, Swift.Error>,
|
||||
type: T.Type
|
||||
) {
|
||||
pendingRequests[id] = PendingRequest(continuation: continuation)
|
||||
}
|
||||
|
||||
private func removePendingRequest(id: ID) {
|
||||
pendingRequests.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
public func initialize() async throws -> Initialize.Result {
|
||||
let request = Initialize.request(
|
||||
.init(
|
||||
protocolVersion: Version.latest,
|
||||
capabilities: capabilities,
|
||||
clientInfo: clientInfo
|
||||
))
|
||||
|
||||
let result = try await send(request)
|
||||
|
||||
self.serverCapabilities = result.capabilities
|
||||
self.serverVersion = result.protocolVersion
|
||||
self.instructions = result.instructions
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
public func ping() async throws {
|
||||
let request = Ping.request()
|
||||
_ = try await send(request)
|
||||
}
|
||||
|
||||
// MARK: - Prompts
|
||||
|
||||
public func getPrompt(name: String, arguments: [String: Value]? = nil) async throws
|
||||
-> (description: String?, messages: [Prompt.Message])
|
||||
{
|
||||
_ = try checkCapability(\.prompts, "Prompts")
|
||||
let request = GetPrompt.request(.init(name: name, arguments: arguments))
|
||||
let result = try await send(request)
|
||||
return (description: result.description, messages: result.messages)
|
||||
}
|
||||
|
||||
public func listPrompts(cursor: String? = nil) async throws
|
||||
-> (prompts: [Prompt], nextCursor: String?)
|
||||
{
|
||||
_ = try checkCapability(\.prompts, "Prompts")
|
||||
let request = ListPrompts.request(.init(cursor: cursor))
|
||||
let result = try await send(request)
|
||||
return (prompts: result.prompts, nextCursor: result.nextCursor)
|
||||
}
|
||||
|
||||
// MARK: - Resources
|
||||
|
||||
public func readResource(uri: String) async throws -> [Resource.Content] {
|
||||
_ = try checkCapability(\.resources?.read, "Resource reading")
|
||||
let request = ReadResource.request(.init(uri: uri))
|
||||
let result = try await send(request)
|
||||
return result.contents
|
||||
}
|
||||
|
||||
public func listResources(cursor: String? = nil) async throws -> (
|
||||
resources: [Resource], nextCursor: String?
|
||||
) {
|
||||
_ = try checkCapability(\.resources?.list, "Resource listing")
|
||||
let request = ListResources.request(.init(cursor: cursor))
|
||||
let result = try await send(request)
|
||||
return (resources: result.resources, nextCursor: result.nextCursor)
|
||||
}
|
||||
|
||||
public func subscribeToResource(uri: String) async throws {
|
||||
_ = try checkCapability(\.resources?.subscribe, "Resource subscription")
|
||||
let request = ResourceSubscribe.request(.init(uri: uri))
|
||||
_ = try await send(request)
|
||||
}
|
||||
|
||||
// MARK: - Tools
|
||||
|
||||
public func listTools(cursor: String? = nil) async throws -> [Tool] {
|
||||
_ = try checkCapability(\.tools, "Tools")
|
||||
let request = ListTools.request(.init(cursor: cursor))
|
||||
let result = try await send(request)
|
||||
return result.tools
|
||||
}
|
||||
|
||||
public func callTool(name: String, arguments: [String: Value]? = nil) async throws -> (
|
||||
content: [Tool.Content], isError: Bool
|
||||
) {
|
||||
_ = try checkCapability(\.tools, "Tools")
|
||||
let request = CallTool.request(.init(name: name, arguments: arguments))
|
||||
let result = try await send(request)
|
||||
return (content: result.content, isError: result.isError)
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private func handleResponse(_ response: Response<AnyMethod>, for request: Any) async {
|
||||
await logger?.debug(
|
||||
"Processing response",
|
||||
metadata: ["id": "\(response.id)"])
|
||||
|
||||
// We know this cast is safe because we only store PendingRequest values
|
||||
guard let typedRequest = request as? PendingRequest<Any> else { return }
|
||||
|
||||
switch response.result {
|
||||
case .success(let value):
|
||||
typedRequest.continuation.resume(returning: value)
|
||||
case .failure(let error):
|
||||
typedRequest.continuation.resume(throwing: error)
|
||||
}
|
||||
|
||||
removePendingRequest(id: response.id)
|
||||
}
|
||||
|
||||
private func handleMessage(_ message: Message<AnyNotification>) async {
|
||||
await logger?.debug(
|
||||
"Processing notification",
|
||||
metadata: ["method": "\(message.method)"])
|
||||
|
||||
// Find notification handlers for this method
|
||||
guard let handlers = notificationHandlers[message.method] else { return }
|
||||
|
||||
// Convert notification parameters to concrete type and call handlers
|
||||
for handler in handlers {
|
||||
do {
|
||||
try await handler(message)
|
||||
} catch {
|
||||
await logger?.error(
|
||||
"Error handling notification",
|
||||
metadata: [
|
||||
"method": "\(message.method)",
|
||||
"error": "\(error)",
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private func checkCapability<T>(_ keyPath: KeyPath<Server.Capabilities, T?>, _ name: String)
|
||||
throws -> T
|
||||
{
|
||||
guard let capabilities = serverCapabilities else {
|
||||
throw Error.methodNotFound("Server capabilities not initialized")
|
||||
}
|
||||
guard let value = capabilities[keyPath: keyPath] else {
|
||||
throw Error.methodNotFound("\(name) is not supported by the server")
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
87
Sources/MCP/Extensions/Data+Extensions.swift
Normal file
87
Sources/MCP/Extensions/Data+Extensions.swift
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import Foundation
|
||||
import RegexBuilder
|
||||
|
||||
extension Data {
|
||||
/// Regex pattern for data URLs
|
||||
@inline(__always) private static var dataURLRegex:
|
||||
Regex<(Substring, Substring, Substring?, Substring)>
|
||||
{
|
||||
Regex {
|
||||
"data:"
|
||||
Capture {
|
||||
ZeroOrMore(.reluctant) {
|
||||
CharacterClass.anyOf(",;").inverted
|
||||
}
|
||||
}
|
||||
Optionally {
|
||||
";charset="
|
||||
Capture {
|
||||
OneOrMore(.reluctant) {
|
||||
CharacterClass.anyOf(",;").inverted
|
||||
}
|
||||
}
|
||||
}
|
||||
Optionally { ";base64" }
|
||||
","
|
||||
Capture {
|
||||
ZeroOrMore { .any }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a given string is a valid data URL.
|
||||
///
|
||||
/// - Parameter string: The string to check.
|
||||
/// - Returns: `true` if the string is a valid data URL, otherwise `false`.
|
||||
/// - SeeAlso: [RFC 2397](https://www.rfc-editor.org/rfc/rfc2397.html)
|
||||
public static func isDataURL(string: String) -> Bool {
|
||||
return string.wholeMatch(of: dataURLRegex) != nil
|
||||
}
|
||||
|
||||
/// Parses a data URL string into its MIME type and data components.
|
||||
///
|
||||
/// - Parameter string: The data URL string to parse.
|
||||
/// - Returns: A tuple containing the MIME type and decoded data, or `nil` if parsing fails.
|
||||
/// - SeeAlso: [RFC 2397](https://www.rfc-editor.org/rfc/rfc2397.html)
|
||||
public static func parseDataURL(_ string: String) -> (mimeType: String, data: Data)? {
|
||||
guard let match = string.wholeMatch(of: dataURLRegex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract components using strongly typed captures
|
||||
let (_, mediatype, charset, encodedData) = match.output
|
||||
|
||||
let isBase64 = string.contains(";base64,")
|
||||
|
||||
// Process MIME type
|
||||
var mimeType = mediatype.isEmpty ? "text/plain" : String(mediatype)
|
||||
if let charset = charset, !charset.isEmpty, mimeType.starts(with: "text/") {
|
||||
mimeType += ";charset=\(charset)"
|
||||
}
|
||||
|
||||
// Decode data
|
||||
let decodedData: Data
|
||||
if isBase64 {
|
||||
guard let base64Data = Data(base64Encoded: String(encodedData)) else { return nil }
|
||||
decodedData = base64Data
|
||||
} else {
|
||||
guard
|
||||
let percentDecodedData = String(encodedData).removingPercentEncoding?.data(
|
||||
using: .utf8)
|
||||
else { return nil }
|
||||
decodedData = percentDecodedData
|
||||
}
|
||||
|
||||
return (mimeType: mimeType, data: decodedData)
|
||||
}
|
||||
|
||||
/// Encodes the data as a data URL string with an optional MIME type.
|
||||
///
|
||||
/// - Parameter mimeType: The MIME type of the data. If `nil`, "text/plain" will be used.
|
||||
/// - Returns: A data URL string representation of the data.
|
||||
/// - SeeAlso: [RFC 2397](https://www.rfc-editor.org/rfc/rfc2397.html)
|
||||
public func dataURLEncoded(mimeType: String? = nil) -> String {
|
||||
let base64Data = self.base64EncodedString()
|
||||
return "data:\(mimeType ?? "text/plain");base64,\(base64Data)"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,364 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
/// Protocol version constants
|
||||
public enum MCPVersion {
|
||||
public static let latest = "1.0.0"
|
||||
public static let supported = ["1.0.0"]
|
||||
}
|
||||
|
||||
/// MCP error types
|
||||
public enum MCPError: LocalizedError {
|
||||
case connectionClosed
|
||||
case invalidResponse
|
||||
case invalidParams(String)
|
||||
case serverError(String)
|
||||
case protocolError(String)
|
||||
case transportError(Error)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .connectionClosed:
|
||||
return "Connection closed"
|
||||
case .invalidResponse:
|
||||
return "Invalid response from server"
|
||||
case .invalidParams(let message):
|
||||
return "Invalid parameters: \(message)"
|
||||
case .serverError(let message):
|
||||
return "Server error: \(message)"
|
||||
case .protocolError(let message):
|
||||
return "Protocol error: \(message)"
|
||||
case .transportError(let error):
|
||||
return "Transport error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Base protocol for MCP messages
|
||||
public protocol MCPMessage: Codable {
|
||||
var jsonrpc: String { get }
|
||||
}
|
||||
|
||||
/// Protocol for MCP requests
|
||||
public protocol MCPRequest: MCPMessage {
|
||||
var id: String { get }
|
||||
var method: String { get }
|
||||
}
|
||||
|
||||
/// Protocol for MCP notifications
|
||||
public protocol MCPNotification: MCPMessage {
|
||||
var method: String { get }
|
||||
}
|
||||
|
||||
/// Protocol for MCP responses
|
||||
public protocol MCPResponse: MCPMessage {
|
||||
var id: String { get }
|
||||
}
|
||||
|
||||
/// Implementation information
|
||||
public struct Implementation: Codable {
|
||||
public let name: String
|
||||
public let version: String
|
||||
|
||||
public init(name: String, version: String) {
|
||||
self.name = name
|
||||
self.version = version
|
||||
}
|
||||
}
|
||||
|
||||
/// Client capabilities
|
||||
public struct ClientCapabilities: Codable {
|
||||
public var sampling: Bool?
|
||||
public var experimental: [String: [String: Any]]?
|
||||
public var roots: RootsCapability?
|
||||
|
||||
public init(sampling: Bool? = nil,
|
||||
experimental: [String: [String: Any]]? = nil,
|
||||
roots: RootsCapability? = nil) {
|
||||
self.sampling = sampling
|
||||
self.experimental = experimental
|
||||
self.roots = roots
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case sampling
|
||||
case experimental
|
||||
case roots
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
sampling = try container.decodeIfPresent(Bool.self, forKey: .sampling)
|
||||
experimental = try container.decodeIfPresent([String: [String: Any]].self, forKey: .experimental)
|
||||
roots = try container.decodeIfPresent(RootsCapability.self, forKey: .roots)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encodeIfPresent(sampling, forKey: .sampling)
|
||||
try container.encodeIfPresent(experimental, forKey: .experimental)
|
||||
try container.encodeIfPresent(roots, forKey: .roots)
|
||||
}
|
||||
}
|
||||
|
||||
/// Roots capability configuration
|
||||
public struct RootsCapability: Codable {
|
||||
public var listChanged: Bool?
|
||||
|
||||
public init(listChanged: Bool? = nil) {
|
||||
self.listChanged = listChanged
|
||||
}
|
||||
}
|
||||
|
||||
/// Server capabilities
|
||||
public struct ServerCapabilities: Codable {
|
||||
public var logging: Bool?
|
||||
public var prompts: Bool?
|
||||
public var resources: ResourcesCapability?
|
||||
public var tools: Bool?
|
||||
|
||||
public init(logging: Bool? = nil,
|
||||
prompts: Bool? = nil,
|
||||
resources: ResourcesCapability? = nil,
|
||||
tools: Bool? = nil) {
|
||||
self.logging = logging
|
||||
self.prompts = prompts
|
||||
self.resources = resources
|
||||
self.tools = tools
|
||||
}
|
||||
}
|
||||
|
||||
/// Resources capability configuration
|
||||
public struct ResourcesCapability: Codable {
|
||||
public var subscribe: Bool?
|
||||
|
||||
public init(subscribe: Bool? = nil) {
|
||||
self.subscribe = subscribe
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize request parameters
|
||||
public struct InitializeRequestParams: Codable {
|
||||
public let protocolVersion: String
|
||||
public let capabilities: ClientCapabilities
|
||||
public let clientInfo: Implementation
|
||||
|
||||
public init(protocolVersion: String = MCPVersion.latest,
|
||||
capabilities: ClientCapabilities,
|
||||
clientInfo: Implementation) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.capabilities = capabilities
|
||||
self.clientInfo = clientInfo
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize request
|
||||
public struct InitializeRequest: MCPRequest {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let method: String = "initialize"
|
||||
public let params: InitializeRequestParams
|
||||
|
||||
public init(id: String = UUID().uuidString, params: InitializeRequestParams) {
|
||||
self.id = id
|
||||
self.params = params
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize result
|
||||
public struct InitializeResult: MCPResponse {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let result: InitializeResultData
|
||||
|
||||
public struct InitializeResultData: Codable {
|
||||
public let protocolVersion: String
|
||||
public let capabilities: ServerCapabilities
|
||||
public let serverInfo: Implementation
|
||||
public let instructions: String?
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP Client protocol
|
||||
public protocol MCPClientProtocol {
|
||||
func connect() async throws
|
||||
func disconnect() async
|
||||
func initialize() async throws -> InitializeResult
|
||||
func ping() async throws
|
||||
|
||||
// Resource methods
|
||||
func listResources() async throws -> ListResourcesResponse
|
||||
func readResource(_ uri: String) async throws -> ReadResourceResponse
|
||||
func subscribeResource(_ uri: String) async throws
|
||||
func unsubscribeResource(_ uri: String) async throws
|
||||
|
||||
// Tool methods
|
||||
func listTools() async throws -> ListToolsResponse
|
||||
func callTool(name: String, arguments: [String: Any]? = nil) async throws -> CallToolResponse
|
||||
}
|
||||
|
||||
/// MCP Client implementation
|
||||
public actor MCPClient: MCPClientProtocol {
|
||||
private let transport: MCPTransport
|
||||
private let clientInfo: Implementation
|
||||
private var capabilities: ClientCapabilities
|
||||
private var serverCapabilities: ServerCapabilities?
|
||||
private var serverVersion: Implementation?
|
||||
private var instructions: String?
|
||||
|
||||
public init(transport: MCPTransport,
|
||||
clientInfo: Implementation,
|
||||
capabilities: ClientCapabilities = ClientCapabilities()) {
|
||||
self.transport = transport
|
||||
self.clientInfo = clientInfo
|
||||
self.capabilities = capabilities
|
||||
}
|
||||
|
||||
public func connect() async throws {
|
||||
try await transport.connect()
|
||||
}
|
||||
|
||||
public func disconnect() async {
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
public func initialize() async throws -> InitializeResult {
|
||||
let request = InitializeRequest(params: InitializeRequestParams(
|
||||
capabilities: capabilities,
|
||||
clientInfo: clientInfo
|
||||
))
|
||||
|
||||
let response: InitializeResult = try await sendRequest(request)
|
||||
|
||||
// Validate protocol version
|
||||
guard MCPVersion.supported.contains(response.result.protocolVersion) else {
|
||||
throw MCPError.protocolError("Unsupported protocol version: \(response.result.protocolVersion)")
|
||||
}
|
||||
|
||||
// Store server information
|
||||
serverCapabilities = response.result.capabilities
|
||||
serverVersion = response.result.serverInfo
|
||||
instructions = response.result.instructions
|
||||
|
||||
// Send initialized notification
|
||||
try await sendNotification(InitializedNotification())
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
public func ping() async throws {
|
||||
let request = PingRequest()
|
||||
try await sendRequest(request)
|
||||
}
|
||||
|
||||
// MARK: - Resource Methods
|
||||
|
||||
public func listResources() async throws -> ListResourcesResponse {
|
||||
guard serverCapabilities?.resources != nil else {
|
||||
throw MCPError.protocolError("Server does not support resources")
|
||||
}
|
||||
|
||||
let request = ListResourcesRequest()
|
||||
return try await sendRequest(request)
|
||||
}
|
||||
|
||||
public func readResource(_ uri: String) async throws -> ReadResourceResponse {
|
||||
guard serverCapabilities?.resources != nil else {
|
||||
throw MCPError.protocolError("Server does not support resources")
|
||||
}
|
||||
|
||||
let request = ReadResourceRequest(uri: uri)
|
||||
return try await sendRequest(request)
|
||||
}
|
||||
|
||||
public func subscribeResource(_ uri: String) async throws {
|
||||
guard let resources = serverCapabilities?.resources,
|
||||
resources.subscribe == true else {
|
||||
throw MCPError.protocolError("Server does not support resource subscriptions")
|
||||
}
|
||||
|
||||
let request = SubscribeRequest(uri: uri)
|
||||
try await sendRequest(request)
|
||||
}
|
||||
|
||||
public func unsubscribeResource(_ uri: String) async throws {
|
||||
guard let resources = serverCapabilities?.resources,
|
||||
resources.subscribe == true else {
|
||||
throw MCPError.protocolError("Server does not support resource subscriptions")
|
||||
}
|
||||
|
||||
let request = UnsubscribeRequest(uri: uri)
|
||||
try await sendRequest(request)
|
||||
}
|
||||
|
||||
// MARK: - Tool Methods
|
||||
|
||||
public func listTools() async throws -> ListToolsResponse {
|
||||
guard serverCapabilities?.tools == true else {
|
||||
throw MCPError.protocolError("Server does not support tools")
|
||||
}
|
||||
|
||||
let request = ListToolsRequest()
|
||||
return try await sendRequest(request)
|
||||
}
|
||||
|
||||
public func callTool(name: String, arguments: [String: Any]? = nil) async throws -> CallToolResponse {
|
||||
guard serverCapabilities?.tools == true else {
|
||||
throw MCPError.protocolError("Server does not support tools")
|
||||
}
|
||||
|
||||
let request = CallToolRequest(name: name, arguments: arguments)
|
||||
return try await sendRequest(request)
|
||||
}
|
||||
|
||||
private func sendRequest<T: MCPRequest, U: MCPResponse>(_ request: T) async throws -> U {
|
||||
let data = try JSONEncoder().encode(request)
|
||||
let responseData = try await transport.sendRequest(data)
|
||||
return try JSONDecoder().decode(U.self, from: responseData)
|
||||
}
|
||||
|
||||
private func sendNotification<T: MCPNotification>(_ notification: T) async throws {
|
||||
let data = try JSONEncoder().encode(notification)
|
||||
try await transport.sendNotification(data)
|
||||
}
|
||||
|
||||
// MARK: - Public Properties
|
||||
|
||||
/// Get the server's capabilities after initialization
|
||||
public var serverCapabilitiesAfterInit: ServerCapabilities? {
|
||||
serverCapabilities
|
||||
}
|
||||
|
||||
/// Get the server's version information after initialization
|
||||
public var serverVersionAfterInit: Implementation? {
|
||||
serverVersion
|
||||
}
|
||||
|
||||
/// Get the server's instructions after initialization
|
||||
public var serverInstructions: String? {
|
||||
instructions
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP Transport protocol
|
||||
public protocol MCPTransport {
|
||||
func connect() async throws
|
||||
func disconnect() async
|
||||
func sendRequest(_ data: Data) async throws -> Data
|
||||
func sendNotification(_ data: Data) async throws
|
||||
}
|
||||
|
||||
/// Basic notification types
|
||||
public struct InitializedNotification: MCPNotification {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let method: String = "notifications/initialized"
|
||||
}
|
||||
|
||||
public struct PingRequest: MCPRequest {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let method: String = "ping"
|
||||
|
||||
public init(id: String = UUID().uuidString) {
|
||||
self.id = id
|
||||
}
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
/// Resource metadata
|
||||
public struct ResourceMetadata: Codable {
|
||||
public var name: String
|
||||
public var uri: String
|
||||
public var metadata: [String: String]?
|
||||
|
||||
public init(name: String, uri: String, metadata: [String: String]? = nil) {
|
||||
self.name = name
|
||||
self.uri = uri
|
||||
self.metadata = metadata
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource content types
|
||||
public enum ResourceContent: Codable {
|
||||
case text(String)
|
||||
case binary(Data)
|
||||
case embedded([EmbeddedResource])
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case content
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(String.self, forKey: .type)
|
||||
|
||||
switch type {
|
||||
case "text":
|
||||
let content = try container.decode(String.self, forKey: .content)
|
||||
self = .text(content)
|
||||
case "binary":
|
||||
let content = try container.decode(Data.self, forKey: .content)
|
||||
self = .binary(content)
|
||||
case "embedded":
|
||||
let content = try container.decode([EmbeddedResource].self, forKey: .content)
|
||||
self = .embedded(content)
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown resource type")
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
switch self {
|
||||
case .text(let content):
|
||||
try container.encode("text", forKey: .type)
|
||||
try container.encode(content, forKey: .content)
|
||||
case .binary(let content):
|
||||
try container.encode("binary", forKey: .type)
|
||||
try container.encode(content, forKey: .content)
|
||||
case .embedded(let content):
|
||||
try container.encode("embedded", forKey: .type)
|
||||
try container.encode(content, forKey: .content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Embedded resource type
|
||||
public struct EmbeddedResource: Codable {
|
||||
public var type: String
|
||||
public var data: String
|
||||
public var metadata: [String: String]?
|
||||
|
||||
public init(type: String, data: String, metadata: [String: String]? = nil) {
|
||||
self.type = type
|
||||
self.data = data
|
||||
self.metadata = metadata
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource request types
|
||||
public struct ListResourcesRequest: MCPRequest {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let method: String = "resources/list"
|
||||
|
||||
public init(id: String = UUID().uuidString) {
|
||||
self.id = id
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReadResourceRequest: MCPRequest {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let method: String = "resources/read"
|
||||
public let params: ReadResourceParams
|
||||
|
||||
public struct ReadResourceParams: Codable {
|
||||
public let uri: String
|
||||
}
|
||||
|
||||
public init(id: String = UUID().uuidString, uri: String) {
|
||||
self.id = id
|
||||
self.params = ReadResourceParams(uri: uri)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SubscribeRequest: MCPRequest {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let method: String = "resources/subscribe"
|
||||
public let params: SubscribeParams
|
||||
|
||||
public struct SubscribeParams: Codable {
|
||||
public let uri: String
|
||||
}
|
||||
|
||||
public init(id: String = UUID().uuidString, uri: String) {
|
||||
self.id = id
|
||||
self.params = SubscribeParams(uri: uri)
|
||||
}
|
||||
}
|
||||
|
||||
public struct UnsubscribeRequest: MCPRequest {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let method: String = "resources/unsubscribe"
|
||||
public let params: UnsubscribeParams
|
||||
|
||||
public struct UnsubscribeParams: Codable {
|
||||
public let uri: String
|
||||
}
|
||||
|
||||
public init(id: String = UUID().uuidString, uri: String) {
|
||||
self.id = id
|
||||
self.params = UnsubscribeParams(uri: uri)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource response types
|
||||
public struct ListResourcesResponse: MCPResponse {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let result: ListResourcesResult
|
||||
|
||||
public struct ListResourcesResult: Codable {
|
||||
public let resources: [ResourceMetadata]
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReadResourceResponse: MCPResponse {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let result: ReadResourceResult
|
||||
|
||||
public struct ReadResourceResult: Codable {
|
||||
public let content: ResourceContent
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource notification types
|
||||
public struct ResourceUpdatedNotification: MCPNotification {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let method: String = "notifications/resource/updated"
|
||||
public let params: ResourceUpdatedParams
|
||||
|
||||
public struct ResourceUpdatedParams: Codable {
|
||||
public let uri: String
|
||||
public let content: ResourceContent
|
||||
}
|
||||
}
|
||||
206
Sources/MCP/Server/Prompts.swift
Normal file
206
Sources/MCP/Server/Prompts.swift
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import Foundation
|
||||
|
||||
/// The Model Context Protocol (MCP) provides a standardized way
|
||||
/// for servers to expose prompt templates to clients.
|
||||
/// Prompts allow servers to provide structured messages and instructions
|
||||
/// for interacting with language models.
|
||||
/// Clients can discover available prompts, retrieve their contents,
|
||||
/// and provide arguments to customize them.
|
||||
///
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/
|
||||
public struct Prompt: Hashable, Codable, Sendable {
|
||||
/// The prompt name
|
||||
public let name: String
|
||||
/// The prompt description
|
||||
public let description: String?
|
||||
/// The prompt arguments
|
||||
public let arguments: [Argument]?
|
||||
|
||||
public init(name: String, description: String? = nil, arguments: [Argument]? = nil) {
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.arguments = arguments
|
||||
}
|
||||
|
||||
/// An argument for a prompt
|
||||
public struct Argument: Hashable, Codable, Sendable {
|
||||
/// The argument name
|
||||
public let name: String
|
||||
/// The argument description
|
||||
public let description: String?
|
||||
/// Whether the argument is required
|
||||
public let required: Bool?
|
||||
|
||||
public init(name: String, description: String? = nil, required: Bool? = nil) {
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.required = required
|
||||
}
|
||||
}
|
||||
|
||||
/// A message in a prompt
|
||||
public struct Message: Hashable, Codable, Sendable {
|
||||
/// The message role
|
||||
public enum Role: String, Hashable, Codable, Sendable {
|
||||
/// A user message
|
||||
case user
|
||||
/// An assistant message
|
||||
case assistant
|
||||
}
|
||||
|
||||
/// The message role
|
||||
public let role: Role
|
||||
/// The message content
|
||||
public let content: Content
|
||||
|
||||
public init(role: Role, content: Content) {
|
||||
self.role = role
|
||||
self.content = content
|
||||
}
|
||||
|
||||
/// Content types for messages
|
||||
public enum Content: Hashable, Codable, Sendable {
|
||||
/// Text content
|
||||
case text(text: String)
|
||||
/// Image content
|
||||
case image(data: String, mimeType: String)
|
||||
/// Embedded resource content
|
||||
case resource(uri: String, mimeType: String, text: String?, blob: String?)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type, text, data, mimeType, uri, blob
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
switch self {
|
||||
case .text(let text):
|
||||
try container.encode("text", forKey: .type)
|
||||
try container.encode(text, forKey: .text)
|
||||
case .image(let data, let mimeType):
|
||||
try container.encode("image", forKey: .type)
|
||||
try container.encode(data, forKey: .data)
|
||||
try container.encode(mimeType, forKey: .mimeType)
|
||||
case .resource(let uri, let mimeType, let text, let blob):
|
||||
try container.encode("resource", forKey: .type)
|
||||
try container.encode(uri, forKey: .uri)
|
||||
try container.encode(mimeType, forKey: .mimeType)
|
||||
try container.encodeIfPresent(text, forKey: .text)
|
||||
try container.encodeIfPresent(blob, forKey: .blob)
|
||||
}
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(String.self, forKey: .type)
|
||||
|
||||
switch type {
|
||||
case "text":
|
||||
let text = try container.decode(String.self, forKey: .text)
|
||||
self = .text(text: text)
|
||||
case "image":
|
||||
let data = try container.decode(String.self, forKey: .data)
|
||||
let mimeType = try container.decode(String.self, forKey: .mimeType)
|
||||
self = .image(data: data, mimeType: mimeType)
|
||||
case "resource":
|
||||
let uri = try container.decode(String.self, forKey: .uri)
|
||||
let mimeType = try container.decode(String.self, forKey: .mimeType)
|
||||
let text = try container.decodeIfPresent(String.self, forKey: .text)
|
||||
let blob = try container.decodeIfPresent(String.self, forKey: .blob)
|
||||
self = .resource(uri: uri, mimeType: mimeType, text: text, blob: blob)
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .type,
|
||||
in: container,
|
||||
debugDescription: "Unknown content type")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference type for prompts
|
||||
public struct Reference: Hashable, Codable, Sendable {
|
||||
/// The prompt reference name
|
||||
public let name: String
|
||||
|
||||
public init(name: String) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type, name
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode("ref/prompt", forKey: .type)
|
||||
try container.encode(name, forKey: .name)
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
_ = try container.decode(String.self, forKey: .type)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
/// To retrieve available prompts, clients send a `prompts/list` request.
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/#listing-prompts
|
||||
public enum ListPrompts: Method {
|
||||
public static let name: String = "prompts/list"
|
||||
|
||||
public struct Parameters: Hashable, Codable, Sendable {
|
||||
public let cursor: String?
|
||||
|
||||
public init(cursor: String? = nil) {
|
||||
self.cursor = cursor
|
||||
}
|
||||
}
|
||||
|
||||
public struct Result: Hashable, Codable, Sendable {
|
||||
public let prompts: [Prompt]
|
||||
public let nextCursor: String?
|
||||
|
||||
public init(prompts: [Prompt], nextCursor: String? = nil) {
|
||||
self.prompts = prompts
|
||||
self.nextCursor = nextCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// To retrieve a specific prompt, clients send a `prompts/get` request.
|
||||
/// Arguments may be auto-completed through the completion API.
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/#getting-a-prompt
|
||||
public enum GetPrompt: Method {
|
||||
public static let name: String = "prompts/get"
|
||||
|
||||
public struct Parameters: Hashable, Codable, Sendable {
|
||||
public let name: String
|
||||
public let arguments: [String: Value]?
|
||||
|
||||
public init(name: String, arguments: [String: Value]? = nil) {
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
}
|
||||
}
|
||||
|
||||
public struct Result: Hashable, Codable, Sendable {
|
||||
public let description: String?
|
||||
public let messages: [Prompt.Message]
|
||||
|
||||
public init(description: String?, messages: [Prompt.Message]) {
|
||||
self.description = description
|
||||
self.messages = messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When the list of available prompts changes, servers that declared the listChanged capability SHOULD send a notification.
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/#list-changed-notification
|
||||
public struct PromptListChangedNotification: Notification {
|
||||
public static let name: String = "notifications/prompts/list_changed"
|
||||
}
|
||||
195
Sources/MCP/Server/Resources.swift
Normal file
195
Sources/MCP/Server/Resources.swift
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import Foundation
|
||||
|
||||
/// The Model Context Protocol (MCP) provides a standardized way
|
||||
/// for servers to expose resources to clients.
|
||||
/// Resources allow servers to share data that provides context to language models,
|
||||
/// such as files, database schemas, or application-specific information.
|
||||
/// Each resource is uniquely identified by a URI.
|
||||
///
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/
|
||||
public struct Resource: Hashable, Codable, Sendable {
|
||||
/// The resource name
|
||||
public var name: String
|
||||
/// The resource URI
|
||||
public var uri: String
|
||||
/// The resource description
|
||||
public var description: String?
|
||||
/// The resource MIME type
|
||||
public var mimeType: String?
|
||||
/// The resource metadata
|
||||
public var metadata: [String: String]?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
uri: String,
|
||||
description: String? = nil,
|
||||
mimeType: String? = nil,
|
||||
metadata: [String: String]? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.uri = uri
|
||||
self.description = description
|
||||
self.mimeType = mimeType
|
||||
self.metadata = metadata
|
||||
}
|
||||
|
||||
/// Content of a resource.
|
||||
public struct Content: Hashable, Codable, Sendable {
|
||||
/// The resource URI
|
||||
public let uri: String
|
||||
/// The resource MIME type
|
||||
public let mimeType: String?
|
||||
/// The resource text content
|
||||
public let text: String?
|
||||
/// The resource binary content
|
||||
public let blob: String?
|
||||
|
||||
public static func text(_ content: String, uri: String, mimeType: String? = nil) -> Self {
|
||||
.init(uri: uri, mimeType: mimeType, text: content)
|
||||
}
|
||||
|
||||
public static func binary(_ data: Data, uri: String, mimeType: String? = nil) -> Self {
|
||||
.init(uri: uri, mimeType: mimeType, blob: data.base64EncodedString())
|
||||
}
|
||||
|
||||
private init(uri: String, mimeType: String? = nil, text: String? = nil) {
|
||||
self.uri = uri
|
||||
self.mimeType = mimeType
|
||||
self.text = text
|
||||
self.blob = nil
|
||||
}
|
||||
|
||||
private init(uri: String, mimeType: String? = nil, blob: String) {
|
||||
self.uri = uri
|
||||
self.mimeType = mimeType
|
||||
self.text = nil
|
||||
self.blob = blob
|
||||
}
|
||||
}
|
||||
|
||||
/// A resource template.
|
||||
public struct Template: Hashable, Codable, Sendable {
|
||||
/// The URI template pattern
|
||||
public var uriTemplate: String
|
||||
/// The template name
|
||||
public var name: String
|
||||
/// The template description
|
||||
public var description: String?
|
||||
/// The resource MIME type
|
||||
public var mimeType: String?
|
||||
|
||||
public init(
|
||||
uriTemplate: String,
|
||||
name: String,
|
||||
description: String? = nil,
|
||||
mimeType: String? = nil
|
||||
) {
|
||||
self.uriTemplate = uriTemplate
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.mimeType = mimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
/// To discover available resources, clients send a `resources/list` request.
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#listing-resources
|
||||
public enum ListResources: Method {
|
||||
public static let name: String = "resources/list"
|
||||
|
||||
public struct Parameters: Hashable, Codable, Sendable {
|
||||
public let cursor: String?
|
||||
|
||||
public init(cursor: String? = nil) {
|
||||
self.cursor = cursor
|
||||
}
|
||||
}
|
||||
|
||||
public struct Result: Hashable, Codable, Sendable {
|
||||
public let resources: [Resource]
|
||||
public let nextCursor: String?
|
||||
|
||||
public init(resources: [Resource], nextCursor: String? = nil) {
|
||||
self.resources = resources
|
||||
self.nextCursor = nextCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// To retrieve resource contents, clients send a `resources/read` request:
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#reading-resources
|
||||
public enum ReadResource: Method {
|
||||
public static let name: String = "resources/read"
|
||||
|
||||
public struct Parameters: Hashable, Codable, Sendable {
|
||||
public let uri: String
|
||||
|
||||
public init(uri: String) {
|
||||
self.uri = uri
|
||||
}
|
||||
}
|
||||
|
||||
public struct Result: Hashable, Codable, Sendable {
|
||||
public let contents: [Resource.Content]
|
||||
|
||||
public init(contents: [Resource.Content]) {
|
||||
self.contents = contents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// To discover available resource templates, clients send a `resources/templates/list` request.
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#resource-templates
|
||||
public enum ListResourceTemplates: Method {
|
||||
public static let name: String = "resources/templates/list"
|
||||
|
||||
public typealias Parameters = Empty
|
||||
|
||||
public struct Result: Hashable, Codable, Sendable {
|
||||
public let templates: [Resource.Template]
|
||||
|
||||
public init(templates: [Resource.Template]) {
|
||||
self.templates = templates
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case templates = "resourceTemplates"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When the list of available resources changes, servers that declared the listChanged capability SHOULD send a notification.
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#list-changed-notification
|
||||
public struct ResourceListChangedNotification: Notification {
|
||||
public static let name: String = "notifications/resources/list_changed"
|
||||
|
||||
public typealias Parameters = Empty
|
||||
}
|
||||
|
||||
/// Clients can subscribe to specific resources and receive notifications when they change.
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#subscriptions
|
||||
public enum ResourceSubscribe: Method {
|
||||
public static let name: String = "resources/subscribe"
|
||||
|
||||
public struct Parameters: Hashable, Codable, Sendable {
|
||||
public let uri: String
|
||||
}
|
||||
|
||||
public typealias Result = Empty
|
||||
}
|
||||
|
||||
/// When a resource changes, servers that declared the updated capability SHOULD send a notification to subscribed clients.
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#subscriptions
|
||||
public struct ResourceUpdatedNotification: Notification {
|
||||
public static let name: String = "notifications/resources/updated"
|
||||
|
||||
public struct Parameters: Hashable, Codable, Sendable {
|
||||
public let uri: String
|
||||
|
||||
public init(uri: String) {
|
||||
self.uri = uri
|
||||
}
|
||||
}
|
||||
}
|
||||
393
Sources/MCP/Server/Server.swift
Normal file
393
Sources/MCP/Server/Server.swift
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import Logging
|
||||
import SystemPackage
|
||||
|
||||
import struct Foundation.Data
|
||||
import struct Foundation.Date
|
||||
import class Foundation.JSONDecoder
|
||||
import class Foundation.JSONEncoder
|
||||
|
||||
/// Model Context Protocol server
|
||||
public actor Server {
|
||||
/// Implementation information
|
||||
public struct Info: Hashable, Codable, Sendable {
|
||||
/// The server name
|
||||
public let name: String
|
||||
/// The server version
|
||||
public let version: String
|
||||
|
||||
public init(name: String, version: String) {
|
||||
self.name = name
|
||||
self.version = version
|
||||
}
|
||||
}
|
||||
|
||||
/// Server capabilities
|
||||
public struct Capabilities: Hashable, Codable, Sendable {
|
||||
/// Resources capabilities
|
||||
public struct Resources: Hashable, Codable, Sendable {
|
||||
/// Whether the list of resources has changed
|
||||
public var list: Bool?
|
||||
/// Whether the resource can be read
|
||||
public var read: Bool?
|
||||
/// Whether the resource can be subscribed to
|
||||
public var subscribe: Bool?
|
||||
/// Whether the list of resources has changed
|
||||
public var listChanged: Bool?
|
||||
|
||||
public init(
|
||||
list: Bool? = nil,
|
||||
read: Bool? = nil,
|
||||
subscribe: Bool? = nil,
|
||||
listChanged: Bool? = nil
|
||||
) {
|
||||
self.list = list
|
||||
self.read = read
|
||||
self.subscribe = subscribe
|
||||
self.listChanged = listChanged
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools capabilities
|
||||
public struct Tools: Hashable, Codable, Sendable {
|
||||
/// Whether the server notifies clients when tools change
|
||||
public var listChanged: Bool?
|
||||
|
||||
public init(listChanged: Bool? = nil) {
|
||||
self.listChanged = listChanged
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompts capabilities
|
||||
public struct Prompts: Hashable, Codable, Sendable {
|
||||
/// Whether the server notifies clients when prompts change
|
||||
public var listChanged: Bool?
|
||||
|
||||
public init(listChanged: Bool? = nil) {
|
||||
self.listChanged = listChanged
|
||||
}
|
||||
}
|
||||
|
||||
/// Logging capabilities
|
||||
public struct Logging: Hashable, Codable, Sendable {
|
||||
public init() {}
|
||||
}
|
||||
|
||||
/// Logging capabilities
|
||||
public var logging: Logging?
|
||||
/// Prompts capabilities
|
||||
public var prompts: Prompts?
|
||||
/// Resources capabilities
|
||||
public var resources: Resources?
|
||||
/// Tools capabilities
|
||||
public var tools: Tools?
|
||||
|
||||
public init(
|
||||
logging: Logging? = nil,
|
||||
prompts: Prompts? = nil,
|
||||
resources: Resources? = nil,
|
||||
tools: Tools? = nil
|
||||
) {
|
||||
self.logging = logging
|
||||
self.prompts = prompts
|
||||
self.resources = resources
|
||||
self.tools = tools
|
||||
}
|
||||
}
|
||||
|
||||
/// Server information
|
||||
private let serverInfo: Server.Info
|
||||
/// The server connection
|
||||
private var connection: (any Transport)?
|
||||
/// The server logger
|
||||
private var logger: Logger? {
|
||||
get async {
|
||||
await connection?.logger
|
||||
}
|
||||
}
|
||||
|
||||
/// The server name
|
||||
public nonisolated var name: String { serverInfo.name }
|
||||
/// The server version
|
||||
public nonisolated var version: String { serverInfo.version }
|
||||
/// The server capabilities
|
||||
public var capabilities = Capabilities()
|
||||
|
||||
/// Request handlers
|
||||
private var methodHandlers: [String: RequestHandlerBox] = [:]
|
||||
/// Notification handlers
|
||||
private var notificationHandlers: [String: [NotificationHandlerBox]] = [:]
|
||||
|
||||
/// Whether the server is initialized
|
||||
private var isInitialized = false
|
||||
/// The client information
|
||||
private var clientInfo: Client.Info?
|
||||
/// The client capabilities
|
||||
private var clientCapabilities: Client.Capabilities?
|
||||
/// The protocol version
|
||||
private var protocolVersion: String?
|
||||
/// The list of subscriptions
|
||||
private var subscriptions: [String: Set<ID>] = [:]
|
||||
/// The task for the message handling loop
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
version: String,
|
||||
capabilities: Server.Capabilities = .init()
|
||||
) {
|
||||
self.serverInfo = Server.Info(name: name, version: version)
|
||||
self.capabilities = capabilities
|
||||
}
|
||||
|
||||
/// Start the server
|
||||
public func start(transport: any Transport) async throws {
|
||||
self.connection = transport
|
||||
registerDefaultHandlers()
|
||||
try await transport.connect()
|
||||
|
||||
await logger?.info(
|
||||
"Server started", metadata: ["name": "\(name)", "version": "\(version)"])
|
||||
|
||||
// Start message handling loop
|
||||
task = Task {
|
||||
do {
|
||||
let stream = await transport.receive()
|
||||
for try await string in stream {
|
||||
if Task.isCancelled { break } // Check cancellation inside loop
|
||||
|
||||
var requestID: ID?
|
||||
do {
|
||||
guard let data = string.data(using: .utf8) else {
|
||||
throw Error.parseError("Invalid UTF-8 data")
|
||||
}
|
||||
|
||||
// Attempt to decode string data as AnyRequest or AnyMessage
|
||||
let decoder = JSONDecoder()
|
||||
if let request = try? decoder.decode(AnyRequest.self, from: data) {
|
||||
try await handleRequest(request)
|
||||
} else if let message = try? decoder.decode(AnyMessage.self, from: data) {
|
||||
try await handleMessage(message)
|
||||
} else {
|
||||
// Try to extract request ID from raw JSON if possible
|
||||
if let json = try? JSONDecoder().decode(
|
||||
[String: Value].self, from: data),
|
||||
let idValue = json["id"]
|
||||
{
|
||||
if let strValue = idValue.stringValue {
|
||||
requestID = .string(strValue)
|
||||
} else if let intValue = idValue.intValue {
|
||||
requestID = .number(intValue)
|
||||
}
|
||||
}
|
||||
throw Error.parseError("Invalid message format")
|
||||
}
|
||||
} catch let error as Errno where error == .resourceTemporarilyUnavailable {
|
||||
// Resource temporarily unavailable, retry after a short delay
|
||||
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
||||
continue
|
||||
} catch {
|
||||
await logger?.error(
|
||||
"Error processing message", metadata: ["error": "\(error)"])
|
||||
// Only send error responses for requests
|
||||
if let requestID = requestID {
|
||||
let mcpError =
|
||||
error as? Error ?? Error.internalError(error.localizedDescription)
|
||||
let response = AnyMethod.response(id: requestID, error: mcpError)
|
||||
try? await send(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await logger?.error(
|
||||
"Fatal error in message handling loop", metadata: ["error": "\(error)"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the server
|
||||
public func stop() async {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
if let connection = connection {
|
||||
await connection.disconnect()
|
||||
}
|
||||
connection = nil
|
||||
}
|
||||
|
||||
// MARK: - Registration
|
||||
|
||||
/// Register a method handler
|
||||
@discardableResult
|
||||
public func withMethodHandler<M: Method>(
|
||||
_ type: M.Type,
|
||||
handler: @escaping @Sendable (M.Parameters) async throws -> M.Result
|
||||
) -> Self {
|
||||
methodHandlers[M.name] = TypedRequestHandler { (request: Request<M>) -> Response<M> in
|
||||
let result = try await handler(request.params)
|
||||
return Response(id: request.id, result: result)
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
/// Register a notification handler
|
||||
@discardableResult
|
||||
public func onNotification<N: Notification>(
|
||||
_ type: N.Type,
|
||||
handler: @escaping @Sendable (Message<N>) async throws -> Void
|
||||
) -> Self {
|
||||
let handlers = notificationHandlers[N.name, default: []]
|
||||
notificationHandlers[N.name] = handlers + [TypedNotificationHandler(handler)]
|
||||
return self
|
||||
}
|
||||
|
||||
// MARK: - Sending
|
||||
|
||||
/// Send a response to a client
|
||||
public func send<M: Method>(_ response: Response<M>) async throws {
|
||||
guard let connection = connection else {
|
||||
throw Error.internalError("Server connection not initialized")
|
||||
}
|
||||
let responseData = try JSONEncoder().encode(response)
|
||||
if let responseStr = String(data: responseData, encoding: .utf8) {
|
||||
try await connection.send(responseStr)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a notification to connected clients
|
||||
public func notify<N: Notification>(_ notification: Message<N>) async throws {
|
||||
guard let connection = connection else {
|
||||
throw Error.internalError("Server connection not initialized")
|
||||
}
|
||||
let notificationData = try JSONEncoder().encode(notification)
|
||||
if let notificationStr = String(data: notificationData, encoding: .utf8) {
|
||||
try await connection.send(notificationStr)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private func handleRequest(_ request: Request<AnyMethod>) async throws {
|
||||
await logger?.debug(
|
||||
"Processing request",
|
||||
metadata: [
|
||||
"method": "\(request.method)",
|
||||
"id": "\(request.id)",
|
||||
])
|
||||
|
||||
// The client SHOULD NOT send requests other than pings
|
||||
// before the server has responded to the initialize request.
|
||||
switch request.method {
|
||||
case Initialize.name, Ping.name:
|
||||
break
|
||||
default:
|
||||
try checkInitialized()
|
||||
}
|
||||
|
||||
// Find handler for method name
|
||||
guard let handler = methodHandlers[request.method] else {
|
||||
let error = Error.methodNotFound("Unknown method: \(request.method)")
|
||||
let response = AnyMethod.response(id: request.id, error: error)
|
||||
try await send(response)
|
||||
throw error
|
||||
}
|
||||
|
||||
do {
|
||||
// Handle request and get response
|
||||
let response = try await handler(request)
|
||||
try await send(response)
|
||||
} catch {
|
||||
let mcpError = error as? Error ?? Error.internalError(error.localizedDescription)
|
||||
let response = AnyMethod.response(id: request.id, error: mcpError)
|
||||
try await send(response)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMessage(_ message: Message<AnyNotification>) async throws {
|
||||
await logger?.debug(
|
||||
"Processing notification",
|
||||
metadata: ["method": "\(message.method)"])
|
||||
|
||||
// Check initialization state unless this is an initialized notification
|
||||
if message.method != InitializedNotification.name {
|
||||
try checkInitialized()
|
||||
}
|
||||
|
||||
// Find notification handlers for this method
|
||||
guard let handlers = notificationHandlers[message.method] else { return }
|
||||
|
||||
// Convert notification parameters to concrete type and call handlers
|
||||
for handler in handlers {
|
||||
do {
|
||||
try await handler(message)
|
||||
} catch {
|
||||
await logger?.error(
|
||||
"Error handling notification",
|
||||
metadata: [
|
||||
"method": "\(message.method)",
|
||||
"error": "\(error)",
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkInitialized() throws {
|
||||
guard isInitialized else {
|
||||
throw Error.invalidRequest("Server is not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
private func registerDefaultHandlers() {
|
||||
// Initialize
|
||||
withMethodHandler(Initialize.self) { [weak self] params in
|
||||
guard let self = self else {
|
||||
throw Error.internalError("Server was deallocated")
|
||||
}
|
||||
|
||||
guard await !self.isInitialized else {
|
||||
throw Error.invalidRequest("Server is already initialized")
|
||||
}
|
||||
|
||||
// Validate protocol version
|
||||
guard Version.latest == params.protocolVersion else {
|
||||
throw Error.invalidRequest(
|
||||
"Unsupported protocol version: \(params.protocolVersion)")
|
||||
}
|
||||
|
||||
await self.setInitialState(
|
||||
clientInfo: params.clientInfo,
|
||||
clientCapabilities: params.capabilities,
|
||||
protocolVersion: params.protocolVersion
|
||||
)
|
||||
|
||||
let result = Initialize.Result(
|
||||
protocolVersion: Version.latest,
|
||||
capabilities: await self.capabilities,
|
||||
serverInfo: self.serverInfo,
|
||||
instructions: nil
|
||||
)
|
||||
|
||||
// Send initialized notification after a short delay
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
||||
try? await self.notify(InitializedNotification.message())
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Ping
|
||||
withMethodHandler(Ping.self) { _ in return Empty() }
|
||||
}
|
||||
|
||||
private func setInitialState(
|
||||
clientInfo: Client.Info,
|
||||
clientCapabilities: Client.Capabilities,
|
||||
protocolVersion: String
|
||||
) async {
|
||||
self.clientInfo = clientInfo
|
||||
self.clientCapabilities = clientCapabilities
|
||||
self.protocolVersion = protocolVersion
|
||||
self.isInitialized = true
|
||||
}
|
||||
}
|
||||
170
Sources/MCP/Server/Tools.swift
Normal file
170
Sources/MCP/Server/Tools.swift
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import Foundation
|
||||
|
||||
/// The Model Context Protocol (MCP) allows servers to expose tools
|
||||
/// that can be invoked by language models.
|
||||
/// Tools enable models to interact with external systems, such as
|
||||
/// querying databases, calling APIs, or performing computations.
|
||||
/// Each tool is uniquely identified by a name and includes metadata
|
||||
/// describing its schema.
|
||||
///
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/
|
||||
public struct Tool: Hashable, Codable, Sendable {
|
||||
/// The tool name
|
||||
public let name: String
|
||||
/// The tool description
|
||||
public let description: String
|
||||
/// The tool input schema
|
||||
public let inputSchema: Value?
|
||||
|
||||
public init(name: String, description: String, inputSchema: Value? = nil) {
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.inputSchema = inputSchema
|
||||
}
|
||||
|
||||
/// Content types that can be returned by a tool
|
||||
public enum Content: Hashable, Codable, Sendable {
|
||||
/// Text content
|
||||
case text(String)
|
||||
/// Image content
|
||||
case image(data: String, mimeType: String, metadata: [String: String]?)
|
||||
/// Embedded resource content
|
||||
case resource(uri: String, mimeType: String, text: String?)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case text
|
||||
case image
|
||||
case resource
|
||||
case uri
|
||||
case mimeType
|
||||
case data
|
||||
case metadata
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(String.self, forKey: .type)
|
||||
|
||||
switch type {
|
||||
case "text":
|
||||
let text = try container.decode(String.self, forKey: .text)
|
||||
self = .text(text)
|
||||
case "image":
|
||||
let data = try container.decode(String.self, forKey: .data)
|
||||
let mimeType = try container.decode(String.self, forKey: .mimeType)
|
||||
let metadata = try container.decodeIfPresent(
|
||||
[String: String].self, forKey: .metadata)
|
||||
self = .image(data: data, mimeType: mimeType, metadata: metadata)
|
||||
case "resource":
|
||||
let uri = try container.decode(String.self, forKey: .uri)
|
||||
let mimeType = try container.decode(String.self, forKey: .mimeType)
|
||||
let text = try container.decodeIfPresent(String.self, forKey: .text)
|
||||
self = .resource(uri: uri, mimeType: mimeType, text: text)
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .type, in: container, debugDescription: "Unknown tool content type")
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
switch self {
|
||||
case .text(let text):
|
||||
try container.encode("text", forKey: .type)
|
||||
try container.encode(text, forKey: .text)
|
||||
case .image(let data, let mimeType, let metadata):
|
||||
try container.encode("image", forKey: .type)
|
||||
try container.encode(data, forKey: .data)
|
||||
try container.encode(mimeType, forKey: .mimeType)
|
||||
try container.encodeIfPresent(metadata, forKey: .metadata)
|
||||
case .resource(let uri, let mimeType, let text):
|
||||
try container.encode("resource", forKey: .type)
|
||||
try container.encode(uri, forKey: .uri)
|
||||
try container.encode(mimeType, forKey: .mimeType)
|
||||
try container.encodeIfPresent(text, forKey: .text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case description
|
||||
case inputSchema
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
description = try container.decode(String.self, forKey: .description)
|
||||
inputSchema = try container.decodeIfPresent(Value.self, forKey: .inputSchema)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(description, forKey: .description)
|
||||
if let schema = inputSchema {
|
||||
try container.encode(schema, forKey: .inputSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
/// To discover available tools, clients send a `tools/list` request.
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#listing-tools
|
||||
public enum ListTools: Method {
|
||||
public static let name = "tools/list"
|
||||
|
||||
public struct Parameters: Hashable, Codable, Sendable {
|
||||
public let cursor: String?
|
||||
|
||||
public init(cursor: String? = nil) {
|
||||
self.cursor = cursor
|
||||
}
|
||||
}
|
||||
|
||||
public struct Result: Hashable, Codable, Sendable {
|
||||
public let tools: [Tool]
|
||||
public let nextCursor: String?
|
||||
|
||||
public init(tools: [Tool], nextCursor: String? = nil) {
|
||||
self.tools = tools
|
||||
self.nextCursor = nextCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// To call a tool, clients send a `tools/call` request.
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#calling-tools
|
||||
public enum CallTool: Method {
|
||||
public static let name = "tools/call"
|
||||
|
||||
public struct Parameters: Hashable, Codable, Sendable {
|
||||
public let name: String
|
||||
public let arguments: [String: Value]?
|
||||
|
||||
public init(name: String, arguments: [String: Value]? = nil) {
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
}
|
||||
}
|
||||
|
||||
public struct Result: Hashable, Codable, Sendable {
|
||||
public let content: [Tool.Content]
|
||||
public let isError: Bool
|
||||
|
||||
public init(content: [Tool.Content], isError: Bool) {
|
||||
self.content = content
|
||||
self.isError = isError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When the list of available tools changes, servers that declared the listChanged capability SHOULD send a notification:
|
||||
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#list-changed-notification
|
||||
public struct ToolListChangedNotification: Notification {
|
||||
public static let name: String = "notifications/tools/list_changed"
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
/// A transport implementation that communicates over standard input/output
|
||||
public actor StdioTransport: MCPTransport {
|
||||
private var isConnected = false
|
||||
private let processQueue = DispatchQueue(label: "com.mcp.stdio", qos: .userInitiated)
|
||||
private let process: Process
|
||||
private let inputPipe = Pipe()
|
||||
private let outputPipe = Pipe()
|
||||
private let errorPipe = Pipe()
|
||||
|
||||
public init(command: String, arguments: [String] = [], environment: [String: String]? = nil) {
|
||||
self.process = Process()
|
||||
self.process.executableURL = URL(fileURLWithPath: command)
|
||||
self.process.arguments = arguments
|
||||
if let env = environment {
|
||||
self.process.environment = env
|
||||
}
|
||||
|
||||
self.process.standardInput = inputPipe
|
||||
self.process.standardOutput = outputPipe
|
||||
self.process.standardError = errorPipe
|
||||
}
|
||||
|
||||
public func connect() async throws {
|
||||
guard !isConnected else { return }
|
||||
|
||||
try process.run()
|
||||
isConnected = true
|
||||
|
||||
// Start reading output asynchronously
|
||||
Task {
|
||||
await readOutput()
|
||||
}
|
||||
}
|
||||
|
||||
public func disconnect() async {
|
||||
guard isConnected else { return }
|
||||
|
||||
process.terminate()
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
public func sendRequest(_ data: Data) async throws -> Data {
|
||||
guard isConnected else {
|
||||
throw MCPError.connectionClosed
|
||||
}
|
||||
|
||||
// Write request
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
processQueue.async {
|
||||
do {
|
||||
let message = data + "\n".data(using: .utf8)!
|
||||
try self.inputPipe.fileHandleForWriting.write(contentsOf: message)
|
||||
|
||||
// Read response
|
||||
if let response = try self.outputPipe.fileHandleForReading.readToEnd() {
|
||||
continuation.resume(returning: response)
|
||||
} else {
|
||||
continuation.resume(throwing: MCPError.invalidResponse)
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: MCPError.transportError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func sendNotification(_ data: Data) async throws {
|
||||
guard isConnected else {
|
||||
throw MCPError.connectionClosed
|
||||
}
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
processQueue.async {
|
||||
do {
|
||||
let message = data + "\n".data(using: .utf8)!
|
||||
try self.inputPipe.fileHandleForWriting.write(contentsOf: message)
|
||||
continuation.resume()
|
||||
} catch {
|
||||
continuation.resume(throwing: MCPError.transportError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func readOutput() async {
|
||||
let handle = outputPipe.fileHandleForReading
|
||||
|
||||
while isConnected {
|
||||
do {
|
||||
let data = try handle.read(upToCount: 4096)
|
||||
if let data = data, !data.isEmpty {
|
||||
// Process received data
|
||||
// In a real implementation, you would parse the JSON-RPC messages
|
||||
// and handle them appropriately
|
||||
print("Received data: \(String(data: data, encoding: .utf8) ?? "")")
|
||||
}
|
||||
} catch {
|
||||
print("Error reading output: \(error)")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
/// Tool metadata
|
||||
public struct Tool: Codable {
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let inputSchema: [String: Any]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case description
|
||||
case inputSchema
|
||||
}
|
||||
|
||||
public init(name: String, description: String? = nil, inputSchema: [String: Any]? = nil) {
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.inputSchema = inputSchema
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
description = try container.decodeIfPresent(String.self, forKey: .description)
|
||||
inputSchema = try container.decodeIfPresent([String: Any].self, forKey: .inputSchema)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encodeIfPresent(description, forKey: .description)
|
||||
if let schema = inputSchema {
|
||||
try container.encode(schema, forKey: .inputSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool request types
|
||||
public struct ListToolsRequest: MCPRequest {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let method: String = "tools/list"
|
||||
|
||||
public init(id: String = UUID().uuidString) {
|
||||
self.id = id
|
||||
}
|
||||
}
|
||||
|
||||
public struct CallToolRequest: MCPRequest {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let method: String = "tools/call"
|
||||
public let params: CallToolParams
|
||||
|
||||
public struct CallToolParams: Codable {
|
||||
public let name: String
|
||||
public let arguments: [String: Any]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case arguments
|
||||
}
|
||||
|
||||
public init(name: String, arguments: [String: Any]? = nil) {
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
arguments = try container.decodeIfPresent([String: Any].self, forKey: .arguments)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(name, forKey: .name)
|
||||
if let args = arguments {
|
||||
try container.encode(args, forKey: .arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public init(id: String = UUID().uuidString, name: String, arguments: [String: Any]? = nil) {
|
||||
self.id = id
|
||||
self.params = CallToolParams(name: name, arguments: arguments)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool response types
|
||||
public struct ListToolsResponse: MCPResponse {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let result: ListToolsResult
|
||||
|
||||
public struct ListToolsResult: Codable {
|
||||
public let tools: [Tool]
|
||||
}
|
||||
}
|
||||
|
||||
public struct CallToolResponse: MCPResponse {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let id: String
|
||||
public let result: CallToolResult
|
||||
|
||||
public struct CallToolResult: Codable {
|
||||
public let content: [ToolContent]
|
||||
public let isError: Bool?
|
||||
|
||||
public init(content: [ToolContent], isError: Bool? = nil) {
|
||||
self.content = content
|
||||
self.isError = isError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool content types
|
||||
public enum ToolContent: Codable {
|
||||
case text(String)
|
||||
case image(ImageContent)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case text
|
||||
case image
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(String.self, forKey: .type)
|
||||
|
||||
switch type {
|
||||
case "text":
|
||||
let text = try container.decode(String.self, forKey: .text)
|
||||
self = .text(text)
|
||||
case "image":
|
||||
let image = try container.decode(ImageContent.self, forKey: .image)
|
||||
self = .image(image)
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown tool content type")
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
switch self {
|
||||
case .text(let text):
|
||||
try container.encode("text", forKey: .type)
|
||||
try container.encode(text, forKey: .text)
|
||||
case .image(let image):
|
||||
try container.encode("image", forKey: .type)
|
||||
try container.encode(image, forKey: .image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Image content type
|
||||
public struct ImageContent: Codable {
|
||||
public let data: String
|
||||
public let mimeType: String
|
||||
public let metadata: [String: String]?
|
||||
|
||||
public init(data: String, mimeType: String, metadata: [String: String]? = nil) {
|
||||
self.data = data
|
||||
self.mimeType = mimeType
|
||||
self.metadata = metadata
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool notification types
|
||||
public struct ToolListChangedNotification: MCPNotification {
|
||||
public let jsonrpc: String = "2.0"
|
||||
public let method: String = "notifications/tools/list_changed"
|
||||
}
|
||||
124
Tests/MCPTests/ClientTests.swift
Normal file
124
Tests/MCPTests/ClientTests.swift
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import Testing
|
||||
|
||||
@testable import MCP
|
||||
|
||||
@Suite("Client Tests")
|
||||
struct ClientTests {
|
||||
@Test("Client connect and disconnect")
|
||||
func testClientConnectAndDisconnect() async throws {
|
||||
let transport = MockTransport()
|
||||
let client = Client(name: "TestClient", version: "1.0")
|
||||
|
||||
#expect(await transport.isConnected == false)
|
||||
try await client.connect(transport: transport)
|
||||
#expect(await transport.isConnected == true)
|
||||
await client.disconnect()
|
||||
#expect(await transport.isConnected == false)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"Initialize request",
|
||||
.timeLimit(.minutes(1))
|
||||
)
|
||||
func testClientInitialize() async throws {
|
||||
let transport = MockTransport()
|
||||
let client = Client(name: "TestClient", version: "1.0")
|
||||
|
||||
try await client.connect(transport: transport)
|
||||
// Small delay to ensure message loop is started
|
||||
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
||||
|
||||
// Create a task for initialize that we'll cancel
|
||||
let initTask = Task {
|
||||
try await client.initialize()
|
||||
}
|
||||
|
||||
// Give it a moment to send the request
|
||||
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
||||
|
||||
#expect(await transport.sentMessages.count == 1)
|
||||
#expect(await transport.sentMessages[0].contains(Initialize.name))
|
||||
#expect(await transport.sentMessages[0].contains(client.name))
|
||||
#expect(await transport.sentMessages[0].contains(client.version))
|
||||
|
||||
// Cancel the initialize task
|
||||
initTask.cancel()
|
||||
|
||||
// Disconnect client to clean up message loop and give time for continuation cleanup
|
||||
await client.disconnect()
|
||||
try await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
||||
}
|
||||
|
||||
@Test(
|
||||
"Ping request",
|
||||
.timeLimit(.minutes(1))
|
||||
)
|
||||
func testClientPing() async throws {
|
||||
let transport = MockTransport()
|
||||
let client = Client(name: "TestClient", version: "1.0")
|
||||
|
||||
try await client.connect(transport: transport)
|
||||
// Small delay to ensure message loop is started
|
||||
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
||||
|
||||
// Create a task for the ping that we'll cancel
|
||||
let pingTask = Task {
|
||||
try await client.ping()
|
||||
}
|
||||
|
||||
// Give it a moment to send the request
|
||||
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
||||
|
||||
#expect(await transport.sentMessages.count == 1)
|
||||
#expect(await transport.sentMessages[0].contains(Ping.name))
|
||||
|
||||
// Cancel the ping task
|
||||
pingTask.cancel()
|
||||
|
||||
// Disconnect client to clean up message loop and give time for continuation cleanup
|
||||
await client.disconnect()
|
||||
try await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
||||
}
|
||||
|
||||
@Test("Connection failure handling")
|
||||
func testClientConnectionFailure() async {
|
||||
let transport = MockTransport()
|
||||
await transport.setFailConnect(true)
|
||||
let client = Client(name: "TestClient", version: "1.0")
|
||||
|
||||
do {
|
||||
try await client.connect(transport: transport)
|
||||
#expect(Bool(false), "Expected connection to fail")
|
||||
} catch let error as Error {
|
||||
if case Error.transportError = error {
|
||||
#expect(Bool(true))
|
||||
} else {
|
||||
#expect(Bool(false), "Expected transport error")
|
||||
}
|
||||
} catch {
|
||||
#expect(Bool(false), "Expected MCP.Error")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Send failure handling")
|
||||
func testClientSendFailure() async throws {
|
||||
let transport = MockTransport()
|
||||
await transport.setFailSend(true)
|
||||
let client = Client(name: "TestClient", version: "1.0")
|
||||
|
||||
try await client.connect(transport: transport)
|
||||
|
||||
do {
|
||||
try await client.ping()
|
||||
#expect(Bool(false), "Expected ping to fail")
|
||||
} catch let error as Error {
|
||||
if case Error.transportError = error {
|
||||
#expect(Bool(true))
|
||||
} else {
|
||||
#expect(Bool(false), "Expected transport error")
|
||||
}
|
||||
} catch {
|
||||
#expect(Bool(false), "Expected MCP.Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
106
Tests/MCPTests/Helpers/MockTransport.swift
Normal file
106
Tests/MCPTests/Helpers/MockTransport.swift
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import Foundation
|
||||
import Logging
|
||||
|
||||
@testable import MCP
|
||||
|
||||
/// Mock transport for testing
|
||||
actor MockTransport: Transport {
|
||||
var logger: Logger
|
||||
var isConnected = false
|
||||
private(set) var sentMessages: [String] = []
|
||||
private var messagesToReceive: [String] = []
|
||||
private var messageStreamContinuation: AsyncThrowingStream<String, Swift.Error>.Continuation?
|
||||
var shouldFailConnect = false
|
||||
var shouldFailSend = false
|
||||
|
||||
init(logger: Logger = Logger(label: "mcp.test.transport")) {
|
||||
self.logger = logger
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
if shouldFailConnect {
|
||||
throw Error.transportError(POSIXError(.ECONNREFUSED))
|
||||
}
|
||||
isConnected = true
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
isConnected = false
|
||||
messageStreamContinuation?.finish()
|
||||
messageStreamContinuation = nil
|
||||
}
|
||||
|
||||
func send<T: Encodable & Sendable>(_ message: T) async throws {
|
||||
if shouldFailSend {
|
||||
throw Error.transportError(POSIXError(.EIO))
|
||||
}
|
||||
let data = try JSONEncoder().encode(message)
|
||||
let str = String(data: data, encoding: .utf8)!
|
||||
sentMessages.append(str)
|
||||
}
|
||||
|
||||
func receive() -> AsyncThrowingStream<String, Swift.Error> {
|
||||
return AsyncThrowingStream<String, Swift.Error> { continuation in
|
||||
messageStreamContinuation = continuation
|
||||
// Send any queued messages
|
||||
for message in messagesToReceive {
|
||||
continuation.yield(message)
|
||||
}
|
||||
messagesToReceive.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func queueRequest<M: MCP.Method>(_ request: Request<M>) throws {
|
||||
let data = try JSONEncoder().encode(request)
|
||||
let str = String(data: data, encoding: .utf8)!
|
||||
if let continuation = messageStreamContinuation {
|
||||
continuation.yield(str)
|
||||
} else {
|
||||
sentMessages.append(str)
|
||||
}
|
||||
}
|
||||
|
||||
func queueResponse<M: MCP.Method>(_ response: Response<M>) throws {
|
||||
let data = try JSONEncoder().encode(response)
|
||||
let str = String(data: data, encoding: .utf8)!
|
||||
if let continuation = messageStreamContinuation {
|
||||
continuation.yield(str)
|
||||
} else {
|
||||
messagesToReceive.append(str)
|
||||
}
|
||||
}
|
||||
|
||||
func queueNotification<N: MCP.Notification>(_ notification: Message<N>) throws {
|
||||
let data = try JSONEncoder().encode(notification)
|
||||
let str = String(data: data, encoding: .utf8)!
|
||||
if let continuation = messageStreamContinuation {
|
||||
continuation.yield(str)
|
||||
} else {
|
||||
messagesToReceive.append(str)
|
||||
}
|
||||
}
|
||||
|
||||
func getLastSentMessage<T: Decodable>() -> T? {
|
||||
print("SENT:", sentMessages)
|
||||
guard let lastMessage = sentMessages.last else { return nil }
|
||||
do {
|
||||
let data = lastMessage.data(using: .utf8)!
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func clearMessages() {
|
||||
sentMessages.removeAll()
|
||||
messagesToReceive.removeAll()
|
||||
}
|
||||
|
||||
func setFailConnect(_ shouldFail: Bool) {
|
||||
shouldFailConnect = shouldFail
|
||||
}
|
||||
|
||||
func setFailSend(_ shouldFail: Bool) {
|
||||
shouldFailSend = shouldFail
|
||||
}
|
||||
}
|
||||
51
Tests/MCPTests/IDTests.swift
Normal file
51
Tests/MCPTests/IDTests.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import Testing
|
||||
|
||||
import class Foundation.JSONDecoder
|
||||
import class Foundation.JSONEncoder
|
||||
import struct Foundation.UUID
|
||||
|
||||
@testable import MCP
|
||||
|
||||
@Suite("ID Tests")
|
||||
struct IDTests {
|
||||
@Test("String ID initialization and encoding")
|
||||
func testStringID() throws {
|
||||
let id: ID = "test-id"
|
||||
#expect(id.description == "test-id")
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(id)
|
||||
let decoded = try decoder.decode(ID.self, from: data)
|
||||
#expect(decoded == id)
|
||||
}
|
||||
|
||||
@Test("Number ID initialization and encoding")
|
||||
func testNumberID() throws {
|
||||
let id: ID = 42
|
||||
#expect(id.description == "42")
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(id)
|
||||
let decoded = try decoder.decode(ID.self, from: data)
|
||||
#expect(decoded == id)
|
||||
}
|
||||
|
||||
@Test("Random ID generation")
|
||||
func testRandomID() throws {
|
||||
let id1 = ID.random
|
||||
let id2 = ID.random
|
||||
#expect(id1 != id2, "Random IDs should be unique")
|
||||
|
||||
if case .string(let str) = id1 {
|
||||
#expect(!str.isEmpty)
|
||||
// Verify it's a valid UUID string
|
||||
#expect(UUID(uuidString: str) != nil)
|
||||
} else {
|
||||
#expect(Bool(false), "Random ID should be string type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import Testing
|
||||
@testable import MCP
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
89
Tests/MCPTests/NotificationTests.swift
Normal file
89
Tests/MCPTests/NotificationTests.swift
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import Testing
|
||||
|
||||
import class Foundation.JSONDecoder
|
||||
import class Foundation.JSONEncoder
|
||||
|
||||
@testable import MCP
|
||||
|
||||
@Suite("Notification Tests")
|
||||
struct NotificationTests {
|
||||
struct TestNotification: Notification {
|
||||
struct Parameters: Codable, Hashable, Sendable {
|
||||
let event: String
|
||||
}
|
||||
static let name = "test.notification"
|
||||
}
|
||||
|
||||
struct InitializedNotification: Notification {
|
||||
static let name = "notifications/initialized"
|
||||
}
|
||||
|
||||
@Test("Notification initialization with parameters")
|
||||
func testNotificationWithParameters() throws {
|
||||
let params = TestNotification.Parameters(event: "test-event")
|
||||
let notification = TestNotification.message(params)
|
||||
|
||||
#expect(notification.method == TestNotification.name)
|
||||
#expect(notification.params.event == "test-event")
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(notification)
|
||||
let decoded = try decoder.decode(Message<TestNotification>.self, from: data)
|
||||
|
||||
#expect(decoded.method == notification.method)
|
||||
#expect(decoded.params.event == notification.params.event)
|
||||
}
|
||||
|
||||
@Test("Empty parameters notification")
|
||||
func testEmptyParametersNotification() throws {
|
||||
struct EmptyNotification: Notification {
|
||||
static let name = "empty.notification"
|
||||
}
|
||||
|
||||
let notification = EmptyNotification.message()
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(notification)
|
||||
let decoded = try decoder.decode(Message<EmptyNotification>.self, from: data)
|
||||
|
||||
#expect(decoded.method == notification.method)
|
||||
}
|
||||
|
||||
@Test("Initialized notification encoding")
|
||||
func testInitializedNotificationEncoding() throws {
|
||||
let notification = InitializedNotification.message()
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(notification)
|
||||
|
||||
// Verify the exact JSON structure
|
||||
let json = try JSONDecoder().decode([String: Value].self, from: data)
|
||||
#expect(json["jsonrpc"] == "2.0")
|
||||
#expect(json["method"] == "notifications/initialized")
|
||||
#expect(json.count == 2, "Should only contain jsonrpc and method fields")
|
||||
|
||||
// Verify we can decode it back
|
||||
let decoded = try decoder.decode(Message<InitializedNotification>.self, from: data)
|
||||
#expect(decoded.method == InitializedNotification.name)
|
||||
}
|
||||
|
||||
@Test("Initialized notification decoding")
|
||||
func testInitializedNotificationDecoding() throws {
|
||||
// Create a minimal JSON string
|
||||
let jsonString = """
|
||||
{"jsonrpc":"2.0","method":"notifications/initialized"}
|
||||
"""
|
||||
let data = jsonString.data(using: .utf8)!
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let decoded = try decoder.decode(Message<InitializedNotification>.self, from: data)
|
||||
|
||||
#expect(decoded.method == InitializedNotification.name)
|
||||
}
|
||||
}
|
||||
164
Tests/MCPTests/PromptTests.swift
Normal file
164
Tests/MCPTests/PromptTests.swift
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import MCP
|
||||
|
||||
@Suite("Prompt Tests")
|
||||
struct PromptTests {
|
||||
@Test("Prompt initialization with valid parameters")
|
||||
func testPromptInitialization() throws {
|
||||
let argument = Prompt.Argument(
|
||||
name: "test_arg",
|
||||
description: "A test argument",
|
||||
required: true
|
||||
)
|
||||
|
||||
let prompt = Prompt(
|
||||
name: "test_prompt",
|
||||
description: "A test prompt",
|
||||
arguments: [argument]
|
||||
)
|
||||
|
||||
#expect(prompt.name == "test_prompt")
|
||||
#expect(prompt.description == "A test prompt")
|
||||
#expect(prompt.arguments?.count == 1)
|
||||
#expect(prompt.arguments?[0].name == "test_arg")
|
||||
#expect(prompt.arguments?[0].description == "A test argument")
|
||||
#expect(prompt.arguments?[0].required == true)
|
||||
}
|
||||
|
||||
@Test("Prompt Message encoding and decoding")
|
||||
func testPromptMessageEncodingDecoding() throws {
|
||||
let textMessage = Prompt.Message(
|
||||
role: .user,
|
||||
content: .text(text: "Hello, world!")
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(textMessage)
|
||||
let decoded = try decoder.decode(Prompt.Message.self, from: data)
|
||||
|
||||
#expect(decoded.role == .user)
|
||||
if case .text(let text) = decoded.content {
|
||||
#expect(text == "Hello, world!")
|
||||
} else {
|
||||
#expect(Bool(false), "Expected text content")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Prompt Message Content types encoding and decoding")
|
||||
func testPromptMessageContentTypes() throws {
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
// Test text content
|
||||
let textContent = Prompt.Message.Content.text(text: "Test text")
|
||||
let textData = try encoder.encode(textContent)
|
||||
let decodedText = try decoder.decode(Prompt.Message.Content.self, from: textData)
|
||||
if case .text(let text) = decodedText {
|
||||
#expect(text == "Test text")
|
||||
} else {
|
||||
#expect(Bool(false), "Expected text content")
|
||||
}
|
||||
|
||||
// Test image content
|
||||
let imageContent = Prompt.Message.Content.image(data: "base64data", mimeType: "image/png")
|
||||
let imageData = try encoder.encode(imageContent)
|
||||
let decodedImage = try decoder.decode(Prompt.Message.Content.self, from: imageData)
|
||||
if case .image(let data, let mimeType) = decodedImage {
|
||||
#expect(data == "base64data")
|
||||
#expect(mimeType == "image/png")
|
||||
} else {
|
||||
#expect(Bool(false), "Expected image content")
|
||||
}
|
||||
|
||||
// Test resource content
|
||||
let resourceContent = Prompt.Message.Content.resource(
|
||||
uri: "file://test.txt",
|
||||
mimeType: "text/plain",
|
||||
text: "Sample text",
|
||||
blob: "blob_data"
|
||||
)
|
||||
let resourceData = try encoder.encode(resourceContent)
|
||||
let decodedResource = try decoder.decode(Prompt.Message.Content.self, from: resourceData)
|
||||
if case .resource(let uri, let mimeType, let text, let blob) = decodedResource {
|
||||
#expect(uri == "file://test.txt")
|
||||
#expect(mimeType == "text/plain")
|
||||
#expect(text == "Sample text")
|
||||
#expect(blob == "blob_data")
|
||||
} else {
|
||||
#expect(Bool(false), "Expected resource content")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Prompt Reference validation")
|
||||
func testPromptReference() throws {
|
||||
let reference = Prompt.Reference(name: "test_prompt")
|
||||
#expect(reference.name == "test_prompt")
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(reference)
|
||||
let decoded = try decoder.decode(Prompt.Reference.self, from: data)
|
||||
|
||||
#expect(decoded.name == "test_prompt")
|
||||
}
|
||||
|
||||
@Test("GetPrompt parameters validation")
|
||||
func testGetPromptParameters() throws {
|
||||
let arguments: [String: Value] = [
|
||||
"param1": .string("value1"),
|
||||
"param2": .int(42),
|
||||
]
|
||||
|
||||
let params = GetPrompt.Parameters(name: "test_prompt", arguments: arguments)
|
||||
#expect(params.name == "test_prompt")
|
||||
#expect(params.arguments?["param1"] == .string("value1"))
|
||||
#expect(params.arguments?["param2"] == .int(42))
|
||||
}
|
||||
|
||||
@Test("GetPrompt result validation")
|
||||
func testGetPromptResult() throws {
|
||||
let messages = [
|
||||
Prompt.Message(role: .user, content: .text(text: "User message")),
|
||||
Prompt.Message(role: .assistant, content: .text(text: "Assistant response")),
|
||||
]
|
||||
|
||||
let result = GetPrompt.Result(description: "Test description", messages: messages)
|
||||
#expect(result.description == "Test description")
|
||||
#expect(result.messages.count == 2)
|
||||
#expect(result.messages[0].role == .user)
|
||||
#expect(result.messages[1].role == .assistant)
|
||||
}
|
||||
|
||||
@Test("ListPrompts parameters validation")
|
||||
func testListPromptsParameters() throws {
|
||||
let params = ListPrompts.Parameters(cursor: "next_page")
|
||||
#expect(params.cursor == "next_page")
|
||||
|
||||
let emptyParams = ListPrompts.Parameters()
|
||||
#expect(emptyParams.cursor == nil)
|
||||
}
|
||||
|
||||
@Test("ListPrompts result validation")
|
||||
func testListPromptsResult() throws {
|
||||
let prompts = [
|
||||
Prompt(name: "prompt1", description: "First prompt"),
|
||||
Prompt(name: "prompt2", description: "Second prompt"),
|
||||
]
|
||||
|
||||
let result = ListPrompts.Result(prompts: prompts, nextCursor: "next_page")
|
||||
#expect(result.prompts.count == 2)
|
||||
#expect(result.prompts[0].name == "prompt1")
|
||||
#expect(result.prompts[1].name == "prompt2")
|
||||
#expect(result.nextCursor == "next_page")
|
||||
}
|
||||
|
||||
@Test("PromptListChanged notification name validation")
|
||||
func testPromptListChangedNotification() throws {
|
||||
#expect(PromptListChangedNotification.name == "notifications/prompts/list_changed")
|
||||
}
|
||||
}
|
||||
79
Tests/MCPTests/RequestTests.swift
Normal file
79
Tests/MCPTests/RequestTests.swift
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import Testing
|
||||
|
||||
import class Foundation.JSONDecoder
|
||||
import class Foundation.JSONEncoder
|
||||
|
||||
@testable import MCP
|
||||
|
||||
@Suite("Request Tests")
|
||||
struct RequestTests {
|
||||
struct TestMethod: Method {
|
||||
struct Parameters: Codable, Hashable, Sendable {
|
||||
let value: String
|
||||
}
|
||||
struct Result: Codable, Hashable, Sendable {
|
||||
let success: Bool
|
||||
}
|
||||
static let name = "test.method"
|
||||
}
|
||||
|
||||
struct EmptyMethod: Method {
|
||||
static let name = "empty.method"
|
||||
}
|
||||
|
||||
@Test("Request initialization with parameters")
|
||||
func testRequestInitialization() throws {
|
||||
let id: ID = "test-id"
|
||||
let params = TestMethod.Parameters(value: "test")
|
||||
let request = Request<TestMethod>(id: id, method: TestMethod.name, params: params)
|
||||
|
||||
#expect(request.id == id)
|
||||
#expect(request.method == TestMethod.name)
|
||||
#expect(request.params.value == "test")
|
||||
}
|
||||
|
||||
@Test("Request encoding and decoding")
|
||||
func testRequestEncodingDecoding() throws {
|
||||
let request = TestMethod.request(id: "test-id", TestMethod.Parameters(value: "test"))
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(request)
|
||||
let decoded = try decoder.decode(Request<TestMethod>.self, from: data)
|
||||
|
||||
#expect(decoded.id == request.id)
|
||||
#expect(decoded.method == request.method)
|
||||
#expect(decoded.params.value == request.params.value)
|
||||
}
|
||||
|
||||
@Test("Empty parameters request encoding")
|
||||
func testEmptyParametersRequestEncoding() throws {
|
||||
let request = EmptyMethod.request(id: "test-id")
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(request)
|
||||
|
||||
// Verify we can decode it back
|
||||
let decoded = try decoder.decode(Request<EmptyMethod>.self, from: data)
|
||||
#expect(decoded.id == request.id)
|
||||
#expect(decoded.method == request.method)
|
||||
}
|
||||
|
||||
@Test("Empty parameters request decoding")
|
||||
func testEmptyParametersRequestDecoding() throws {
|
||||
// Create a minimal JSON string
|
||||
let jsonString = """
|
||||
{"jsonrpc":"2.0","id":"test-id","method":"empty.method"}
|
||||
"""
|
||||
let data = jsonString.data(using: .utf8)!
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let decoded = try decoder.decode(Request<EmptyMethod>.self, from: data)
|
||||
|
||||
#expect(decoded.id == "test-id")
|
||||
#expect(decoded.method == EmptyMethod.name)
|
||||
}
|
||||
}
|
||||
138
Tests/MCPTests/ResourceTests.swift
Normal file
138
Tests/MCPTests/ResourceTests.swift
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import MCP
|
||||
|
||||
@Suite("Resource Tests")
|
||||
struct ResourceTests {
|
||||
@Test("Resource initialization with valid parameters")
|
||||
func testResourceInitialization() throws {
|
||||
let resource = Resource(
|
||||
name: "test_resource",
|
||||
uri: "file://test.txt",
|
||||
description: "A test resource",
|
||||
mimeType: "text/plain",
|
||||
metadata: ["key": "value"]
|
||||
)
|
||||
|
||||
#expect(resource.name == "test_resource")
|
||||
#expect(resource.uri == "file://test.txt")
|
||||
#expect(resource.description == "A test resource")
|
||||
#expect(resource.mimeType == "text/plain")
|
||||
#expect(resource.metadata?["key"] == "value")
|
||||
}
|
||||
|
||||
@Test("Resource encoding and decoding")
|
||||
func testResourceEncodingDecoding() throws {
|
||||
let resource = Resource(
|
||||
name: "test_resource",
|
||||
uri: "file://test.txt",
|
||||
description: "Test resource description",
|
||||
mimeType: "text/plain",
|
||||
metadata: ["key1": "value1", "key2": "value2"]
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(resource)
|
||||
let decoded = try decoder.decode(Resource.self, from: data)
|
||||
|
||||
#expect(decoded.name == resource.name)
|
||||
#expect(decoded.uri == resource.uri)
|
||||
#expect(decoded.description == resource.description)
|
||||
#expect(decoded.mimeType == resource.mimeType)
|
||||
#expect(decoded.metadata == resource.metadata)
|
||||
}
|
||||
|
||||
@Test("Resource.Content text initialization and encoding")
|
||||
func testResourceContentTextEncoding() throws {
|
||||
let content = Resource.Content.text(
|
||||
"Hello, world!", uri: "file://test.txt", mimeType: "text/plain")
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(content)
|
||||
let decoded = try decoder.decode(Resource.Content.self, from: data)
|
||||
|
||||
#expect(decoded.uri == "file://test.txt")
|
||||
#expect(decoded.mimeType == "text/plain")
|
||||
#expect(decoded.text == "Hello, world!")
|
||||
#expect(decoded.blob == nil)
|
||||
}
|
||||
|
||||
@Test("Resource.Content binary initialization and encoding")
|
||||
func testResourceContentBinaryEncoding() throws {
|
||||
let binaryData = "Test binary data".data(using: .utf8)!
|
||||
let content = Resource.Content.binary(
|
||||
binaryData, uri: "file://test.bin", mimeType: "application/octet-stream")
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(content)
|
||||
let decoded = try decoder.decode(Resource.Content.self, from: data)
|
||||
|
||||
#expect(decoded.uri == "file://test.bin")
|
||||
#expect(decoded.mimeType == "application/octet-stream")
|
||||
#expect(decoded.text == nil)
|
||||
#expect(decoded.blob == binaryData.base64EncodedString())
|
||||
}
|
||||
|
||||
@Test("ListResources parameters validation")
|
||||
func testListResourcesParameters() throws {
|
||||
let params = ListResources.Parameters(cursor: "next_page")
|
||||
#expect(params.cursor == "next_page")
|
||||
|
||||
let emptyParams = ListResources.Parameters()
|
||||
#expect(emptyParams.cursor == nil)
|
||||
}
|
||||
|
||||
@Test("ListResources result validation")
|
||||
func testListResourcesResult() throws {
|
||||
let resources = [
|
||||
Resource(name: "resource1", uri: "file://test1.txt"),
|
||||
Resource(name: "resource2", uri: "file://test2.txt"),
|
||||
]
|
||||
|
||||
let result = ListResources.Result(resources: resources, nextCursor: "next_page")
|
||||
#expect(result.resources.count == 2)
|
||||
#expect(result.resources[0].name == "resource1")
|
||||
#expect(result.resources[1].name == "resource2")
|
||||
#expect(result.nextCursor == "next_page")
|
||||
}
|
||||
|
||||
@Test("ReadResource parameters validation")
|
||||
func testReadResourceParameters() throws {
|
||||
let params = ReadResource.Parameters(uri: "file://test.txt")
|
||||
#expect(params.uri == "file://test.txt")
|
||||
}
|
||||
|
||||
@Test("ReadResource result validation")
|
||||
func testReadResourceResult() throws {
|
||||
let contents = [
|
||||
Resource.Content.text("Content 1", uri: "file://test1.txt"),
|
||||
Resource.Content.text("Content 2", uri: "file://test2.txt"),
|
||||
]
|
||||
|
||||
let result = ReadResource.Result(contents: contents)
|
||||
#expect(result.contents.count == 2)
|
||||
}
|
||||
|
||||
@Test("ResourceSubscribe parameters validation")
|
||||
func testResourceSubscribeParameters() throws {
|
||||
let params = ResourceSubscribe.Parameters(uri: "file://test.txt")
|
||||
#expect(params.uri == "file://test.txt")
|
||||
}
|
||||
|
||||
@Test("ResourceUpdatedNotification parameters validation")
|
||||
func testResourceUpdatedNotification() throws {
|
||||
let params = ResourceUpdatedNotification.Parameters(uri: "file://test.txt")
|
||||
#expect(params.uri == "file://test.txt")
|
||||
#expect(ResourceUpdatedNotification.name == "notifications/resources/updated")
|
||||
}
|
||||
|
||||
@Test("ResourceListChangedNotification name validation")
|
||||
func testResourceListChangedNotification() throws {
|
||||
#expect(ResourceListChangedNotification.name == "notifications/resources/list_changed")
|
||||
}
|
||||
}
|
||||
119
Tests/MCPTests/ResponseTests.swift
Normal file
119
Tests/MCPTests/ResponseTests.swift
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import Testing
|
||||
|
||||
import class Foundation.JSONDecoder
|
||||
import class Foundation.JSONEncoder
|
||||
|
||||
@testable import MCP
|
||||
|
||||
@Suite("Response Tests")
|
||||
struct ResponseTests {
|
||||
struct TestMethod: Method {
|
||||
struct Parameters: Codable, Hashable, Sendable {
|
||||
let value: String
|
||||
}
|
||||
struct Result: Codable, Hashable, Sendable {
|
||||
let success: Bool
|
||||
}
|
||||
static let name = "test.method"
|
||||
}
|
||||
|
||||
struct EmptyMethod: Method {
|
||||
static let name = "empty.method"
|
||||
}
|
||||
|
||||
@Test("Success response initialization and encoding")
|
||||
func testSuccessResponse() throws {
|
||||
let id: ID = "test-id"
|
||||
let result = TestMethod.Result(success: true)
|
||||
let response = Response<TestMethod>(id: id, result: result)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(response)
|
||||
let decoded = try decoder.decode(Response<TestMethod>.self, from: data)
|
||||
|
||||
if case .success(let decodedResult) = decoded.result {
|
||||
#expect(decodedResult.success == true)
|
||||
} else {
|
||||
#expect(Bool(false), "Expected success result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Error response initialization and encoding")
|
||||
func testErrorResponse() throws {
|
||||
let id: ID = "test-id"
|
||||
let error = MCP.Error.parseError(nil)
|
||||
let response = Response<TestMethod>(id: id, error: error)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(response)
|
||||
let decoded = try decoder.decode(Response<TestMethod>.self, from: data)
|
||||
|
||||
if case .failure(let decodedError) = decoded.result {
|
||||
#expect(decodedError.code == -32700)
|
||||
#expect(
|
||||
decodedError.localizedDescription
|
||||
== "Parse error: Invalid JSON: Parse error: Invalid JSON")
|
||||
} else {
|
||||
#expect(Bool(false), "Expected error result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Error response with detail")
|
||||
func testErrorResponseWithDetail() throws {
|
||||
let id: ID = "test-id"
|
||||
let error = MCP.Error.parseError("Invalid syntax")
|
||||
let response = Response<TestMethod>(id: id, error: error)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(response)
|
||||
let decoded = try decoder.decode(Response<TestMethod>.self, from: data)
|
||||
|
||||
if case .failure(let decodedError) = decoded.result {
|
||||
#expect(decodedError.code == -32700)
|
||||
#expect(
|
||||
decodedError.localizedDescription
|
||||
== "Parse error: Invalid JSON: Parse error: Invalid JSON: Invalid syntax")
|
||||
} else {
|
||||
#expect(Bool(false), "Expected error result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Empty result response encoding")
|
||||
func testEmptyResultResponseEncoding() throws {
|
||||
let response = EmptyMethod.response(id: "test-id")
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(response)
|
||||
|
||||
// Verify we can decode it back
|
||||
let decoded = try decoder.decode(Response<EmptyMethod>.self, from: data)
|
||||
#expect(decoded.id == response.id)
|
||||
}
|
||||
|
||||
@Test("Empty result response decoding")
|
||||
func testEmptyResultResponseDecoding() throws {
|
||||
// Create a minimal JSON string
|
||||
let jsonString = """
|
||||
{"jsonrpc":"2.0","id":"test-id","result":{}}
|
||||
"""
|
||||
let data = jsonString.data(using: .utf8)!
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let decoded = try decoder.decode(Response<EmptyMethod>.self, from: data)
|
||||
|
||||
#expect(decoded.id == "test-id")
|
||||
if case .success = decoded.result {
|
||||
// Success
|
||||
} else {
|
||||
#expect(Bool(false), "Expected success result")
|
||||
}
|
||||
}
|
||||
}
|
||||
73
Tests/MCPTests/RoundtripTests.swift
Normal file
73
Tests/MCPTests/RoundtripTests.swift
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import Foundation
|
||||
import Logging
|
||||
import SystemPackage
|
||||
import Testing
|
||||
|
||||
@testable import MCP
|
||||
|
||||
@Suite("Roundtrip Tests")
|
||||
struct RoundtripTests {
|
||||
@Test(
|
||||
"Initialize roundtrip",
|
||||
.timeLimit(.minutes(1))
|
||||
)
|
||||
func testInitializeRoundtrip() async throws {
|
||||
let (clientToServerRead, clientToServerWrite) = try FileDescriptor.pipe()
|
||||
let (serverToClientRead, serverToClientWrite) = try FileDescriptor.pipe()
|
||||
|
||||
var logger = Logger(
|
||||
label: "mcp.test.initialize",
|
||||
factory: { StreamLogHandler.standardError(label: $0) })
|
||||
logger.logLevel = .debug
|
||||
|
||||
let serverTransport = StdioTransport(
|
||||
input: clientToServerRead,
|
||||
output: serverToClientWrite,
|
||||
logger: logger
|
||||
)
|
||||
let clientTransport = StdioTransport(
|
||||
input: serverToClientRead,
|
||||
output: clientToServerWrite,
|
||||
logger: logger
|
||||
)
|
||||
|
||||
let server = Server(
|
||||
name: "TestServer",
|
||||
version: "1.0.0",
|
||||
capabilities: .init(prompts: .init(), tools: .init())
|
||||
)
|
||||
let client = Client(name: "TestClient", version: "1.0")
|
||||
|
||||
try await server.start(transport: serverTransport)
|
||||
try await client.connect(transport: clientTransport)
|
||||
|
||||
let initTask = Task {
|
||||
let result = try await client.initialize()
|
||||
|
||||
#expect(result.serverInfo.name == "TestServer")
|
||||
#expect(result.serverInfo.version == "1.0.0")
|
||||
#expect(result.capabilities.prompts != nil)
|
||||
#expect(result.capabilities.tools != nil)
|
||||
#expect(result.protocolVersion == Version.latest)
|
||||
}
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
try await Task.sleep(for: .seconds(1))
|
||||
initTask.cancel()
|
||||
throw CancellationError()
|
||||
}
|
||||
group.addTask {
|
||||
try await initTask.value
|
||||
}
|
||||
try await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
|
||||
await server.stop()
|
||||
await client.disconnect()
|
||||
try? clientToServerRead.close()
|
||||
try? clientToServerWrite.close()
|
||||
try? serverToClientRead.close()
|
||||
try? serverToClientWrite.close()
|
||||
}
|
||||
}
|
||||
51
Tests/MCPTests/ServerTests.swift
Normal file
51
Tests/MCPTests/ServerTests.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import MCP
|
||||
|
||||
@Suite("Server Tests")
|
||||
struct ServerTests {
|
||||
@Test("Start and stop server")
|
||||
func testServerStartAndStop() async throws {
|
||||
let transport = MockTransport()
|
||||
let server = Server(name: "TestServer", version: "1.0")
|
||||
|
||||
#expect(await transport.isConnected == false)
|
||||
try await server.start(transport: transport)
|
||||
#expect(await transport.isConnected == true)
|
||||
await server.stop()
|
||||
#expect(await transport.isConnected == false)
|
||||
}
|
||||
|
||||
@Test("Initialize request handling")
|
||||
func testServerHandleInitialize() async throws {
|
||||
let transport = MockTransport()
|
||||
|
||||
// Queue an initialize request
|
||||
try await transport.queueRequest(
|
||||
Initialize.request(
|
||||
.init(
|
||||
protocolVersion: Version.latest,
|
||||
capabilities: .init(),
|
||||
clientInfo: .init(name: "TestClient", version: "1.0")
|
||||
)
|
||||
))
|
||||
|
||||
// Start the server
|
||||
let server: Server = Server(
|
||||
name: "TestServer",
|
||||
version: "1.0"
|
||||
)
|
||||
try await server.start(transport: transport)
|
||||
|
||||
// Wait for message processing and response
|
||||
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
||||
|
||||
#expect(await transport.sentMessages.count == 1)
|
||||
#expect(await transport.sentMessages[0].contains(Initialize.name))
|
||||
|
||||
// Clean up
|
||||
await server.stop()
|
||||
await transport.disconnect()
|
||||
}
|
||||
}
|
||||
164
Tests/MCPTests/ToolTests.swift
Normal file
164
Tests/MCPTests/ToolTests.swift
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import MCP
|
||||
|
||||
@Suite("Tool Tests")
|
||||
struct ToolTests {
|
||||
@Test("Tool initialization with valid parameters")
|
||||
func testToolInitialization() throws {
|
||||
let tool = Tool(
|
||||
name: "test_tool",
|
||||
description: "A test tool",
|
||||
inputSchema: .object([
|
||||
"param1": .string("Test parameter")
|
||||
])
|
||||
)
|
||||
|
||||
#expect(tool.name == "test_tool")
|
||||
#expect(tool.description == "A test tool")
|
||||
#expect(tool.inputSchema != nil)
|
||||
}
|
||||
|
||||
@Test("Tool encoding and decoding")
|
||||
func testToolEncodingDecoding() throws {
|
||||
let tool = Tool(
|
||||
name: "test_tool",
|
||||
description: "Test tool description",
|
||||
inputSchema: .object([
|
||||
"param1": .string("String parameter"),
|
||||
"param2": .int(42),
|
||||
])
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(tool)
|
||||
let decoded = try decoder.decode(Tool.self, from: data)
|
||||
|
||||
#expect(decoded.name == tool.name)
|
||||
#expect(decoded.description == tool.description)
|
||||
#expect(decoded.inputSchema == tool.inputSchema)
|
||||
}
|
||||
|
||||
@Test("Text content encoding and decoding")
|
||||
func testToolContentTextEncoding() throws {
|
||||
let content = Tool.Content.text("Hello, world!")
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(content)
|
||||
let decoded = try decoder.decode(Tool.Content.self, from: data)
|
||||
|
||||
if case .text(let text) = decoded {
|
||||
#expect(text == "Hello, world!")
|
||||
} else {
|
||||
#expect(Bool(false), "Expected text content")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Image content encoding and decoding")
|
||||
func testToolContentImageEncoding() throws {
|
||||
let content = Tool.Content.image(
|
||||
data: "base64data",
|
||||
mimeType: "image/png",
|
||||
metadata: ["width": "100", "height": "100"]
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(content)
|
||||
let decoded = try decoder.decode(Tool.Content.self, from: data)
|
||||
|
||||
if case .image(let data, let mimeType, let metadata) = decoded {
|
||||
#expect(data == "base64data")
|
||||
#expect(mimeType == "image/png")
|
||||
#expect(metadata?["width"] == "100")
|
||||
#expect(metadata?["height"] == "100")
|
||||
} else {
|
||||
#expect(Bool(false), "Expected image content")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Resource content encoding and decoding")
|
||||
func testToolContentResourceEncoding() throws {
|
||||
let content = Tool.Content.resource(
|
||||
uri: "file://test.txt",
|
||||
mimeType: "text/plain",
|
||||
text: "Sample text"
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let data = try encoder.encode(content)
|
||||
let decoded = try decoder.decode(Tool.Content.self, from: data)
|
||||
|
||||
if case .resource(let uri, let mimeType, let text) = decoded {
|
||||
#expect(uri == "file://test.txt")
|
||||
#expect(mimeType == "text/plain")
|
||||
#expect(text == "Sample text")
|
||||
} else {
|
||||
#expect(Bool(false), "Expected resource content")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("ListTools parameters validation")
|
||||
func testListToolsParameters() throws {
|
||||
let params = ListTools.Parameters(cursor: "next_page")
|
||||
#expect(params.cursor == "next_page")
|
||||
|
||||
let emptyParams = ListTools.Parameters()
|
||||
#expect(emptyParams.cursor == nil)
|
||||
}
|
||||
|
||||
@Test("ListTools result validation")
|
||||
func testListToolsResult() throws {
|
||||
let tools = [
|
||||
Tool(name: "tool1", description: "First tool", inputSchema: nil),
|
||||
Tool(name: "tool2", description: "Second tool", inputSchema: nil),
|
||||
]
|
||||
|
||||
let result = ListTools.Result(tools: tools, nextCursor: "next_page")
|
||||
#expect(result.tools.count == 2)
|
||||
#expect(result.tools[0].name == "tool1")
|
||||
#expect(result.tools[1].name == "tool2")
|
||||
#expect(result.nextCursor == "next_page")
|
||||
}
|
||||
|
||||
@Test("CallTool parameters validation")
|
||||
func testCallToolParameters() throws {
|
||||
let arguments: [String: Value] = [
|
||||
"param1": .string("value1"),
|
||||
"param2": .int(42),
|
||||
]
|
||||
|
||||
let params = CallTool.Parameters(name: "test_tool", arguments: arguments)
|
||||
#expect(params.name == "test_tool")
|
||||
#expect(params.arguments?["param1"] == .string("value1"))
|
||||
#expect(params.arguments?["param2"] == .int(42))
|
||||
}
|
||||
|
||||
@Test("CallTool result validation")
|
||||
func testCallToolResult() throws {
|
||||
let content = [
|
||||
Tool.Content.text("Result 1"),
|
||||
Tool.Content.text("Result 2"),
|
||||
]
|
||||
|
||||
let result = CallTool.Result(content: content, isError: false)
|
||||
#expect(result.content.count == 2)
|
||||
#expect(result.isError == false)
|
||||
|
||||
if case .text(let text) = result.content[0] {
|
||||
#expect(text == "Result 1")
|
||||
} else {
|
||||
#expect(Bool(false), "Expected text content")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("ToolListChanged notification name validation")
|
||||
func testToolListChangedNotification() throws {
|
||||
#expect(ToolListChangedNotification.name == "notifications/tools/list_changed")
|
||||
}
|
||||
}
|
||||
122
Tests/MCPTests/TransportTests.swift
Normal file
122
Tests/MCPTests/TransportTests.swift
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import Foundation
|
||||
import SystemPackage
|
||||
import Testing
|
||||
|
||||
@testable import MCP
|
||||
|
||||
@Suite("Stdio Transport Tests")
|
||||
struct StdioTransportTests {
|
||||
@Test("Connection")
|
||||
func testStdioTransportConnection() async throws {
|
||||
let (input, _) = try FileDescriptor.pipe()
|
||||
let (_, output) = try FileDescriptor.pipe()
|
||||
let transport = StdioTransport(input: input, output: output, logger: nil)
|
||||
try await transport.connect()
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
@Test("Send Message")
|
||||
func testStdioTransportSendMessage() async throws {
|
||||
let (reader, output) = try FileDescriptor.pipe()
|
||||
let (input, _) = try FileDescriptor.pipe()
|
||||
let transport = StdioTransport(input: input, output: output, logger: nil)
|
||||
try await transport.connect()
|
||||
|
||||
// Test sending a simple message
|
||||
let message = #"{"key":"value"}"#
|
||||
try await transport.send(message)
|
||||
|
||||
// Read and verify the output
|
||||
var buffer = [UInt8](repeating: 0, count: 1024)
|
||||
let bytesRead = try buffer.withUnsafeMutableBufferPointer { pointer in
|
||||
try reader.read(into: UnsafeMutableRawBufferPointer(pointer))
|
||||
}
|
||||
let data = Data(buffer[..<bytesRead])
|
||||
let expectedOutput = message.data(using: .utf8)! + "\n".data(using: .utf8)!
|
||||
#expect(data == expectedOutput)
|
||||
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
@Test("Receive Message")
|
||||
func testStdioTransportReceiveMessage() async throws {
|
||||
let (input, writer) = try FileDescriptor.pipe()
|
||||
let (_, output) = try FileDescriptor.pipe()
|
||||
let transport = StdioTransport(input: input, output: output, logger: nil)
|
||||
try await transport.connect()
|
||||
|
||||
// Write test message to input pipe
|
||||
let message = ["key": "value"]
|
||||
let messageData = try JSONEncoder().encode(message) + "\n".data(using: .utf8)!
|
||||
try writer.writeAll(messageData)
|
||||
try writer.close()
|
||||
|
||||
// Start receiving messages
|
||||
let stream: AsyncThrowingStream<String, Swift.Error> = await transport.receive()
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
|
||||
// Get first message
|
||||
let received = try await iterator.next()
|
||||
#expect(received == #"{"key":"value"}"#)
|
||||
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
@Test("Invalid JSON")
|
||||
func testStdioTransportInvalidJSON() async throws {
|
||||
let (input, writer) = try FileDescriptor.pipe()
|
||||
let (_, output) = try FileDescriptor.pipe()
|
||||
let transport = StdioTransport(input: input, output: output, logger: nil)
|
||||
try await transport.connect()
|
||||
|
||||
// Write invalid JSON to input pipe
|
||||
let invalidJSON = #"{ invalid json }"#
|
||||
try writer.writeAll(invalidJSON.data(using: .utf8)!)
|
||||
try writer.close()
|
||||
|
||||
let stream: AsyncThrowingStream<String, Swift.Error> = await transport.receive()
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
|
||||
_ = try await iterator.next()
|
||||
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
@Test("Send Error")
|
||||
func testStdioTransportSendError() async throws {
|
||||
let (input, _) = try FileDescriptor.pipe()
|
||||
let transport = StdioTransport(
|
||||
input: input,
|
||||
output: FileDescriptor(rawValue: -1), // Invalid fd
|
||||
logger: nil
|
||||
)
|
||||
|
||||
do {
|
||||
try await transport.connect()
|
||||
#expect(Bool(false), "Expected connect to throw an error")
|
||||
} catch {
|
||||
#expect(error is MCP.Error)
|
||||
}
|
||||
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
@Test("Receive Error")
|
||||
func testStdioTransportReceiveError() async throws {
|
||||
let (_, output) = try FileDescriptor.pipe()
|
||||
let transport = StdioTransport(
|
||||
input: FileDescriptor(rawValue: -1), // Invalid fd
|
||||
output: output,
|
||||
logger: nil
|
||||
)
|
||||
|
||||
do {
|
||||
try await transport.connect()
|
||||
#expect(Bool(false), "Expected connect to throw an error")
|
||||
} catch {
|
||||
#expect(error is MCP.Error)
|
||||
}
|
||||
|
||||
await transport.disconnect()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue