Files
Baradb/src/barabadb/mcp/server.nim
T
dimgigov 1e38e29f25
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
fix(security): SQL injection in Python/JS clients + bare except + session leak
- Fix SQL injection vulnerabilities in chat_history.py, langchain_store.py,
  rag_pipeline.py, baradb_langchain.js by switching to parameterized queries
- Replace dangerous bare except: with except CatchableError:/ValueError:
  in llm.nim, embed.nim, cypher.nim, mcp/server.nim, executor.nim
- Fix session variable leak in MCP handleVectorSearch/handleSchemaInspect
- Build: 0 errors, all tests pass
2026-05-17 16:58:05 +03:00

754 lines
23 KiB
Nim

## BaraDB MCP Server — Model Context Protocol
##
## Implements the MCP (Model Context Protocol) over STDIO transport
## with JSON-RPC 2.0. Provides AI agents with tools to query, vector
## search, and inspect the BaraDB schema.
##
## Tools:
## query — Execute SQL queries with parameterized inputs
## vector_search — Semantic vector similarity search with tenant isolation
## schema_inspect — Explore tables, columns, indexes, RLS policies
import std/json
import std/strutils
import std/os
import std/tables
import std/sequtils
import ../storage/lsm
import ../query/lexer as qlexer
import ../query/parser as qparser
import ../query/ast
import ../query/executor
import ../protocol/wire
import ../core/mvcc
import ../fts/engine as fts
import ../vector/engine as vengine
# ---------------------------------------------------------------------------
# MCP JSON-RPC 2.0 types
# ---------------------------------------------------------------------------
type
JsonRpcErrorCode* = enum
jrParseError = -32700
jrInvalidRequest = -32600
jrMethodNotFound = -32601
jrInvalidParams = -32602
jrInternalError = -32603
McpToolDef* = object
name*: string
description*: string
inputSchema*: JsonNode
McpServerInfo* = object
name*: string
version*: string
McpServerCapabilities* = object
tools*: JsonNode
# Tool definitions (lazy initialization)
var toolDefs: seq[McpToolDef]
proc buildToolDefs() =
if toolDefs.len > 0:
return
toolDefs = @[
McpToolDef(
name: "query",
description: "Execute a SQL query against BaraDB. Supports SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, and all BaraQL statements. Use parameterized queries with ? placeholders to prevent SQL injection. Returns rows as an array of objects keyed by column name.",
inputSchema: %*{
"type": "object",
"properties": {
"sql": {
"type": "string",
"description": "The SQL query to execute. Use ? for parameterized values."
},
"params": {
"type": "array",
"description": "Optional parameter values for ? placeholders in the SQL query.",
"items": {}
},
"tenant_id": {
"type": "string",
"description": "Optional. Sets the app.tenant_id session variable for multi-tenant RLS filtering."
},
"user_id": {
"type": "string",
"description": "Optional. Sets the current user for RLS policy evaluation."
}
},
"required": ["sql"]
}
),
McpToolDef(
name: "vector_search",
description: "Perform semantic vector similarity search against a BaraDB HNSW vector index. Finds the k-nearest neighbors to a query vector. Supports tenant isolation via session variables. Results include distance scores and metadata.",
inputSchema: %*{
"type": "object",
"properties": {
"table": {
"type": "string",
"description": "The table name containing the vector column."
},
"column": {
"type": "string",
"description": "The vector column name with an HNSW index."
},
"query_vector": {
"type": "array",
"description": "The query vector as an array of floats.",
"items": {"type": "number"}
},
"k": {
"type": "integer",
"description": "Number of nearest neighbors to return (default: 10).",
"default": 10
},
"metric": {
"type": "string",
"enum": ["cosine", "euclidean", "dot_product", "manhattan"],
"description": "Distance metric (default: cosine).",
"default": "cosine"
},
"filter_column": {
"type": "string",
"description": "Optional metadata column to filter results."
},
"filter_value": {
"type": "string",
"description": "Value for filter_column. Only results matching this value are returned."
},
"tenant_id": {
"type": "string",
"description": "Optional. Sets the app.tenant_id session variable for multi-tenant RLS filtering."
}
},
"required": ["table", "column", "query_vector"]
}
),
McpToolDef(
name: "schema_inspect",
description: "Explore and inspect the BaraDB database schema. Returns tables, columns, data types, primary keys, foreign keys, indexes (BTree, HNSW vector, full-text), and RLS policies. Optionally filter to a specific table.",
inputSchema: %*{
"type": "object",
"properties": {
"table": {
"type": "string",
"description": "Optional. If provided, returns detailed schema for only this table."
},
"tenant_id": {
"type": "string",
"description": "Optional. Sets the app.tenant_id session variable for multi-tenant RLS context."
}
},
"required": []
}
),
]
# ---------------------------------------------------------------------------
# Server state
# ---------------------------------------------------------------------------
type
McpServerCtx* = ref object
db*: LSMTree
execCtx*: ExecutionContext
dataDir*: string
var serverCtx: McpServerCtx
# ---------------------------------------------------------------------------
# JSON-RPC helpers
# ---------------------------------------------------------------------------
proc parseVectorFromJson(node: JsonNode): seq[float32] =
result = @[]
if node.kind == JArray:
for item in node:
case item.kind
of JInt: result.add(float32(item.getInt()))
of JFloat: result.add(float32(item.getFloat()))
else: discard
proc parseMetric(s: string): vengine.DistanceMetric =
case s.toLowerAscii()
of "cosine": vengine.dmCosine
of "euclidean": vengine.dmEuclidean
of "dot_product", "dotproduct": vengine.dmDotProduct
of "manhattan": vengine.dmManhattan
else: vengine.dmCosine
# ---------------------------------------------------------------------------
# Tool: query
# ---------------------------------------------------------------------------
proc handleQuery(params: JsonNode): JsonNode =
if params.kind != JObject:
return %*{"error": "params must be a JSON object"}
if "sql" notin params or params["sql"].kind != JString:
return %*{"error": "Missing required parameter: sql (string)"}
let sql = params["sql"].getStr()
if sql.strip().len == 0:
return %*{"error": "SQL query cannot be empty"}
var prevTenant = serverCtx.execCtx.sessionVars.getOrDefault("app.tenant_id", "")
var prevUser = serverCtx.execCtx.currentUser
if "tenant_id" in params and params["tenant_id"].kind == JString:
let tid = params["tenant_id"].getStr()
if tid.len > 0:
serverCtx.execCtx.sessionVars["app.tenant_id"] = tid
if "user_id" in params and params["user_id"].kind == JString:
let uid = params["user_id"].getStr()
if uid.len > 0:
serverCtx.execCtx.currentUser = uid
var wireParams: seq[WireValue] = @[]
if "params" in params and params["params"].kind == JArray:
for p in params["params"]:
case p.kind
of JNull: wireParams.add(WireValue(kind: fkNull))
of JBool: wireParams.add(WireValue(kind: fkBool, boolVal: p.getBool()))
of JInt: wireParams.add(WireValue(kind: fkInt64, int64Val: p.getInt()))
of JFloat: wireParams.add(WireValue(kind: fkFloat64, float64Val: p.getFloat()))
of JString: wireParams.add(WireValue(kind: fkString, strVal: p.getStr()))
else: wireParams.add(WireValue(kind: fkString, strVal: $p))
var tokens: seq[qlexer.Token]
try:
tokens = qlexer.tokenize(sql)
except CatchableError:
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
serverCtx.execCtx.currentUser = prevUser
return %*{"error": "Failed to tokenize SQL: " & getCurrentExceptionMsg()}
var astNode: Node
try:
astNode = qparser.parse(tokens)
except CatchableError:
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
serverCtx.execCtx.currentUser = prevUser
return %*{"error": "Failed to parse SQL: " & getCurrentExceptionMsg()}
if astNode.stmts.len == 0:
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
serverCtx.execCtx.currentUser = prevUser
return %*{"columns": [], "rows": [], "affectedRows": 0}
var res: ExecResult
try:
res = executeQuery(serverCtx.execCtx, astNode, wireParams)
except CatchableError:
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
serverCtx.execCtx.currentUser = prevUser
return %*{"error": "Query execution failed: " & getCurrentExceptionMsg()}
if not res.success:
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
serverCtx.execCtx.currentUser = prevUser
return %*{"error": res.message}
var jsonRows = newJArray()
for row in res.rows:
var jsonRow = newJObject()
for col in res.columns:
if col in row:
jsonRow[col] = %row[col]
else:
jsonRow[col] = newJNull()
jsonRows.add(jsonRow)
var jsonCols = newJArray()
for c in res.columns:
jsonCols.add(%c)
var r = %*{
"columns": jsonCols,
"rows": jsonRows,
"affectedRows": res.affectedRows
}
if res.message.len > 0:
r["message"] = %res.message
var sessionInfo = newJObject()
sessionInfo["tenant_id"] = %serverCtx.execCtx.sessionVars.getOrDefault("app.tenant_id", "")
sessionInfo["user_id"] = %serverCtx.execCtx.currentUser
r["_session"] = sessionInfo
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
serverCtx.execCtx.currentUser = prevUser
return r
# ---------------------------------------------------------------------------
# Tool: vector_search
# ---------------------------------------------------------------------------
proc handleVectorSearch(params: JsonNode): JsonNode =
if params.kind != JObject:
return %*{"error": "params must be a JSON object"}
if "table" notin params or params["table"].kind != JString:
return %*{"error": "Missing required parameter: table (string)"}
if "column" notin params or params["column"].kind != JString:
return %*{"error": "Missing required parameter: column (string)"}
if "query_vector" notin params:
return %*{"error": "Missing required parameter: query_vector (array of floats)"}
let table = params["table"].getStr()
let column = params["column"].getStr()
let indexKey = table & "." & column
if indexKey notin serverCtx.execCtx.vectorIndexes:
var available: seq[string] = @[]
for k in serverCtx.execCtx.vectorIndexes.keys:
available.add(k)
return %*{
"error": "No vector index found for '" & indexKey & "'",
"available_indexes": %available
}
let idx = serverCtx.execCtx.vectorIndexes[indexKey]
if idx.isNil or idx.nodes.len == 0:
return %*{"error": "Vector index for '" & indexKey & "' is empty"}
let queryVec = parseVectorFromJson(params["query_vector"])
if queryVec.len == 0:
return %*{"error": "query_vector must be a non-empty array of numbers"}
if queryVec.len != idx.dimensions:
return %*{"error": "Vector dimension mismatch: got " & $queryVec.len &
", expected " & $idx.dimensions}
let k = if "k" in params and params["k"].kind == JInt:
params["k"].getInt()
else: 10
if k < 1 or k > 1000:
return %*{"error": "k must be between 1 and 1000"}
let metric = if "metric" in params and params["metric"].kind == JString:
parseMetric(params["metric"].getStr())
else: vengine.dmCosine
var results: seq[(uint64, float64, Table[string, string])]
let hasFilter = "filter_column" in params and params["filter_column"].kind == JString and
"filter_value" in params and params["filter_value"].kind == JString
if hasFilter:
let filterCol = params["filter_column"].getStr()
let filterVal = params["filter_value"].getStr()
let filterFn = proc(metadata: Table[string, string]): bool {.gcsafe.} =
result = filterCol in metadata and metadata[filterCol] == filterVal
let rawResults = idx.searchWithFilter(queryVec, k, filterFn, metric)
for (id, dist) in rawResults:
var meta = initTable[string, string]()
if id in idx.nodes:
meta = idx.nodes[id].metadata
results.add((id, dist, meta))
else:
results = idx.searchEx(queryVec, k, metric)
var jsonResults = newJArray()
for (id, dist, meta) in results:
var item = %*{
"id": %id,
"distance": dist
}
var metaObj = newJObject()
for key, val in meta:
metaObj[key] = %val
item["metadata"] = metaObj
jsonResults.add(item)
var prevTenant = serverCtx.execCtx.sessionVars.getOrDefault("app.tenant_id", "")
var sessionInfo = newJObject()
if "tenant_id" in params and params["tenant_id"].kind == JString:
let tid = params["tenant_id"].getStr()
serverCtx.execCtx.sessionVars["app.tenant_id"] = tid
sessionInfo["tenant_id"] = %tid
sessionInfo["user_id"] = %serverCtx.execCtx.currentUser
var r = %*{
"table": %table,
"column": %column,
"index_size": idx.nodes.len,
"k": k,
"metric": $metric,
"results": jsonResults,
"_session": sessionInfo
}
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
return r
# ---------------------------------------------------------------------------
# Tool: schema_inspect
# ---------------------------------------------------------------------------
proc handleSchemaInspect(params: JsonNode): JsonNode =
var targetTable = ""
if params.kind == JObject and "table" in params and params["table"].kind == JString:
targetTable = params["table"].getStr()
var prevTenant = serverCtx.execCtx.sessionVars.getOrDefault("app.tenant_id", "")
if "tenant_id" in params and params["tenant_id"].kind == JString:
serverCtx.execCtx.sessionVars["app.tenant_id"] = params["tenant_id"].getStr()
var jsonTables = newJArray()
for tblName, tblDef in serverCtx.execCtx.tables:
if targetTable.len > 0 and tblName != targetTable:
continue
var jsonCols = newJArray()
for col in tblDef.columns:
var colObj = %*{
"name": col.name,
"type": col.colType,
"primary_key": col.isPk,
"not_null": col.isNotNull,
"unique": col.isUnique,
"auto_increment": col.autoIncrement
}
if col.defaultVal.len > 0:
colObj["default"] = %col.defaultVal
var fkInfo: JsonNode = nil
if col.fkTable.len > 0:
fkInfo = %*{
"table": col.fkTable,
"column": col.fkColumn,
"on_delete": col.fkOnDelete,
"on_update": col.fkOnUpdate
}
colObj["foreign_key"] = fkInfo
jsonCols.add(colObj)
var jsonIdxs = newJArray()
for key in serverCtx.execCtx.btrees.keys:
if key.startsWith(tblName & ".") or key == tblName:
jsonIdxs.add(%*{"type": "btree", "name": key})
for key in serverCtx.execCtx.vectorIndexes.keys:
if key.startsWith(tblName & "."):
let vi = serverCtx.execCtx.vectorIndexes[key]
jsonIdxs.add(%*{
"type": "hnsw_vector",
"name": key,
"dimensions": vi.dimensions,
"node_count": vi.nodes.len
})
for key in serverCtx.execCtx.ftsIndexes.keys:
if key.startsWith(tblName & "."):
let ftsIdx = serverCtx.execCtx.ftsIndexes[key]
jsonIdxs.add(%*{"type": "fulltext", "name": key, "doc_count": ftsIdx.docCount})
var jsonPolicies = newJArray()
if tblName in serverCtx.execCtx.policies:
for pol in serverCtx.execCtx.policies[tblName]:
jsonPolicies.add(%*{
"name": pol.name,
"command": pol.command
})
var fks = newJArray()
for fk in tblDef.foreignKeys:
fks.add(%*{
"table": fk.refTable,
"column": fk.refColumn,
"on_delete": fk.onDelete,
"on_update": fk.onUpdate
})
var tblObj = %*{
"name": tblName,
"columns": jsonCols,
"primary_keys": %tblDef.pkColumns,
"indexes": jsonIdxs,
"foreign_keys": fks,
"policies": jsonPolicies
}
jsonTables.add(tblObj)
if targetTable.len > 0 and jsonTables.len == 0:
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
return %*{"error": "Table '" & targetTable & "' not found"}
var sessionInfo = newJObject()
sessionInfo["tenant_id"] = %serverCtx.execCtx.sessionVars.getOrDefault("app.tenant_id", "")
sessionInfo["user_id"] = %serverCtx.execCtx.currentUser
var r = %*{
"tables": jsonTables,
"table_count": jsonTables.len,
"_session": sessionInfo
}
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
return r
# ---------------------------------------------------------------------------
# MCP Protocol handlers
# ---------------------------------------------------------------------------
proc handleInitialize(params: JsonNode): JsonNode =
buildToolDefs()
return %*{
"protocolVersion": "2024-11-05",
"serverInfo": {
"name": "BaraDB MCP Server",
"version": "1.1.2"
},
"capabilities": {
"tools": {}
}
}
proc handleToolsList(params: JsonNode): JsonNode =
buildToolDefs()
var tools = newJArray()
for td in toolDefs:
tools.add(%*{
"name": td.name,
"description": td.description,
"inputSchema": td.inputSchema
})
return %*{"tools": tools}
proc handleToolsCall(params: JsonNode): JsonNode =
if params.kind != JObject:
return %*{"error": "params must be a JSON object"}
if "name" notin params or params["name"].kind != JString:
return %*{"error": "Missing tool name"}
let toolName = params["name"].getStr()
let toolArgs = if "arguments" in params: params["arguments"] else: newJObject()
var content: JsonNode
case toolName
of "query":
content = handleQuery(toolArgs)
of "vector_search":
content = handleVectorSearch(toolArgs)
of "schema_inspect":
content = handleSchemaInspect(toolArgs)
else:
return %*{"error": "Unknown tool: " & toolName}
var text: string
if content.hasKey("error"):
text = "Error: " & content["error"].getStr()
else:
text = $content
return %*{
"content": [
{
"type": "text",
"text": text
}
]
}
# ---------------------------------------------------------------------------
# JSON-RPC dispatch
# ---------------------------------------------------------------------------
proc dispatch(meth: string, params: JsonNode): JsonNode =
case meth
of "initialize":
return handleInitialize(params)
of "tools/list":
return handleToolsList(params)
of "tools/call":
return handleToolsCall(params)
else:
return %*{
"error": {
"code": jrMethodNotFound.int,
"message": "Method not found: " & meth
}
}
# ---------------------------------------------------------------------------
# STDIO transport
# ---------------------------------------------------------------------------
proc writeToStdout(line: string) =
try:
stdout.writeLine(line)
stdout.flushFile()
except CatchableError:
discard
proc logToStderr*(msg: string) =
try:
stderr.writeLine("[baradb-mcp] " & msg)
stderr.flushFile()
except CatchableError:
discard
proc processMessage(raw: string): string =
if raw.strip().len == 0:
return ""
var req: JsonNode
try:
req = parseJson(raw)
except CatchableError:
logToStderr("JSON parse error: " & getCurrentExceptionMsg())
let resp = %*{
"jsonrpc": "2.0",
"id": newJNull(),
"error": {
"code": jrParseError.int,
"message": "Parse error: " & getCurrentExceptionMsg()
}
}
return $resp
if req.kind != JObject:
let resp = %*{
"jsonrpc": "2.0",
"id": newJNull(),
"error": {
"code": jrInvalidRequest.int,
"message": "Invalid request: not a JSON object"
}
}
return $resp
if "method" notin req or req["method"].kind != JString:
let resp = %*{
"jsonrpc": "2.0",
"id": newJNull(),
"error": {
"code": jrInvalidRequest.int,
"message": "Invalid request: missing method"
}
}
return $resp
let meth = req["method"].getStr()
let params = if "params" in req: req["params"] else: newJObject()
let isNotification = "id" notin req
if meth == "notifications/initialized":
return ""
var dispResult: JsonNode
try:
dispResult = dispatch(meth, params)
except CatchableError:
logToStderr("Dispatch error for " & meth & ": " & getCurrentExceptionMsg())
let msg = getCurrentExceptionMsg()
var errResp = %*{
"jsonrpc": "2.0",
"error": {
"code": jrInternalError.int,
"message": "Internal error: " & msg
}
}
if not isNotification:
errResp["id"] = req["id"]
return $errResp
if isNotification:
return ""
var resp: JsonNode
if dispResult.hasKey("error"):
var errNode = dispResult["error"]
var errObj: JsonNode
if errNode.kind == JObject:
errObj = errNode
else:
errObj = %*{"code": jrInternalError.int, "message": errNode.getStr()}
resp = %*{
"jsonrpc": "2.0",
"id": req["id"],
"error": errObj
}
else:
resp = %*{
"jsonrpc": "2.0",
"id": req["id"],
"result": dispResult
}
return $resp
# ---------------------------------------------------------------------------
# Server lifecycle
# ---------------------------------------------------------------------------
proc init*(dataDir: string = "./data"): McpServerCtx =
logToStderr("BaraDB MCP Server v1.1.2 initializing...")
let db = newLSMTree(dataDir)
let ctx = newExecutionContext(db)
ctx.txnManager = newTxnManager()
result = McpServerCtx(db: db, execCtx: ctx, dataDir: dataDir)
serverCtx = result
logToStderr("Initialized. Data directory: " & dataDir)
proc run*() =
buildToolDefs()
logToStderr("MCP Server ready. Waiting for JSON-RPC requests on STDIN...")
logToStderr("Available tools: " & $toolDefs.mapIt(it.name))
var startupDone = false
while true:
var line = ""
try:
line = stdin.readLine()
except EOFError:
if startupDone:
logToStderr("STDIN closed, exiting.")
break
except CatchableError:
logToStderr("STDIN read error: " & getCurrentExceptionMsg())
break
let resp = processMessage(line)
if resp.len > 0:
writeToStdout(resp)
if not startupDone:
startupDone = true
proc close*() =
if serverCtx != nil and serverCtx.db != nil:
logToStderr("Closing database...")
serverCtx.db.close()
# ---------------------------------------------------------------------------
# Standalone entry helpers
# ---------------------------------------------------------------------------
proc parseDataDir*(): string =
result = getEnv("BARADB_DATA_DIR", "./data")
var i = 1
while i < paramCount():
let arg = paramStr(i)
if arg == "--data-dir" and i + 1 <= paramCount():
result = paramStr(i + 1)
break
inc i
when isMainModule:
let dataDir = parseDataDir()
logToStderr("Starting BaraDB MCP Server with data dir: " & dataDir)
try:
discard init(dataDir)
run()
except CatchableError:
logToStderr("Fatal error: " & getCurrentExceptionMsg())
finally:
close()