Skip to content

Commit f0f7aac

Browse files
committed
Escape quotes propertly, preserve paragraph breaks, but replace single paragraph breaks with space. Fixes #12
1 parent 0bc42d8 commit f0f7aac

File tree

6 files changed

+108
-36
lines changed

6 files changed

+108
-36
lines changed

Demos/SwiftMCPDemo/Calculator.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,22 @@ import SwiftMCP
33

44
/**
55
A Calculator for simple math doing additionals, subtractions etc.
6+
7+
Testing "quoted" stuff. And on multiple lines. 'single quotes'
68
*/
79
@MCPServer(name: "SwiftMCP Demo")
810
actor Calculator {
911

1012
// must be CaseIterable to
11-
enum Options {
13+
enum Options: CaseIterable, CustomStringConvertible {
14+
var description: String
15+
{
16+
switch self {
17+
case .all: return "ALL"
18+
case .unread: return "UNREAD"
19+
}
20+
}
21+
1222
case all
1323
case unread
1424
}

Sources/SwiftMCPMacros/Documentation.swift

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ struct Documentation {
2323

2424
// Remove comment markers and extra whitespace from each line.
2525
var cleanedLines = [String]()
26+
var previousLineWasEmpty = false
2627
for var line in lines {
2728
// Trim whitespace first.
2829
line = line.trimmingCharacters(in: .whitespaces)
@@ -51,10 +52,15 @@ struct Documentation {
5152
// Remove unprintable ASCII characters
5253
line = line.removingUnprintableCharacters
5354

54-
// If the line isn't empty after cleaning, keep it.
55-
if !line.isEmpty {
55+
// If the line is empty and the previous line wasn't empty, keep it to preserve paragraph breaks
56+
if line.isEmpty {
57+
if !previousLineWasEmpty {
58+
cleanedLines.append(line)
59+
}
60+
} else {
5661
cleanedLines.append(line)
5762
}
63+
previousLineWasEmpty = line.isEmpty
5864
}
5965

6066
// We'll accumulate the initial description and any parameter descriptions.
@@ -73,7 +79,8 @@ struct Documentation {
7379
func flushCurrentParameter() {
7480
if let paramName = currentParameterName {
7581
let fullDescription = currentParameterLines.joined(separator: " ").trimmingCharacters(in: .whitespaces)
76-
parameters[paramName] = fullDescription
82+
// Escape the description when storing it
83+
parameters[paramName] = fullDescription.escapedForSwiftString
7784
}
7885
currentParameterName = nil
7986
currentParameterLines = []
@@ -209,15 +216,45 @@ struct Documentation {
209216
// Flush any parameter still being accumulated.
210217
flushCurrentParameter()
211218

212-
// Combine initial description lines into a single string.
213-
let initialDescription = initialDescriptionLines.joined(separator: " ").trimmingCharacters(in: .whitespaces)
219+
// Combine initial description lines into a single string, preserving paragraph breaks
220+
var initialDescription = ""
221+
previousLineWasEmpty = false // Reuse the existing variable
222+
for line in initialDescriptionLines {
223+
if line.isEmpty {
224+
if !previousLineWasEmpty {
225+
initialDescription += "\n\n"
226+
}
227+
} else {
228+
if !initialDescription.isEmpty && !previousLineWasEmpty {
229+
initialDescription += " "
230+
}
231+
initialDescription += line
232+
}
233+
previousLineWasEmpty = line.isEmpty
234+
}
235+
initialDescription = initialDescription.trimmingCharacters(in: .whitespacesAndNewlines)
214236

215-
// Combine returns lines into a single string.
216-
let returnsDescription = returnsLines.isEmpty ? nil : returnsLines.joined(separator: " ").trimmingCharacters(in: .whitespaces)
237+
// Combine returns lines into a single string, preserving paragraph breaks
238+
var returnsDescription = ""
239+
previousLineWasEmpty = false // Reuse the existing variable
240+
for line in returnsLines {
241+
if line.isEmpty {
242+
if !previousLineWasEmpty {
243+
returnsDescription += "\n\n"
244+
}
245+
} else {
246+
if !returnsDescription.isEmpty && !previousLineWasEmpty {
247+
returnsDescription += " "
248+
}
249+
returnsDescription += line
250+
}
251+
previousLineWasEmpty = line.isEmpty
252+
}
253+
returnsDescription = returnsDescription.trimmingCharacters(in: .whitespacesAndNewlines)
217254

218-
self.description = initialDescription
255+
self.description = initialDescription.escapedForSwiftString
219256
self.parameters = parameters
220-
self.returns = returnsDescription
257+
self.returns = returnsDescription.isEmpty ? nil : returnsDescription.escapedForSwiftString
221258
}
222259
}
223260

@@ -239,13 +276,3 @@ fileprivate func parseParameterLine(from line: String) -> (name: String, descrip
239276
}
240277
return nil
241278
}
242-
243-
extension String {
244-
var removingUnprintableCharacters: String {
245-
// Create a character set of printable ASCII characters (32-126) plus newline, tab, etc.
246-
let printableCharacters = CharacterSet(charactersIn: " \t\n\r").union(CharacterSet(charactersIn: UnicodeScalar(32)...UnicodeScalar(126)))
247-
248-
// Filter out any characters that are not in the printable set
249-
return unicodeScalars.filter { printableCharacters.contains($0) }.map { String($0) }.joined()
250-
}
251-
}

Sources/SwiftMCPMacros/MCPServerMacro.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ public struct MCPServerMacro: MemberMacro, ExtensionMacro {
100100
// Extract description from leading documentation
101101
let leadingTrivia = declaration.leadingTrivia.description
102102
let documentation = Documentation(from: leadingTrivia)
103-
let serverDescription = documentation.description.isEmpty ? "nil" : "\"\(documentation.description.replacingOccurrences(of: "\"", with: "\\\""))\""
103+
let serverDescription = documentation.description.isEmpty ? "nil" : "\"\(documentation.description)\""
104104

105105
let nameProperty = "private let __mcpServerName = \"\(serverName)\""
106106
let versionProperty = "private let __mcpServerVersion = \"\(serverVersion)\""

Sources/SwiftMCPMacros/MCPToolMacro.swift

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ public struct MCPToolMacro: PeerMacro {
8282
let stringValue = stringLiteral.segments.description
8383
// Remove quotes and escape special characters for string interpolation
8484
let cleanedValue = stringValue
85-
.replacingOccurrences(of: "\"", with: "\\\"")
8685
descriptionArg = "\"\(cleanedValue)\""
8786
hasExplicitDescription = true
8887
break
@@ -111,12 +110,8 @@ public struct MCPToolMacro: PeerMacro {
111110
}
112111
// Use the extracted description if available
113112
else if !documentation.description.isEmpty {
114-
// Ensure the description is properly escaped and doesn't contain unprintable characters
115-
let escapedDescription = documentation.description
116-
.replacingOccurrences(of: "\"", with: "\\\"")
117-
.replacingOccurrences(of: "\t", with: " ") // Replace tabs with spaces
118-
119-
descriptionArg = "\"\(escapedDescription)\""
113+
// The description is already escaped from the Documentation struct
114+
descriptionArg = "\"\(documentation.description)\""
120115
foundDescriptionInDocs = true
121116
}
122117
}
@@ -169,12 +164,8 @@ public struct MCPToolMacro: PeerMacro {
169164
}
170165
// Get parameter description from the Documentation struct
171166
else if let description = documentation.parameters[paramName], !description.isEmpty {
172-
// Ensure the parameter description is properly escaped and doesn't contain unprintable characters
173-
let escapedDescription = description
174-
.replacingOccurrences(of: "\"", with: "\\\"")
175-
.replacingOccurrences(of: "\t", with: " ") // Replace tabs with spaces
176-
177-
paramDescription = "\"\(escapedDescription)\""
167+
// The description is already escaped from the Documentation struct
168+
paramDescription = "\"\(description)\""
178169
}
179170

180171
// Extract default value if it exists
@@ -228,7 +219,7 @@ public struct MCPToolMacro: PeerMacro {
228219
\(parameterString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.joined(separator: ",\n "))
229220
],
230221
returnType: \(returnTypeString),
231-
returnTypeDescription: \(documentation.returns.map { "\"\($0.replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "\t", with: " "))\"" } ?? "nil"),
222+
returnTypeDescription: \(documentation.returns.map { "\"\($0)\"" } ?? "nil"),
232223
isAsync: \(funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil),
233224
isThrowing: \(funcDecl.signature.effectSpecifiers?.throwsClause?.throwsSpecifier != nil)
234225
)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//
2+
// String+Documentation.swift
3+
// SwiftMCP
4+
//
5+
// Created by Oliver Drobnik on 27.03.25.
6+
//
7+
8+
import Foundation
9+
10+
extension String {
11+
var removingUnprintableCharacters: String {
12+
// Create a character set of printable ASCII characters (32-126) plus newline, tab, etc.
13+
let printableCharacters = CharacterSet(charactersIn: " \t\n\r").union(CharacterSet(charactersIn: UnicodeScalar(32)...UnicodeScalar(126)))
14+
15+
// Filter out any characters that are not in the printable set
16+
return unicodeScalars.filter { printableCharacters.contains($0) }.map { String($0) }.joined()
17+
}
18+
19+
/// Escapes a string for use in a Swift string literal.
20+
/// This handles quotes, backslashes, and other special characters.
21+
var escapedForSwiftString: String {
22+
return self
23+
.replacingOccurrences(of: "\\", with: "\\\\") // Escape backslashes first
24+
.replacingOccurrences(of: "\"", with: "\\\"") // Escape double quotes
25+
.replacingOccurrences(of: "\'", with: "\\\'") // Escape single quotes
26+
.replacingOccurrences(of: "\n", with: "\\n") // Escape newlines
27+
.replacingOccurrences(of: "\r", with: "\\r") // Escape carriage returns
28+
.replacingOccurrences(of: "\t", with: "\\t") // Escape tabs
29+
.replacingOccurrences(of: "\0", with: "\\0") // Escape null bytes
30+
}
31+
}

Tests/SwiftMCPTests/MacroDocumentationTests.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,19 @@ func testEmptyLinesBetweenFields() {
225225
#expect(doc.description == "This is a function description")
226226
#expect(doc.parameters["x"] == "X parameter")
227227
#expect(doc.returns == "Return value")
228-
}
228+
}
229+
230+
@Test("Preserves paragraph breaks and escapes quotes in documentation")
231+
func testParagraphBreaksAndQuotes() {
232+
let docText = """
233+
/**
234+
A Calculator for simple math doing additionals, subtractions etc.
235+
236+
Testing "quoted" stuff. And on multiple lines. 'single quotes'
237+
*/
238+
"""
239+
let doc = Documentation(from: docText)
240+
#expect(doc.description == "A Calculator for simple math doing additionals, subtractions etc.\\n\\nTesting \\\"quoted\\\" stuff. And on multiple lines. \\'single quotes\\'")
241+
}
229242

230243
#endif

0 commit comments

Comments
 (0)