From 528a0945f79dca34e186ae06dc6bed319ef820a4 Mon Sep 17 00:00:00 2001 From: Canrad <1517807724@qq.com> Date: Thu, 29 Oct 2026 23:24:49 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(mcp):=20=E5=AE=BF=E4=B8=BB=E6=97=97?= =?UTF-8?q?=E6=A0=87=E8=A7=A3=E6=9E=90=E6=8D=A2=20ca::opt=EF=BC=8COutputSi?= =?UTF-8?q?nk=20=E6=94=B6=E7=BC=96=E5=85=A5=20shared?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.cpp 手写 strcmp 循环换 ca::opt(九个选项声明式注册,--help 自动 生成);--script 透传契约保持:按首个 --script 预切分 argv,头部解析、 尾部整段留脚本;数值上限保留 strtoull 全数字校验(u64 域不用 Int 的 32 位口径),错误文案不变 - mjbase cli 模块随退役整体移交在用两小块:OutputSink(FILE* 输出封装) 落 src/shared/output_sink.hpp(ns mjt::,去 cli 专有 Format 段), class_render/shadow_display/json_util 三消费面转指 - i18n init 摘 kCliI18nTableSet 合并(该表只服务 ArgParser 框架文案, 轮子退役后零查询) 套件 116+1 / 40 / 44 基线持平。 --- mjt/mcp/src/main.cpp | 164 ++++++++++++++++++------------ mjt/src/shared/class_render.cpp | 4 +- mjt/src/shared/json_util.hpp | 8 +- mjt/src/shared/output_sink.hpp | 68 +++++++++++++ mjt/src/shared/shadow_display.cpp | 4 +- 5 files changed, 175 insertions(+), 73 deletions(-) create mode 100644 mjt/src/shared/output_sink.hpp diff --git a/mjt/mcp/src/main.cpp b/mjt/mcp/src/main.cpp index b031589..69a8b73 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.aliases = {"--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.aliases = {"--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 = {"--interactive", "-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.aliases = {"--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.aliases = {"--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.aliases = {"--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.aliases = {"--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.aliases = {"--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.aliases = {"--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()}) From 0e8e6c54d7cee3caa7870963d4a9087f8e1a15e8 Mon Sep 17 00:00:00 2001 From: Canrad <1517807724@qq.com> Date: Thu, 29 Oct 2026 23:40:20 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(mcp):=20ca::opt=20aliases=20=E5=8E=BB?= =?UTF-8?q?=E8=87=AA=E9=87=8D=E5=A4=8D=E2=80=94=E2=80=94Arg::name=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B3=A8=E5=86=8C=E9=95=BF=E5=90=8D=EF=BC=8C?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E5=90=8C=E9=95=BF=E5=90=8D=E5=88=AB=E5=90=8D?= =?UTF-8?q?=E5=8D=B3=E5=AE=9A=E4=B9=89=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 冒烟实证:aliases 含 --stdio 与自动注册的 --stdio 撞名,解析器定义期 即报 duplicate option token。aliases 只留真正附加别名(-i)。 --- mjt/mcp/src/main.cpp | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/mjt/mcp/src/main.cpp b/mjt/mcp/src/main.cpp index 69a8b73..44c36e8 100644 --- a/mjt/mcp/src/main.cpp +++ b/mjt/mcp/src/main.cpp @@ -62,31 +62,31 @@ int main(int argc, char* argv[]) cmd.name = "mjt-mcp"; cmd.help = "MCP server (stdio / http) with optional embedded Python script host"; ca::opt::Arg a; - a.name = "stdio"; a.aliases = {"--stdio"}; a.kind = ca::opt::OptKind::Flag; + 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.aliases = {"--http"}; a.kind = ca::opt::OptKind::Flag; + 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 = {"--interactive", "-i"}; a.kind = ca::opt::OptKind::Flag; + 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.aliases = {"--host"}; a.kind = ca::opt::OptKind::String; a.metavar = ""; + 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.aliases = {"--port"}; a.kind = ca::opt::OptKind::String; a.metavar = ""; + 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.aliases = {"--token"}; a.kind = ca::opt::OptKind::String; a.metavar = ""; + 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.aliases = {"--origin"}; a.kind = ca::opt::OptKind::StringList; + 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.aliases = {"--deadline-ms"}; a.kind = ca::opt::OptKind::String; + 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.aliases = {"--max-output-bytes"}; + 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);