diff --git a/docs/reference/command-line.md b/docs/reference/command-line.md index 5fae7c45da5..9ca7b76f503 100644 --- a/docs/reference/command-line.md +++ b/docs/reference/command-line.md @@ -84,6 +84,7 @@ seconv list-rf-rules # list remove-formatting rule IDs (alias: list-remov seconv dump-settings # print a full --settings JSON with libse defaults (alias: default-settings) seconv info # print format/encoding/duration/language for a file seconv lint # validate subtitle(s); exit 1 if issues found +seconv mcp # run as a Model Context Protocol server over stdio seconv --help # show help (same text as -h, /? and /help) seconv --help-json # print the whole command-line schema as JSON seconv --version # print version and exit @@ -91,7 +92,7 @@ seconv --version # print version and exit ### Machine-readable output -Every subcommand above accepts `--json`, and so does a conversion run. Scripts and agents should prefer it: the tables are hundreds of box-drawing lines, while the JSON gives you the exact tokens each option accepts. +Every ordinary CLI subcommand above except `mcp` accepts `--json`, and so does a conversion run. `mcp` instead reserves stdout for JSON-RPC frames. Scripts and agents using the CLI should prefer `--json`: the tables are hundreds of box-drawing lines, while the JSON gives you the exact tokens each option accepts. ```bash seconv formats --json | jq -r '.formats[] | select(.inputOnly | not) | .id' @@ -124,6 +125,39 @@ seconv lint *.srt # check overlaps, line lengths, tags, ... seconv lint *.srt --json # CI-friendly: exit 1 on any issue ``` +### MCP server + +`seconv mcp` exposes the engine as a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio. +Register it as a local stdio server in an MCP-capable client: + +```json +{ "mcpServers": { "seconv": { "command": "seconv", "args": ["mcp"] } } } +``` + +The server exposes: + +| Tool | What it does | +|---|---| +| `list_formats` | Lists readable/writable formats. Optional substring filter. | +| `subtitle_info` | Detects format, encoding, paragraph count, time range, duration and language. | +| `read_subtitle` | Reads paged subtitle paragraphs from supported text or binary formats. | +| `lint_subtitle` | Runs the same subtitle validation rules as `seconv lint`. | +| `convert_subtitle` | Converts one or more local subtitle inputs using a focused subset of CLI controls (format/output, encoding, timing, track selection, OCR and cleanup operations). Advanced translation/image-style/custom-format/settings controls remain CLI-only. | +| `list_fix_common_errors_rules` | Lists rule ids accepted by `fixCommonErrorsRules`. | +| `list_remove_formatting_rules` | Lists rule ids accepted by `removeFormattingRules`. | + +The six inspection/list tools are advertised read-only and non-destructive. `convert_subtitle` is explicitly +advertised as write-capable and destructive-capable because `overwrite=true` may replace an existing output file. +All tools are closed-world local-file operations. Client cancellation is propagated cooperatively into conversion; +legacy synchronous/native stages that do not accept a cancellation token are stopped at the surrounding checkpoints. + +The MCP server does not add a filesystem sandbox. Paths are resolved as local paths under the operating-system permissions of the `seconv` process; clients should apply their normal tool-approval and sandbox policies. + +A failed or cancelled multi-file call can have completed earlier output files before the later failure or cancellation. Inspect the returned per-file conversion data and existing outputs before retrying; a tool error is not a transaction rollback. + +Stdout is reserved exclusively for MCP JSON-RPC traffic. Logs and diagnostics go to stderr; start the server with +`seconv mcp --verbose` for debug logging. + ## Options ### File / I/O diff --git a/src/seconv/Helpers/CliSchema.cs b/src/seconv/Helpers/CliSchema.cs index dd0469eedfb..11b45a64b8a 100644 --- a/src/seconv/Helpers/CliSchema.cs +++ b/src/seconv/Helpers/CliSchema.cs @@ -373,6 +373,7 @@ public static string ToJson() new { name = "dump-settings", json = true, description = "Print a full --settings JSON with libse defaults. Always JSON; redirect to a file." }, new { name = "info", json = true, description = "Print format / encoding / duration / language for one file. Usage: seconv info " }, new { name = "lint", json = true, description = "Validate subtitles; exit 1 if any issues found. Usage: seconv lint ..." }, + new { name = "mcp", json = false, description = "Run seconv as a Model Context Protocol server over stdio. Tools: list_formats, subtitle_info, read_subtitle, lint_subtitle, convert_subtitle, list_fix_common_errors_rules, list_remove_formatting_rules. Usage: seconv mcp [--verbose]" }, new { name = "--help-json", json = true, description = "Print this schema." }, new { name = "--version", json = false, description = "Print the seconv version and exit." }, }, diff --git a/src/seconv/Helpers/HelpDisplay.cs b/src/seconv/Helpers/HelpDisplay.cs index 77b8d409204..d5605de6684 100644 --- a/src/seconv/Helpers/HelpDisplay.cs +++ b/src/seconv/Helpers/HelpDisplay.cs @@ -161,6 +161,7 @@ private static void ShowHelp(IAnsiConsole console) ShowParameter(console, "dump-settings", "Print a full --settings JSON with libse defaults (redirect to a file)"); ShowParameter(console, "info ", "Print format / encoding / duration / language info"); ShowParameter(console, "lint ", "Validate subtitle(s); exit 1 if any issues found"); + ShowParameter(console, "mcp", "Run as a Model Context Protocol server over stdio (Claude, Cursor, ...)"); console.WriteLine(); ShowSection(console, "Examples", null); diff --git a/src/seconv/Mcp/McpServerHost.cs b/src/seconv/Mcp/McpServerHost.cs new file mode 100644 index 00000000000..4b13cbb02d9 --- /dev/null +++ b/src/seconv/Mcp/McpServerHost.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using SeConv.Helpers; + +namespace SeConv.Mcp; + +/// +/// seconv mcp: runs seconv as a Model Context Protocol server over stdio so an MCP client +/// (Claude Desktop, Claude Code, Cursor, ...) can call the tools in +/// without a shell. The JSON-RPC transport owns stdout; logs and any incidental console output +/// from libse or Spectre go to stderr so they can never corrupt a protocol frame. +/// +internal static class McpServerHost +{ + private const string Instructions = + "seconv exposes Subtitle Edit's conversion engine for hundreds of subtitle formats. " + + "Start with subtitle_info to detect a file's format, encoding and duration; use read_subtitle to see " + + "its paragraphs (any format, paged); lint_subtitle to find timing/line-length problems; " + + "convert_subtitle to write a new file in another format, optionally shifting times, changing " + + "frame rate or applying operations such as FixCommonErrors. Paths are local file-system paths. " + + "The server does not add a filesystem sandbox; access is limited by the operating-system permissions of the seconv process."; + + public static async Task RunAsync(string[] args) + { + if (!TryParseArguments(args, out var verbose, out var error)) + { + Console.Error.WriteLine(error); + return 1; + } + + // Stdout is the protocol channel. The stdio transport writes to the raw standard output + // stream, so redirecting Console.Out only affects incidental writers (libse notices, + // Spectre markup from a non-quiet code path) - they land on stderr instead of inside a frame. + Console.SetOut(Console.Error); + + var builder = Host.CreateApplicationBuilder(Array.Empty()); + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + builder.Logging.SetMinimumLevel(verbose ? LogLevel.Debug : LogLevel.Warning); + + builder.Services + .AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = "seconv", Version = CliSchema.Version }; + options.ServerInstructions = Instructions; + }) + .WithStdioServerTransport() + .WithTools(); + + await builder.Build().RunAsync(); + return 0; + } + + internal static bool TryParseArguments(string[] args, out bool verbose, out string? error) + { + verbose = false; + error = null; + + foreach (var arg in args) + { + if (arg.Equals("--verbose", StringComparison.OrdinalIgnoreCase) || + arg.Equals("-v", StringComparison.OrdinalIgnoreCase)) + { + verbose = true; + continue; + } + + error = $"Unknown mcp option '{arg}'. Usage: seconv mcp [--verbose]"; + return false; + } + + return true; + } +} diff --git a/src/seconv/Mcp/SubtitleTools.cs b/src/seconv/Mcp/SubtitleTools.cs new file mode 100644 index 00000000000..3a528e1a870 --- /dev/null +++ b/src/seconv/Mcp/SubtitleTools.cs @@ -0,0 +1,459 @@ +using System.ComponentModel; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using SeConv.Core; + +namespace SeConv.Mcp; + +/// +/// The MCP tool surface of seconv mcp. Each tool is a thin adapter over the same Core +/// helpers the CLI subcommands use (, , +/// , ...). The MCP conversion tool intentionally exposes a focused +/// subset of the CLI's current options; controls it does expose use the same core semantics. +/// Every tool returns a directly: on success a single compact JSON +/// text block; validation/business errors are actionable, while unexpected internal exceptions are +/// logged to stderr and returned as a generic tool error so local paths/details are not leaked. +/// +[McpServerToolType] +internal sealed class SubtitleTools +{ + private SubtitleTools() + { + // Static tool methods only; the SDK's WithTools() needs a non-static type argument. + } + + private static readonly JsonSerializerOptions Json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + private const int DefaultReadCount = 100; + private const int MaxReadCount = 1000; + + [McpServerTool(Name = "list_formats", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Description("List the subtitle formats seconv can read and write. 'id' is the exact value convert_subtitle's 'format' parameter accepts; 'inputOnly' formats can be read but not written.")] + public static CallToolResult ListFormats( + [Description("Optional case-insensitive substring matched against id, name and extension (e.g. 'srt', 'ebu', 'vtt'). Omit to list everything.")] string? filter = null, + CancellationToken cancellationToken = default) + => Run(() => + { + var formats = LibSEIntegration.GetAvailableFormats() + .Select(entry => new + { + id = entry.Format.Name.Replace(" ", string.Empty), + name = entry.Format.Name, + extension = entry.Format.Extension, + type = entry.Kind.StartsWith("binary", StringComparison.Ordinal) ? "binary" : "text", + inputOnly = entry.Kind.Contains("(input)", StringComparison.Ordinal), + }) + .Where(f => string.IsNullOrWhiteSpace(filter) || + f.id.Contains(filter, StringComparison.OrdinalIgnoreCase) || + f.name.Contains(filter, StringComparison.OrdinalIgnoreCase) || + f.extension.Contains(filter, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + return new + { + total = formats.Count, + formats, + extraIds = new[] { "plaintext", "bluraysup", "vobsub", "bdnxml" }, + }; + }, cancellationToken); + + [McpServerTool(Name = "subtitle_info", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Description("Detect a subtitle file's format, encoding, paragraph count, first/last time codes, duration and language. Works across supported text and binary subtitle formats.")] + public static CallToolResult SubtitleInfo( + [Description("Path to the subtitle file.")] string path, + CancellationToken cancellationToken = default) + => Run(() => SubtitleInfoGatherer.Gather(path), cancellationToken); + + [McpServerTool(Name = "read_subtitle", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Description("Read the paragraphs (number, start/end time, text) of a subtitle file in any supported text or binary format, paged. Use this instead of reading the raw file when the format is not plain SRT/VTT.")] + public static CallToolResult ReadSubtitle( + [Description("Path to the subtitle file.")] string path, + [Description("1-based number of the first paragraph to return (default 1).")] int start = 1, + [Description("Maximum number of paragraphs to return (default 100, max 1000).")] int count = DefaultReadCount, + [Description("Input text encoding (e.g. 'windows-1252'). Default: auto-detect.")] string? encoding = null, + CancellationToken cancellationToken = default) + => Run(() => + { + var (subtitle, format) = LibSEIntegration.LoadSubtitleWithFormat(path, encoding); + var total = subtitle.Paragraphs.Count; + var first = Math.Max(1, start); + var take = Math.Clamp(count, 1, MaxReadCount); + + var paragraphs = subtitle.Paragraphs + .Skip(first - 1) + .Take(take) + .Select((p, i) => new + { + number = first + i, + start = p.StartTime.ToDisplayString(), + end = p.EndTime.ToDisplayString(), + startMs = (long)p.StartTime.TotalMilliseconds, + endMs = (long)p.EndTime.TotalMilliseconds, + text = p.Text, + }) + .ToList(); + + return new + { + path, + format = format.Name, + total, + start = first, + returned = paragraphs.Count, + hasMore = first - 1 + paragraphs.Count < total, + paragraphs, + }; + }, cancellationToken); + + [McpServerTool(Name = "lint_subtitle", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Description("Validate a subtitle file without modifying it: overlapping or too short/long display times, lines that are too long, too many lines, empty paragraphs, mismatched italic/bold tags. Returns the issues per paragraph number.")] + public static CallToolResult LintSubtitle( + [Description("Path to the subtitle file.")] string path, + CancellationToken cancellationToken = default) + => Run(() => SubtitleLinter.Lint(path), cancellationToken); + + [McpServerTool(Name = "list_fix_common_errors_rules", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Description("List the FixCommonErrors rule ids accepted by convert_subtitle's 'fixCommonErrorsRules' parameter, with the matching Subtitle Edit GUI label and the language gate (if any).")] + public static CallToolResult ListFixCommonErrorsRules(CancellationToken cancellationToken = default) + => Run(() => new + { + total = FixCommonErrorsRunner.AvailableRuleIds.Count, + rules = FixCommonErrorsRunner.AvailableRuleIds.Select(id => new + { + id, + guiLabel = FixCommonErrorsRunner.GuiLabels.TryGetValue(id, out var label) ? label : null, + languageGate = FixCommonErrorsRunner.LanguageGates.TryGetValue(id, out var lang) ? lang : null, + }), + syntax = new { all = "all", subset = "FixCommas,FixEllipsesStart", allExcept = "all,-FixDanishLetterI" }, + note = "A language-gated rule runs only when the subtitle's language matches (auto-detected, or forced with fixCommonErrorsLanguage). Naming a gated rule selects it but does not bypass the gate.", + }, cancellationToken); + + [McpServerTool(Name = "list_remove_formatting_rules", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Description("List the RemoveFormatting rule ids accepted by convert_subtitle's 'removeFormattingRules' parameter, with the matching Subtitle Edit GUI label.")] + public static CallToolResult ListRemoveFormattingRules(CancellationToken cancellationToken = default) + => Run(() => new + { + total = RemoveFormattingRunner.AvailableRuleIds.Count, + rules = RemoveFormattingRunner.AvailableRuleIds.Select(id => new + { + id, + guiLabel = RemoveFormattingRunner.GuiLabels.TryGetValue(id, out var label) ? label : null, + }), + syntax = new { all = "all", subset = "RemoveItalic,RemoveColor", allExcept = "all,-RemoveItalic" }, + }, cancellationToken); + + [McpServerTool(Name = "convert_subtitle", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false)] + [Description("Convert one or more subtitle files to another format using a focused subset of seconv's CLI options, including timing changes, track selection, OCR and cleanup operations. Writes output files next to the inputs unless outputFolder is given; existing files are never overwritten unless overwrite is true. Multi-file failures or cancellation can occur after earlier outputs were completed, so inspect returned per-file results and existing outputs before retrying. For advanced CLI-only controls such as translation, image styling, custom formats or settings overlays, use seconv directly.")] + public static Task ConvertSubtitle( + [Description("Input file paths or glob patterns (e.g. 'C:/subs/*.srt'). Containers (.mkv/.mp4/.ts/.avi) and image-based subtitles (.sup, .sub+.idx) are accepted too.")] string[] inputs, + [Description("Target format id from list_formats (e.g. 'SubRip', 'WebVTT', 'AdvancedSubStationAlpha'); short aliases such as 'srt', 'vtt', 'ass', 'ebu' and 'plaintext' also work.")] string format, + [Description("Folder to write the output files to. Default: next to each input file.")] string? outputFolder = null, + [Description("Explicit output file name. Only valid with a single input file.")] string? outputFilename = null, + [Description("Output text encoding: 'utf-8' (default, with BOM), 'utf-8-nobom', a code page name such as 'windows-1252', or 'source' to keep the input file's encoding.")] string? encoding = null, + [Description("Overwrite an existing output file. Default false: a numeric suffix is added instead.")] bool overwrite = false, + [Description("Shift every time code by this offset, e.g. '00:00:02.500', '2.5', '-1.2' (seconds) or '-00:00:01,000'.")] string? offset = null, + [Description("Source frame rate for frame-based formats (e.g. 25).")] double? fps = null, + [Description("Convert timings from 'fps' to this target frame rate (e.g. 23.976).")] double? targetFps = null, + [Description("Renumber paragraphs starting at this value.")] int? renumber = null, + [Description("Add this many milliseconds to every paragraph's duration (negative shortens).")] int? adjustDurationMs = null, + [Description("Speed change in percent: 125 = 1.25x faster, 80 = slower.")] double? changeSpeedPercent = null, + [Description("Bridge gaps shorter than this many milliseconds by extending the previous paragraph.")] int? bridgeGapsMaxMs = null, + [Description("Enforce a minimum gap of this many milliseconds between consecutive paragraphs.")] int? applyMinGapMs = null, + [Description("Delete the first N paragraphs.")] int? deleteFirst = null, + [Description("Delete the last N paragraphs.")] int? deleteLast = null, + [Description("Delete every paragraph whose text contains this string.")] string? deleteContains = null, + [Description("Operations to apply, in this order. Valid names: FixCommonErrors, RemoveFormatting, RemoveTextForHI, BalanceLines, SplitLongLines, MergeShortLines, MergeSameTexts, MergeSameTimeCodes, RedoCasing, ApplyDurationLimits, BeautifyTimeCodes, ConvertColorsToDialog, FixRtlViaUnicodeChars, ReverseRtlStartEnd, RemoveLineBreaks, RemoveUnicodeControlChars.")] string[]? operations = null, + [Description("FixCommonErrors rule selection: comma-separated ids from list_fix_common_errors_rules, 'all', or 'all,-RuleId'. Implies the FixCommonErrors operation.")] string? fixCommonErrorsRules = null, + [Description("Force the language used by FixCommonErrors' language-gated rules (two-letter code such as 'en' or 'es'). Default: auto-detect from the text.")] string? fixCommonErrorsLanguage = null, + [Description("RemoveFormatting rule selection: comma-separated ids from list_remove_formatting_rules, or 'all,-RuleId'. Implies the RemoveFormatting operation.")] string? removeFormattingRules = null, + [Description("Container inputs: subtitle track numbers to extract. Default: every text track.")] int[]? trackNumbers = null, + [Description("Image-based inputs: keep only the time codes and skip OCR (text is left empty).")] bool timeCodesOnly = false, + [Description("OCR engine for image-based inputs: tesseract (default), nocr, binaryocr, ollama, paddle or llamacpp.")] string? ocrEngine = null, + [Description("OCR language for image-based inputs (Tesseract ISO 639-2 code such as 'eng').")] string? ocrLanguage = null, + [Description("Output resolution for image-based targets, e.g. '1920x1080'.")] string? resolution = null, + CancellationToken cancellationToken = default) + => RunAsync(async () => + { + if (inputs is null || inputs.All(string.IsNullOrWhiteSpace)) + { + throw new McpException("At least one input path or pattern is required."); + } + + if (string.IsNullOrWhiteSpace(format)) + { + throw new McpException("A target format is required. Use list_formats to see the ids."); + } + + var normalizedRequestedFormat = format.Replace(" ", string.Empty); + if (normalizedRequestedFormat.Equals("customtext", StringComparison.OrdinalIgnoreCase) || + normalizedRequestedFormat.Equals("customtextformat", StringComparison.OrdinalIgnoreCase)) + { + throw new McpException( + "Custom text output requires a template and is not exposed by convert_subtitle. " + + "Use the seconv CLI with --format customtext --custom-format:."); + } + + var ops = NormalizeOperations(operations); + + if (changeSpeedPercent.HasValue && changeSpeedPercent.Value <= 0) + { + throw new McpException($"changeSpeedPercent must be greater than 0 (got {changeSpeedPercent.Value})."); + } + + IReadOnlyList fceRules = []; + if (!string.IsNullOrWhiteSpace(fixCommonErrorsRules)) + { + fceRules = ParseRuleIds( + () => FixCommonErrorsRunner.ResolveRuleIds(fixCommonErrorsRules), + "seconv list-fce-rules", + "list_fix_common_errors_rules"); + EnsureOperation(ops, "FixCommonErrors"); + } + + IReadOnlyList? rfRules = null; + if (!string.IsNullOrWhiteSpace(removeFormattingRules)) + { + rfRules = ParseRuleIds( + () => RemoveFormattingRunner.ResolveRuleIds(removeFormattingRules), + "seconv list-rf-rules", + "list_remove_formatting_rules"); + EnsureOperation(ops, "RemoveFormatting"); + } + + if (!string.IsNullOrWhiteSpace(fixCommonErrorsLanguage) && + FixCommonErrorsRunner.NormalizeLanguageOverride(fixCommonErrorsLanguage) is null) + { + throw new McpException( + $"fixCommonErrorsLanguage '{fixCommonErrorsLanguage}' is not recognized. " + + "Use a supported language code or English language name, or omit it for auto-detection."); + } + + var options = new ConversionOptions + { + Patterns = inputs.Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(), + Format = format, + OutputFolder = outputFolder, + OutputFilename = outputFilename, + Encoding = encoding, + Overwrite = overwrite, + Offset = string.IsNullOrWhiteSpace(offset) ? null : ParseClientInput(() => OffsetParser.Parse(offset)), + Fps = fps, + TargetFps = targetFps, + Renumber = renumber, + AdjustDurationMs = adjustDurationMs, + ChangeSpeedPercent = changeSpeedPercent, + BridgeGapsMaxMs = bridgeGapsMaxMs, + ApplyMinGapMs = applyMinGapMs, + DeleteFirst = deleteFirst, + DeleteLast = deleteLast, + DeleteContains = deleteContains, + Operations = ops, + FixCommonErrorsRules = fceRules, + FixCommonErrorsLanguage = fixCommonErrorsLanguage, + RemoveFormattingRules = rfRules, + TrackNumbers = trackNumbers ?? [], + TimeCodesOnly = timeCodesOnly, + OcrEngine = string.IsNullOrWhiteSpace(ocrEngine) ? "tesseract" : ocrEngine, + OcrLanguage = string.IsNullOrWhiteSpace(ocrLanguage) ? "eng" : ocrLanguage, + Resolution = string.IsNullOrWhiteSpace(resolution) ? null : ParseClientInput(() => ResolutionParser.Parse(resolution)), + // The converter narrates progress on stdout when not quiet; stdout is the MCP channel. + Quiet = true, + }; + + var conversion = await new SubtitleConverter().ConvertAsync(options, cancellationToken); + return JsonResult(conversion, isError: !conversion.Success); + }, cancellationToken); + + /// + /// Maps caller-supplied operation names onto the canonical names in + /// (case-insensitive, dashes and + /// underscores ignored, so "fix-common-errors" and "fixcommonerrors" both work). An unknown + /// name is an error rather than a silent no-op, matching the CLI's strict option parsing. + /// + private static List NormalizeOperations(string[]? operations) + { + var result = new List(); + if (operations is null) + { + return result; + } + + foreach (var raw in operations) + { + if (string.IsNullOrWhiteSpace(raw)) + { + continue; + } + + var key = raw.Replace("-", string.Empty).Replace("_", string.Empty); + var canonical = OperationOrderParser.ToggleOperations + .FirstOrDefault(op => op.Equals(key, StringComparison.OrdinalIgnoreCase)); + if (canonical is null) + { + throw new McpException( + $"Unknown operation '{raw}'. Valid operations: {string.Join(", ", OperationOrderParser.ToggleOperations)}."); + } + + result.Add(canonical); + } + + return result; + } + + private static void EnsureOperation(List operations, string name) + { + if (!operations.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + operations.Add(name); + } + } + + private static T ParseClientInput(Func parser) + { + try + { + return parser(); + } + catch (ArgumentException ex) + { + throw new McpException(ex.Message); + } + catch (FormatException ex) + { + throw new McpException(ex.Message); + } + } + + private static T ParseRuleIds(Func parser, string cliCommand, string mcpTool) + { + try + { + return parser(); + } + catch (ArgumentException ex) + { + var message = ex.Message.Replace( + $"Run '{cliCommand}' to see available IDs.", + $"Use {mcpTool} to see available IDs.", + StringComparison.Ordinal); + throw new McpException(message); + } + } + + // libse/seconv still uses process-wide settings in several read/convert paths. Keep the MCP + // surface serialized until those settings are made request-scoped; the converter itself also + // has a defensive gate for non-MCP callers. + private static readonly SemaphoreSlim ToolGate = new(1, 1); + + private static CallToolResult Run(Func body, CancellationToken cancellationToken) + { + ToolGate.Wait(cancellationToken); + try + { + cancellationToken.ThrowIfCancellationRequested(); + var result = Ok(body()); + cancellationToken.ThrowIfCancellationRequested(); + return result; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return ErrorForClient(ex); + } + finally + { + ToolGate.Release(); + } + } + + private static async Task RunAsync(Func> body, CancellationToken cancellationToken) + { + await ToolGate.WaitAsync(cancellationToken); + try + { + cancellationToken.ThrowIfCancellationRequested(); + return await body(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return ErrorForClient(ex); + } + finally + { + ToolGate.Release(); + } + } + + private static CallToolResult Ok(object value) => JsonResult(value, isError: false); + + private static CallToolResult JsonResult(object value, bool isError) => new() + { + IsError = isError, + Content = [new TextContentBlock { Text = JsonSerializer.Serialize(value, Json) }], + }; + + private static string? GetSafeMissingFileName(FileNotFoundException ex) + { + if (!string.IsNullOrWhiteSpace(ex.FileName)) + { + return Path.GetFileName(ex.FileName); + } + + const string subtitlePrefix = "Subtitle file not found: "; + if (ex.Message.StartsWith(subtitlePrefix, StringComparison.Ordinal)) + { + var path = ex.Message[subtitlePrefix.Length..].Trim(); + var fileName = Path.GetFileName(path); + return string.IsNullOrWhiteSpace(fileName) ? null : fileName; + } + + return null; + } + + private static CallToolResult ErrorForClient(Exception ex) + { + string message; + switch (ex) + { + case McpException: + message = ex.Message; + break; + case FileNotFoundException fileNotFound: + var missingFileName = GetSafeMissingFileName(fileNotFound); + message = missingFileName is null ? "File not found." : $"File not found: {missingFileName}"; + break; + case DirectoryNotFoundException: + message = "Directory not found."; + break; + case UnauthorizedAccessException: + message = "Access denied."; + break; + default: + Console.Error.WriteLine(ex); + message = "The operation failed. See the seconv MCP server log for details."; + break; + } + + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = message }], + }; + } +} diff --git a/src/seconv/Program.cs b/src/seconv/Program.cs index f049344ebc8..2bbceede61f 100644 --- a/src/seconv/Program.cs +++ b/src/seconv/Program.cs @@ -121,6 +121,11 @@ static int Main(string[] args) { return RunLintCommand(args.Skip(1).ToArray()); } + if (first.Equals("mcp", StringComparison.OrdinalIgnoreCase)) + { + // MCP server over stdio: from here on stdout carries JSON-RPC frames only. + return Mcp.McpServerHost.RunAsync(args.Skip(1).ToArray()).GetAwaiter().GetResult(); + } } // Capture the raw args so the convert command can recover operation order/repetition diff --git a/src/seconv/README.md b/src/seconv/README.md index 243648492b4..3d80deef1b6 100644 --- a/src/seconv/README.md +++ b/src/seconv/README.md @@ -57,6 +57,23 @@ An unrecognised option is an error, never a silent no-op: `seconv` exits 1 and n real option rather than converting the file without the operation that was asked for. Exit codes are only ever 0 (success) or 1 (any failure). +## MCP server + +`seconv mcp` runs the same engine as a [Model Context Protocol](https://modelcontextprotocol.io) server +over stdio, so an AI client can inspect and convert subtitles without a shell. Register it as a stdio server: + +```json +{ "mcpServers": { "seconv": { "command": "seconv", "args": ["mcp"] } } } +``` + +Tools: `list_formats`, `subtitle_info`, `read_subtitle` (paged paragraphs of any format), `lint_subtitle`, +`convert_subtitle` (format, encoding, offset, fps, operations such as FixCommonErrors, ...), +`list_fix_common_errors_rules`, `list_remove_formatting_rules`. `convert_subtitle` exposes a focused subset of the CLI conversion options; use the CLI directly for advanced controls such as translation, image styling, custom-format/settings overlays and the newest specialist flags. Read-only tools are advertised as +non-destructive; `convert_subtitle` is explicitly destructive-capable because `overwrite=true` can replace files. +Logs go to stderr; add `--verbose` for debug output. Client cancellation is propagated cooperatively into conversion. +The server does not add its own filesystem sandbox: input/output paths are local paths accessible to the `seconv` process and remain subject to the operating system's permissions and the MCP client's approval/policy. +A failed or cancelled multi-file conversion may have completed earlier output files before the later failure/cancellation. Inspect per-file results and existing outputs before retrying; do not assume an MCP error means that no filesystem changes occurred. + ## Full reference ➡ **[Command Line (seconv) — full reference](../../docs/reference/command-line.md)** diff --git a/src/seconv/SeConv.csproj b/src/seconv/SeConv.csproj index 5a4db169c55..05523b2d6ff 100644 --- a/src/seconv/SeConv.csproj +++ b/src/seconv/SeConv.csproj @@ -18,6 +18,10 @@ + + + + 00:00:05,000\nFirst.\n\n" + + "2\n00:00:04,000 --> 00:00:06,000\nSecond.\n"); + + var report = Payload(SubtitleTools.LintSubtitle(path)); + Assert.False(report.GetProperty("isClean").GetBoolean()); + Assert.Contains(report.GetProperty("issues").EnumerateArray(), + i => i.GetProperty("type").GetString() == "overlap"); + } + + [Fact] + public void ListRules_ExposeIds() + { + var fce = Payload(SubtitleTools.ListFixCommonErrorsRules()); + Assert.Contains(fce.GetProperty("rules").EnumerateArray(), r => r.GetProperty("id").GetString() == "FixCommas"); + + var rf = Payload(SubtitleTools.ListRemoveFormattingRules()); + Assert.True(rf.GetProperty("total").GetInt32() > 0); + } + + [Fact] + public async Task ConvertSubtitle_WritesTargetFormatWithOffset() + { + var result = Payload(await SubtitleTools.ConvertSubtitle( + inputs: [Fixtures.Path("test.srt")], + format: "webvtt", + outputFolder: _tempDir, + offset: "00:00:01.000")); + + Assert.True(result.GetProperty("success").GetBoolean()); + var file = Assert.Single(result.GetProperty("files").EnumerateArray()); + var output = file.GetProperty("output").GetString()!; + Assert.EndsWith(".vtt", output); + Assert.True(File.Exists(output)); + + var source = Payload(SubtitleTools.ReadSubtitle(Fixtures.Path("test.srt"), count: 1)); + var converted = Payload(SubtitleTools.ReadSubtitle(output, count: 1)); + var sourceStart = source.GetProperty("paragraphs")[0].GetProperty("startMs").GetInt64(); + var convertedStart = converted.GetProperty("paragraphs")[0].GetProperty("startMs").GetInt64(); + Assert.Equal(sourceStart + 1000, convertedStart); + } + + [Fact] + public async Task ConvertSubtitle_CoreFailure_IsToolErrorWithJsonPayload() + { + var missing = Path.Combine(_tempDir, "missing-input.srt"); + var result = await SubtitleTools.ConvertSubtitle( + inputs: [missing], + format: "srt", + outputFolder: _tempDir); + + Assert.True(result.IsError); + var text = Assert.IsType(Assert.Single(result.Content)).Text; + var payload = JsonDocument.Parse(text).RootElement; + Assert.False(payload.GetProperty("success").GetBoolean()); + Assert.True(payload.GetProperty("errors").GetArrayLength() > 0); + Assert.Empty(Directory.GetFiles(_tempDir)); + } + + [Fact] + public async Task ConvertSubtitle_UnknownOperation_IsToolError() + { + var message = ErrorText(await SubtitleTools.ConvertSubtitle( + inputs: [Fixtures.Path("test.srt")], + format: "srt", + outputFolder: _tempDir, + operations: ["Bogus"])); + + Assert.Contains("Unknown operation 'Bogus'", message); + Assert.Empty(Directory.GetFiles(_tempDir)); + } + + [Theory] + [InlineData("offset", "not-a-time", "offset value")] + [InlineData("resolution", "1920-by-1080", "Resolution")] + public async Task ConvertSubtitle_InvalidParsedInput_IsActionable(string field, string value, string expected) + { + CallToolResult result; + if (field == "offset") + { + result = await SubtitleTools.ConvertSubtitle( + inputs: [Fixtures.Path("test.srt")], + format: "srt", + outputFolder: _tempDir, + offset: value); + } + else + { + result = await SubtitleTools.ConvertSubtitle( + inputs: [Fixtures.Path("test.srt")], + format: "srt", + outputFolder: _tempDir, + resolution: value); + } + + Assert.Contains(expected, ErrorText(result), StringComparison.OrdinalIgnoreCase); + Assert.Empty(Directory.GetFiles(_tempDir)); + } + + [Fact] + public async Task ConvertSubtitle_UnknownRule_IsActionable() + { + var message = ErrorText(await SubtitleTools.ConvertSubtitle( + inputs: [Fixtures.Path("test.srt")], + format: "srt", + outputFolder: _tempDir, + fixCommonErrorsRules: "DefinitelyNotARule")); + + Assert.Contains("Unknown FixCommonErrors rule", message); + Assert.Contains("list_fix_common_errors_rules", message); + Assert.DoesNotContain("seconv list-fce-rules", message); + Assert.Empty(Directory.GetFiles(_tempDir)); + } + + [Fact] + public async Task ConvertSubtitle_UnknownFceLanguage_IsRejectedInsteadOfFallingBack() + { + var message = ErrorText(await SubtitleTools.ConvertSubtitle( + inputs: [Fixtures.Path("test.srt")], + format: "srt", + outputFolder: _tempDir, + fixCommonErrorsRules: "FixCommas", + fixCommonErrorsLanguage: "definitely-not-a-language")); + + Assert.Contains("not recognized", message); + Assert.Contains("auto-detection", message); + Assert.Empty(Directory.GetFiles(_tempDir)); + } + + [Fact] + public async Task ConvertSubtitle_NonPositiveSpeed_IsRejected() + { + var message = ErrorText(await SubtitleTools.ConvertSubtitle( + inputs: [Fixtures.Path("test.srt")], + format: "srt", + outputFolder: _tempDir, + changeSpeedPercent: 0)); + + Assert.Contains("greater than 0", message); + Assert.Empty(Directory.GetFiles(_tempDir)); + } + + [Fact] + public async Task ConvertSubtitle_RuleSelectionImpliesOperation() + { + var path = Path.Combine(_tempDir, "dots.srt"); + File.WriteAllText(path, "1\n00:00:01,000 --> 00:00:03,000\nHello world..\n"); + + var result = Payload(await SubtitleTools.ConvertSubtitle( + inputs: [path], + format: "srt", + outputFilename: Path.Combine(_tempDir, "out.srt"), + removeFormattingRules: "all")); + + Assert.True(result.GetProperty("success").GetBoolean()); + var converted = Payload(SubtitleTools.ReadSubtitle(Path.Combine(_tempDir, "out.srt"))); + var text = converted.GetProperty("paragraphs")[0].GetProperty("text").GetString(); + Assert.DoesNotContain("", text); + } + + [Fact] + public async Task ConvertSubtitle_PreCancelledToken_ThrowsWithoutWriting() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => + SubtitleTools.ConvertSubtitle( + inputs: [Fixtures.Path("test.srt")], + format: "srt", + outputFolder: _tempDir, + cancellationToken: cts.Token)); + + Assert.Empty(Directory.GetFiles(_tempDir)); + } + + [Fact] + public async Task StdioServer_ListsToolsWithSafeAnnotationsAndAnswersCalls() + { + var dll = Path.Combine(AppContext.BaseDirectory, "seconv.dll"); + Assert.True(File.Exists(dll), $"seconv.dll not found next to the tests: {dll}"); + + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "seconv", + Command = "dotnet", + Arguments = [dll, "mcp"], + }); + + var ct = TestContext.Current.CancellationToken; + await using var client = await McpClient.CreateAsync(transport, cancellationToken: ct); + Assert.Equal("seconv", client.ServerInfo.Name); + + var tools = await client.ListToolsAsync(cancellationToken: ct); + var names = tools.Select(t => t.Name).ToHashSet(); + foreach (var expected in new[] + { + "list_formats", "subtitle_info", "read_subtitle", "lint_subtitle", + "convert_subtitle", "list_fix_common_errors_rules", "list_remove_formatting_rules", + }) + { + Assert.Contains(expected, names); + } + + foreach (var tool in tools) + { + var annotations = Assert.IsType(tool.ProtocolTool.Annotations); + if (tool.Name == "convert_subtitle") + { + Assert.Equal(false, annotations.ReadOnlyHint); + Assert.Equal(true, annotations.DestructiveHint); + Assert.Equal(false, annotations.IdempotentHint); + Assert.Equal(false, annotations.OpenWorldHint); + } + else + { + Assert.Equal(true, annotations.ReadOnlyHint); + Assert.Equal(false, annotations.DestructiveHint); + Assert.Equal(true, annotations.IdempotentHint); + Assert.Equal(false, annotations.OpenWorldHint); + } + + var properties = tool.ProtocolTool.InputSchema.GetProperty("properties"); + Assert.False(properties.TryGetProperty("cancellationToken", out _)); + } + + var info = await client.CallToolAsync("subtitle_info", + new Dictionary { ["path"] = Fixtures.Path("test.vtt") }, + cancellationToken: ct); + var payload = Payload(info); + Assert.Equal("WebVTT", payload.GetProperty("format").GetString()); + + var failure = await client.CallToolAsync("subtitle_info", + new Dictionary { ["path"] = Path.Combine(_tempDir, "missing.srt") }, + cancellationToken: ct); + var failureText = ErrorText(failure); + Assert.Contains("missing.srt", failureText); + Assert.DoesNotContain(_tempDir, failureText); + } +}