Skip to content
Closed
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
4 changes: 4 additions & 0 deletions src/include/quackapi_server.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
207 changes: 207 additions & 0 deletions src/quackapi_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value> 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<Value> 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<string, string> arg_map;
{
auto stmt = con.Prepare("SELECT key, $1::JSON ->> key FROM json_each($1::JSON)");
vector<Value> 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 &params) {
if (query.empty()) {
Expand Down Expand Up @@ -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/" ||
Expand Down
103 changes: 103 additions & 0 deletions test/sql/quackapi_mcp.test
Original file line number Diff line number Diff line change
@@ -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'
Loading