One Iceberg MCP Server, Seven Catalogs: What It Takes to Reach Each One A developer built a single MCP server for Apache Iceberg tables that reads from seven different Iceberg REST catalogs, switching catalogs via environment variables while keeping the server code unchanged. The server exposes four read-only tools — list_tables, describe_table, count_rows, and scan_table — and was tested against Polaris, BigLake, OneLake, Glue, S3 Tables, and Horizon, with each catalog requiring its own login and storage configuration. Failed catalog calls return text beginning with 'CATALOG ERROR' so an agent can report what it could not read. This article provides a step by step guide to one MCP server for Apache Iceberg tables, pointed at seven Iceberg REST catalogs in turn. The server offers four read-only tools, and one environment variable decides which catalog it reads. https://github.com/xbill9/lakehouse-iceberg-2026 https://github.com/xbill9/lakehouse-iceberg-2026 An MCP server gives any MCP client, such as Claude Code or another coding agent, a fixed set of tools. This one has four, all reads: iceberg list tables namespace.table in the catalog iceberg describe table iceberg count rows iceberg scan table Every Iceberg REST catalog speaks the same protocol, so in principle the same server should work against all of them. This project checks that against seven catalogs: The server is called directly over MCP, with no AI model involved, so each result depends only on the catalog and the server's settings. Measured on 2026-09-18 and 2026-09-19 UTC , one run per catalog, reads only. The strategy is an incremental step by step approach. First, the local Polaris catalog, which needs only Docker. Then the MCP server, called directly to check all four tools. Then one catalog at a time: its login, its storage package, and a run of the same four calls. pyiceberg 0.12.0 — this run used Python 3.14.7 gcloud , az , aws or a Snowflake key pair bash $ git clone https://github.com/xbill9/lakehouse-iceberg-2026 $ cd lakehouse-iceberg-2026/iceberg-conformance $ ./polaris-up.sh $ export POLARIS CLIENT ID=root POLARIS CLIENT SECRET=s3cr3t $ python3 seed table.py --catalog apache-polaris That creates probe ns.probe table : 11 rows, partitioned by day, with four snapshots. The server is iceberg-mcp-hosts/servers/iceberg mcp.py . It speaks MCP over stdio as newline-delimited JSON-RPC and answers initialize , tools/list , tools/call and ping , with no MCP SDK. It picks its catalog from two environment variables: bash $ ICEBERG CATALOG=aws-glue ICEBERG CATALOGS FILE=catalogs.yaml python3 servers/iceberg mcp.py catalogs.yaml holds one entry per catalog: the URL, the warehouse, and how to log in. The server code is the same for every catalog. sweep catalogs.py starts the server once per catalog and makes the same MCP calls each time: initialize , tools/list , then the four tools on the first table listed. bash $ cd ../iceberg-mcp-hosts $ python3 sweep catalogs.py --only apache-polaris apache-polaris probe ns.probe table list tables=ok 1.0s describe table=ok 0.4s count rows=ok 0.0s scan table=ok 0.1s The scan's reply shows what a client receives: id | ts | payload | region 0 | 2026-09-01 00:00:00+00:00 | row-0-0 | None 2 | 2026-09-01 02:00:00+00:00 | row-0-2 | None 3 | 2026-09-01 03:00:00+00:00 | row-0-3 | None 3 of 11 row s shown, read from snapshot-id 1196292829914853564 COUNT: exactly 11 row s are in the table in snapshot-id 1196292829914853564. Exact, over the whole table. MIN and MAX of id over those 11 row s : 0 and 23. Exact. A failed catalog call comes back as text starting CATALOG ERROR , so an agent can still say what it could not read. The sweep counts that text as a failure. Each managed catalog needs its own login in catalogs.yaml . The server builds a PyIceberg RestCatalog from it: | catalog | login | how files are read | |---|---|---| | Polaris | OAuth2 client ID and secret | PyArrow, local file: | | BigLake | gcloud token, x-goog-user-project header | PyArrow, gs:// | | OneLake | az token | fsspec with adlfs , abfss:// | | Glue | SigV4, service glue | PyArrow, s3:// , local AWS login | | S3 Tables | SigV4, service s3tables | fsspec with s3fs , catalog-issued credential | | Horizon | Snowflake key-pair JWT | PyArrow, s3:// , catalog-issued credential | S3 Tables issues a storage credential when asked with the X-Iceberg-Access-Delegation: vended-credentials header. Horizon returns one without being asked. Listing, describing and counting read only catalog metadata, so they work with pyiceberg alone. Scanning reads data files, and three catalogs need packages that pyiceberg does not install by default. Without them the server answers three of the four tools and the scan fails: CATALOG ERROR while scanning dbo.probe table: ModuleNotFoundError: No module named 'adlfs'. CATALOG ERROR while scanning probe ns.probe table: ModuleNotFoundError: No module named 's3fs'. AWS logins made with the newer aws login command need one more package before any call works: CATALOG ERROR while listing tables: MissingDependencyException: Missing Dependency: Using the login credential provider requires an additional dependency. You will need to pip install "botocore crt " before proceeding. bash $ pip install adlfs s3fs "botocore crt " | package | needed for | |---|---| | adlfs | OneLake scans | | s3fs | S3 Tables scans | | botocore crt | Glue and S3 Tables with an aws login session | OneLake's data files need an Azure storage credential as well as the catalog token. DefaultAzureCredential is the usual choice, and it tries the Azure VM metadata service before the az login. Off Azure, that attempt waits out its retries: 917ms No environment configuration found. 921ms ManagedIdentityCredential will use IMDS 553989ms DefaultAzureCredential acquired a token from AzureCliCredential AzureCliCredential 0.6s DefaultAzureCredential 553.1s Through the MCP server, a three-row OneLake scan took 858.5 seconds with DefaultAzureCredential and 3.4 seconds with AzureCliCredential . Every other tool call was unaffected, because only the scan reads files. bash $ python3 sweep catalogs.py --only apache-polaris,google-lakehouse,microsoft-onelake,aws-glue,aws-s3tables,snowflake-horizon $ python3 sweep catalogs.py --report-only catalog measured UTC table rows partitioned seconds: list describe count scan apache-polaris 2026-09-18T23:58:37Z probe ns.probe table 11 ts day 1.0 0.5 0.0 0.1 aws-glue 2026-09-18T23:58:51Z probe ns.probe table 11 ts day 2.1 0.7 0.2 1.5 aws-s3tables 2026-09-18T23:58:57Z probe ns.probe table 11 ts day 2.2 0.3 0.2 2.0 google-lakehouse 2026-09-19T00:11:14Z probe ns.probe table 11 ts day 10.1 1.6 0.5 3.6 microsoft-onelake 2026-09-18T23:58:45Z dbo.probe table 6 unpartitioned 2.5 0.2 0.2 3.2 snowflake-horizon 2026-09-18T23:59:02Z PROBE NS.PROBE TABLE 12 ts day 7.3 2.2 1.4 1.9 All four tools work on all six catalogs. Each one reported the same four tools from tools/list , and no call returned an error. The first call on each catalog also builds the client and fetches a login token, which is why list is the slowest column. Each figure is a single call, so the times show scale only. The tables were created separately for an earlier article https://dev.to/gde/seven-iceberg-rest-catalogs-what-they-declare-and-what-they-serve-40oj , and the server reports what each catalog holds: dbo , stores id as an optional int , and has 6 rows in an unpartitioned table PROBE NS.PROBE TABLE , and has 12 rows A client only has to use the names iceberg list tables returns. The server passes them back unchanged, so dbo and upper-case names need no special handling. Loading a Horizon table leaves S3 credentials on the client's file reader, although the catalog entry asks for none: snowflake-horizon client.region py-io-impl s3.access-key-id s3.secret-access-key s3.session-token s3.session-token-expires-at-ms Horizon's catalog entry supplies no storage login, so its scan reads files with that credential. It also means a Horizon table load carries a live storage credential, so logs of that response need redacting. Any MCP client that starts stdio servers can run it. For Claude Code, a project .mcp.json : { "mcpServers": { "iceberg": { "command": "python3", "args": "iceberg-mcp-hosts/servers/iceberg mcp.py" , "env": { "ICEBERG CATALOG": "aws-glue", "ICEBERG CATALOGS FILE": "iceberg-conformance/catalogs.yaml" } } } } To reach a different catalog, change ICEBERG CATALOG . The measurements above call the server directly and do not use this file. | catalog | four tools | extra package | login on the machine | |---|---|---|---| | Apache Polaris | 🟢 | — | OAuth2 client secret | | Google BigLake | 🟢 | — | gcloud | | Microsoft OneLake | 🟢 | adlfs | az | | AWS Glue | 🟢 | botocore crt with aws login | aws | | AWS S3 Tables | 🟢 | s3fs , botocore crt with aws login | aws | | Snowflake Horizon | 🟢 | — | key pair | | Databricks Unity | not run | | | The goal of this article was to point one Iceberg MCP server at seven catalogs and record what each one takes. The key to the solution was keeping the server code fixed, changing only the catalog entry, and calling the tools directly so that no model sits between the catalog and the result. The results were: catalogs.yaml adlfs for OneLake, s3fs for S3 Tables, and botocore crt for an aws login session; without them the metadata tools work and the scan fails DefaultAzureCredential took 553.1 seconds off Azure; AzureCliCredential took 0.6 Scope: iceberg mcp.py 1.0.0 on pyiceberg 0.12.0, pyarrow 25.0.1, s3fs 2026.9.0, adlfs 2026.8.0, botocore 1.43.75 and azure-identity 1.25.3, on Python 3.14.7. Apache Polaris 1.7.0 in Docker with local file storage; the managed catalogs over the internet from one machine, AWS in us-east-1 . One run per catalog on 2026-09-18 and 2026-09-19 UTC ; Unity not run. Reads only. The tools are checked for answering without error; the tables differ between catalogs, so values are reported and not compared. Managed catalogs do not report a version. The strategy for pointing one Iceberg MCP server at seven catalogs was validated with an incremental step by step approach.