diff --git a/mjt/mcp/src/main.cpp b/mjt/mcp/src/main.cpp index b031589..44c36e8 100644 --- a/mjt/mcp/src/main.cpp +++ b/mjt/mcp/src/main.cpp @@ -7,9 +7,9 @@ #include #include -#include "mjbase/cli/i18n_tables.hpp" #include "mjbase/log/log_init.hpp" #include "libca/log/log_macros.hpp" +#include "libca/opt/opt.hpp" #include "i18n_mjt_shared_translations.generated.hpp" #include "libca/i18n/i18n.hpp" @@ -32,80 +32,114 @@ int main(int argc, char* argv[]) { //MCP 是协议文本面,固定英文:共享层错误经 errStatus 在构造时渲染, //故进程启动即锁定 en_US(环境变量对 MCP 不生效)。 - ca::i18n::init({mjbase::cli::kCliI18nTableSet, kI18nTableSet_mjt_shared}, {"MJT_LANG"}); + ca::i18n::init({kI18nTableSet_mjt_shared}, {"MJT_LANG"}); //诊断统一走 ca::log 门面。 mjbase::log::initDefaultLogger(); ca::i18n::set_lang("en_US"); mjt::mcp::HttpOptions options; - bool http = false; - bool interactive = false; - std::string scriptPath; - std::vector scriptArgs; - long long deadlineMs = 0; - unsigned long long maxOutputBytes = 0; + + //--script 之后的参数全部原样透传给脚本(不再按宿主旗标解析), + //所以宿主旗标必须写在 --script 之前:先按首个 --script 预切分 argv, + //头部交 ca::opt 解析,尾部整段留给脚本。 + std::vector headArgv{argv[0]}; + std::string scriptPath; + std::vector scriptArgs; for (int index = 1; index < argc; ++index) { - const std::string argument = argv[index]; - if (argument == "--stdio") continue; - if (argument == "--http") { - http = true; - continue; - } - if (argument == "-i") { - interactive = true; - continue; - } - //脚本资源上限(只认 --script 之前;缺省读同名环境变量)。 - if (argument == "--deadline-ms" || argument == "--max-output-bytes") { - if (index + 1 >= argc) return fail(argument + " requires a value"); - const std::string value = argv[++index]; - unsigned long long parsed = 0; - if (value.empty() || - !std::all_of(value.begin(), value.end(), - [](unsigned char ch) { return std::isdigit(ch) != 0; })) { - return fail(argument + " must be a non-negative integer"); - } - parsed = std::strtoull(value.c_str(), nullptr, 10); - if (argument == "--deadline-ms") deadlineMs = static_cast(parsed); - else maxOutputBytes = parsed; - continue; - } - //--script 之后的参数全部原样透传给脚本(不再按宿主旗标解析), - //所以 -i 等宿主旗标必须写在 --script 之前。 - if (argument == "--script") { + if (std::string_view{argv[index]} == "--script") { if (index + 1 >= argc) return fail("--script requires a script path"); scriptPath = argv[++index]; for (int rest = index + 1; rest < argc; ++rest) scriptArgs.emplace_back(argv[rest]); break; } - if ((argument == "--host" || argument == "--port" || argument == "--token" || - argument == "--origin") && - index + 1 < argc) { - const std::string value = argv[++index]; - if (argument == "--host") options.host = value; - else if (argument == "--port") { - //严格整端口校验:stoi 会静默接受 "8080abc" 这类前缀数字, - //这里要求全数字且落在合法端口范围。 - if (value.empty() || - !std::all_of(value.begin(), value.end(), - [](unsigned char ch) { return std::isdigit(ch) != 0; })) { - return fail("--port must be an integer"); - } - const long parsed = std::strtol(value.c_str(), nullptr, 10); - if (parsed < 1 || parsed > 65535) return fail("--port out of range (1-65535)"); - options.port = static_cast(parsed); - } - else if (argument == "--token") options.token = value; - else options.allowedOrigins.push_back(value); - continue; - } - if (argument == "--help") { - std::cout << "Usage: mjt-mcp [--stdio] [--http --host --port " - "--token --origin ]\n" - " mjt-mcp --script [script-args...] [-i]\n"; + headArgv.push_back(argv[index]); + } + + //宿主旗标面(ca::opt):--stdio/--http/-i 开关 + 四个带值选项。 + //数值上限语义保持 strtoull 全数字校验(u64 域,不用 Int 的 32 位口径), + //错误文案与 help 原样保留。 + ca::opt::Command cmd; + cmd.name = "mjt-mcp"; + cmd.help = "MCP server (stdio / http) with optional embedded Python script host"; + ca::opt::Arg a; + a.name = "stdio"; a.kind = ca::opt::OptKind::Flag; + a.help = "run over stdio (default)"; cmd.args.push_back(a); + a = ca::opt::Arg{}; + a.name = "http"; a.kind = ca::opt::OptKind::Flag; + a.help = "run over http"; cmd.args.push_back(a); + a = ca::opt::Arg{}; + a.name = "interactive"; a.aliases = {"-i"}; a.kind = ca::opt::OptKind::Flag; + a.help = "interactive script mode (requires --script)"; cmd.args.push_back(a); + a = ca::opt::Arg{}; + a.name = "host"; a.kind = ca::opt::OptKind::String; a.metavar = ""; + a.help = "http bind host"; cmd.args.push_back(a); + a = ca::opt::Arg{}; + a.name = "port"; a.kind = ca::opt::OptKind::String; a.metavar = ""; + a.help = "http bind port (1-65535)"; cmd.args.push_back(a); + a = ca::opt::Arg{}; + a.name = "token"; a.kind = ca::opt::OptKind::String; a.metavar = ""; + a.help = "http auth token"; cmd.args.push_back(a); + a = ca::opt::Arg{}; + a.name = "origin"; a.kind = ca::opt::OptKind::StringList; + a.metavar = ""; a.help = "allowed origin (repeatable)"; cmd.args.push_back(a); + a = ca::opt::Arg{}; + a.name = "deadline-ms"; a.kind = ca::opt::OptKind::String; + a.metavar = ""; a.help = "script wall-clock deadline"; cmd.args.push_back(a); + a = ca::opt::Arg{}; + a.name = "max-output-bytes"; + a.kind = ca::opt::OptKind::String; a.metavar = ""; + a.help = "script output cap in bytes"; cmd.args.push_back(a); + + ca::opt::Parser parser{std::move(cmd)}; + const auto parsed = parser.parse(static_cast(headArgv.size()), headArgv.data()); + if (parsed.is_err()) { + const auto err = std::move(parsed).unwrap_err(); + if (err.category == ca::opt::ParseErrorCategory::HelpRequested) { + std::cout << err.message; return 0; } - return fail("unknown or incomplete argument: " + argument); + return fail(err.message); + } + const auto& result = std::move(parsed).unwrap(); + + const bool http = result.has("http"); + const bool interactive = result.has("interactive"); + long long deadlineMs = 0; + unsigned long long maxOutputBytes = 0; + if (result.has("host")) options.host = result.get("host"); + if (result.has("token")) options.token = result.get("token"); + if (result.has("origin")) options.allowedOrigins = result.get_list("origin"); + if (result.has("port")) { + const std::string& value = result.get("port"); + //严格整端口校验:stoi 会静默接受 "8080abc" 这类前缀数字, + //这里要求全数字且落在合法端口范围。 + if (value.empty() || + !std::all_of(value.begin(), value.end(), + [](unsigned char ch) { return std::isdigit(ch) != 0; })) { + return fail("--port must be an integer"); + } + const long parsedPort = std::strtol(value.c_str(), nullptr, 10); + if (parsedPort < 1 || parsedPort > 65535) return fail("--port out of range (1-65535)"); + options.port = static_cast(parsedPort); + } + //脚本资源上限(缺省读同名环境变量,旗标显式给值时覆盖)。 + if (result.has("deadline-ms")) { + const std::string& value = result.get("deadline-ms"); + if (value.empty() || + !std::all_of(value.begin(), value.end(), + [](unsigned char ch) { return std::isdigit(ch) != 0; })) { + return fail("--deadline-ms must be a non-negative integer"); + } + deadlineMs = static_cast(std::strtoull(value.c_str(), nullptr, 10)); + } + if (result.has("max-output-bytes")) { + const std::string& value = result.get("max-output-bytes"); + if (value.empty() || + !std::all_of(value.begin(), value.end(), + [](unsigned char ch) { return std::isdigit(ch) != 0; })) { + return fail("--max-output-bytes must be a non-negative integer"); + } + maxOutputBytes = std::strtoull(value.c_str(), nullptr, 10); } if (interactive && scriptPath.empty()) return fail("-i requires --script "); @@ -137,8 +171,8 @@ int main(int argc, char* argv[]) mjt::mcp::McpServer server; if (!http) return server.runStdio(std::cin, std::cout); - const auto result = server.runHttp(options); - if (result == 2) + const auto httpResult = server.runHttp(options); + if (httpResult == 2) return fail("HTTP host/port is invalid, the server is already running, or non-loopback HTTP lacks --token"); - return result; + return httpResult; } diff --git a/mjt/src/shared/class_render.cpp b/mjt/src/shared/class_render.cpp index be6bb8d..4e58414 100644 --- a/mjt/src/shared/class_render.cpp +++ b/mjt/src/shared/class_render.cpp @@ -8,7 +8,7 @@ #include #include -#include "mjbase/cli/output_sink.hpp" +#include "output_sink.hpp" #include "mjbase/mapping/identifier_util.hpp" #include "mjbase/renamer/shadow_alias.hpp" #include "shadow_display.hpp" @@ -32,7 +32,7 @@ namespace jcm = jclassfile::model; using mjbase::mapping::IdentifierUtil; -using mjbase::cli::out; +using mjt::out; namespace mjt::render { diff --git a/mjt/src/shared/json_util.hpp b/mjt/src/shared/json_util.hpp index a5b83de..d91e005 100644 --- a/mjt/src/shared/json_util.hpp +++ b/mjt/src/shared/json_util.hpp @@ -3,7 +3,7 @@ #include #include -#include "mjbase/cli/output_sink.hpp" +#include "output_sink.hpp" namespace mjt::json { @@ -103,7 +103,7 @@ class JsonArrayWriter ///@param sink 输出目标(通常是 cli::out)。 ///不标 noexcept:构造/析构中的 printf 走输出通道,抛异常时 noexcept 只会 ///把可恢复错误升级成 terminate。 - explicit JsonArrayWriter(mjbase::cli::OutputSink& sink) + explicit JsonArrayWriter(mjt::OutputSink& sink) : sink_(sink) { sink_.printf("[\n"); @@ -119,7 +119,7 @@ class JsonArrayWriter ///@brief 标记开始一个新元素,在非首元素前输出 `,\n` 分隔符。 ///@return 输出 sink 的引用,便于调用方在分隔符后直接 printf 元素内容。 - mjbase::cli::OutputSink& next() + mjt::OutputSink& next() { if (!first_) sink_.printf(",\n"); first_ = false; @@ -127,7 +127,7 @@ class JsonArrayWriter } private: - mjbase::cli::OutputSink& sink_; + mjt::OutputSink& sink_; bool first_ = true; }; diff --git a/mjt/src/shared/output_sink.hpp b/mjt/src/shared/output_sink.hpp new file mode 100644 index 0000000..c9a157b --- /dev/null +++ b/mjt/src/shared/output_sink.hpp @@ -0,0 +1,68 @@ +#pragma once + +// ── 输出抽象(FILE* 封装)────────────────────────────────────────── +// +// MCP 渲染面的输出 sink(自 mjbase cli/output_sink 移交,随 cli 模块退役): +// 默认指向 stdout/stderr,可指向任意 FILE*(测试捕获、重定向、换输出后端)。 +// +// 用法: +// mjt::out().printf(" Version: %d.%d\n", major, minor); +// mjt::err().printf("Error: %s\n", msg); +// mjt::out().puts("done"); +// + +#include +#include +#include + +namespace mjt { + +/// FILE* 封装的输出 sink。printf 签名与 fprintf 一致(首参为 sink 自身), +/// 故 fprintf(stdout, fmt, ...) → out().printf(fmt, ...) 是 1:1 机械替换。 +class OutputSink +{ +public: + explicit OutputSink(FILE* f) + : file_(f) + {} + + /// 变参格式化输出(与 fprintf 同语义,写到 sink 持有的 FILE*)。 + void printf(const char* fmt, ...) const + { + va_list ap; + va_start(ap, fmt); + std::vfprintf(file_, fmt, ap); + va_end(ap); + } + + /// 输出一行(自动加换行)。 + void puts(const std::string& s) const + { + std::fputs(s.c_str(), file_); + std::fputc('\n', file_); + } + + /// 输出原始字符串(不加换行)。 + void write(const std::string& s) const { std::fputs(s.c_str(), file_); } + + FILE* file() const { return file_; } + +private: + FILE* file_; +}; + +/// 默认 stdout sink(进程级单例,避免每个 cmd 都构造)。 +inline OutputSink& out() +{ + static OutputSink sink(stdout); + return sink; +} + +/// 默认 stderr sink。 +inline OutputSink& err() +{ + static OutputSink sink(stderr); + return sink; +} + +} // namespace mjt diff --git a/mjt/src/shared/shadow_display.cpp b/mjt/src/shared/shadow_display.cpp index 39fd36c..0ec8258 100644 --- a/mjt/src/shared/shadow_display.cpp +++ b/mjt/src/shared/shadow_display.cpp @@ -8,7 +8,7 @@ #include #include "libca/i18n/i18n.hpp" -#include "mjbase/cli/output_sink.hpp" +#include "output_sink.hpp" #include "mjbase/mapping/identifier_util.hpp" #include "mjbase/mapping/shadow_alias_format.hpp" @@ -111,7 +111,7 @@ ShadowDisplay loadShadowDisplay(const std::string& jarPath, const std::string& e auto mappings = mjbase::mapping::readShadowAliasFile(refOf(path)); if (mappings.is_err()) { auto error = std::move(mappings).unwrap_err(); - mjbase::cli::err().printf( + mjt::err().printf( "%s\n", ca::i18n::trf("mjt.cmd.shadow.err.sidecar_invalid", {path, error.message.to_std_string()})