Configuring Polaris as a metastore for Spark

This topic describes the full integration of the MCP server with Apache Polaris, Apache Spark, and Keycloak SSO.

Data Flow

  1. User → MCP Server — HTTPS request with Authorization: Bearer <JWT> (or Basic auth). Contains the MCP tool call (create_sql_session or execute_query).

  2. MCP Server → Livy — Creates a Spark session. Injects the user's JWT into the session config as the Polaris catalog token. Spark starts on YARN.

  3. Spark execution — Spark executes the SQL. Before reading any table, it calls Polaris to resolve table metadata and file locations.

  4. Spark → Polaris — Iceberg REST API call with the user's JWT in Authorization: Bearer. Polaris validates the token against Keycloak, resolves the Polaris principal, and checks catalog role privileges.

  5. Spark → MapR FS — Reads Parquet files at the locations returned by Polaris. Returns rows to Livy, which returns them to MCP Server, which returns them to the user.

Authentication Flow (SSO)

On first connection, the client (for example, mcp-remote) triggers an OAuth PKCE flow — a browser window opens to Keycloak. After the user logs in, Keycloak issues an access token + refresh token. The access token is sent with every subsequent MCP request. The MCP server validates the JWT signature using Keycloak's JWKS endpoint, then forwards the same token to Polaris.

Key design points
  • Single credential end-to-end. The user's Keycloak JWT is used by MCP Server, Spark, and Polaris. No service account impersonation.

  • Polaris principal = Keycloak username. Polaris extracts the preferred_username claim from the JWT to identify the principal. The names must match exactly.

  • The Spark catalog configuration is injected dynamically. The MCP server injects the Polaris catalog URL, warehouse, and user JWT into the Livy session config at creation time. No static Spark catalog config is needed in spark-defaults.conf.

Keycloak Setup

Keycloak runs on port 6443, realm master. The edf-client OAuth client is pre-configured by the platform installation. It must be of type confidential with client_credentials and authorization_code grant types enabled.

Get admin token
ADMIN_TOKEN=$(curl -sk -X POST \
  https://<keycloak-host>:6443/realms/master/protocol/openid-connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=admin-cli&username=admin&password=<admin-password>&grant_type=password" \
  | jq -r .access_token)

Create a user

The Keycloak username must exactly match the Polaris principal name created in the Polaris Setup section.
curl -sk -X POST https://<keycloak-host>:6443/admin/realms/master/users \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "username": "mapruser1",
    "enabled": true,
    "credentials": [{"type": "password", "value": "mapruser1", "temporary": false}]
  }'

Obtain the edf-client secret

The edf-client secret is needed when configuring the MCP client (Claude Code, Claude Desktop, etc.). It rotates when the cluster is reinstalled.
CLIENT_UUID=$(curl -sk \
  "https://<keycloak-host>:6443/admin/realms/master/clients?clientId=edf-client" \
  -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id')

CLIENT_SECRET=$(curl -sk \
  "https://<keycloak-host>:6443/admin/realms/master/clients/$CLIENT_UUID/client-secret" \
  -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r .value)

echo $CLIENT_SECRET

Polaris Setup

Polaris runs on port 8181. Administration is supported by the Polaris CLI or REST API. Authenticate using service account credentials (edf-client), not the user's credentials.

Obtain the Polaris admin token
POLARIS_TOKEN=$(curl -sk -X POST \
  https://<keycloak-host>:6443/realms/master/protocol/openid-connect/token \
  -d "client_id=edf-client&client_secret=$CLIENT_SECRET&grant_type=client_credentials" \
  | jq -r .access_token)
Create the catalog
POLARIS=/opt/mapr/polaris/current/client/bin/polaris

$POLARIS --access-token $POLARIS_TOKEN \
  catalogs create \
  --storage-type MFS \
  --default-base-location maprfs:///polaris/mcp-warehouse \
  mcp-catalog

Create the Polaris principal name

The Polaris principal name must match the Keycloak username created earlier in the Keycloak Setup > Create a user section.
$POLARIS --access-token $POLARIS_TOKEN principals create mapruser1

Create roles

There are two types of roles:

  • Principal role - assigned to a principal; groups catalog roles

  • Catalog role - scoped to a specific catalog; holds catalog privileges

# Principal role
$POLARIS --access-token $POLARIS_TOKEN principal-roles create mcp-principal-role

# Catalog role
$POLARIS --access-token $POLARIS_TOKEN catalog-roles create \
  --catalog mcp-catalog mcp-catalog-role

Grant privileges

# Grant full catalog access to the catalog role
$POLARIS --access-token $POLARIS_TOKEN privileges catalog grant \
  --catalog mcp-catalog \
  --catalog-role mcp-catalog-role \
  CATALOG_MANAGE_CONTENT

Available privileges: CATALOG_MANAGE_CONTENT (full DDL+DML), TABLE_READ_DATA (SELECT only), TABLE_WRITE_DATA, NAMESPACE_CREATE

Assign roles
# Attach catalog role to principal role
$POLARIS --access-token $POLARIS_TOKEN catalog-roles grant \
  --catalog mcp-catalog \
  --principal-role mcp-principal-role \
  mcp-catalog-role

# Attach principal role to principal
$POLARIS --access-token $POLARIS_TOKEN principal-roles grant \
  --principal mapruser1 \
  mcp-principal-role

MCP server configuration

Config file: /opt/mapr/data-access-gateway/conf/mcp-server.conf
mcp = {
  http = {
    port = 5679
    secure = true
    auth.enabled = true
  }
  file.enabled = true
  spark {
    enabled = true
    impersonation-enabled = true   # Run Spark jobs as the authenticated user
    disable-sql-policy = true      # Allow DDL/DML (CREATE, INSERT, DROP)

    iceberg {
      catalog {
        backend = "polaris"
        name = "mcp-catalog"
        uri = "https://<polaris-host>:8181/api/catalog"
        warehouse = "mcp-catalog"
        use-bearer-token = true     # Forward user JWT to Polaris
        scope = "PRINCIPAL_ROLE:ALL"
        token-refresh-enabled = true
        access-delegation = "vended-credentials"
        client.region = "us-east-1"
      }
    }
  }
}
Key options:
Option Description
impersonation-enabled

Livy starts the Spark job under the user's Linux account (MapR ticket required)

disable-sql-policy

Without this, only SELECT is allowed

use-bearer-token

When true: user JWT is passed as spark.sql.catalog.<name>.token. When false: client.id/client.secret are used instead (service account mode)

scope

Polaris OAuth scope. PRINCIPAL_ROLE:ALL grants access based on all assigned principal roles

Restart after changes:
maprcli node services -name data-access-gateway -action restart -nodes $(hostname -f)

Spark configuration

Config file: /opt/mapr/spark/spark-3.5.5/conf/spark-defaults.conf
spark.jars.ext-libs.use                     true
spark.sql.extensions                         org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions
Do not add spark.sql.catalog.* entries here for the Polaris catalog. The MCP server injects the full catalog configuration dynamically at session creation time:
spark.sql.catalog.mcp-catalog                     org.apache.iceberg.spark.SparkCatalog
spark.sql.catalog.mcp-catalog.type                rest
spark.sql.catalog.mcp-catalog.uri                 https://<polaris-host>:8181/api/catalog
spark.sql.catalog.mcp-catalog.warehouse           mcp-catalog
spark.sql.catalog.mcp-catalog.rest.auth.type      oauth2
spark.sql.catalog.mcp-catalog.token               <user JWT>
spark.sql.catalog.mcp-catalog.scope               PRINCIPAL_ROLE:ALL
If you also need a static Hive catalog (for non-Polaris use):
spark.sql.catalog.hive         org.apache.iceberg.spark.SparkCatalog
spark.sql.catalog.hive.type    hive
spark.sql.catalog.hive.uri     thrift://<hive-host>:9083

Claude Code Configuration

Claude Code reads MCP Server configuration from:
  • Global: ~/.claude.json - applies to all projects
  • Project-level: .claude/settings.json (committed) or .claude/settings.local.json (ignored by Git, for secrets)
Add via CLI
claude mcp add spark-mcp \
  --env NODE_TLS_REJECT_UNAUTHORIZED=0 \
  -- npx -y mcp-remote https://<mcp-host>:5679/spark \
  --static-oauth-client-info "{\"client_id\":\"edf-client\",\"client_secret\":\"$CLIENT_SECRET\"}" \
  --debug

--scope user adds it globally; without the flag it's scoped to the current project.

Alternatively, edit .claude/settings.local.json directly:
{
  "mcpServers": {
    "spark-mcp": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://<mcp-host>:5679/spark",
        "--static-oauth-client-info",
        "{\"client_id\":\"edf-client\",\"client_secret\":\"<edf-client-secret>\"}",
        "--debug"
      ],
      "env": {
        "NODE_TLS_REJECT_UNAUTHORIZED": "0"
      }
    }
  }
}

NODE_TLS_REJECT_UNAUTHORIZED=0 disables TLS certificate verification - this is required when the cluster uses a self-signed MapR CA certificate.

First launch - SSO login

On the first claude session after adding the server, mcp-remote prints a URL to the terminal. Open it in a browser and log in with your cluster credentials. After login, the token is cached at ~/.mcp-auth/mcp-remote-*/ and reused on subsequent runs.

To force re-athentication:
rm -rf ~/.mcp-auth/mcp-remote-*/
Verify the connection
claude mcp list          # Should show spark-mcp
claude mcp get spark-mcp # Shows connection status and available tools

Example Prompt

Start a Claude Code session in a project connected to the MCP server:
claude

Use a natural prompt. For example:

I need to analyze user activity data. First, set up a Spark session, then show me what tables are available in the mcp-catalog. After that, count the number of events per user for the last 7 days and show me the top 10 most active users.

Claude will:
  1. Call create_sql_session - waits ~2 min for Spark to start on YARN

  2. Call execute_query with SHOW NAMESPACES IN \ mcp-catalog``
  3. Call execute_query with SHOW TABLES IN \ mcp-catalog`.db`

  4. Construct and execute the aggregation query:
    SELECT user_id, COUNT(*) AS event_count
    FROM `mcp-catalog`.db.events
    WHERE event_date >= DATE_SUB(CURRENT_DATE(), 7)
    GROUP BY user_id
    ORDER BY event_count DESC
    LIMIT 10
  5. Return formatted results and optionally suggest follow-up analyses.
Follow-up example prompts:
  • "Now join this with the users table to get the actual usernames"

  • "Export the top 10 results to a new table called mcp-catalog.analytics.top_users"

  • "Show me the event trend by day for these top 10 users"

  • Describe the schema of the events table in detail"

Troubleshooting

Symptom Cause Resolution
401 Unauthorized from MCP Invalid or expired JWT; wrong basic credentials Re-authenticate; get fresh token from Keycloak
invalid_client_credentials in mcp-remote log

edf-client secret rotated (cluster reinstall)

Re-fetch secret (section 2.3), update config, clear ~/.mcp-auth/

No active idle user session

execute_query called before create_sql_session

Always call create_sql_session first

create_sql_session times out

YARN is slow to allocate containers Wait; first session takes 2-3 minutes

Failed to execute query on Polaris table

Missing catalog name prefix or access denied

Use `mcp-catalog`.db.table format; verify principal has privileges

policy denied: Statement 1 blocked

SQL policy blocks DDL/DML

Set disable-sql-policy = true in mcp-server.conf

No code verifier saved for session

mcp-remote lost state mid-auth flow

Fix the secret, clear ~/.mcp-auth/, retry