Add MCP server with OAuth/API-key auth (#6570)

Adds an optional MCP server (proprietary module) that exposes Stirling's
PDF operations and AI capabilities to MCP clients. Off by default, zero
footprint when disabled.

### What
- New `/mcp` endpoint: streamable-HTTP + JSON-RPC 2.0; 8 tools
(describe_operation, pages/convert/misc/security category tools, AI,
upload, download).
- Runs real operations over an internal loopback; results returned
inline as base64 (small) or by fileId (large).

### Auth (two modes)
- OAuth2 resource server: RFC 9728 protected-resource metadata, RFC 8707
audience binding, JWKS, `mcp.tools.read/write` scopes; binds each token
to a provisioned Stirling account.
- API-key mode: reuses Stirling per-user `X-API-KEY` (no IdP needed).

### Security
- Per-user file ownership in FileStorage: async/queued writes scoped to
the submitting user; legacy/owner-less files stay readable.
- Admin allow/block list controls which operations are exposed.
- Python engine gated behind a shared secret (`X-Engine-Auth`).
- MCP filter chain is isolated and cannot weaken the main app's
security.
- Hardened: no upstream error-body leakage, log injection sanitized,
fileId path/sidecar enumeration blocked.

### Config / footprint
- Disabled by default (`mcp.enabled=false`); all beans
`@ConditionalOnProperty`.
---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-06-10 09:46:25 +00:00
committed by GitHub
parent 84aca12055
commit 3ecd95b779
80 changed files with 7712 additions and 56 deletions
@@ -0,0 +1,134 @@
services:
keycloak-mcp-db:
container_name: stirling-keycloak-mcp-db
image: postgres:16-alpine
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 5s
timeout: 5s
retries: 10
networks:
- stirling-mcp-test
keycloak-mcp:
container_name: stirling-keycloak-mcp
image: quay.io/keycloak/keycloak:24.0
command:
- start-dev
- --import-realm
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-mcp-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
# Issuer hostname must resolve identically from host and Stirling container (see start-mcp-test.sh preflight).
KC_HOSTNAME: "${KEYCLOAK_HOST:-kubernetes.docker.internal}"
KC_HOSTNAME_PORT: 9080
KC_HOSTNAME_STRICT: "false"
KC_HTTP_ENABLED: "true"
ports:
- "9080:8080"
volumes:
- ./keycloak-realm-mcp.json:/opt/keycloak/data/import/realm-export.json:ro
depends_on:
keycloak-mcp-db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/stirling-mcp HTTP/1.1\\nHost: localhost\\nConnection: close\\n\\n' >&3 && timeout 2 cat <&3 | head -n 1 | grep -q '200'"]
interval: 10s
timeout: 10s
retries: 30
start_period: 60s
networks:
- stirling-mcp-test
stirling-pdf-mcp:
container_name: stirling-pdf-mcp-test
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
build:
context: ../..
dockerfile: docker/embedded/Dockerfile
extra_hosts:
localhost: host-gateway
kubernetes.docker.internal: host-gateway
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 30
ports:
- "8080:8080"
volumes:
- ../../../stirling/keycloak-mcp-test/data:/usr/share/tessdata:rw
- ../../../stirling/keycloak-mcp-test/config:/configs:rw
- ../../../stirling/keycloak-mcp-test/logs:/logs:rw
environment:
# Security/login on so we have real Stirling accounts to bind to.
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_LOGINMETHOD: "normal"
SYSTEM_DEFAULTLOCALE: en-US
SYSTEM_BACKENDURL: "http://localhost:8080"
PREMIUM_KEY: "${PREMIUM_KEY:-00000000-0000-0000-0000-000000000000}"
PREMIUM_ENABLED: "true"
UI_APPNAME: Stirling-PDF MCP Test
UI_HOMEDESCRIPTION: Keycloak MCP (OAuth resource server) Test Instance
UI_APPNAMENAVBAR: Stirling-PDF MCP
SYSTEM_MAXFILESIZE: "100"
# Seed the Stirling account the Keycloak "email" claim binds to (proves account-binding).
SECURITY_INITIALLOGIN_USERNAME: "[email protected]"
SECURITY_INITIALLOGIN_PASSWORD: "mcppassword"
# MCP server: OAuth2 resource-server mode (validates Keycloak tokens at /mcp, no web SSO).
MCP_ENABLED: "true"
MCP_AUTH_MODE: "${MCP_AUTH_MODE:-oauth}"
# Lowered so the oversized-body 413 check stays fast; production default is 10MB.
MCP_MAXREQUESTBYTES: "262144"
# Must match the token "iss" exactly.
MCP_AUTH_ISSUERURI: "http://${KEYCLOAK_HOST:-kubernetes.docker.internal}:9080/realms/stirling-mcp"
# RFC 8707 audience - must equal the "aud" the realm's mapper injects.
MCP_AUTH_RESOURCEID: "http://localhost:8080/mcp"
# Bind the JWT to a Stirling user by the "email" claim.
MCP_AUTH_USERNAMECLAIM: "email"
MCP_AUTH_REQUIREEXISTINGACCOUNT: "true"
# Resource server only - disable the web SSO client paths.
SECURITY_OAUTH2_ENABLED: "false"
SECURITY_SAML2_ENABLED: "false"
# Debug logging
LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_SECURITY_OAUTH2: DEBUG
LOGGING_LEVEL_STIRLING_SOFTWARE_PROPRIETARY_MCP: DEBUG
# LibreOffice settings
PROCESS_EXECUTOR_AUTO_UNO_SERVER: "true"
PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT: "1"
# Permissions
PUID: 1002
PGID: 1002
UMASK: "022"
# Features
DISABLE_ADDITIONAL_FEATURES: "false"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SHOW_SURVEY: "false"
depends_on:
keycloak-mcp:
condition: service_healthy
networks:
- stirling-mcp-test
restart: on-failure:5
networks:
stirling-mcp-test:
driver: bridge
+438
View File
@@ -0,0 +1,438 @@
{
"id": "stirling-mcp",
"realm": "stirling-mcp",
"displayName": "Stirling PDF MCP Test",
"displayNameHtml": "<div class=\"kc-logo-text\"><span>Stirling PDF MCP</span></div>",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"registrationEmailAsUsername": true,
"rememberMe": true,
"verifyEmail": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": false,
"accessTokenLifespan": 300,
"accessTokenLifespanForImplicitFlow": 900,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"offlineSessionIdleTimeout": 2592000,
"users": [
{
"username": "[email protected]",
"email": "[email protected]",
"emailVerified": true,
"firstName": "MCP",
"lastName": "TestUser",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "mcppassword",
"temporary": false
}
],
"realmRoles": ["user"]
},
{
"username": "[email protected]",
"email": "[email protected]",
"emailVerified": true,
"firstName": "Ghost",
"lastName": "NoAccount",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "ghostpassword",
"temporary": false
}
],
"realmRoles": ["user"]
}
],
"roles": {
"realm": [
{
"name": "user",
"description": "Regular user role",
"composite": false,
"clientRole": false
}
]
},
"clients": [
{
"clientId": "mcp-test-client",
"name": "Stirling PDF MCP Test Client (confidential)",
"description": "Confidential client for scripted MCP testing: ROPC (direct grant) for the validation script and standard/PKCE flow for browser-based MCP clients.",
"rootUrl": "http://localhost:8080",
"baseUrl": "http://localhost:8080",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "mcp-test-secret",
"redirectUris": [
"http://localhost:8080/*",
"http://localhost:6274/*",
"http://127.0.0.1:6274/*"
],
"webOrigins": ["*"],
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {
"oidc.ciba.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"oauth2.device.authorization.grant.enabled": "false",
"display.on.consent.screen": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"pkce.code.challenge.method": "S256"
},
"fullScopeAllowed": true,
"protocolMappers": [
{
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String"
}
},
{
"name": "preferred_username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "preferred_username",
"jsonType.label": "String"
}
},
{
"name": "mcp-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "http://localhost:8080/mcp",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
],
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"email",
"mcp.tools.read",
"mcp.tools.write"
],
"optionalClientScopes": ["offline_access", "microprofile-jwt"]
},
{
"clientId": "mcp-client",
"name": "MCP public client (authorization-code + PKCE)",
"description": "Generic public client for any browser-based MCP client. Authorization-code + PKCE, no secret. Broad localhost redirect URIs so any local MCP client callback port is accepted.",
"rootUrl": "http://localhost",
"baseUrl": "http://localhost",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
"http://localhost:*",
"http://127.0.0.1:*",
"https://localhost:*",
"https://127.0.0.1:*"
],
"webOrigins": ["*"],
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": true,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {
"oidc.ciba.grant.enabled": "false",
"oauth2.device.authorization.grant.enabled": "false",
"display.on.consent.screen": "false",
"pkce.code.challenge.method": "S256"
},
"fullScopeAllowed": true,
"protocolMappers": [
{
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String"
}
},
{
"name": "preferred_username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "preferred_username",
"jsonType.label": "String"
}
},
{
"name": "mcp-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "http://localhost:8080/mcp",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
],
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"email",
"mcp.tools.read",
"mcp.tools.write"
],
"optionalClientScopes": ["offline_access", "microprofile-jwt"]
},
{
"clientId": "other-client",
"name": "Unrelated client (negative test)",
"description": "Issues VALID tokens (good signature + issuer) that do NOT carry the MCP audience and have no mcp.tools.* scopes - used to prove the resource server rejects tokens minted for a different audience (RFC 8707).",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"publicClient": true,
"bearerOnly": false,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"protocol": "openid-connect",
"attributes": {
"pkce.code.challenge.method": "S256"
},
"fullScopeAllowed": false,
"defaultClientScopes": ["web-origins", "acr", "profile", "roles", "email"],
"optionalClientScopes": ["offline_access", "microprofile-jwt"]
}
],
"clientScopes": [
{
"name": "mcp.tools.read",
"description": "MCP: read-only tool access (list / describe / inspect PDF operations)",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
}
},
{
"name": "mcp.tools.write",
"description": "MCP: write tool access (run PDF operations that produce output)",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
}
},
{
"name": "mcp-audience",
"description": "Injects the MCP resource-server audience (RFC 8707) into access tokens. A default scope so even dynamically-registered clients produce tokens Stirling accepts.",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "mcp-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "http://localhost:8080/mcp",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
},
{
"name": "email",
"description": "OpenID Connect built-in scope: email",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String"
}
},
{
"name": "email verified",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "emailVerified",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email_verified",
"jsonType.label": "boolean"
}
}
]
},
{
"name": "profile",
"description": "OpenID Connect built-in scope: profile",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "preferred_username",
"jsonType.label": "String"
}
}
]
},
{
"name": "roles",
"description": "OpenID Connect scope for user roles",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "realm roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "roles",
"jsonType.label": "String",
"multivalued": "true"
}
}
]
}
],
"defaultDefaultClientScopes": [
"profile",
"email",
"roles",
"web-origins",
"acr",
"mcp.tools.read",
"mcp.tools.write",
"mcp-audience"
],
"defaultOptionalClientScopes": ["offline_access", "microprofile-jwt"],
"browserSecurityHeaders": {
"contentSecurityPolicyReportOnly": "",
"xContentTypeOptions": "nosniff",
"referrerPolicy": "no-referrer",
"xRobotsTag": "none",
"xFrameOptions": "SAMEORIGIN",
"contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';",
"xXSSProtection": "1; mode=block",
"strictTransportSecurity": "max-age=31536000; includeSubDomains"
},
"eventsEnabled": false,
"eventsListeners": ["jboss-logging"],
"enabledEventTypes": [],
"adminEventsEnabled": false,
"adminEventsDetailsEnabled": false,
"internationalizationEnabled": false,
"supportedLocales": [],
"components": {
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
{
"name": "Trusted Hosts",
"providerId": "trusted-hosts",
"subType": "anonymous",
"config": {
"trusted-hosts": ["localhost", "127.0.0.1"],
"host-sending-registration-request-must-match": ["false"],
"client-uris-must-match": ["true"]
}
},
{
"name": "Allowed Client Scopes",
"providerId": "allowed-client-templates",
"subType": "anonymous",
"config": {
"allow-default-scopes": ["true"]
}
}
]
},
"keycloakVersion": "24.0.0"
}
@@ -0,0 +1,2 @@
node_modules/
package-lock.json
+100
View File
@@ -0,0 +1,100 @@
/**
* Real MCP client validation via the official @modelcontextprotocol/sdk over streamable HTTP.
*
* Auth header (non-interactive):
* MCP_BEARER=<jwt> -> Authorization: Bearer <jwt> (oauth)
* MCP_APIKEY=<key> -> X-API-KEY: <key> (apikey)
* Env: MCP_URL (default http://localhost:8080/mcp), MODE (output label). Exit 0 = passed.
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const url = process.env.MCP_URL || "http://localhost:8080/mcp";
const mode = process.env.MODE || "oauth";
const bearer = process.env.MCP_BEARER;
const apikey = process.env.MCP_APIKEY;
const headers = {};
if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
if (apikey) headers["X-API-KEY"] = apikey;
function fail(msg) {
console.error(`[${mode}] FAIL: ${msg}`);
process.exit(1);
}
const REQUIRED = [
"stirling_describe_operation",
"stirling_convert",
"stirling_pages",
"stirling_misc",
"stirling_security",
"stirling_upload",
"stirling_download",
];
const transport = new StreamableHTTPClientTransport(new URL(url), {
requestInit: { headers },
});
const client = new Client(
{ name: "stirling-mcp-validator", version: "1.0.0" },
{ capabilities: {} },
);
try {
// connect() performs the initialize handshake (version negotiation + capabilities)
await client.connect(transport);
const { tools } = await client.listTools();
const names = tools.map((t) => t.name).sort();
for (const r of REQUIRED) {
if (!names.includes(r)) fail(`tools/list missing ${r} (got: ${names.join(", ")})`);
}
const res = await client.callTool({
name: "stirling_describe_operation",
arguments: { operation: "add-password" },
});
if (!JSON.stringify(res).includes("parametersSchema")) {
fail(`describe_operation returned no parametersSchema: ${JSON.stringify(res).slice(0, 300)}`);
}
// File I/O round-trip: upload bytes, then download them back unchanged.
const original = "hello mcp round-trip";
const up = await client.callTool({
name: "stirling_upload",
arguments: { file: Buffer.from(original, "utf8").toString("base64"), fileName: "hello.txt" },
});
const upMatch = JSON.stringify(up).match(/fileId=([A-Za-z0-9_-]+)/);
if (!upMatch) fail(`stirling_upload returned no fileId: ${JSON.stringify(up).slice(0, 200)}`);
const fileId = upMatch[1];
const down = await client.callTool({ name: "stirling_download", arguments: { fileId } });
const resBlock = (down.content || []).find((b) => b.type === "resource");
if (!resBlock?.resource?.blob) {
fail(`stirling_download returned no resource blob: ${JSON.stringify(down).slice(0, 200)}`);
}
const roundTripped = Buffer.from(resBlock.resource.blob, "base64").toString("utf8");
if (roundTripped !== original) {
fail(`upload/download round-trip mismatch: got "${roundTripped}"`);
}
// A category tool with no file must surface an honest error, not a fake success.
const cat = await client.callTool({
name: "stirling_security",
arguments: { operation: "add-password" },
});
if (cat.isError !== true) {
fail(`stirling_security with no file should report isError, got: ${JSON.stringify(cat).slice(0, 200)}`);
}
console.log(
`[${mode}] OK: real MCP SDK client connected, negotiated protocol, listed ${names.length} tools, ` +
`describe_operation returned a schema, upload/download round-trip matched, category tool returned isError.`,
);
console.log(`[${mode}] tools: ${names.join(", ")}`);
await client.close();
process.exit(0);
} catch (e) {
fail(`${e?.stack || e?.message || e}`);
}
@@ -0,0 +1,10 @@
{
"name": "stirling-mcp-client-check",
"private": true,
"type": "module",
"version": "1.0.0",
"description": "Real MCP client (official @modelcontextprotocol/sdk) used to validate the Stirling MCP server end-to-end over streamable HTTP.",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0"
}
}
+290
View File
@@ -0,0 +1,290 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Manual-testing panel: generic "connect your MCP client" settings.
print_manual_panel() {
echo ""
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ MCP MANUAL TESTING - connect a client ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${GREEN}Add an MCP server in your client with these settings:${NC}"
echo ""
echo -e " Server URL ${YELLOW}http://localhost:8080/mcp${NC}"
echo -e " Connection type ${YELLOW}HTTP${NC} (streamable HTTP)"
echo -e " Authentication ${YELLOW}OAuth 2.0${NC}"
echo -e " OAuth scopes ${YELLOW}mcp.tools.read mcp.tools.write${NC}"
echo -e " OAuth client id ${YELLOW}mcp-client${NC} (public - leave the client secret blank)"
echo ""
echo -e " ${BLUE}The client redirects you to a sign-in page; log in with:${NC}"
echo -e " ${YELLOW}[email protected]${NC} / ${YELLOW}mcppassword${NC}"
echo ""
echo -e "${BLUE}Stop:${NC} docker-compose -f docker-compose-keycloak-mcp.yml down -v"
echo ""
}
# Manual API-key panel: mint a Stirling per-user key and print header-based client settings.
print_manual_panel_apikey() {
local jwt key
jwt=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"[email protected]","password":"mcppassword"}' \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
# Reuse the user's existing key if any; else create one.
key=$(curl -s -X POST http://localhost:8080/api/v1/user/get-api-key \
-H "Authorization: Bearer $jwt" \
| sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')
if [ -z "$key" ]; then
key=$(curl -s -X POST http://localhost:8080/api/v1/user/update-api-key \
-H "Authorization: Bearer $jwt" \
| sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')
fi
echo ""
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ MCP MANUAL TESTING - API KEY (no OAuth / no IdP) ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${GREEN}Add an MCP server in your client with these settings:${NC}"
echo ""
echo -e " Server URL ${YELLOW}http://localhost:8080/mcp${NC}"
echo -e " Connection type ${YELLOW}HTTP${NC} (streamable HTTP)"
echo -e " Authentication ${YELLOW}None${NC} (no OAuth - just a header)"
if [ -n "$key" ]; then
echo -e " Custom header ${YELLOW}X-API-KEY: $key${NC}"
else
echo -e " ${RED}(could not mint a key automatically; is the stack up in apikey mode?)${NC}"
fi
echo ""
echo -e " ${BLUE}No browser redirect, no IdP. The key maps to ${YELLOW}[email protected]${BLUE} and${NC}"
echo -e " ${BLUE}every call is audited as that user. Use this when a client's OAuth can't reach localhost.${NC}"
echo ""
echo -e "${BLUE}Stop:${NC} docker-compose -f docker-compose-keycloak-mcp.yml down -v"
echo ""
}
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Stirling PDF + Keycloak MCP Test Environment ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
echo ""
COMPOSE_UP_ARGS=(-d --build)
RUN_VALIDATE=false
MANUAL=false
APIKEY_MODE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--nobuild)
COMPOSE_UP_ARGS=(-d)
shift
;;
--validate)
RUN_VALIDATE=true
shift
;;
--manual)
MANUAL=true
shift
;;
--apikey)
# API-key manual testing mode (no OAuth/IdP), with a freshly minted key.
APIKEY_MODE=true
MANUAL=true
export MCP_AUTH_MODE=apikey
shift
;;
--license-key)
if [[ -z "${2:-}" ]]; then
echo -e "${RED}Missing value for --license-key${NC}"
exit 1
fi
export PREMIUM_KEY="$2"
shift 2
;;
--license-key=*)
export PREMIUM_KEY="${1#*=}"
shift
;;
-k)
if [[ -z "${2:-}" ]]; then
echo -e "${RED}Missing value for -k${NC}"
exit 1
fi
export PREMIUM_KEY="$2"
shift 2
;;
-h|--help)
echo "Usage: $0 [--nobuild] [--validate] [--manual] [--apikey] [--license-key <KEY>]"
echo ""
echo " --nobuild Skip building images (use existing images)"
echo " --validate Run validate-mcp-test.sh after the stack is up"
echo " --manual Manual testing mode (OAuth): bring the stack up and print"
echo " copy-paste client settings for the OAuth flow."
echo " --apikey Manual testing mode (API key): bring Stirling up in apikey"
echo " mode (no OAuth/IdP), mint a key, and print client settings."
echo " Ideal for clients whose OAuth can't reach localhost."
echo " --license-key <KEY> Premium license key (skips the interactive prompt)"
echo " Equivalent to setting PREMIUM_KEY in the environment."
exit 0
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
exit 1
;;
esac
done
if ! docker info > /dev/null 2>&1; then
echo -e "${RED}✗ Docker is not running${NC}"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Keycloak issuer hostname (must resolve on host + containers)
KEYCLOAK_HOST="${KEYCLOAK_HOST:-kubernetes.docker.internal}"
export KEYCLOAK_HOST
# Preflight: the host must resolve the issuer hostname.
if [ "${SKIP_MCP_PREFLIGHT:-false}" != "true" ]; then
if ! curl -sf --connect-timeout 2 --max-time 3 "http://${KEYCLOAK_HOST}:9080/realms/stirling-mcp" >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Cannot reach http://${KEYCLOAK_HOST}:9080 from this machine yet.${NC}"
echo -e "${YELLOW} That is expected before the stack is up. If validation later fails to${NC}"
echo -e "${YELLOW} resolve the host, add a hosts entry pointing ${KEYCLOAK_HOST} to 127.0.0.1:${NC}"
echo ""
echo -e "${BLUE}Windows:${NC} C:\\Windows\\System32\\drivers\\etc\\hosts"
echo -e "${BLUE}macOS/Linux:${NC} /etc/hosts"
echo ""
echo -e "${GREEN}127.0.0.1 ${KEYCLOAK_HOST}${NC}"
echo ""
fi
fi
# Prompt for license key (optional).
if [ -z "$PREMIUM_KEY" ]; then
echo -e "${YELLOW}Enter license key (press Enter to use default test key):${NC}"
read -r LICENSE_INPUT
if [ -n "$LICENSE_INPUT" ]; then
export PREMIUM_KEY="$LICENSE_INPUT"
echo -e "${GREEN}✓ Using provided license key${NC}"
else
echo -e "${BLUE}Using default test license key${NC}"
fi
echo ""
fi
echo -e "${YELLOW}▶ Starting Keycloak (MCP) containers...${NC}"
docker-compose -f docker-compose-keycloak-mcp.yml up "${COMPOSE_UP_ARGS[@]}" keycloak-mcp-db keycloak-mcp
echo ""
echo -e "${YELLOW}▶ Waiting for Keycloak (MCP)...${NC}"
MAX_WAIT=180
WAITED=0
while [ $WAITED -lt $MAX_WAIT ]; do
if curl -sf http://localhost:9080/realms/stirling-mcp > /dev/null 2>&1; then
echo -e "${GREEN}✓ Keycloak is ready${NC}"
break
fi
echo -n "."
sleep 2
WAITED=$((WAITED + 2))
done
if [ $WAITED -ge $MAX_WAIT ]; then
echo -e "${RED}✗ Keycloak failed to start${NC}"
exit 1
fi
echo ""
echo -e "${YELLOW}▶ Starting Stirling PDF (MCP resource server)...${NC}"
docker-compose -f docker-compose-keycloak-mcp.yml up "${COMPOSE_UP_ARGS[@]}" stirling-pdf-mcp
echo ""
echo -e "${YELLOW}▶ Waiting for Stirling PDF...${NC}"
WAITED=0
while [ $WAITED -lt $MAX_WAIT ]; do
if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q "UP"; then
echo -e "${GREEN}✓ Stirling PDF is ready${NC}"
break
fi
echo -n "."
sleep 2
WAITED=$((WAITED + 2))
done
if [ $WAITED -ge $MAX_WAIT ]; then
echo -e "${RED}✗ Stirling PDF failed to start${NC}"
exit 1
fi
echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ MCP Test Environment Ready! ✓ ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BLUE}🔑 Auth mode:${NC} ${GREEN}${MCP_AUTH_MODE:-oauth}${NC}"
echo ""
echo -e "${BLUE}📍 Services:${NC}"
echo -e " Stirling PDF: ${GREEN}http://localhost:8080${NC}"
echo -e " MCP endpoint: ${GREEN}http://localhost:8080/mcp${NC}"
if [ "$APIKEY_MODE" != true ]; then
echo -e " PRM metadata: ${GREEN}http://localhost:8080/.well-known/oauth-protected-resource${NC}"
fi
echo -e " Keycloak Admin: ${GREEN}http://${KEYCLOAK_HOST}:9080/admin${NC} (admin / admin)"
echo ""
echo -e "${BLUE}👤 Test user (exists in Keycloak AND as a Stirling account):${NC}"
echo -e " Email: ${GREEN}[email protected]${NC}"
echo -e " Password: ${GREEN}mcppassword${NC}"
echo ""
if [ "$APIKEY_MODE" != true ]; then
echo -e "${BLUE}👻 Negative-test user (valid Keycloak login, NO Stirling account):${NC}"
echo -e " Email: ${GREEN}[email protected]${NC}"
echo -e " Password: ${GREEN}ghostpassword${NC} (expect HTTP 403 at /mcp)"
echo ""
echo -e "${BLUE}🔐 OAuth clients:${NC}"
echo -e " public (for MCP clients): ${GREEN}mcp-client${NC} (authcode + PKCE, no secret)"
echo -e " confidential (scripts): ${GREEN}mcp-test-client${NC} / ${GREEN}mcp-test-secret${NC}"
echo ""
fi
if [ "$APIKEY_MODE" = true ]; then
echo -e "${BLUE}🧪 Automated validation (apikey):${NC}"
echo -e " bash testing/compose/validate-mcp-apikey.sh"
else
echo -e "${BLUE}🧪 Automated validation (no token 401, valid token 200, ghost 403):${NC}"
echo -e " bash testing/compose/validate-mcp-test.sh"
fi
echo ""
echo -e "${BLUE}🔌 Connect an MCP client: re-run with ${GREEN}--manual${NC} (OAuth) or ${GREEN}--apikey${NC} (header).${NC}"
echo ""
echo -e "${BLUE}📊 View logs:${NC}"
echo -e " docker-compose -f docker-compose-keycloak-mcp.yml logs -f"
echo ""
echo -e "${BLUE}⏹ Stop:${NC}"
echo -e " docker-compose -f docker-compose-keycloak-mcp.yml down -v"
echo ""
if [ "$RUN_VALIDATE" = true ]; then
echo -e "${YELLOW}▶ Running validation...${NC}"
echo ""
if [ "$APIKEY_MODE" = true ]; then
bash "$SCRIPT_DIR/validate-mcp-apikey.sh"
else
bash "$SCRIPT_DIR/validate-mcp-test.sh"
fi
fi
if [ "$APIKEY_MODE" = true ]; then
print_manual_panel_apikey
elif [ "$MANUAL" = true ]; then
print_manual_panel
else
echo -e "${BLUE}💡 Tip:${NC} re-run with ${GREEN}--manual${NC} (OAuth) or ${GREEN}--apikey${NC} (API-key header) for client setup."
echo ""
fi
+134
View File
@@ -0,0 +1,134 @@
#!/bin/bash
# Validate the MCP server in apikey auth mode (curl + real MCP SDK client), then restore oauth.
# Runs every check and exits non-zero on any failure.
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE="$SCRIPT_DIR/docker-compose-keycloak-mcp.yml"
CHECK_DIR="$SCRIPT_DIR/mcp-client-check"
MCP_URL="http://localhost:8080/mcp"
RPC_LIST='{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
PASS=0; FAIL=0
pass() { echo -e "${GREEN}${NC} $1"; PASS=$((PASS + 1)); }
fail() { echo -e "${RED}$1${NC}"; FAIL=$((FAIL + 1)); }
# Wait on the container healthcheck. Returns 0 once healthy, 1 after the timeout.
wait_up() {
local w=0
while [ $w -lt 360 ]; do
if [ "$(docker inspect -f '{{.State.Health.Status}}' stirling-pdf-mcp-test 2>/dev/null)" = "healthy" ]; then
echo ""
return 0
fi
sleep 5; w=$((w + 5)); echo -n "."
done
echo ""
return 1
}
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Validating MCP - API-KEY mode (+ real client) ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${YELLOW}▶ Recreating Stirling in apikey mode...${NC}"
MCP_AUTH_MODE=apikey PREMIUM_KEY="${PREMIUM_KEY:-}" \
docker compose -f "$COMPOSE" up -d --no-build --force-recreate stirling-pdf-mcp >/dev/null 2>&1
if ! wait_up; then
fail "Stirling did not become healthy in apikey mode"
echo -e "${RED}Aborting.${NC}"; exit 1
fi
# In apikey mode the OAuth metadata is not served (404, or 401 from the app chain).
META_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/.well-known/oauth-protected-resource)
if [ "$META_CODE" = "404" ] || [ "$META_CODE" = "401" ]; then
pass "apikey mode active (OAuth metadata not served -> $META_CODE)"
else
fail "expected OAuth metadata absent in apikey mode, got $META_CODE"
fi
echo -e "${YELLOW}▶ Minting a Stirling API key for mcpuser...${NC}"
JWT=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"[email protected]","password":"mcppassword"}' \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
if [ -n "$JWT" ]; then
pass "logged in as mcpuser (got a session token)"
else
fail "could not log in as mcpuser - cannot mint an API key"
fi
APIKEY=$(curl -s -X POST http://localhost:8080/api/v1/user/get-api-key \
-H "Authorization: Bearer $JWT" | sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')
if [ -z "$APIKEY" ]; then
APIKEY=$(curl -s -X POST http://localhost:8080/api/v1/user/update-api-key \
-H "Authorization: Bearer $JWT" | sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')
fi
if [ -n "$APIKEY" ]; then
pass "minted an API key for mcpuser"
else
fail "could not mint an API key"
fi
echo ""
echo -e "${YELLOW}[1] curl checks (apikey)${NC}"
NO_KEY=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" -H "Content-Type: application/json" -d "$RPC_LIST")
[ "$NO_KEY" = "401" ] && pass "no key -> 401" || fail "no key -> $NO_KEY (expected 401)"
BAD_KEY=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" -H "Content-Type: application/json" -H "X-API-KEY: not-a-real-key" -d "$RPC_LIST")
[ "$BAD_KEY" = "401" ] && pass "bad key -> 401" || fail "bad key -> $BAD_KEY (expected 401)"
if [ -n "$APIKEY" ]; then
OK_CODE=$(curl -s -o /tmp/mcp_apikey_list.json -w "%{http_code}" -X POST "$MCP_URL" -H "Content-Type: application/json" -H "X-API-KEY: $APIKEY" -d "$RPC_LIST")
if [ "$OK_CODE" = "200" ] && grep -q "stirling_describe_operation" /tmp/mcp_apikey_list.json; then
pass "valid X-API-KEY -> 200 + tools listed"
else
fail "valid X-API-KEY -> $OK_CODE (tools listed? check body)"
fi
rm -f /tmp/mcp_apikey_list.json
fi
echo ""
echo -e "${YELLOW}[2] Real MCP SDK client (apikey / X-API-KEY)${NC}"
if ! command -v node >/dev/null 2>&1; then
echo -e " ${YELLOW}(node not installed - skipping real-client check)${NC}"
elif [ -z "$APIKEY" ]; then
fail "skipping real-client check - no API key"
else
[ -d "$CHECK_DIR/node_modules" ] || (cd "$CHECK_DIR" && npm install --silent --no-fund --no-audit >/dev/null 2>&1)
if MODE=apikey MCP_URL="$MCP_URL" MCP_APIKEY="$APIKEY" node "$CHECK_DIR/check.mjs"; then
pass "official MCP SDK client connected + validated (apikey)"
else
fail "official MCP SDK client could not validate the server (apikey)"
fi
fi
echo ""
echo -e "${YELLOW}[3] Real operation execution (rotate-pdf, inline file)${NC}"
SAMPLE_PDF="$SCRIPT_DIR/../../app/common/src/test/resources/example.pdf"
if [ -z "$APIKEY" ]; then
fail "skipping execution check - no API key"
elif [ ! -f "$SAMPLE_PDF" ]; then
fail "sample PDF not found at $SAMPLE_PDF"
else
B64=$(base64 -w0 "$SAMPLE_PDF" 2>/dev/null || base64 "$SAMPLE_PDF" | tr -d '\n')
EXEC_RPC=$(printf '{"jsonrpc":"2.0","id":91,"method":"tools/call","params":{"name":"stirling_pages","arguments":{"operation":"rotate-pdf","fileName":"example.pdf","parameters":{"angle":90},"file":"%s"}}}' "$B64")
EXEC=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" -H "X-API-KEY: $APIKEY" -d "$EXEC_RPC")
if echo "$EXEC" | grep -q '"isError":true'; then
fail "rotate-pdf returned isError: $(printf '%s' "$EXEC" | head -c 300)"
elif echo "$EXEC" | grep -q 'fileId='; then
pass "rotate-pdf processed a real PDF over MCP and returned a result fileId"
else
fail "rotate-pdf returned no result fileId: $(printf '%s' "$EXEC" | head -c 300)"
fi
fi
echo ""
echo -e "${YELLOW}▶ Restoring oauth mode...${NC}"
PREMIUM_KEY="${PREMIUM_KEY:-}" docker compose -f "$COMPOSE" up -d --no-build --force-recreate stirling-pdf-mcp >/dev/null 2>&1
wait_up && pass "restored oauth mode" || fail "Stirling not healthy after restoring oauth"
echo ""
echo -e "${BLUE}────────────────────────────────────────────────────${NC}"
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}API-key mode checks passed! ($PASS passed)${NC}"; exit 0
else
echo -e "${RED}API-key mode validation failed: $FAIL failed, $PASS passed.${NC}"; exit 1
fi
+397
View File
@@ -0,0 +1,397 @@
#!/bin/bash
# Validate the Keycloak MCP OAuth2 resource-server path end-to-end against a real Keycloak.
# Runs every check (no set -e) and exits non-zero if anything failed.
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
KEYCLOAK_HOST="${KEYCLOAK_HOST:-kubernetes.docker.internal}"
REALM="stirling-mcp"
CLIENT_ID="mcp-test-client"
CLIENT_SECRET="mcp-test-secret"
MCP_URL="http://localhost:8080/mcp"
PRM_URL="http://localhost:8080/.well-known/oauth-protected-resource"
PASS=0
FAIL=0
pass() { echo -e "${GREEN}${NC} $1"; PASS=$((PASS + 1)); }
fail() { echo -e "${RED}$1${NC}"; FAIL=$((FAIL + 1)); }
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Validating MCP Test Environment ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
echo ""
# Pick a Keycloak base URL that resolves from this host (both share KC_HOSTNAME issuer).
KC_BASE="http://${KEYCLOAK_HOST}:9080"
if ! curl -sf --max-time 3 "$KC_BASE/realms/$REALM" >/dev/null 2>&1; then
KC_BASE="http://localhost:9080"
fi
TOKEN_URL="$KC_BASE/realms/$REALM/protocol/openid-connect/token"
echo -e "${BLUE}Using Keycloak base:${NC} $KC_BASE"
echo ""
# liveness
echo -e "${YELLOW}[0] Liveness${NC}"
if curl -sf "$KC_BASE/realms/$REALM" > /dev/null 2>&1; then
pass "Keycloak realm '$REALM' reachable"
else
fail "Keycloak realm '$REALM' not reachable - is the stack up?"
fi
if curl -sf "$KC_BASE/realms/$REALM/.well-known/openid-configuration" > /dev/null 2>&1; then
pass "Keycloak OIDC discovery reachable"
else
fail "Keycloak OIDC discovery not reachable"
fi
if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q "UP"; then
pass "Stirling PDF is UP"
else
fail "Stirling PDF is not UP"
fi
echo ""
# protected-resource metadata (RFC 9728)
echo -e "${YELLOW}[1] Protected-resource metadata (RFC 9728)${NC}"
PRM=$(curl -s -o /tmp/mcp_prm.json -w "%{http_code}" "$PRM_URL")
PRM_BODY=$(cat /tmp/mcp_prm.json 2>/dev/null)
if [ "$PRM" = "200" ]; then
pass "GET $PRM_URL -> 200 (publicly discoverable)"
else
fail "GET $PRM_URL -> $PRM (expected 200)"
fi
if echo "$PRM_BODY" | grep -q '"resource"'; then
pass "metadata advertises a 'resource' id"
else
fail "metadata missing 'resource' id"
fi
if echo "$PRM_BODY" | grep -q "realms/$REALM"; then
pass "metadata points clients at the Keycloak authorization server"
else
fail "metadata missing the Keycloak authorization server (realms/$REALM)"
fi
if echo "$PRM_BODY" | grep -q "mcp.tools"; then
pass "metadata advertises mcp.tools scopes"
else
fail "metadata missing mcp.tools scopes"
fi
echo ""
# unauthenticated access is rejected
echo -e "${YELLOW}[2] Unauthenticated requests are rejected${NC}"
RPC_LIST='{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
NO_TOKEN=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" -d "$RPC_LIST")
if [ "$NO_TOKEN" = "401" ]; then
pass "POST /mcp with no token -> 401"
else
fail "POST /mcp with no token -> $NO_TOKEN (expected 401)"
fi
BAD_TOKEN=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer not-a-real-jwt" -d "$RPC_LIST")
if [ "$BAD_TOKEN" = "401" ]; then
pass "POST /mcp with garbage token -> 401"
else
fail "POST /mcp with garbage token -> $BAD_TOKEN (expected 401)"
fi
echo ""
# fetch a real user token from Keycloak (password grant)
echo -e "${YELLOW}[3] Fetch a real access token from Keycloak (mcpuser)${NC}"
get_token() {
# $1 username, $2 password -> access_token (empty on failure)
curl -s -X POST "$TOKEN_URL" \
--data-urlencode "grant_type=password" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET" \
--data-urlencode "username=$1" \
--data-urlencode "password=$2" \
--data-urlencode "scope=openid email mcp.tools.read mcp.tools.write" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p'
}
USER_TOKEN=$(get_token "[email protected]" "mcppassword")
if [ -n "$USER_TOKEN" ]; then
pass "Obtained access token for [email protected]"
else
fail "Could not obtain access token for [email protected] (check Keycloak/client)"
fi
echo ""
# authenticated tools/list (account-binding succeeds)
echo -e "${YELLOW}[4] Authenticated MCP access (provisioned user)${NC}"
if [ -n "$USER_TOKEN" ]; then
LIST_CODE=$(curl -s -o /tmp/mcp_list.json -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d "$RPC_LIST")
LIST_BODY=$(cat /tmp/mcp_list.json 2>/dev/null)
if [ "$LIST_CODE" = "200" ]; then
pass "POST /mcp tools/list with valid token -> 200"
else
fail "POST /mcp tools/list with valid token -> $LIST_CODE (expected 200)"
fi
for t in stirling_describe_operation stirling_convert stirling_pages stirling_misc stirling_security; do
if echo "$LIST_BODY" | grep -q "\"$t\""; then
pass "tools/list advertises $t"
else
fail "tools/list missing $t"
fi
done
else
fail "Skipping authenticated checks - no user token"
fi
echo ""
# tools/call stirling_describe_operation
echo -e "${YELLOW}[5] Deep schema via stirling_describe_operation${NC}"
if [ -n "$USER_TOKEN" ]; then
OP=$(echo "$LIST_BODY" | grep -oE '"enum":\[[^]]+\]' | head -1 | grep -oE '"[^"]+"' | sed -n '2p' | tr -d '"')
if [ -n "$OP" ]; then
echo -e " ${BLUE}using operation:${NC} $OP"
DESC_RPC="{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"stirling_describe_operation\",\"arguments\":{\"operation\":\"$OP\"}}}"
DESC_CODE=$(curl -s -o /tmp/mcp_desc.json -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d "$DESC_RPC")
DESC_BODY=$(cat /tmp/mcp_desc.json 2>/dev/null)
if [ "$DESC_CODE" = "200" ] && echo "$DESC_BODY" | grep -q "parametersSchema"; then
pass "describe_operation('$OP') returned a parameters schema"
else
fail "describe_operation('$OP') -> $DESC_CODE (no parametersSchema in body)"
fi
else
fail "Could not extract an operation id from tools/list (no enabled ops?)"
fi
else
fail "Skipping describe check - no user token"
fi
echo ""
# account-binding: valid Keycloak user with no Stirling account -> 403
echo -e "${YELLOW}[6] Account-binding rejects users without a Stirling account${NC}"
GHOST_TOKEN=$(get_token "[email protected]" "ghostpassword")
if [ -n "$GHOST_TOKEN" ]; then
pass "Obtained a valid Keycloak token for [email protected]"
GHOST_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $GHOST_TOKEN" -d "$RPC_LIST")
if [ "$GHOST_CODE" = "403" ]; then
pass "Valid token, no Stirling account -> 403 (account-binding enforced)"
else
fail "ghost user -> $GHOST_CODE (expected 403)"
fi
else
fail "Could not obtain a token for [email protected] (cannot test account-binding)"
fi
echo ""
# hardening: bad tokens & endpoint isolation
echo -e "${YELLOW}[7] Hardening - bad tokens & endpoint isolation${NC}"
b64url() { printf '%s' "$1" | base64 | tr '+/' '-_' | tr -d '='; }
# unsigned alg:none token must be rejected
NONE_JWT="$(b64url '{"alg":"none","typ":"JWT"}').$(b64url '{"sub":"mcpuser","iss":"http://kubernetes.docker.internal:9080/realms/stirling-mcp","aud":"http://localhost:8080/mcp","email":"[email protected]","scope":"mcp.tools.read mcp.tools.write","exp":9999999999}')."
NONE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" -H "Authorization: Bearer $NONE_JWT" -d "$RPC_LIST")
if [ "$NONE_CODE" = "401" ] || [ "$NONE_CODE" = "400" ]; then
pass "Unsigned alg:none token -> $NONE_CODE (rejected; a real signature is required)"
else
fail "alg:none token -> $NONE_CODE (expected 400/401)"
fi
# valid token minted for a different audience must be rejected (RFC 8707)
WRONG_AUD=$(curl -s -X POST "$TOKEN_URL" \
--data-urlencode "grant_type=password" \
--data-urlencode "client_id=other-client" \
--data-urlencode "[email protected]" \
--data-urlencode "password=mcppassword" \
--data-urlencode "scope=openid email" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
if [ -n "$WRONG_AUD" ]; then
WA_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" -H "Authorization: Bearer $WRONG_AUD" -d "$RPC_LIST")
if [ "$WA_CODE" = "401" ]; then
pass "Valid token, wrong audience -> 401 (RFC 8707 audience binding)"
else
fail "wrong-audience token -> $WA_CODE (expected 401)"
fi
else
fail "Could not mint a wrong-audience token from other-client"
fi
if [ -n "$USER_TOKEN" ]; then
# the /mcp guard covers every method: GET without a token -> 401
GET_NOAUTH=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$MCP_URL")
if [ "$GET_NOAUTH" = "401" ]; then
pass "GET /mcp with no token -> 401 (chain guards all methods, not just POST)"
else
fail "GET /mcp no token -> $GET_NOAUTH (expected 401)"
fi
# unknown JSON-RPC method is refused with -32601
UNK=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"jsonrpc":"2.0","id":9,"method":"admin/deleteEverything"}')
if echo "$UNK" | grep -q "32601"; then
pass "Unknown JSON-RPC method -> error -32601 (not dispatched)"
else
fail "Unknown method not rejected as expected: $UNK"
fi
# isolation: the MCP token must not unlock admin endpoints
ADM_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET \
"http://localhost:8080/api/v1/admin/settings" \
-H "Authorization: Bearer $USER_TOKEN")
if [ "$ADM_CODE" = "401" ] || [ "$ADM_CODE" = "403" ] || [ "$ADM_CODE" = "302" ]; then
pass "MCP token on /api/v1/admin/settings -> $ADM_CODE (no cross-endpoint access)"
else
fail "MCP token reached an admin endpoint -> $ADM_CODE (expected 401/403/302)"
fi
else
fail "Skipping method/isolation checks - no user token"
fi
echo ""
# regression checks
echo -e "${YELLOW}[8] Regression checks${NC}"
if [ -n "$USER_TOKEN" ]; then
# a PDF category tool must return an honest error (isError), not a fake success
CAT=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"jsonrpc":"2.0","id":81,"method":"tools/call","params":{"name":"stirling_security","arguments":{"operation":"add-password"}}}')
if echo "$CAT" | grep -q '"isError":true'; then
pass "category tool returns isError, not a fake success"
else
fail "category tool did not return isError: $CAT"
fi
# malformed JSON -> JSON-RPC parse error (-32700) envelope
MAL=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d '{ not valid json ')
if echo "$MAL" | grep -q "32700"; then
pass "malformed JSON -> -32700 envelope"
else
fail "malformed JSON not enveloped as -32700: $MAL"
fi
# valid JSON but wrong shape -> invalid request (-32600)
WS=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d '{"hello":"world"}')
if echo "$WS" | grep -q "32600"; then
pass "wrong-shape JSON -> -32600"
else
fail "wrong-shape JSON wrong error code: $WS"
fi
# initialize echoes the client protocolVersion (negotiation)
INIT=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"jsonrpc":"2.0","id":82,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}')
if echo "$INIT" | grep -q '"protocolVersion":"2025-03-26"'; then
pass "initialize negotiates the client's protocolVersion"
else
fail "initialize did not echo client protocolVersion: $INIT"
fi
else
fail "Skipping regression checks - no user token"
fi
# chunked body (no Content-Length) over the cap -> 413, pre-auth
CHUNK=$( { printf '{"jsonrpc":"2.0","id":1,"method":"x","params":"'; head -c 300000 /dev/zero | tr '\0' A; printf '"}'; } \
| curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" -H "Transfer-Encoding: chunked" --data-binary @- )
if [ "$CHUNK" = "413" ]; then
pass "chunked oversized body -> 413"
else
fail "chunked oversized body -> $CHUNK (expected 413)"
fi
# behind a proxy, the 401 resource_metadata URL must honour X-Forwarded-*
WWW=$(curl -s -D - -o /dev/null -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: mcp.example.com" -d "$RPC_LIST" \
| tr -d '\r' | grep -i "^www-authenticate:")
if echo "$WWW" | grep -q "https://mcp.example.com/.well-known/oauth-protected-resource"; then
pass "401 resource_metadata honours X-Forwarded-*"
else
fail "resource_metadata ignored forwarded headers: $WWW"
fi
echo ""
# CORS for browser-based MCP clients
echo -e "${YELLOW}[9] CORS (browser MCP clients)${NC}"
ORIGIN="http://127.0.0.1:6274"
PF=$(curl -s -D - -o /dev/null -X OPTIONS "$MCP_URL" \
-H "Origin: $ORIGIN" -H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type" | tr -d '\r')
PF_CODE=$(printf '%s' "$PF" | sed -n 's#^HTTP/[0-9.]* \([0-9][0-9][0-9]\).*#\1#p' | head -1)
if printf '%s' "$PF_CODE" | grep -qE '^2'; then
pass "OPTIONS /mcp preflight -> $PF_CODE (not 401 - browsers can call /mcp)"
else
fail "OPTIONS /mcp preflight -> $PF_CODE (expected 2xx)"
fi
if printf '%s' "$PF" | grep -qi "access-control-allow-origin"; then
pass "preflight advertises Access-Control-Allow-Origin"
else
fail "preflight missing Access-Control-Allow-Origin"
fi
if curl -s -D - -o /dev/null -H "Origin: $ORIGIN" "$PRM_URL" | tr -d '\r' | grep -qi "access-control-allow-origin"; then
pass "protected-resource metadata sends Access-Control-Allow-Origin (browser-readable)"
else
fail "metadata missing Access-Control-Allow-Origin (browser can't read discovery)"
fi
echo ""
# real MCP client (official @modelcontextprotocol/sdk)
echo -e "${YELLOW}[10] Real MCP SDK client (not just curl)${NC}"
CHECK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/mcp-client-check"
if ! command -v node >/dev/null 2>&1; then
echo -e " ${YELLOW}(node not installed - skipping real-client check)${NC}"
elif [ -z "$USER_TOKEN" ]; then
fail "skipping real-client check - no user token"
else
if [ ! -d "$CHECK_DIR/node_modules" ]; then
echo " installing MCP SDK (first run)..."
(cd "$CHECK_DIR" && npm install --silent --no-fund --no-audit >/dev/null 2>&1)
fi
if MODE=oauth MCP_URL="$MCP_URL" MCP_BEARER="$USER_TOKEN" node "$CHECK_DIR/check.mjs"; then
pass "official MCP SDK client connected + validated over streamable HTTP (oauth)"
else
fail "official MCP SDK client could not validate the server (oauth)"
fi
fi
echo ""
# real operation execution (rotate a real PDF, inline)
echo -e "${YELLOW}[11] Real operation execution (rotate-pdf, inline file)${NC}"
SAMPLE_PDF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../app/common/src/test/resources/example.pdf"
if [ -z "$USER_TOKEN" ]; then
fail "skipping execution check - no user token"
elif [ ! -f "$SAMPLE_PDF" ]; then
fail "sample PDF not found at $SAMPLE_PDF"
else
B64=$(base64 -w0 "$SAMPLE_PDF" 2>/dev/null || base64 "$SAMPLE_PDF" | tr -d '\n')
EXEC_RPC=$(printf '{"jsonrpc":"2.0","id":91,"method":"tools/call","params":{"name":"stirling_pages","arguments":{"operation":"rotate-pdf","fileName":"example.pdf","parameters":{"angle":90},"file":"%s"}}}' "$B64")
EXEC=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d "$EXEC_RPC")
if echo "$EXEC" | grep -q '"isError":true'; then
fail "rotate-pdf returned isError: $(printf '%s' "$EXEC" | head -c 300)"
elif echo "$EXEC" | grep -q 'fileId='; then
pass "rotate-pdf processed a real PDF over MCP and returned a result fileId"
else
fail "rotate-pdf returned no result fileId: $(printf '%s' "$EXEC" | head -c 300)"
fi
fi
echo ""
# summary
rm -f /tmp/mcp_prm.json /tmp/mcp_list.json /tmp/mcp_desc.json 2>/dev/null
echo -e "${BLUE}────────────────────────────────────────────────────${NC}"
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}All MCP environment checks passed! ($PASS passed)${NC}"
exit 0
else
echo -e "${RED}MCP validation failed: $FAIL failed, $PASS passed.${NC}"
exit 1
fi