mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user