Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7038d83fd4
|
||
|
|
9b8e284cf0 | ||
|
|
b04c73be47
|
||
|
|
ed57dd854a | ||
|
|
b00dac7390
|
||
|
|
f6b36362ef
|
||
|
|
c651650b07
|
||
|
|
f2afad47cb
|
||
|
|
ddb3b2fdf6 | ||
|
|
b043a489b4 | ||
|
|
b75c73c8c6 | ||
|
|
b9e4cf7187 | ||
|
|
209cfe02a0 | ||
|
|
f700284785
|
||
|
|
602d552416 | ||
|
|
d741606984
|
||
|
|
84b1872f48
|
||
|
|
26745a9090 | ||
|
|
c9b2ef07f1 |
No files matched your search
+10
-53
@@ -1,57 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.5.0
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
|
||||
test_only:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release/.*|hotfix/.*|integration.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /release\/.*/
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
only: [ main ]
|
||||
tags:
|
||||
only: [ /v.*/ ]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /hotfix\/.*/
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,24 @@
|
||||
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
|
||||
name: qrun-release
|
||||
on:
|
||||
push: {}
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
jobs:
|
||||
release:
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
browsers: true
|
||||
browser-runner: browser-build
|
||||
postpublish:
|
||||
if: ${{ needs.release.outputs.capture == 'true' }}
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
@@ -0,0 +1 @@
|
||||
* @KofTwentyTwo
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
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
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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`).
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-middleware-mcp</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<version>${revision}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Middleware MCP</name>
|
||||
@@ -66,6 +66,9 @@
|
||||
<properties>
|
||||
<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>
|
||||
@@ -73,7 +76,9 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>0.40.0-SNAPSHOT</version>
|
||||
<!-- 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.21</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
@@ -97,7 +102,14 @@
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.14.0</version>
|
||||
</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>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
@@ -108,13 +120,48 @@
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>3.27.3</version>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
+7
@@ -33,6 +33,7 @@ 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;
|
||||
@@ -77,6 +78,7 @@ 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())
|
||||
@@ -84,6 +86,11 @@ public class TableListResourceHandler
|
||||
String tableName = entry.getKey();
|
||||
QTableMetaData table = entry.getValue();
|
||||
|
||||
if(!visible.contains(tableName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(Boolean.TRUE.equals(table.getIsHidden()))
|
||||
{
|
||||
continue;
|
||||
|
||||
+2
-1
@@ -34,6 +34,7 @@ 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;
|
||||
@@ -88,7 +89,7 @@ public class TableSchemaResourceHandler
|
||||
QContext.init(qInstance, sessionProvider.createSession(exchange));
|
||||
try
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
QTableMetaData table = TableAccess.isDiscoverable(qInstance, mcpMetaData, tableName) ? qInstance.getTable(tableName) : null;
|
||||
if(table == null)
|
||||
{
|
||||
String errorJson = "{\"error\": \"Table not found: " + tableName + "\"}";
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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,6 +30,7 @@ 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;
|
||||
|
||||
@@ -58,6 +59,7 @@ 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())
|
||||
@@ -65,6 +67,11 @@ 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,6 +31,7 @@ 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;
|
||||
|
||||
@@ -64,7 +65,11 @@ public class TableSchemaToolHandler extends AbstractQQQToolHandler
|
||||
return Map.of("error", "tableName is required");
|
||||
}
|
||||
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 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;
|
||||
if(table == null)
|
||||
{
|
||||
return Map.of("error", "Table not found: " + tableName);
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* 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));
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
/*
|
||||
* 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.setAuthentication(new QAuthenticationMetaData()
|
||||
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData()
|
||||
.withName("mock")
|
||||
.withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ class RunProcessToolHandlerTest
|
||||
qInstance.addProcess(new QProcessMetaData()
|
||||
.withName(PROCESS_NAME)
|
||||
.withLabel("Test Process")
|
||||
.addStep(new QBackendStepMetaData()
|
||||
.withStep(new QBackendStepMetaData()
|
||||
.withName("step1")
|
||||
.withCode(new QCodeReference(EchoBackendStep.class.getName(), QCodeType.JAVA))));
|
||||
|
||||
|
||||
Reference in new issue
Block a user