Skip to content
Merged
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
164 changes: 99 additions & 65 deletions mjt/mcp/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
#include <string>
#include <vector>

#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"
Expand All @@ -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<std::string> scriptArgs;
long long deadlineMs = 0;
unsigned long long maxOutputBytes = 0;

//--script 之后的参数全部原样透传给脚本(不再按宿主旗标解析),
//所以宿主旗标必须写在 --script 之前:先按首个 --script 预切分 argv,
//头部交 ca::opt 解析,尾部整段留给脚本。
std::vector<const char*> headArgv{argv[0]};
std::string scriptPath;
std::vector<std::string> 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<long long>(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<int>(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 <ip> --port <port> "
"--token <token> --origin <origin>]\n"
" mjt-mcp --script <file.py> [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 = "<ip>";
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 = "<port>";
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 = "<token>";
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 = "<origin>"; 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 = "<ms>"; 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 = "<n>";
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<int>(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<int>(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<long long>(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 <file>");
Expand Down Expand Up @@ -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;
}
4 changes: 2 additions & 2 deletions mjt/src/shared/class_render.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
#include <cstdio>
#include <string>

#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"
Expand All @@ -32,7 +32,7 @@

namespace jcm = jclassfile::model;
using mjbase::mapping::IdentifierUtil;
using mjbase::cli::out;
using mjt::out;

namespace mjt::render {

Expand Down
8 changes: 4 additions & 4 deletions mjt/src/shared/json_util.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
#include <cstddef>
#include <string>

#include "mjbase/cli/output_sink.hpp"
#include "output_sink.hpp"

namespace mjt::json {

Expand Down Expand Up @@ -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");
Expand All @@ -119,15 +119,15 @@ class JsonArrayWriter

///@brief 标记开始一个新元素,在非首元素前输出 `,\n` 分隔符。
///@return 输出 sink 的引用,便于调用方在分隔符后直接 printf 元素内容。
mjbase::cli::OutputSink& next()
mjt::OutputSink& next()
{
if (!first_) sink_.printf(",\n");
first_ = false;
return sink_;
}

private:
mjbase::cli::OutputSink& sink_;
mjt::OutputSink& sink_;
bool first_ = true;
};

Expand Down
68 changes: 68 additions & 0 deletions mjt/src/shared/output_sink.hpp
Original file line number Diff line number Diff line change
@@ -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 <cstdio>
#include <cstdarg>
#include <string>

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
4 changes: 2 additions & 2 deletions mjt/src/shared/shadow_display.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
#include <filesystem>

#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"

Expand Down Expand Up @@ -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()})
Expand Down
Loading