Compare commits

..
Author SHA1 Message Date
james.maes 27f22a873d Add .circleci/config.yml 2026-03-20 13:11:38 -05:00
16 changed files with 44 additions and 887 deletions

No files matched your search

+28 -11
View File
@@ -1,14 +1,31 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/reference/configuration-reference
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#jobs-overview & https://circleci.com/docs/reference/configuration-reference/#jobs
jobs:
say-hello:
# Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub.
# See: https://circleci.com/docs/guides/execution-managed/executor-intro/ & https://circleci.com/docs/reference/configuration-reference/#executor-job
docker:
# Specify the version you desire here
# See: https://circleci.com/developer/images/image/cimg/base
- image: cimg/base:current
# Add steps to the job
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#steps-overview & https://circleci.com/docs/reference/configuration-reference/#steps
steps:
# Checkout the code as the first step.
- checkout
- run:
name: "Say hello"
command: "echo Hello, World!"
# Orchestrate jobs using workflows
# See: https://circleci.com/docs/guides/orchestrate/workflows/ & https://circleci.com/docs/reference/configuration-reference/#workflows
workflows:
setup:
say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow.
# Inside the workflow, you define the jobs you want to run.
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
- say-hello
-7
View File
@@ -1,7 +0,0 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
-1
View File
@@ -1 +0,0 @@
* @KofTwentyTwo
-16
View File
@@ -1,16 +0,0 @@
profile: qbit
orb_version: '0.8'
default_branch: main
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: false
-11
View File
@@ -1,11 +0,0 @@
# qbit-middleware-mcp
## Knowledge base
A reviewed dossier for this repo lives in the second-brain vault:
- Hub / map of all QQQ notes: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-middleware-mcp.md`
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
Reviewed at commit `c9b2ef07f1c6` (branch `main`, 2026-07-04). Read the dossier before substantive work here — it covers the MCP tool/resource/prompt surface, the session-provider auth model, the expired `qqq-bom-pom:0.40.0-SNAPSHOT` dependency (clean builds currently fail), the AGPL metadata remnant in the `qbit-build-parent` parent pom, and the two test-only usages of qqq-4.0-removed APIs (`setAuthentication`, `addStep`).
+12 -53
View File
@@ -24,12 +24,12 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.2.1</version>
<version>1.5.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-middleware-mcp</artifactId>
<version>${revision}</version>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>QBit Middleware MCP</name>
@@ -64,12 +64,8 @@
</scm>
<properties>
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
<revision>0.1.0-SNAPSHOT</revision>
<mcp.sdk.version>0.12.0</mcp.sdk.version>
<maven.compiler.release>21</maven.compiler.release>
<!-- jacoco sets argLine; the empty default keeps the UTC argLine below valid when jacoco is not active -->
<argLine></argLine>
</properties>
<dependencyManagement>
@@ -77,9 +73,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<!-- Published QQQ 4.1 core (Central JAR/POM): the one BOM version shared by the four app-QBit patches. Needs the QQQ 4.1 family
(bearer-principal filter, table access); see the handoff for the compile evidence. -->
<version>4.1.0-RC.26</version>
<version>0.40.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -103,14 +97,7 @@
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Servlet filter API for the bearer authentication filter (provided by the Javalin/Jetty host) -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.0.0</version>
<scope>provided</scope>
<version>2.14.0</version>
</dependency>
<dependency>
@@ -121,48 +108,13 @@
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-postgres</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.220</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>2.0.3</version>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<!-- PostgreSQL instants (McpHttpSecurityTest) are only correct under a UTC JVM -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -Duser.timezone=UTC</argLine>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
@@ -175,4 +127,11 @@
</plugins>
</build>
<repositories>
<repository>
<id>sonatype-snapshots</id>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
</repository>
</repositories>
</project>
@@ -1,91 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.middlewaremcp;
import java.util.Set;
import com.kingsrook.qbits.middlewaremcp.model.MCPTableMetaData;
import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Which tables the CURRENT session may even learn about through MCP. A table is
** discoverable only if the server exposes it (allow list, not hidden, not
** excluded) AND the session's own permissions let it see the table (the same
** decision the application's metadata makes for that session), so listing and
** describing never disclose a table the caller could not open. Must be called
** with the caller's QContext in place.
*******************************************************************************/
public final class TableAccess
{
/***************************************************************************
** Utility class.
***************************************************************************/
private TableAccess()
{
}
/***************************************************************************
** The table names the current session may see, from the application's own
** metadata for that session.
***************************************************************************/
public static Set<String> visibleToSession() throws QException
{
return new MetaDataAction().execute(new MetaDataInput()).getTables().keySet();
}
/***************************************************************************
** Whether the table is exposed by this server (independent of the caller).
***************************************************************************/
public static boolean isExposed(QInstance qInstance, QMCPMetaData mcpMetaData, String tableName)
{
QTableMetaData table = qInstance.getTable(tableName);
if(table == null || Boolean.TRUE.equals(table.getIsHidden()))
{
return false;
}
Set<String> exposed = mcpMetaData.getExposedTableNames();
if(exposed != null && !exposed.contains(tableName))
{
return false;
}
MCPTableMetaData supplemental = (MCPTableMetaData) table.getSupplementalMetaData(MCPTableMetaData.TYPE);
return supplemental == null || !supplemental.isExcluded();
}
/***************************************************************************
** Exposed by this server and visible to the current session.
***************************************************************************/
public static boolean isDiscoverable(QInstance qInstance, QMCPMetaData mcpMetaData, String tableName) throws QException
{
return isExposed(qInstance, mcpMetaData, tableName) && visibleToSession().contains(tableName);
}
}
@@ -33,7 +33,6 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qbits.middlewaremcp.QMCPMetaData;
import com.kingsrook.qbits.middlewaremcp.TableAccess;
import com.kingsrook.qbits.middlewaremcp.model.MCPTableMetaData;
import com.kingsrook.qbits.middlewaremcp.session.MCPSessionProviderInterface;
import io.modelcontextprotocol.server.McpSyncServerExchange;
@@ -78,7 +77,6 @@ public class TableListResourceHandler
try
{
Set<String> exposedTableNames = mcpMetaData.getExposedTableNames();
Set<String> visible = TableAccess.visibleToSession();
List<Map<String, Object>> tables = new ArrayList<>();
for(Map.Entry<String, QTableMetaData> entry : qInstance.getTables().entrySet())
@@ -86,11 +84,6 @@ public class TableListResourceHandler
String tableName = entry.getKey();
QTableMetaData table = entry.getValue();
if(!visible.contains(tableName))
{
continue;
}
if(Boolean.TRUE.equals(table.getIsHidden()))
{
continue;
@@ -34,7 +34,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qbits.middlewaremcp.QMCPMetaData;
import com.kingsrook.qbits.middlewaremcp.TableAccess;
import com.kingsrook.qbits.middlewaremcp.model.MCPTableMetaData;
import com.kingsrook.qbits.middlewaremcp.session.MCPSessionProviderInterface;
import io.modelcontextprotocol.server.McpSyncServerExchange;
@@ -89,7 +88,7 @@ public class TableSchemaResourceHandler
QContext.init(qInstance, sessionProvider.createSession(exchange));
try
{
QTableMetaData table = TableAccess.isDiscoverable(qInstance, mcpMetaData, tableName) ? qInstance.getTable(tableName) : null;
QTableMetaData table = qInstance.getTable(tableName);
if(table == null)
{
String errorJson = "{\"error\": \"Table not found: " + tableName + "\"}";
@@ -1,60 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.middlewaremcp.session;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.McpSyncServerExchange;
/*******************************************************************************
** The session of an MCP call is the QQQ session the caller authenticated as on
** THIS HTTP request (see {@link com.kingsrook.qbits.middlewaremcp.transport.McpBearerAuthenticationFilter}),
** never a server-wide identity: each call runs as exactly its own caller, under
** that caller's permissions, security keys (tenant) and revocation state. A call
** without an authenticated principal, or with a system session, is refused.
*******************************************************************************/
public class HttpPrincipalMCPSessionProvider implements MCPSessionProviderInterface
{
/** Key of the authenticated QSession in the per-request transport context. */
public static final String CONTEXT_KEY = "com.kingsrook.qbits.middlewaremcp.session";
/***************************************************************************
** The calling principal's session, or a refusal.
***************************************************************************/
@Override
public QSession createSession(McpSyncServerExchange exchange) throws QException
{
McpTransportContext context = exchange == null ? null : exchange.transportContext();
Object value = context == null ? null : context.get(CONTEXT_KEY);
if(!(value instanceof QSession session) || session instanceof QSystemUserSession || session.getUser() == null)
{
throw new QAuthenticationException("No authenticated principal for this MCP request");
}
return session;
}
}
@@ -30,7 +30,6 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qbits.middlewaremcp.QMCPMetaData;
import com.kingsrook.qbits.middlewaremcp.TableAccess;
import com.kingsrook.qbits.middlewaremcp.model.MCPTableMetaData;
import com.kingsrook.qbits.middlewaremcp.session.MCPSessionProviderInterface;
@@ -59,7 +58,6 @@ public class ListTablesToolHandler extends AbstractQQQToolHandler
protected Object doExecute(Map<String, Object> arguments) throws QException
{
Set<String> exposedTableNames = mcpMetaData.getExposedTableNames();
Set<String> visible = TableAccess.visibleToSession();
List<Map<String, Object>> tables = new ArrayList<>();
for(Map.Entry<String, QTableMetaData> entry : qInstance.getTables().entrySet())
@@ -67,11 +65,6 @@ public class ListTablesToolHandler extends AbstractQQQToolHandler
String tableName = entry.getKey();
QTableMetaData table = entry.getValue();
if(!visible.contains(tableName))
{
continue;
}
// Skip hidden tables
if(Boolean.TRUE.equals(table.getIsHidden()))
{
@@ -31,7 +31,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qbits.middlewaremcp.QMCPMetaData;
import com.kingsrook.qbits.middlewaremcp.TableAccess;
import com.kingsrook.qbits.middlewaremcp.model.MCPTableMetaData;
import com.kingsrook.qbits.middlewaremcp.session.MCPSessionProviderInterface;
@@ -65,11 +64,7 @@ public class TableSchemaToolHandler extends AbstractQQQToolHandler
return Map.of("error", "tableName is required");
}
////////////////////////////////////////////////////////////////////////////
// a table that is not exposed, or that this caller cannot see, is reported //
// exactly like one that does not exist //
////////////////////////////////////////////////////////////////////////////
QTableMetaData table = TableAccess.isDiscoverable(qInstance, mcpMetaData, tableName) ? qInstance.getTable(tableName) : null;
QTableMetaData table = qInstance.getTable(tableName);
if(table == null)
{
return Map.of("error", "Table not found: " + tableName);
@@ -1,158 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.middlewaremcp.transport;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import com.kingsrook.qbits.middlewaremcp.session.HttpPrincipalMCPSessionProvider;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.McpTransportContextExtractor;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/*******************************************************************************
** Authenticates EVERY MCP HTTP request (initialize, list, read, call, notifications
** and stream opens alike) with the application's own authentication module, from
** an <code>Authorization: Bearer</code> token only: no cookies, API keys, basic
** credentials or query parameters are accepted here. The resulting QSession is
** carried to the tool handlers in the per-request transport context
** ({@link #extractor()}), so the MCP server holds no identity of its own. A
** request that cannot authenticate is answered 401 and never reaches the MCP
** server; an MCP session id does not substitute for credentials, because the
** token is checked again on every request.
*******************************************************************************/
public class McpBearerAuthenticationFilter implements Filter
{
private static final QLogger LOG = QLogger.getLogger(McpBearerAuthenticationFilter.class);
private static final String REQUEST_ATTRIBUTE = McpBearerAuthenticationFilter.class.getName() + ".session";
private static final String BEARER_PREFIX = "Bearer ";
private final QInstance qInstance;
/***************************************************************************
** Bind to the application whose authentication is used. Authentication
** must exist and permissions must be enforced in core actions, otherwise
** the tools would run unprotected.
***************************************************************************/
public McpBearerAuthenticationFilter(QInstance qInstance)
{
if(qInstance == null || qInstance.getAuthentication() == null)
{
throw new IllegalArgumentException("MCP over HTTP needs an application authentication provider");
}
if(!Boolean.TRUE.equals(qInstance.getEnforcePermissionsInCoreActions()))
{
throw new IllegalArgumentException("MCP over HTTP requires permission enforcement in core actions");
}
this.qInstance = qInstance;
}
/***************************************************************************
** Authenticate, then continue; or answer 401.
***************************************************************************/
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException
{
HttpServletRequest http = (HttpServletRequest) request;
HttpServletResponse answer = (HttpServletResponse) response;
String header = http.getHeader("Authorization");
if(header == null || !header.startsWith(BEARER_PREFIX) || header.length() == BEARER_PREFIX.length())
{
reject(answer);
return;
}
QSession session = null;
try
{
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
Map<String, String> context = new HashMap<>();
context.put("accessToken", header.substring(BEARER_PREFIX.length()).trim());
QContext.init(qInstance, null);
session = module.createSession(qInstance, qInstance.getAuthentication(), context);
}
catch(Exception e)
{
LOG.info("MCP request authentication failed", e);
}
finally
{
QContext.clear();
}
if(session == null || session instanceof QSystemUserSession || session.getUser() == null)
{
reject(answer);
return;
}
request.setAttribute(REQUEST_ATTRIBUTE, session);
chain.doFilter(request, response);
}
/***************************************************************************
** 401 with a challenge and no detail.
***************************************************************************/
private static void reject(HttpServletResponse response) throws IOException
{
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setHeader("WWW-Authenticate", "Bearer");
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"unauthorized\"}");
}
/***************************************************************************
** The extractor that hands the request's authenticated session to the MCP
** server, for use when building the transport.
***************************************************************************/
public static McpTransportContextExtractor<HttpServletRequest> extractor()
{
return request ->
{
Object session = request.getAttribute(REQUEST_ATTRIBUTE);
return session == null ? McpTransportContext.EMPTY : McpTransportContext.create(Map.of(HttpPrincipalMCPSessionProvider.CONTEXT_KEY, session));
};
}
}
@@ -1,455 +0,0 @@
/*
* Copyright 2025 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.middlewaremcp;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Duration;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.middlewaremcp.model.MCPOperation;
import com.kingsrook.qbits.middlewaremcp.session.HttpPrincipalMCPSessionProvider;
import com.kingsrook.qbits.middlewaremcp.transport.McpBearerAuthenticationFilter;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
import io.javalin.Javalin;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.servlet.DispatcherType;
import org.eclipse.jetty.ee10.servlet.FilterHolder;
import org.eclipse.jetty.ee10.servlet.ServletHolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The Streamable HTTP transport as a host mounts it, driven by a REAL MCP client
** (the SDK's own client over HTTP): every request carries its own bearer token,
** the application's authentication module turns it into that caller's QSession,
** and the tools run under that session's table permissions and tenant lock.
** Read-only is the default; writes exist only when enabled, and then still only
** under the caller's real permissions.
*******************************************************************************/
class McpHttpSecurityTest
{
private static final ObjectMapper MAPPER = new ObjectMapper().findAndRegisterModules();
private QInstance instance;
private Javalin readOnlyServer;
private Javalin writableServer;
private McpSyncServer readOnlyMcp;
private McpSyncServer writableMcp;
private int readOnlyPort;
private int writablePort;
/*******************************************************************************
** An application with a tenant-locked table, a table the server does not
** expose, an exposed table nobody here may read, and two MCP servers.
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
ConnectionManager.resetConnectionProviders();
RDBMSBackendMetaData backend = new RDBMSBackendMetaData().withName("db").withVendor("h2").withHostName("mem")
.withDatabaseName("mcp_" + UUID.randomUUID().toString().replace("-", "")).withUsername("sa");
instance = new QInstance();
instance.setEnforcePermissionsInCoreActions(true);
instance.addBackend(backend);
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("tokens").withType(QAuthenticationType.CUSTOM)
.withModuleCodeReference(new QCodeReference(TokenAuthenticationModule.class)));
instance.addSecurityKeyType(new QSecurityKeyType().withName("tenant"));
instance.addTable(table("customer", true));
instance.addTable(table("vault", false));
instance.addTable(table("secret", false));
new com.kingsrook.qqq.backend.core.instances.QInstanceValidator().validate(instance);
try(Connection connection = ConnectionManager.getConnection(backend); Statement statement = connection.createStatement())
{
for(String name : List.of("customer", "vault", "secret"))
{
statement.execute("CREATE TABLE " + name + " (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100), tenant_id INT)");
}
statement.execute("INSERT INTO customer (name, tenant_id) VALUES ('Acme', 1), ('Bolt', 1), ('Cog', 2)");
statement.execute("INSERT INTO vault (name, tenant_id) VALUES ('V1', 1)");
statement.execute("INSERT INTO secret (name, tenant_id) VALUES ('S1', 1)");
}
Set<String> exposed = Set.of("customer", "vault");
readOnlyMcp = mcp(new QMCPMetaData().withServerName("test-readonly").withSessionProvider(new HttpPrincipalMCPSessionProvider()).withExposedTableNames(exposed));
readOnlyServer = serve(readOnlyMcp);
readOnlyPort = readOnlyServer.port();
writableMcp = mcp(new QMCPMetaData().withServerName("test-writable").withSessionProvider(new HttpPrincipalMCPSessionProvider()).withExposedTableNames(exposed)
.withDefaultTableOperations(EnumSet.of(MCPOperation.QUERY, MCPOperation.COUNT, MCPOperation.GET, MCPOperation.INSERT)));
writableServer = serve(writableMcp);
writablePort = writableServer.port();
}
/*******************************************************************************
** Stop everything.
*******************************************************************************/
@AfterEach
void tearDown()
{
readOnlyServer.stop();
writableServer.stop();
readOnlyMcp.close();
writableMcp.close();
}
/*******************************************************************************
** Without a valid bearer token nothing reaches the MCP server: not initialize,
** not a cookie, not a basic credential, not a made-up token.
*******************************************************************************/
@Test
void testUnauthenticatedRequestsAreRefused() throws Exception
{
String initialize = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{},\"clientInfo\":{\"name\":\"t\",\"version\":\"1\"}}}";
for(String authorization : new String[] { null, "", "Bearer", "Bearer ", "Basic YWxpY2U6cHc=", "Bearer not-a-token", "bearer alice-token" })
{
HttpRequest.Builder builder = HttpRequest.newBuilder(java.net.URI.create("http://localhost:" + readOnlyPort + "/mcp"))
.header("Content-Type", "application/json").header("Accept", "application/json, text/event-stream").header("Cookie", "sessionId=alice-token")
.POST(HttpRequest.BodyPublishers.ofString(initialize));
if(authorization != null)
{
builder.header("Authorization", authorization);
}
HttpResponse<String> response = HttpClient.newHttpClient().send(builder.build(), HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).as("authorization=" + authorization).isEqualTo(401);
assertThat(response.headers().firstValue("WWW-Authenticate")).contains("Bearer");
assertThat(response.body()).doesNotContain("serverInfo");
}
}
/*******************************************************************************
** initialize, list, read and query as one tenant's user: only what that
** caller's permissions and tenant allow.
*******************************************************************************/
@Test
void testAliceSeesOnlyHerPermittedTenantData() throws Exception
{
try(McpSyncClient client = client(readOnlyPort, "alice-token"))
{
McpSchema.InitializeResult init = client.initialize();
assertThat(init.serverInfo().name()).isEqualTo("test-readonly");
List<String> tools = client.listTools().tools().stream().map(McpSchema.Tool::name).toList();
assertThat(tools).contains("list_tables", "describe_table", "query_customer", "count_customer", "get_customer");
assertThat(tools).noneMatch(name -> name.startsWith("insert_") || name.startsWith("update_") || name.startsWith("delete_") || name.startsWith("run_process_"));
assertThat(tools).doesNotContain("query_secret");
assertThat(client.listResources().resources().stream().map(McpSchema.Resource::uri)).contains("qqq://tables");
assertThat(client.listPrompts().prompts().stream().map(McpSchema.Prompt::name)).contains("explore_table", "summarize_data");
// tables: customer only (vault is exposed but alice cannot read it; secret is not exposed)
String tableList = text(client.callTool(new McpSchema.CallToolRequest("list_tables", Map.of())));
assertThat(tableList).contains("customer").doesNotContain("vault").doesNotContain("secret");
String resourceList = ((McpSchema.TextResourceContents) client.readResource(new McpSchema.ReadResourceRequest("qqq://tables")).contents().get(0)).text();
assertThat(resourceList).contains("customer").doesNotContain("vault").doesNotContain("secret");
String schema = ((McpSchema.TextResourceContents) client.readResource(new McpSchema.ReadResourceRequest("qqq://table/customer/schema")).contents().get(0)).text();
assertThat(schema).contains("name");
assertThat(text(client.callTool(new McpSchema.CallToolRequest("describe_table", Map.of("tableName", "customer"))))).contains("tenantId");
// not exposed / not visible: reported exactly like a table that does not exist
String missing = text(client.callTool(new McpSchema.CallToolRequest("describe_table", Map.of("tableName", "no_such_table"))));
assertThat(text(client.callTool(new McpSchema.CallToolRequest("describe_table", Map.of("tableName", "secret"))))).isEqualTo(missing.replace("no_such_table", "secret"));
assertThat(text(client.callTool(new McpSchema.CallToolRequest("describe_table", Map.of("tableName", "vault"))))).isEqualTo(missing.replace("no_such_table", "vault"));
String vaultSchema = ((McpSchema.TextResourceContents) client.readResource(new McpSchema.ReadResourceRequest("qqq://table/vault/schema")).contents().get(0)).text();
assertThat(vaultSchema).contains("not found");
// data: tenant 1 only
String query = text(client.callTool(new McpSchema.CallToolRequest("query_customer", Map.of())));
assertThat(query).contains("Acme").contains("Bolt").doesNotContain("Cog");
assertThat(text(client.callTool(new McpSchema.CallToolRequest("count_customer", Map.of())))).contains("2");
// the other tenant's record, by id, is not available
McpSchema.CallToolResult other = client.callTool(new McpSchema.CallToolRequest("get_customer", Map.of("id", 3)));
assertThat(text(other)).doesNotContain("Cog");
// an exposed table she cannot read: refused with the real permission error
McpSchema.CallToolResult denied = client.callTool(new McpSchema.CallToolRequest("query_vault", Map.of()));
assertThat(denied.isError()).isTrue();
assertThat(text(denied)).containsIgnoringCase("permission").doesNotContain("V1");
}
}
/*******************************************************************************
** The same server, another caller: each request is its own caller, so another
** tenant's user sees only their own rows, and a caller with no permissions is
** refused everything.
*******************************************************************************/
@Test
void testOtherActorsAreSeparate() throws Exception
{
try(McpSyncClient bob = client(readOnlyPort, "bob-token"); McpSyncClient eve = client(readOnlyPort, "eve-token"); McpSyncClient alice = client(readOnlyPort, "alice-token"))
{
bob.initialize();
eve.initialize();
alice.initialize();
String bobRows = text(bob.callTool(new McpSchema.CallToolRequest("query_customer", Map.of())));
assertThat(bobRows).contains("Cog").doesNotContain("Acme").doesNotContain("Bolt");
String aliceRows = text(alice.callTool(new McpSchema.CallToolRequest("query_customer", Map.of())));
assertThat(aliceRows).contains("Acme").doesNotContain("Cog");
McpSchema.CallToolResult eveQuery = eve.callTool(new McpSchema.CallToolRequest("query_customer", Map.of()));
assertThat(eveQuery.isError()).isTrue();
assertThat(text(eveQuery)).doesNotContain("Acme").doesNotContain("Cog");
assertThat(text(eve.callTool(new McpSchema.CallToolRequest("list_tables", Map.of())))).doesNotContain("customer");
}
}
/*******************************************************************************
** Read-only by default: no write tools exist, and a write call is unknown. With
** writes enabled they still run only under the caller's own permission and
** tenant lock.
*******************************************************************************/
@Test
void testMutationsNeedBothOptInAndPermission() throws Exception
{
try(McpSyncClient writer = client(readOnlyPort, "writer-token"))
{
writer.initialize();
assertThatThrownBy(() -> writer.callTool(new McpSchema.CallToolRequest("insert_customer", Map.of("record", Map.of("name", "X", "tenantId", 1))))).isInstanceOf(RuntimeException.class);
}
try(McpSyncClient writer = client(writablePort, "writer-token"); McpSyncClient alice = client(writablePort, "alice-token"))
{
writer.initialize();
alice.initialize();
assertThat(writer.listTools().tools().stream().map(McpSchema.Tool::name)).contains("insert_customer").doesNotContain("update_customer", "delete_customer");
McpSchema.CallToolResult inserted = writer.callTool(new McpSchema.CallToolRequest("insert_customer", Map.of("record", Map.of("name", "Dyna", "tenantId", 1))));
assertThat(inserted.isError()).as(text(inserted)).isFalse();
assertThat(text(inserted)).as("insert result").doesNotContain("rror");
assertThat(text(writer.callTool(new McpSchema.CallToolRequest("query_customer", Map.of())))).contains("Dyna");
// the tenant lock applies to the write itself
McpSchema.CallToolResult crossTenant = writer.callTool(new McpSchema.CallToolRequest("insert_customer", Map.of("record", Map.of("name", "Sneaky", "tenantId", 2))));
assertThat(text(crossTenant)).doesNotContain("\"id\" : 5");
try(McpSyncClient bob = client(writablePort, "bob-token"))
{
bob.initialize();
assertThat(text(bob.callTool(new McpSchema.CallToolRequest("query_customer", Map.of())))).doesNotContain("Sneaky").doesNotContain("Dyna");
}
// alice has no insert permission: refused, nothing written
McpSchema.CallToolResult aliceInsert = alice.callTool(new McpSchema.CallToolRequest("insert_customer", Map.of("record", Map.of("name", "Nope", "tenantId", 1))));
assertThat(aliceInsert.isError()).isTrue();
assertThat(text(alice.callTool(new McpSchema.CallToolRequest("query_customer", Map.of())))).doesNotContain("Nope").doesNotContain("Sneaky");
}
}
/*******************************************************************************
** The server can never run as a fixed or system identity over HTTP, and the HTTP
** transport will not start without authentication and enforcement.
*******************************************************************************/
@Test
void testNoFixedOrSystemIdentity() throws Exception
{
HttpPrincipalMCPSessionProvider provider = new HttpPrincipalMCPSessionProvider();
assertThatThrownBy(() -> provider.createSession(null)).isInstanceOf(QAuthenticationException.class);
QInstance noEnforcement = new QInstance();
noEnforcement.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("tokens").withType(QAuthenticationType.CUSTOM)
.withModuleCodeReference(new QCodeReference(TokenAuthenticationModule.class)));
assertThatThrownBy(() -> new McpBearerAuthenticationFilter(noEnforcement)).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("enforcement");
assertThatThrownBy(() -> new McpBearerAuthenticationFilter(new QInstance())).isInstanceOf(IllegalArgumentException.class);
// a system session is never accepted as a principal, even from a token
try(McpSyncClient system = client(readOnlyPort, "system-token"))
{
assertThatThrownBy(system::initialize).isInstanceOf(RuntimeException.class);
}
}
/*******************************************************************************
** The text of a tool result.
*******************************************************************************/
private static String text(McpSchema.CallToolResult result)
{
return ((McpSchema.TextContent) result.content().get(0)).text();
}
/*******************************************************************************
** A real MCP client over HTTP with its own bearer token.
*******************************************************************************/
private static McpSyncClient client(int port, String token)
{
HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint("/mcp")
.customizeRequest(request -> request.header("Authorization", "Bearer " + token)).build();
return McpClient.sync(transport).requestTimeout(Duration.ofSeconds(20)).initializationTimeout(Duration.ofSeconds(20)).build();
}
/*******************************************************************************
** A table; the first is tenant-locked.
*******************************************************************************/
private static QTableMetaData table(String name, boolean tenantLocked)
{
QTableMetaData table = new QTableMetaData().withName(name).withBackendName("db").withPrimaryKeyField("id")
.withBackendDetails(new RDBMSTableBackendDetails().withTableName(name))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS).withPermissionBaseName(name))
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withBackendName("id").withIsEditable(false))
.withField(new QFieldMetaData("name", QFieldType.STRING).withBackendName("name").withMaxLength(100))
.withField(new QFieldMetaData("tenantId", QFieldType.INTEGER).withBackendName("tenant_id"));
table.withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("tenant").withFieldName("tenantId"));
return table;
}
/*******************************************************************************
** An MCP server with the metadata, on the streamable servlet transport that
** extracts the authenticated session of each request.
*******************************************************************************/
private final java.util.Map<McpSyncServer, HttpServletStreamableServerTransportProvider> transports = new java.util.HashMap<>();
/*******************************************************************************
** Build one.
*******************************************************************************/
private McpSyncServer mcp(QMCPMetaData metaData)
{
HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider.builder()
.objectMapper(MAPPER).mcpEndpoint("/mcp").contextExtractor(McpBearerAuthenticationFilter.extractor()).build();
McpSyncServer server = QMCPServer.build(instance, metaData, transport);
transports.put(server, transport);
return server;
}
/*******************************************************************************
** Mount the transport, behind the authentication filter, in a Javalin server.
*******************************************************************************/
private Javalin serve(McpSyncServer server)
{
HttpServletStreamableServerTransportProvider transport = transports.get(server);
return Javalin.create(config -> config.jetty.modifyServletContextHandler(context ->
{
context.addFilter(new FilterHolder(new McpBearerAuthenticationFilter(instance)), "/mcp/*", EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC));
context.addServlet(new ServletHolder("mcp", transport), "/mcp/*");
})).start(0);
}
/*******************************************************************************
** Tokens to synthetic identities: the application's authentication module.
*******************************************************************************/
public static class TokenAuthenticationModule implements QAuthenticationModuleInterface
{
/***************************************************************************
** Resolve the token (a real module would verify it with its provider).
***************************************************************************/
@Override
public QSession createSession(QInstance qInstance, Map<String, String> context) throws QAuthenticationException
{
String token = context.get("accessToken");
if(token == null)
{
throw new QAuthenticationException("no token");
}
QSession session = switch(token)
{
case "alice-token" -> identity("alice", 1, "customer.read");
case "bob-token" -> identity("bob", 2, "customer.read");
case "eve-token" -> identity("eve", 1);
case "writer-token" -> identity("writer", 1, "customer.read", "customer.write");
case "system-token" -> new QSystemUserSession();
default -> throw new QAuthenticationException("unknown token");
};
return session;
}
/***************************************************************************
** A synthetic user.
***************************************************************************/
private static QSession identity(String user, int tenant, String... permissions)
{
QSession session = new QSession().withUser(new QUser().withIdReference(user).withFullName(user)).withSecurityKeyValue("tenant", tenant);
session.setUuid(UUID.randomUUID().toString());
session.setIdReference(session.getUuid());
session.setPermissions(Set.of(permissions));
return session;
}
/***************************************************************************
** Always valid for this test module.
***************************************************************************/
@Override
public boolean isSessionValid(QInstance instance, QSession session)
{
return true;
}
}
}
@@ -51,7 +51,7 @@ public class TestUtils
public static QInstance defineInstance()
{
QInstance qInstance = new QInstance();
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData()
qInstance.setAuthentication(new QAuthenticationMetaData()
.withName("mock")
.withType(QAuthenticationType.FULLY_ANONYMOUS));
@@ -65,7 +65,7 @@ class RunProcessToolHandlerTest
qInstance.addProcess(new QProcessMetaData()
.withName(PROCESS_NAME)
.withLabel("Test Process")
.withStep(new QBackendStepMetaData()
.addStep(new QBackendStepMetaData()
.withName("step1")
.withCode(new QCodeReference(EchoBackendStep.class.getName(), QCodeType.JAVA))));