mirror of
https://github.com/ntop/ntopng.git
synced 2026-08-21 22:33:24 +00:00
310 lines
9.6 KiB
Lua
310 lines
9.6 KiB
Lua
--
|
|
-- (C) 2013-26 - ntop.org
|
|
--
|
|
-- MCP (Model Context Protocol) server for ntopng LLM tools.
|
|
-- Transport : MCP 2025-03-26 Streamable HTTP
|
|
-- Auth : HTTP Basic Auth "Authorization: Basic base64(user:pass)"
|
|
-- HTTP Token Auth "Authorization: Token <api-token>"
|
|
|
|
--
|
|
-- POST /lua/rest/v2/exec/llm/mcp.lua (JSON-RPC 2.0 body)
|
|
-- GET /lua/rest/v2/exec/llm/mcp.lua (server health / discovery)
|
|
-- Optional query param: ?ifid=<interface_id> (default: 0)
|
|
|
|
-- Must be set before http_lint runs (parsePOSTpayload checks this flag).
|
|
-- If set too late, http_lint decodes _POST["payload"] into a table and
|
|
-- json.decode(payload) receives a table instead of a string.
|
|
ignore_post_payload_parse = true -- luacheck: ignore
|
|
pragma_once_http_lint = true -- luacheck: ignore
|
|
|
|
local dirs = ntop.getDirs()
|
|
package.path = dirs.installdir .. "/scripts/lua/modules/?.lua;" .. package.path
|
|
|
|
|
|
local json = require("dkjson")
|
|
local rest_utils = require("rest_utils")
|
|
|
|
-- Pro paths: only add if nAnalyst is available
|
|
local page_utils = require("page_utils")
|
|
|
|
if ntop.hasnAnalyst() then
|
|
package.path = dirs.installdir .. "/pro/scripts/lua/modules/?.lua;" .. package.path
|
|
package.path = dirs.installdir .. "/pro/scripts/lua/modules/llm/?.lua;" .. package.path
|
|
end
|
|
|
|
-- Helpers
|
|
local function send_json(body, status_code)
|
|
rest_utils.sendHTTPContentTypeHeader("application/json", nil, nil, nil, status_code)
|
|
if body and body ~= "" then print(body) end
|
|
end
|
|
|
|
local function rpc_ok(id, result)
|
|
return json.encode({ jsonrpc = "2.0", id = id, result = result })
|
|
end
|
|
|
|
local function rpc_err(id, code, message)
|
|
return json.encode({ jsonrpc = "2.0", id = id,
|
|
error = { code = code, message = message } })
|
|
end
|
|
|
|
-- Authentication Checks
|
|
local current_user = (_SESSION and _SESSION["user"]) or ""
|
|
if current_user == "" then
|
|
send_json(rpc_err(nil, -32001, "Unauthorized: valid credentials required"), 401)
|
|
return
|
|
end
|
|
|
|
if not isAdministrator() then
|
|
send_json(rpc_err(nil, -32003, "Forbidden: administrator role required"), 403)
|
|
return
|
|
end
|
|
|
|
|
|
-- Load LLM tools, start by loading community tools, if nAnalyst is available load pro tools
|
|
local _tools_mod = nil
|
|
|
|
local function get_tools()
|
|
if _tools_mod then return _tools_mod, nil end
|
|
|
|
-- Community tools: scripts/lua/modules/llm/tools.lua
|
|
package.path = dirs.installdir .. "/scripts/lua/modules/llm/?.lua;" .. package.path
|
|
local ok, mod = pcall(function() return require("tools") end)
|
|
if not ok then
|
|
return nil, "ntopng LLM tools not available: " .. tostring(mod)
|
|
end
|
|
|
|
-- nAnalyst tools: pro/scripts/lua/modules/llm/pro_tools.lua
|
|
if ntop.hasnAnalyst() then
|
|
local ok2, pro_tools = pcall(function() return require("pro_tools") end)
|
|
if ok2 and pro_tools then
|
|
mod = pro_tools
|
|
else
|
|
tprint("[mcp] pro_tools load failed (using community tools): " .. tostring(pro_tools))
|
|
end
|
|
end
|
|
|
|
_tools_mod = mod
|
|
return mod, nil
|
|
end
|
|
|
|
-- MCP initialize
|
|
local _system_prompt = nil
|
|
|
|
local function get_system_prompt()
|
|
if _system_prompt then return _system_prompt end
|
|
|
|
-- Community tools: scripts/lua/modules/llm/tools.lua
|
|
package.path = dirs.installdir .. "/scripts/lua/modules/llm/?.lua;" .. package.path
|
|
local tools = require("tools")
|
|
_system_prompt = tools.IDENTITY
|
|
|
|
-- If nAnalyst available, load pro system prompt (extends community with SQL rules)
|
|
if ntop.hasnAnalyst() then
|
|
local ok, prompts = pcall(function() return require("prompts") end)
|
|
if ok and prompts and prompts.system_prompt then
|
|
_system_prompt = prompts.system_prompt(false)
|
|
end
|
|
end
|
|
|
|
return _system_prompt
|
|
end
|
|
|
|
local function handle_initialize(id, _params)
|
|
return rpc_ok(id, {
|
|
protocolVersion = "2025-03-26",
|
|
capabilities = { tools = { listChanged = false } },
|
|
serverInfo = { name = "ntopng-mcp", version = "1.0" },
|
|
instructions = get_system_prompt(),
|
|
})
|
|
end
|
|
|
|
-- MCP method: tools/list -> Exposes every entry in tools._registry as an MCP tool.
|
|
local function handle_tools_list(id, _params)
|
|
local tools, err = get_tools()
|
|
if not tools then return rpc_err(id, -32603, err) end
|
|
|
|
local list = {}
|
|
for name, entry in pairs(tools._registry) do
|
|
list[#list + 1] = {
|
|
name = name,
|
|
description = entry.description,
|
|
inputSchema = {
|
|
type = "object",
|
|
properties = {
|
|
content = {
|
|
type = "string",
|
|
description = "Tool input. "
|
|
.. "For 'query': a plain SQL string. "
|
|
.. "For all other tools: a JSON object encoded as a string "
|
|
.. "(e.g. '{\"ip\":\"192.168.1.1\"}').",
|
|
},
|
|
},
|
|
required = { "content" },
|
|
},
|
|
annotations = {
|
|
readOnlyHint = (entry.read_only == true),
|
|
destructiveHint = false,
|
|
requiresClickHouse = (entry.requires_clickhouse == true),
|
|
licenseRequired = entry.license_required,
|
|
},
|
|
}
|
|
end
|
|
|
|
table.sort(list, function(a, b) return a.name < b.name end)
|
|
return rpc_ok(id, { tools = list })
|
|
end
|
|
|
|
-- MCP method: tools/call -> Dispatches to tools.dispatch(name, content).
|
|
local function handle_tools_call(id, params)
|
|
if type(params) ~= "table" then
|
|
return rpc_err(id, -32602, "params must be an object")
|
|
end
|
|
|
|
local tool_name = params.name
|
|
local arguments = params.arguments or {}
|
|
|
|
if type(tool_name) ~= "string" or tool_name == "" then
|
|
return rpc_err(id, -32602, "params.name is required")
|
|
end
|
|
|
|
local tools, err = get_tools()
|
|
if not tools then return rpc_err(id, -32603, err) end
|
|
|
|
-- Derive content from arguments:
|
|
-- {content: "..."} -> pass content value directly (like agentic loop)
|
|
-- {sql: "..."} -> shorthand for query tool
|
|
-- {any other keys} -> JSON-encode and pass as content (dispatch decodes it)
|
|
-- {} -> empty string
|
|
local content
|
|
|
|
if arguments.content ~= nil then
|
|
content = arguments.content
|
|
elseif arguments.sql ~= nil then
|
|
-- Convenience: {sql: "SELECT ..."} for the query tool
|
|
content = arguments.sql
|
|
elseif next(arguments) ~= nil then
|
|
content = json.encode(arguments)
|
|
else
|
|
content = ""
|
|
end
|
|
|
|
-- call tool
|
|
local result, tool_err, artifact = tools.dispatch(tool_name, content)
|
|
|
|
if tool_err and not result then
|
|
return rpc_err(id, -32603, "tool error: " .. tostring(tool_err))
|
|
end
|
|
|
|
local text = result or ""
|
|
if tool_err then
|
|
text = text .. "\n[tool warning: " .. tostring(tool_err) .. "]"
|
|
end
|
|
|
|
local mcp_content = { { type = "text", text = text } }
|
|
|
|
-- Artifact such as chart. Embed it as a tagged text block so MCP clients can parse it if desired.
|
|
if artifact then
|
|
mcp_content[#mcp_content + 1] = {
|
|
type = "text",
|
|
text = "NTOPNG_ARTIFACT:" .. json.encode(artifact),
|
|
}
|
|
end
|
|
|
|
return rpc_ok(id, {
|
|
content = mcp_content,
|
|
isError = (tool_err ~= nil and result == nil),
|
|
})
|
|
end
|
|
|
|
-- JSON-RPC 2.0 dispatcher
|
|
local method_handlers = {
|
|
["initialize"] = handle_initialize,
|
|
["tools/list"] = handle_tools_list,
|
|
["tools/call"] = handle_tools_call,
|
|
["ping"] = function(id, _) return rpc_ok(id, {}) end,
|
|
|
|
-- Notifications: no id, no response expected
|
|
["notifications/initialized"] = function(_, _) return nil end,
|
|
["notifications/cancelled"] = function(_, _) return nil end,
|
|
}
|
|
|
|
local function dispatch_request(req)
|
|
if type(req) ~= "table" then
|
|
return rpc_err(nil, -32600, "invalid request")
|
|
end
|
|
|
|
local req_id = req.id
|
|
local method = req.method
|
|
local params = req.params
|
|
|
|
local handler = method_handlers[method]
|
|
if handler then
|
|
return handler(req_id, params)
|
|
end
|
|
|
|
-- Unknown method: only respond if there is an id, not a notification
|
|
if req_id ~= nil then
|
|
return rpc_err(req_id, -32601, "method not found: " .. tostring(method))
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-------------------------------------------------------------------
|
|
-- Entry point
|
|
local http_method = (_SERVER and _SERVER["REQUEST_METHOD"]) or "GET"
|
|
|
|
-- GET -> health / discovery
|
|
if http_method == "GET" then
|
|
local tools, _ = get_tools()
|
|
local tool_count = 0
|
|
|
|
if tools then
|
|
for _ in pairs(tools._registry) do tool_count = tool_count + 1 end
|
|
end
|
|
|
|
send_json(json.encode({
|
|
server = "ntopng-mcp",
|
|
protocol = "2025-03-26",
|
|
authenticated_as = current_user,
|
|
tools_available = tool_count,
|
|
}))
|
|
return
|
|
end
|
|
|
|
-- POST -> JSON-RPC 2.0
|
|
local payload = _POST and _POST["payload"]
|
|
if not payload or payload == "" then
|
|
send_json(rpc_err(nil, -32700, "empty request body; "
|
|
.. "POST Content-Type must be application/json"), 400)
|
|
return
|
|
end
|
|
|
|
local request, _, parse_err = json.decode(payload)
|
|
if request == nil then
|
|
send_json(rpc_err(nil, -32700, "JSON parse error: " .. tostring(parse_err)), 400)
|
|
return
|
|
end
|
|
|
|
-- Batch request: array of JSON-RPC objects
|
|
if type(request) == "table" and request[1] ~= nil then
|
|
local responses = {}
|
|
|
|
for _, req in ipairs(request) do
|
|
local resp_str = dispatch_request(req)
|
|
if resp_str then
|
|
local obj = json.decode(resp_str)
|
|
if obj then responses[#responses + 1] = obj end
|
|
end
|
|
end
|
|
|
|
-- If all were notifications there is nothing to return
|
|
send_json(#responses > 0 and json.encode(responses) or "")
|
|
return
|
|
end
|
|
|
|
local resp_str = dispatch_request(request)
|
|
if not resp_str then
|
|
rest_utils.sendHTTPContentTypeHeader("application/json", nil, nil, nil, 202)
|
|
else
|
|
send_json(resp_str)
|
|
end
|