Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions scripts/coverage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Generate test coverage reports for .NET and Rust services
set -euo pipefail

COVERAGE_DIR="coverage"
rm -rf "$COVERAGE_DIR"

echo "=== .NET coverage ==="
dotnet test tests/DocxMcp.Tests/ \
--collect:"XPlat Code Coverage" \
--results-directory "$COVERAGE_DIR/dotnet"

if command -v reportgenerator &> /dev/null; then
reportgenerator \
-reports:"$COVERAGE_DIR/dotnet/**/coverage.cobertura.xml" \
-targetdir:"$COVERAGE_DIR/dotnet/html" \
-reporttypes:"Html;TextSummary"
cat "$COVERAGE_DIR/dotnet/html/Summary.txt"
else
echo " (install reportgenerator for HTML reports: dotnet tool install -g dotnet-reportgenerator-globaltool)"
echo " Raw coverage: $COVERAGE_DIR/dotnet/**/coverage.cobertura.xml"
fi

echo ""
echo "=== Rust coverage ==="
if command -v cargo-tarpaulin &> /dev/null; then
cargo tarpaulin --workspace \
--exclude-files "*/build.rs" \
--out Html Xml \
--output-dir "$COVERAGE_DIR/rust"
else
echo " (install tarpaulin for Rust coverage: cargo install cargo-tarpaulin)"
echo " Running tests without coverage..."
cargo test --workspace
fi

echo ""
echo "Reports:"
echo " .NET: $COVERAGE_DIR/dotnet/html/index.html"
echo " Rust: $COVERAGE_DIR/rust/tarpaulin-report.html"
122 changes: 119 additions & 3 deletions src/DocxMcp/Helpers/CommentHelper.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using W15 = DocumentFormat.OpenXml.Office2013.Word;

namespace DocxMcp.Helpers;

Expand Down Expand Up @@ -315,10 +316,23 @@ private static void RemoveCommentAnchors(OpenXmlElement root, string commentId)
public static List<CommentInfo> ListComments(WordprocessingDocument doc, string? authorFilter = null)
{
var results = new List<CommentInfo>();
var commentsPart = doc.MainDocumentPart?.WordprocessingCommentsPart;
var mainPart = doc.MainDocumentPart;
var commentsPart = mainPart?.WordprocessingCommentsPart;
if (commentsPart?.Comments is null) return results;

var body = doc.MainDocumentPart?.Document?.Body;
var body = mainPart?.Document?.Body;

// Build paraId→Done lookup from CommentsExPart
var resolvedParaIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var exPart = mainPart?.WordprocessingCommentsExPart;
if (exPart?.CommentsEx is not null)
{
foreach (var ce in exPart.CommentsEx.Elements<W15.CommentEx>())
{
if (ce.Done?.Value == true && ce.ParaId?.Value is not null)
resolvedParaIds.Add(ce.ParaId.Value);
}
}

foreach (var comment in commentsPart.Comments.Elements<Comment>())
{
Expand All @@ -337,14 +351,20 @@ public static List<CommentInfo> ListComments(WordprocessingDocument doc, string?
anchoredText = GetAnchoredText(body, idStr);
}

// Check resolved state via first paragraph's paraId
var firstPara = comment.Elements<Paragraph>().FirstOrDefault();
var paraId = firstPara?.ParagraphId?.Value;
var isResolved = paraId is not null && resolvedParaIds.Contains(paraId);

results.Add(new CommentInfo
{
Id = int.TryParse(idStr, out var id) ? id : 0,
Author = cAuthor,
Initials = comment.Initials?.Value ?? "",
Date = comment.Date?.Value,
Text = commentText,
AnchoredText = anchoredText
AnchoredText = anchoredText,
Resolved = isResolved
});
}

Expand Down Expand Up @@ -434,6 +454,101 @@ private static void AddCommentAnchorsToParagraph(Paragraph para, int commentId)
para.AppendChild(CreateCommentReferenceRun(commentId));
}

/// <summary>
/// Resolve or un-resolve a comment by ID.
/// Uses WordprocessingCommentsExPart (commentsExtended.xml) with CommentEx.Done attribute.
/// </summary>
public static bool ResolveComment(WordprocessingDocument doc, int commentId, bool resolved)
{
var mainPart = doc.MainDocumentPart
?? throw new InvalidOperationException("Document has no MainDocumentPart.");

// Verify the comment exists
var commentsPart = mainPart.WordprocessingCommentsPart;
if (commentsPart?.Comments is null) return false;

var idStr = commentId.ToString();
var comment = commentsPart.Comments.Elements<Comment>()
.FirstOrDefault(c => c.Id?.Value == idStr);
if (comment is null) return false;

// Get the first paragraph's paraId from the comment
var firstPara = comment.Elements<Paragraph>().FirstOrDefault();
if (firstPara is null) return false;

var paraId = firstPara.ParagraphId?.Value;
if (paraId is null)
{
// Generate a paraId if none exists
paraId = GenerateParaId();
firstPara.ParagraphId = new HexBinaryValue(paraId);
}

// Get or create CommentsExPart
var exPart = mainPart.WordprocessingCommentsExPart;
if (exPart is null)
{
exPart = mainPart.AddNewPart<WordprocessingCommentsExPart>();
exPart.CommentsEx = new W15.CommentsEx();
}
else if (exPart.CommentsEx is null)
{
exPart.CommentsEx = new W15.CommentsEx();
}

// Find existing CommentEx for this paraId, or create one
var commentEx = exPart.CommentsEx.Elements<W15.CommentEx>()
.FirstOrDefault(ce => ce.ParaId?.Value == paraId);

if (commentEx is null)
{
commentEx = new W15.CommentEx { ParaId = new HexBinaryValue(paraId) };
exPart.CommentsEx.AppendChild(commentEx);
}

commentEx.Done = resolved ? true : false;
exPart.CommentsEx.Save();

return true;
}

/// <summary>
/// Check if a comment is resolved.
/// </summary>
public static bool IsCommentResolved(WordprocessingDocument doc, int commentId)
{
var mainPart = doc.MainDocumentPart;
var commentsPart = mainPart?.WordprocessingCommentsPart;
if (commentsPart?.Comments is null) return false;

var idStr = commentId.ToString();
var comment = commentsPart.Comments.Elements<Comment>()
.FirstOrDefault(c => c.Id?.Value == idStr);
if (comment is null) return false;

var firstPara = comment.Elements<Paragraph>().FirstOrDefault();
var paraId = firstPara?.ParagraphId?.Value;
if (paraId is null) return false;

var exPart = mainPart?.WordprocessingCommentsExPart;
if (exPart?.CommentsEx is null) return false;

var commentEx = exPart.CommentsEx.Elements<W15.CommentEx>()
.FirstOrDefault(ce => ce.ParaId?.Value == paraId);

return commentEx?.Done?.Value == true;
}

/// <summary>
/// Generate a random 8-character hex paraId (matching Word's format).
/// </summary>
private static string GenerateParaId()
{
var bytes = new byte[4];
System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
return Convert.ToHexString(bytes);
}

/// <summary>
/// Create the reference Run: contains CommentReference with CommentReference style.
/// </summary>
Expand All @@ -458,4 +573,5 @@ public class CommentInfo
public DateTime? Date { get; set; }
public string Text { get; set; } = "";
public string? AnchoredText { get; set; }
public bool Resolved { get; set; }
}
6 changes: 4 additions & 2 deletions src/DocxMcp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
.WithTools<StyleTools>()
.WithTools<RevisionTools>()
.WithTools<ExternalChangeTools>()
.WithTools<ConnectionTools>();
.WithTools<ConnectionTools>()
.WithTools<DiffTool>();

var app = builder.Build();
app.MapMcp("/mcp");
Expand Down Expand Up @@ -97,7 +98,8 @@
.WithTools<StyleTools>()
.WithTools<RevisionTools>()
.WithTools<ExternalChangeTools>()
.WithTools<ConnectionTools>();
.WithTools<ConnectionTools>()
.WithTools<DiffTool>();

await builder.Build().RunAsync();
}
Expand Down
19 changes: 19 additions & 0 deletions src/DocxMcp/SessionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,16 @@ await _history.AddSessionToIndexAsync(TenantId, session.Id,
Array.Empty<ulong>()));
}

/// <summary>
/// Get the serialized document bytes at a given WAL position without side effects.
/// Does NOT save checkpoints or update the cursor. Used for read-only comparisons.
/// </summary>
public byte[] GetBytesAtPosition(string id, int position)
{
using var session = RebuildDocumentAtPositionAsync(id, position).GetAwaiter().GetResult();
return session.ToBytes();
}

/// <summary>
/// Rebuild document at a given position, save checkpoint there, update cursor.
/// Returns the serialized bytes at that position.
Expand Down Expand Up @@ -830,6 +840,12 @@ private static string GenerateDescription(string patchesJson)
var cid = patch.TryGetProperty("comment_id", out var cidEl) ? cidEl.GetInt32().ToString() : "?";
ops.Add($"delete_comment #{cid}");
}
else if (op == "resolve_comment")
{
var cid = patch.TryGetProperty("comment_id", out var cidEl) ? cidEl.GetInt32().ToString() : "?";
var resolved = patch.TryGetProperty("resolved", out var rEl) && rEl.GetBoolean();
ops.Add(resolved ? $"resolve_comment #{cid}" : $"unresolve_comment #{cid}");
}
else if (op is "style_element" or "style_paragraph" or "style_table")
{
var stylePath = patch.TryGetProperty("path", out var spEl) && spEl.ValueKind == JsonValueKind.String
Expand Down Expand Up @@ -897,6 +913,9 @@ private static void ReplayPatch(DocxSession session, string patchesJson)
case "delete_comment":
Tools.CommentTools.ReplayDeleteComment(patch, wpDoc);
break;
case "resolve_comment":
Tools.CommentTools.ReplayResolveComment(patch, wpDoc);
break;
case "style_element":
Tools.StyleTools.ReplayStyleElement(patch, wpDoc);
break;
Expand Down
58 changes: 57 additions & 1 deletion src/DocxMcp/Tools/CommentTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ public static string CommentList(
["initials"] = c.Initials,
["date"] = c.Date?.ToString("o"),
["text"] = c.Text,
["resolved"] = c.Resolved,
};

if (c.AnchoredText is not null)
Expand Down Expand Up @@ -243,6 +244,49 @@ public static string CommentDelete(
catch (Exception ex) { throw new McpException(ex.Message, ex); }
}

[McpServerTool(Name = "comment_resolve"), Description(
"Mark a comment as resolved or re-open it.\n\n" +
"Resolved comments appear greyed out in Word but are not deleted.\n" +
"Use comment_list to see current resolve status.")]
public static string CommentResolve(
TenantScope tenant,
SyncManager sync,
[Description("Session ID of the document.")] string doc_id,
[Description("ID of the comment to resolve/unresolve.")] int comment_id,
[Description("True to resolve, false to re-open. Default: true.")] bool resolved = true)
{
try
{
var session = tenant.Sessions.Get(doc_id);
var doc = session.Document;

var success = CommentHelper.ResolveComment(doc, comment_id, resolved);
if (!success)
return $"Error: Comment {comment_id} not found.";

// Append to WAL
var walObj = new JsonObject
{
["op"] = "resolve_comment",
["comment_id"] = comment_id,
["resolved"] = resolved
};
var walEntry = new JsonArray();
walEntry.Add((JsonNode)walObj);
var bytes = session.ToBytes();
tenant.Sessions.AppendWal(doc_id, walEntry.ToJsonString(), null, bytes);
sync.MaybeAutoSave(tenant.TenantId, doc_id, bytes);

return resolved
? $"Comment {comment_id} marked as resolved."
: $"Comment {comment_id} re-opened.";
}
catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"resolving comment in '{doc_id}'"); }
catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); }
catch (McpException) { throw; }
catch (Exception ex) { throw new McpException(ex.Message, ex); }
}

/// <summary>
/// Replay an add_comment WAL operation.
/// </summary>
Expand All @@ -255,7 +299,9 @@ internal static void ReplayAddComment(JsonElement patch, WordprocessingDocument
var author = patch.GetProperty("author").GetString() ?? "AI Assistant";
var initials = patch.GetProperty("initials").GetString() ?? "AI";
var dateStr = patch.GetProperty("date").GetString();
var date = dateStr is not null ? DateTime.Parse(dateStr).ToUniversalTime() : DateTime.UtcNow;
var date = dateStr is not null && DateTime.TryParse(dateStr, out var parsedDate)
? parsedDate.ToUniversalTime()
: DateTime.UtcNow;

string? anchorText = null;
if (patch.TryGetProperty("anchor_text", out var at) && at.ValueKind == JsonValueKind.String)
Expand Down Expand Up @@ -287,6 +333,16 @@ internal static void ReplayDeleteComment(JsonElement patch, WordprocessingDocume
CommentHelper.DeleteComment(doc, commentId);
}

/// <summary>
/// Replay a resolve_comment WAL operation.
/// </summary>
internal static void ReplayResolveComment(JsonElement patch, WordprocessingDocument doc)
{
var commentId = patch.GetProperty("comment_id").GetInt32();
var resolved = patch.TryGetProperty("resolved", out var r) && r.GetBoolean();
CommentHelper.ResolveComment(doc, commentId, resolved);
}

private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
Expand Down
2 changes: 2 additions & 0 deletions src/DocxMcp/Tools/ConnectionTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ public static string ListConnectionFiles(
{
try
{
page_size = Math.Clamp(page_size, 1, 100);

var type = source_type switch
{
"local" => SourceType.LocalFile,
Expand Down
2 changes: 1 addition & 1 deletion src/DocxMcp/Tools/CountTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public static string CountElements(
{
var body = doc.MainDocumentPart?.Document?.Body;
if (body is null)
return """{"error": "Document has no body."}""";
throw new InvalidOperationException("Document has no body.");

var result = new JsonObject
{
Expand Down
Loading