载入中...
搜索中...
未找到
McpScriptTools.cpp
浏览该文件的文档.
2
6
8
9#include <Poco/Dynamic/Var.h>
10#include <Poco/JSON/Array.h>
11#include <Poco/JSON/Object.h>
12
13#include <simplesquirrel/simplesquirrel.hpp>
14
15#include <algorithm>
16#include <cstddef>
17#include <cstdint>
18#include <mutex>
19#include <string>
20#include <utility>
21#include <vector>
22
23namespace eve::dev {
24namespace {
25
26// Bounds: a project script is not a trusted schema source, so every field the
27// agent receives is capped and every rejection is reported to the console.
28constexpr std::size_t kMaxTools = 128;
29constexpr std::size_t kMaxNameLength = 64;
30constexpr std::size_t kMinNameLength = 3;
31constexpr std::size_t kMaxDescription = 512;
32constexpr std::size_t kMaxSchemaBytes = 16 * 1024;
33constexpr std::size_t kMaxResultBytes = 256 * 1024;
34constexpr int kMaxArgumentDepth = 16;
35
37const char* const kReservedPrefixes[] = {"eve_", "inspect_", "set_", "capture_", "get_"};
38
39struct ScriptTool {
40 std::string name;
41 std::string description;
42 std::string inputSchemaJson;
43 HSQUIRRELVM vm = nullptr;
44 HSQOBJECT handler{};
45};
46
47std::mutex g_mutex;
48std::vector<ScriptTool> g_tools;
49
50bool hasReservedPrefix(const std::string& name) {
51 for (const char* raw : kReservedPrefixes) {
52 const std::string prefix(raw);
53 if (name.size() > prefix.size() && name.compare(0, prefix.size(), prefix) == 0) return true;
54 }
55 return false;
56}
57
62std::string tableString(HSQUIRRELVM vm, HSQOBJECT table, const char* key, const std::string& defaultValue = {}) {
63 const SQInteger top = sq_gettop(vm);
64 sq_pushobject(vm, table);
65 sq_pushstring(vm, key, -1);
66 std::string value = defaultValue;
67 if (SQ_SUCCEEDED(sq_get(vm, -2)) && sq_gettype(vm, -1) == OT_STRING) {
68 const SQChar* text = nullptr;
69 if (SQ_SUCCEEDED(sq_getstring(vm, -1, &text)) && text) value = text;
70 }
71 sq_settop(vm, top);
72 return value;
73}
74
76std::string tableFieldJson(HSQUIRRELVM vm, HSQOBJECT table, const char* field) {
77 const SQInteger top = sq_gettop(vm);
78 sq_pushobject(vm, table);
79 sq_pushstring(vm, field, -1);
80 std::string json;
81 if (SQ_SUCCEEDED(sq_get(vm, -2)) && sq_gettype(vm, -1) == OT_TABLE) json = sqValueToJson(vm, -1);
82 sq_settop(vm, top);
83 return json;
84}
85
87void pushJsonValue(HSQUIRRELVM vm, const Poco::Dynamic::Var& value, int depth) {
88 if (depth > kMaxArgumentDepth || value.isEmpty()) {
89 sq_pushnull(vm);
90 return;
91 }
92 if (value.isBoolean()) {
93 sq_pushbool(vm, value.convert<bool>() ? SQTrue : SQFalse);
94 return;
95 }
96 if (value.isInteger()) {
97 sq_pushinteger(vm, static_cast<SQInteger>(value.convert<Poco::Int64>()));
98 return;
99 }
100 if (value.isNumeric()) {
101 sq_pushfloat(vm, static_cast<SQFloat>(value.convert<double>()));
102 return;
103 }
104 if (value.isString()) {
105 const std::string text = value.convert<std::string>();
106 sq_pushstring(vm, text.c_str(), static_cast<SQInteger>(text.size()));
107 return;
108 }
109 if (value.type() == typeid(Poco::JSON::Array::Ptr)) {
110 auto array = value.extract<Poco::JSON::Array::Ptr>();
111 sq_newarray(vm, 0);
112 const SQInteger target = sq_gettop(vm);
113 for (unsigned index = 0; index < array->size(); ++index) {
114 pushJsonValue(vm, array->get(index), depth + 1);
115 sq_arrayappend(vm, target);
116 }
117 return;
118 }
119 if (value.type() == typeid(Poco::JSON::Object::Ptr)) {
120 auto object = value.extract<Poco::JSON::Object::Ptr>();
121 sq_newtable(vm);
122 const SQInteger target = sq_gettop(vm);
123 for (const auto& entry : *object) {
124 sq_pushstring(vm, entry.first.c_str(), static_cast<SQInteger>(entry.first.size()));
125 pushJsonValue(vm, entry.second, depth + 1);
126 sq_newslot(vm, target, SQFalse);
127 }
128 return;
129 }
130 sq_pushnull(vm);
131}
132
133std::string scriptErrorText(HSQUIRRELVM vm) {
135 if (!context.empty()) return eve::script::formatScriptError(context);
136 return "tool handler failed";
137}
138
139void reportRejection(const std::string& name, const std::string& reason) {
140 ConsolePanel::instance().addLog("warn", "eve.mcp.tool rejected '" + name + "': " + reason);
141}
142
149void releaseEntry(ScriptTool& tool, HSQUIRRELVM owner) {
150 if (tool.vm != owner || owner == nullptr || sq_isnull(tool.handler)) return;
151 sq_release(owner, &tool.handler);
152}
153
154std::string resultEnvelope(const std::string& text, bool isError) { return textContentResult(text, isError); }
155
157bool registerTool(HSQUIRRELVM vm, const std::string& name, HSQOBJECT spec) {
159 reportRejection(name,
160 "name must match [a-z][a-z0-9_]{2,63} and must not use a built-in prefix "
161 "(eve_ / inspect_ / set_ / capture_ / get_)");
162 return false;
163 }
164 if (spec._type != OT_TABLE) {
165 reportRejection(name, "spec must be a table with a handler closure");
166 return false;
167 }
168
169 const std::string description = tableString(vm, spec, "description");
170 if (description.size() > kMaxDescription) {
171 reportRejection(name, "description is longer than " + std::to_string(kMaxDescription) + " characters");
172 return false;
173 }
174
175 const std::string schema = tableFieldJson(vm, spec, "inputSchema");
176 if (schema.size() > kMaxSchemaBytes) {
177 reportRejection(name, "inputSchema serializes to more than " + std::to_string(kMaxSchemaBytes) + " bytes");
178 return false;
179 }
180 if (!schema.empty() && schema.front() != '{') {
181 reportRejection(name, "inputSchema must be a table describing a JSON object");
182 return false;
183 }
184
185 const SQInteger top = sq_gettop(vm);
186 sq_pushobject(vm, spec);
187 sq_pushstring(vm, "handler", -1);
188 const bool hasHandler =
189 SQ_SUCCEEDED(sq_get(vm, -2)) && (sq_gettype(vm, -1) == OT_CLOSURE || sq_gettype(vm, -1) == OT_NATIVECLOSURE);
190 if (!hasHandler) {
191 sq_settop(vm, top);
192 reportRejection(name, "spec.handler must be a function");
193 return false;
194 }
195 HSQOBJECT handler = {};
196 sq_getstackobj(vm, -1, &handler);
197 sq_addref(vm, &handler);
198 sq_settop(vm, top);
199
200 ScriptTool tool;
201 tool.name = name;
202 tool.description = description.empty() ? ("Project tool '" + name + "'") : description;
203 tool.inputSchemaJson = schema;
204 tool.vm = vm;
205 tool.handler = handler;
206
207 bool full = false;
208 {
209 std::lock_guard<std::mutex> lock(g_mutex);
210 const auto existing =
211 std::find_if(g_tools.begin(), g_tools.end(), [&](const ScriptTool& entry) { return entry.name == name; });
212 if (existing != g_tools.end()) {
213 // Re-registration replaces: a hot-reloaded mcp.nut must not
214 // accumulate duplicates or leak the previous closure.
215 releaseEntry(*existing, vm);
216 *existing = std::move(tool);
217 } else if (g_tools.size() >= kMaxTools) {
218 full = true;
219 } else {
220 g_tools.push_back(std::move(tool));
221 }
222 }
223 if (full) {
224 // push_back did not happen, so `tool` still owns the reference.
225 releaseEntry(tool, vm);
226 reportRejection(name, "registry is full (" + std::to_string(kMaxTools) + " tools)");
227 return false;
228 }
229 ConsolePanel::instance().addLog("info", "eve.mcp.tool registered: " + name);
230 return true;
231}
232
233} // namespace
234
235bool isValidScriptToolName(const std::string& name) {
236 if (name.size() < kMinNameLength || name.size() > kMaxNameLength) return false;
237 if (name.front() < 'a' || name.front() > 'z') return false;
238 for (char c : name) {
239 const bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_';
240 if (!ok) return false;
241 }
242 return !hasReservedPrefix(name);
243}
244
245bool isScriptTool(const std::string& name) {
246 std::lock_guard<std::mutex> lock(g_mutex);
247 return std::any_of(g_tools.begin(), g_tools.end(), [&](const ScriptTool& tool) { return tool.name == name; });
248}
249
250std::vector<std::string> scriptToolNames() {
251 std::lock_guard<std::mutex> lock(g_mutex);
252 std::vector<std::string> names;
253 names.reserve(g_tools.size());
254 for (const auto& tool : g_tools) names.push_back(tool.name);
255 return names;
256}
257
259 std::lock_guard<std::mutex> lock(g_mutex);
260 return static_cast<int>(g_tools.size());
261}
262
263std::string scriptToolSchemas() {
264 std::lock_guard<std::mutex> lock(g_mutex);
265 std::string out;
266 for (const auto& tool : g_tools) {
267 if (!out.empty()) out += ',';
268 out += "{\"name\":\"" + mcpJsonEscape(tool.name) + "\",\"description\":\"" + mcpJsonEscape(tool.description) +
269 "\",\"inputSchema\":";
270 out += tool.inputSchemaJson.empty() ? std::string("{\"type\":\"object\"}") : tool.inputSchemaJson;
271 out += "}";
272 }
273 return out;
274}
275
276std::string callScriptTool(const std::string& name, Poco::JSON::Object::Ptr args) {
277 HSQUIRRELVM vm = nullptr;
278 HSQOBJECT handler = {};
279 {
280 std::lock_guard<std::mutex> lock(g_mutex);
281 const auto found =
282 std::find_if(g_tools.begin(), g_tools.end(), [&](const ScriptTool& tool) { return tool.name == name; });
283 if (found == g_tools.end()) return resultEnvelope("error: unknown tool " + name, true);
284 vm = found->vm;
285 handler = found->handler;
286 // Hold our own reference across the call: the handler may unregister or
287 // clear the registry, and the closure must stay alive while it runs.
288 if (vm && !sq_isnull(handler)) sq_addref(vm, &handler);
289 }
290 if (!vm || sq_isnull(handler)) {
291 if (vm && !sq_isnull(handler)) sq_release(vm, &handler);
292 return resultEnvelope("error: tool '" + name + "' has no handler in this VM", true);
293 }
294
295 const SQInteger top = sq_gettop(vm);
296 sq_pushobject(vm, handler);
297 sq_pushroottable(vm);
298 if (args) {
299 pushJsonValue(vm, Poco::Dynamic::Var(args), 0);
300 } else {
301 sq_newtable(vm);
302 }
303
304 std::string text;
305 bool isError = false;
306 if (SQ_FAILED(sq_call(vm, 2, SQTrue, SQTrue))) {
307 text = "error: " + scriptErrorText(vm);
308 isError = true;
309 } else {
310 text = sqValueToJson(vm, -1);
311 if (text.size() > kMaxResultBytes) {
312 text = "error: tool '" + name + "' returned " + std::to_string(text.size()) + " bytes of JSON, above the " +
313 std::to_string(kMaxResultBytes) + " byte limit";
314 isError = true;
315 }
316 }
317 sq_settop(vm, top);
318 sq_release(vm, &handler);
319 return resultEnvelope(text, isError);
320}
321
323 std::vector<ScriptTool> dropped;
324 {
325 std::lock_guard<std::mutex> lock(g_mutex);
326 dropped.swap(g_tools);
327 }
328 for (auto& tool : dropped) releaseEntry(tool, owner);
329}
330
331void exposeMcpScriptApi(ssq::VM& vm) {
332 HSQUIRRELVM raw = vm.getHandle();
333 if (!raw) return;
334
335 try {
336 ssq::Table eveTable = vm.find("eve").toTable();
337 ssq::Table mcp = eveTable.addTable("mcp");
338
339 mcp.addFunc("tool", [raw](const std::string& name, ssq::Object spec) -> bool {
340 return registerTool(raw, name, spec.getRaw());
341 });
342
343 mcp.addFunc("remove", [raw](const std::string& name) -> bool {
344 bool removed = false;
345 {
346 std::lock_guard<std::mutex> lock(g_mutex);
347 const auto found = std::find_if(g_tools.begin(), g_tools.end(),
348 [&](const ScriptTool& entry) { return entry.name == name; });
349 if (found != g_tools.end()) {
350 releaseEntry(*found, raw);
351 g_tools.erase(found);
352 removed = true;
353 }
354 }
355 if (removed) ConsolePanel::instance().addLog("info", "eve.mcp.tool removed: " + name);
356 return removed;
357 });
358
359 mcp.addFunc("tools", []() { return scriptToolNames(); });
360
361 mcp.addFunc("clear", [raw]() -> int {
362 const int count = scriptToolCount();
363 clearScriptTools(raw);
364 ConsolePanel::instance().addLog("info", "eve.mcp.tool cleared " + std::to_string(count) + " tool(s)");
365 return count;
366 });
367 } catch (...) {
368 // `eve` table missing: leave the MCP export API unavailable rather than
369 // failing the whole DevTools script surface.
370 }
371}
372
373} // namespace eve::dev
LogicalId target
double value
SQInteger top
struct SQVM * HSQUIRRELVM
HSQUIRRELVM vm
Definition ECS.cpp:20
std::uint32_t dropped
std::uint32_t key
std::int32_t c
std::string text
std::string description
std::string inputSchemaJson
HSQOBJECT handler
std::string name
Project-script → MCP export registry.
bool found
int removed
std::uint32_t count
Json object
uint32_t index
std::uint32_t depth
const VegetationPresetContext & context
void addLog(std::string level, std::string text)
Append a leveled log line (thread-safe).
static ConsolePanel & instance()
const char * defaultValue
bool isValidScriptToolName(const std::string &name)
Validate an exported tool name.
int scriptToolCount()
Number of exported tools (for eve_status).
std::string mcpJsonEscape(const std::string &value)
Escape a raw string for embedding inside a JSON string literal.
Definition McpJson.hpp:30
std::string callScriptTool(const std::string &name, Poco::JSON::Object::Ptr args)
Invoke one exported tool.
void clearScriptTools(HSQUIRRELVM owner)
Drop registry entries owned by owner.
std::vector< std::string > scriptToolNames()
Exported tool names in registration order.
std::string textContentResult(const std::string &text, bool isError=false)
MCP tools/call result object carrying one text content item.
Definition McpJson.hpp:59
std::string scriptToolSchemas()
Comma-free MCP tool schema fragments for every exported tool.
void exposeMcpScriptApi(ssq::VM &vm)
Expose eve.mcp.tool/remove/tools/clear on a VM.
std::string sqValueToJson(HSQUIRRELVM vm, SQInteger idx, int depth=0)
Forward declaration: the key serializer falls back to the value serializer.
bool isScriptTool(const std::string &name)
Whether name is exported by the project script registry.
std::string formatScriptError(const ScriptErrorContext &ctx)
Formats a context into a human-readable multi-line report.
ScriptErrorContext takeLastScriptError(HSQUIRRELVM vm)
Consumes and clears the last recorded error for a VM.
Structured snapshot of a script error: message, throw site and stack.
Definition ScriptError.h:25