diff --git a/src/include/quackapi_server.hpp b/src/include/quackapi_server.hpp index 82b940e..3cbd13e 100644 --- a/src/include/quackapi_server.hpp +++ b/src/include/quackapi_server.hpp @@ -308,6 +308,10 @@ class QuackapiHttpServer { private: static void ListenThread(QuackapiHttpServer *server); void HandleRequest(const duckdb_httplib::Request &req, duckdb_httplib::Response &res); + //! POST /mcp: Model Context Protocol (JSON-RPC) over the same routes /openapi.json describes. + //! tools/list = one tool per route operation; tools/call runs that route through HandleRequest. + void HandleMcp(const duckdb_httplib::Request &req, duckdb_httplib::Response &res, DatabaseInstance &db, + const string &server_url); void ApplyCorsHeaders(const duckdb_httplib::Request &req, duckdb_httplib::Response &res); string NextRequestId(DatabaseInstance &db); void EmitAccessLog(const QuackapiRequestRecord &entry); diff --git a/src/quackapi_server.cpp b/src/quackapi_server.cpp index 386c26c..d06eecf 100644 --- a/src/quackapi_server.cpp +++ b/src/quackapi_server.cpp @@ -2331,6 +2331,204 @@ void QuackapiHttpServer::Dispatch(const duckdb_httplib::Request &req, duckdb_htt HandleRequest(req, res); } +//! First column of the first row of a prepared statement, as text; throws the DuckDB error. +static string McpScalar(Connection &con, const string &sql, vector args) { + auto stmt = con.Prepare(sql); + if (stmt->HasError()) { + throw InvalidInputException(stmt->GetError()); + } + auto result = stmt->Execute(args, false); + if (result->HasError()) { + throw InvalidInputException(result->GetError()); + } + auto chunk = result->Fetch(); + if (!chunk || chunk->size() == 0) { + return string(); + } + auto value = chunk->GetValue(0, 0); + return value.IsNull() ? string() : value.ToString(); +} + +void QuackapiHttpServer::HandleMcp(const duckdb_httplib::Request &req, duckdb_httplib::Response &res, + DatabaseInstance &db, const string &server_url) { + Connection con(db); + // The JSON-RPC request, read once into a typed struct. + string id; + string method; + string tool; + string arguments; + try { + auto stmt = con.Prepare("SELECT m.id::VARCHAR, m.method, m.params.name, m.params.arguments::VARCHAR FROM " + "(SELECT from_json($1::JSON, '{\"id\": \"JSON\", \"method\": \"VARCHAR\", \"params\": " + "{\"name\": \"VARCHAR\", \"arguments\": \"JSON\"}}') AS m)"); + vector body_arg {Value(req.body)}; + auto parsed = stmt->Execute(body_arg, false); + if (parsed->HasError()) { + SetJson(res, 400, + "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32700,\"message\":\"Parse error\"}}"); + return; + } + auto chunk = parsed->Fetch(); + auto field = [&](idx_t col) { + auto v = chunk->GetValue(col, 0); + return v.IsNull() ? string() : v.ToString(); + }; + id = field(0); + method = field(1); + tool = field(2); + arguments = field(3); + } catch (std::exception &ex) { + SetInternalError(res, ex.what()); + return; + } + // A notification (no id) gets no reply body. + if (id.empty()) { + res.status = 202; + res.body.clear(); + return; + } + + string result; + string error; + try { + if (method == "initialize") { + result = McpScalar(con, + "SELECT to_json({protocolVersion: '2025-06-18', serverInfo: {name: 'quackapi', version: " + "'1'}, capabilities: {tools: {listChanged: false}}})", + {}); + } else if (method == "ping") { + result = "{}"; + } else if (method == "tools/list") { + // One tool per operation of the OpenAPI document: name = operationId (the route name), input schema = + // the operation's parameters (and the JSON body schema, when the route declares one). + result = McpScalar( + con, + "WITH op AS (SELECT p.key AS path, o.key AS verb, o.value AS op " + " FROM json_each($1::JSON -> 'paths') AS p, json_each(p.value) AS o), " + "params AS (SELECT path, verb, op, " + " coalesce(from_json(op -> 'parameters', '[\"JSON\"]'), []) AS ps FROM op) " + "SELECT to_json({tools: coalesce(list({name: op ->> 'operationId', " + " description: upper(verb) || ' ' || path, " + " inputSchema: {type: 'object', " + " properties: map_from_entries(list_transform(ps, x -> {key: x ->> 'name', value: x -> " + "'schema'})), " + " required: list_filter(list_transform(ps, x -> CASE WHEN (x ->> 'required')::BOOLEAN " + " THEN x ->> 'name' END), n -> n IS NOT NULL)}} " + " ORDER BY op ->> 'operationId'), [])}) FROM params", + {Value(BuildOpenApiDocument(db, server_url))}); + } else if (method == "tools/call") { + // Run the named route through the normal request path; its response body is the tool's text. + auto &qa_state = QuackapiState::Get(db); + auto routes = qa_state.LiveRoutes(); + const QuackapiRoute *target = nullptr; + for (auto &route : *routes) { + if (route.name == tool) { + target = &route; + break; + } + } + if (!target) { + error = "{\"code\":-32602,\"message\":\"Unknown tool\"}"; + } else { + // Explode the arguments object once: `:name` / `{name}` path + // segments are substituted into the pattern (a literal + // "/users/:id" would otherwise match with the placeholder as + // the captured value), the rest become the query string + // (GET/HEAD) or the JSON body. + string body = arguments.empty() ? string("{}") : arguments; + unordered_map arg_map; + { + auto stmt = con.Prepare("SELECT key, $1::JSON ->> key FROM json_each($1::JSON)"); + vector args_arg {Value(body)}; + auto pairs = stmt->Execute(args_arg, false); + if (!pairs->HasError()) { + for (auto chunk = pairs->Fetch(); chunk && chunk->size() > 0; chunk = pairs->Fetch()) { + for (idx_t r = 0; r < chunk->size(); r++) { + arg_map[chunk->GetValue(0, r).ToString()] = chunk->GetValue(1, r).ToString(); + } + } + } + } + string call_path; + string missing_path_param; + { + bool first = true; + idx_t start = 0; + while (start <= target->pattern.size()) { + auto end = target->pattern.find('/', start); + auto seg = target->pattern.substr(start, end == string::npos ? string::npos : end - start); + string pname; + if (!seg.empty() && seg[0] == ':') { + pname = seg.substr(1); + } else if (seg.size() >= 2 && seg.front() == '{' && seg.back() == '}') { + pname = seg.substr(1, seg.size() - 2); + } + if (!pname.empty()) { + auto it = arg_map.find(pname); + if (it == arg_map.end()) { + missing_path_param = pname; + break; + } + seg = it->second; + } + if (!first) { + call_path += '/'; + } + call_path += seg; + first = false; + if (end == string::npos) { + break; + } + start = end + 1; + } + } + if (!missing_path_param.empty()) { + error = McpScalar( + con, "SELECT to_json({code: -32602, message: $1})", + {Value("Missing path parameter '" + missing_path_param + "' for tool '" + tool + "'")}); + } else { + duckdb_httplib::Request call; + call.method = target->method; + call.path = call_path; + call.target = call_path; + call.version = "HTTP/1.1"; + call.remote_addr = req.remote_addr; + call.local_addr = req.local_addr; + for (auto &h : req.headers) { + if (StringUtil::CIEquals(h.first, "Authorization")) { + call.set_header(h.first, h.second); + } + } + if (target->method == "GET" || target->method == "HEAD") { + // Path params already ride in the path; keep them out of + // the query string so each value has a single source. + for (auto &kv : arg_map) { + if (!IsPathParam(target->pattern, kv.first)) { + call.params.emplace(kv.first, kv.second); + } + } + } else { + call.body = body; + call.set_header("Content-Type", "application/json"); + call.set_header("Content-Length", std::to_string(body.size())); + } + duckdb_httplib::Response reply; + HandleRequest(call, reply); + result = McpScalar(con, "SELECT to_json({content: [{type: 'text', text: $1}], isError: $2})", + {Value(reply.body), Value::BOOLEAN(reply.status >= 400)}); + } + } + } else { + error = "{\"code\":-32601,\"message\":\"Method not found\"}"; + } + } catch (std::exception &ex) { + error = McpScalar(con, "SELECT to_json({code: -32603, message: $1})", {Value(string(ex.what()))}); + } + auto envelope = error.empty() ? "SELECT to_json({jsonrpc: '2.0', id: $1::JSON, result: $2::JSON})" + : "SELECT to_json({jsonrpc: '2.0', id: $1::JSON, error: $2::JSON})"; + SetJson(res, 200, McpScalar(con, envelope, {Value(id), Value(error.empty() ? result : error)})); +} + //! Parse application/x-www-form-urlencoded query into httplib Params. void ParseQueryStringIntoParams(const string &query, duckdb_httplib::Params ¶ms) { if (query.empty()) { @@ -3224,6 +3422,15 @@ void QuackapiHttpServer::HandleRequest(const duckdb_httplib::Request &req, duckd } } + // Built-in MCP endpoint, generated from the same route catalog as /openapi.json. + if (req.method == "POST" && (req.path == "/mcp" || req.path == "/mcp/")) { + route_name = "mcp"; + route_path = "/mcp"; + HandleMcp(req, res, *db, StringUtil::Format("http://%s:%d", host, port)); + finish(); + return; + } + // Built-in OPTIONS for docs paths: 204 preflight only when CORS is on; // otherwise 405 like FastAPI without CORSMiddleware. if (req.method == "OPTIONS" && (req.path == "/openapi.json" || req.path == "/docs" || req.path == "/docs/" || diff --git a/test/sql/quackapi_mcp.test b/test/sql/quackapi_mcp.test new file mode 100644 index 0000000..42bade4 --- /dev/null +++ b/test/sql/quackapi_mcp.test @@ -0,0 +1,103 @@ +# name: test/sql/quackapi_mcp.test +# description: POST /mcp speaks MCP (JSON-RPC) over the same routes /openapi.json describes +# group: [sql] + +require quackapi + +require json + +statement ok +CREATE ROUTE add POST '/add' PARAM a INTEGER PARAM b INTEGER AS SELECT ($a + $b) AS sum; + +statement ok +CREATE ROUTE hello GET '/hello' PARAM name VARCHAR DEFAULT 'world' AS SELECT 'hi ' || $name AS msg; + +# initialize +query II +SELECT status, decode(body)::JSON ->> '$.result.serverInfo.name' +FROM quackapi_request('POST', '/mcp', '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'); +---- +200 quackapi + +# a notification (no id) gets 202 and no body +query II +SELECT status, decode(body) +FROM quackapi_request('POST', '/mcp', '{"jsonrpc":"2.0","method":"notifications/initialized"}'); +---- +202 (empty) + +# tools/list: one tool per route, its params (from the same document as /openapi.json) as the input schema +# NB: parameter order is platform-dependent (unordered_map iteration differs +# on MSVC vs libstdc++), so sort the key lists before comparing. +query IIII +SELECT t ->> 'name', t ->> 'description', + list_sort(json_keys(t -> 'inputSchema' -> 'properties')), + list_sort(from_json(t -> 'inputSchema' ->> 'required', '["VARCHAR"]')) +FROM quackapi_request('POST', '/mcp', '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'), + UNNEST(from_json(decode(body)::JSON -> '$.result.tools', '["JSON"]')) AS u(t) +ORDER BY 1; +---- +add POST /add [a, b] [a, b] +hello GET /hello [name] [] + +# tools/call on a POST route: the arguments are the JSON body +query II +SELECT decode(body)::JSON ->> '$.result.content[0].text', decode(body)::JSON ->> '$.result.isError' +FROM quackapi_request('POST', '/mcp', '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":40}}}'); +---- +[{"sum":42}] false + +# tools/call on a GET route: the arguments are the query string +query I +SELECT decode(body)::JSON ->> '$.result.content[0].text' +FROM quackapi_request('POST', '/mcp', '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"hello","arguments":{"name":"duck"}}}'); +---- +[{"msg":"hi duck"}] + +# a failing route is a tool result with isError, not a JSON-RPC error +query I +SELECT decode(body)::JSON ->> '$.result.isError' +FROM quackapi_request('POST', '/mcp', '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"add","arguments":{"a":"x","b":1}}}'); +---- +true + +# unknown tool and unknown method are JSON-RPC errors +query I +SELECT decode(body)::JSON ->> '$.error.code' +FROM quackapi_request('POST', '/mcp', '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"nope","arguments":{}}}'); +---- +-32602 + +query I +SELECT decode(body)::JSON ->> '$.error.code' +FROM quackapi_request('POST', '/mcp', '{"jsonrpc":"2.0","id":7,"method":"resources/list"}'); +---- +-32601 + +# tools/call substitutes path params into the pattern (the literal placeholder +# must not leak through as the captured value) +statement ok +CREATE ROUTE mcpuser GET '/mcpusers/:id' AS SELECT $id AS uid; + +query I +SELECT decode(body)::JSON ->> '$.result.content[0].text' +FROM quackapi_request('POST', '/mcp', '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"mcpuser","arguments":{"id":42}}}'); +---- +[{"uid":"42"}] + +# same for the {id} brace form on a POST route (arguments stay the JSON body) +statement ok +CREATE ROUTE mcppostuser POST '/mcppusers/{id}' AS SELECT $id AS uid; + +query I +SELECT decode(body)::JSON ->> '$.result.content[0].text' +FROM quackapi_request('POST', '/mcp', '{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"mcppostuser","arguments":{"id":7}}}'); +---- +[{"uid":"7"}] + +# a path param with no argument value is -32602, not a silent placeholder value +query II +SELECT decode(body)::JSON ->> '$.error.code', decode(body)::JSON ->> '$.error.message' +FROM quackapi_request('POST', '/mcp', '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"mcpuser","arguments":{}}}'); +---- +-32602 Missing path parameter 'id' for tool 'mcpuser'