The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the SAS Viya listing page.
A Model Context Protocol (MCP) server for executing SAS code, training AutoML projects, scoring models and so much more for SAS Viya environments.
Here you can find getting articles on how to use and integrate the SAS MCP Server in different tools and what to build with it:
Required
Optional
NOTE: This will by default create a virtual environment called .venv in the project's root directory.
If for some reason the virtual environment is not created, please run uv venv and then re-run uv sync.
Edit .env and set
Option A: HTTP mode (pre-run the server, connect from MCP client)
The server will be available at http://localhost:8134/mcp by default. Authentication is handled via OAuth2 PKCE flow in the browser.
Option B: Stdio mode (MCP client starts the server on demand)
Authenticate once. Two equivalent options:
Both flows write an access token to a local cache (~/.sas/credentials.json and ~/.sas-mcp-server/credentials.json respectively); the stdio server reads whichever it finds. When the token expires, re-run the same command.
Then configure your MCP client to launch the server directly (see below).
Option C: Docker / Podman (containerized deployment)
Pull the pre-built image from GitHub Container Registry:
Or build locally from source:
Available image tags:
latest — most recent tagged release<major>.<minor>.<patch> (e.g. 1.0.0) — specific release<major>.<minor> (e.g. 1.0) — latest patch of a minor releaseedge — tip of main (unreleased, for testing)sha-<short> — pinned to a specific commitProgrammatic clients with a pre-existing Viya token
If your caller already holds a Viya access token (e.g. an automation script that obtained one via the SAS Viya CLI), start the HTTP-mode server with ALLOW_RAW_BEARER=true and pass the token directly:
The server validates the token against Viya's JWKS and uses it upstream as-is, bypassing the MCP JWT swap. The default OAuth2 PKCE flow keeps working alongside — both client types share the same /mcp endpoint.
If your Viya APIs are intentionally exposed without auth (for example, a local/dev Compute API endpoint), set VIYA_AUTH=false to bypass all SASLogon/OAuth flows in both HTTP and stdio modes. In this mode the server sends upstream requests without an Authorization header.
If your compute deployment does not expose /compute/contexts and only supports a fixed session, set COMPUTE_SESSION_ID=<session_id>. The compute tools will use that session directly instead of creating context-backed sessions.
| HTTP | Stdio | Docker | Kubernetes | |
|---|---|---|---|---|
| How it runs | Long-running server you start separately | MCP client spawns it on demand | Containerized HTTP server | Containerized, behind an ingress |
| Authentication | OAuth2 PKCE flow (browser popup) | Cached token via sas-viya CLI or sas-mcp-login | OAuth2 PKCE flow (browser popup) | PKCE and/or raw Viya bearer token |
| Best for | Multi-user or shared setups; production-like environments | Single-user local development; quick experimentation | Team deployments; CI/CD; environments without Python installed | Shared/organisational deployments alongside Viya |
| Requires | Python + uv | Python + uv (+ optional sas-viya CLI) | Docker or Podman only | A cluster, an ingress controller, a TLS secret |
| Credentials stored? | No — user authenticates interactively | No — only an access token (not a password) is cached | No — user authenticates interactively | No — a signing key in a Secret; users authenticate themselves |
| MCP client config | Point client to http://localhost:8134/mcp | Client runs uv run app-stdio | Point client to http://host:8134/mcp | Point client to https://<viya-host>/mcp |
Quick guidance:
sas-viya auth loginCode or uv run sas-mcp-login, then your MCP client manages the server lifecycle.app-stdio), not as an HTTP server, so it authenticates from your ~/.sas token cache — which has to be mounted into the container at /app/.sas.Tools are grouped into numbered tiers. By default the server exposes all of them; set MCP_TIERS to expose only a subset — handy for keeping a client's tool list small and focused, or hiding capabilities a deployment shouldn't offer. Accepts ranges and comma lists (e.g. MCP_TIERS=0-4 or MCP_TIERS=0,1,6,7); unset means all tiers.
| Tier | Group |
|---|---|
| 0 | Compute Contexts & Code Execution |
| 1 | Data Discovery |
| 2 | Data Operations & Files |
| 3 | Reports & Visualization |
| 4 | Batch Jobs & Async Execution |
| 5 | Automated Machine Learning |
| 6 | Model Management & Scoring |
| 7 | Decisioning (SAS Intelligent Decisioning) |
| 8 | Workbench (Execute Code Only) |
| 9 | Business Glossary (SAS Data Governance) |
Set MCP_READ_ONLY=true to expose only tools that neither change server-side state nor cause server-side work — 50 of the 91 tools. Withheld tools are never registered, so they are absent from the client's tool list entirely: the model cannot see them, so it cannot attempt them.
This is a filter over the tiers, not a tier of its own — the read/write split cuts across every tier (Tier 3 has both get_report and delete_report). The two settings compose:
The definition is strict: a tool qualifies only if it can neither write nor start work. Beyond the obvious create/update/delete tools, that withholds:
| Withheld | Why |
|---|---|
execute_sas_code, submit_batch_job | Run arbitrary code — can perform any operation, including deletes |
score_data, catalog_run_agent, catalog_run_adhoc_analysis | Start server-side jobs and leave run records, though they return data |
promote_table_to_memory | Mutates CAS in-memory state |
cancel_job, reset_compute_session | Destroy something the caller owns |
Classification is fail-closed: a tool that is not explicitly classified as read-only is withheld. The list lives in src/sas_mcp_server/tools/_access.py, and a test asserts it covers every registered tool, so a newly added tool cannot silently land in read-only mode.
The same classification is advertised to every client as MCP tool annotations on each tools/list entry — whether or not read-only mode is on:
| Hint | Derived from |
|---|---|
readOnlyHint | exactly the read-only set above — one table, so what a client is told and what MCP_READ_ONLY enforces cannot drift |
destructiveHint | tools that can remove or overwrite existing state: arbitrary code (execute_sas_code, submit_batch_job), delete_*, cancel_job, reset_compute_session, the update_* PUTs, apply_report_operations, create_report/copy_report (their replace conflict policy), publish_ml_champion_model |
idempotentHint | reads, the update_* PUTs, deletes, cancel_job, reset_compute_session, promote_table_to_memory |
openWorldHint | only tools that can reach beyond Viya: arbitrary code and the upload tools' url source |
Clients use these to shape their approval UX — e.g. Claude groups read-only tools for one-click approval and warns before destructive ones — and to decide when to interrupt the user. They are hints, not enforcement: the spec tells clients to treat them as untrusted unless the server is trusted, and MCP_READ_ONLY remains the server-side control. Without annotations a client must assume the spec's pessimistic defaults (writable, destructive, open-world) for every tool, so this only ever reduces friction. The browser landing page marks each tool read-only / write / destructive from the same hints.
The headings below match the numbered tiers above, so MCP_TIERS maps directly to the tools you expose (e.g. MCP_TIERS=0-3 gives Tiers 0–3).
Information Catalog (metadata discovery & profiling):
AssetType:Report, ranges). Each hit carries a resource_uri you can hand to the matching tool (e.g. get_report, get_castable_data).catalog_search queries.resource_uri, bridging a search hit to the profiling and download tools without handling an instance id by hand.informationPrivacy, nlpTerms, nlpTags, and mostImportantFields.profile_ready once results have landed on the asset — so a download isn't fired too early.instance_id or resource_uri.CAS data (in-memory):
SELECT against CAS or compute data and get the rows back — one SQL surface over both storage tiers. Pick the tier with target (cas for caslib.table, compute for libref.table); joins, subqueries, aggregation, and UNION all work, and the row cap is applied server-side by the tool (a LIMIT you write is ignored, since a malformed one is silently discarded by CAS). Optionally returns the query as CREATE VIEW text for you to run yourself. Reads only: writes are refused pre-flight, and SAS macro triggers (%/&) are rejected because the macro processor would expand them outside SQL. Note the two tiers cannot be joined in one statement.Compute libraries (SAS/Compute, within a compute context):
file_path (the server reads it off disk) or url (the server fetches it and converts it to the multipart upload the endpoint requires). Ingests the formats the casManagement uploadTable API accepts — csv, tsv (csv + tab delimiter), xls, xlsx (single sheet), sas7bdat, sashdat — auto-detected from the extension or set with data_format. parquet is not accepted by that endpoint and is rejected up front with guidance (load via a path-based caslib + promote_table_to_memory, or convert to csv/sas7bdat).parent_folder_uri). Content comes from exactly one of content (inline text), file_path (read server-side, binary-safe — xlsx, zip, images — gated by ALLOW_LOCAL_FILE_UPLOAD), or url (server-side fetch)package (zip), pdf, png, svg, csv, tsv, xlsx, or summary. Text formats come back inline, png as image content, and binary formats (package/pdf/xlsx) as an embedded file with the right MIME type.object_type= for one object's contract (colloquial aliases like kpi resolve), category= to filter, or operation= for one operation's full shape — operation="addData" documents dataItems (column renames, SAS formats, aggregations, geography classification). Backs the apply_report_operations loop.operations array to build the whole report in one atomic call; the result carries the created page/object names+labels and a verify hint.addData, addPage, addObject, updateObject, setParameterValue, updateData, changeData, applyDataView) to a report. Give a page a title with addPage's title field (a text band at the top of the page body — VA headers are controls-only); title every chart at add time via options.object.title; arrange objects with placement — page, relativeToObject (left/right/top/bottom for columns, rows, and grids), container (group into a standardContainer), or report (new_page creates-and-names a page inline for one-batch multi-page reports). The batch is atomic. Validates every operation, object key, and placement against the catalog first (reporting all errors at once), supports dry_run, handles the ETag concurrency handshake, and — with result_report_name/result_folder — applies the batch save-as to a new report, leaving the source untouched. Typical loop: describe_report_objects → get_castable_columns → apply_report_operations → get_report_outline / export_report (png, page-by-page) to verify.name for placement/updateObject targets, label for export_report, page label for page placement).changeData operation for the copy-and-replace pattern.Build and manage SAS Intelligent Decisioning rule sets and decision flows end to end, then publish a flow to Micro Analytic Score (MAS) so score_data can execute it.
Business rules — rule sets:
Business rules — rules:
Decision flows:
moduleId (directly usable with get_mas_module_step_signature / score_data)Read and author the SAS Business Glossary, and link its terms to the columns they describe. Tier 1 tells you a column is called CD_NAC_RSK; this tier tells you what that means and who says so.
Two things about the glossary are worth knowing before you start, because both are invisible in the raw API and both are handled for you here:
term_id (glossary) and catalog_entity_id (catalog) — so you never have to work out which one you are holding.{"Scope": "Group"}), validating required attributes and single-select values before the call is made.Dictionary:
assigned_asset_count, so you can see whether a term is actually in useinclude_attributes returns each term's attribute values (free — the listing already carries them), and attribute_filter keeps only the terms matching, e.g. {"Used in Risk": true}. The glossary cannot filter on attributes server-side, so that filter is applied here and the result reports how much of the dictionary it scannedWhere terms meet data:
Authoring:
update_glossary_term(publish=true) promotes one laterterm_id of each row's term, so the next step needs no lookup, and separates rows that were genuinely new from rows whose term already existed — the import job counts both as successful. A row is written whole, so update_existing replaces the term at that path rather than merging into itparent_id moves it in the hierarchy, and publish promotes a draft. A draft is a separate resource in the API, so this routes the write accordingly — editing one otherwise fails with a bare 404Designing the vocabulary:
attribute_id alongside the new label, since a new label matches nothing and would otherwise mint a new attribute; delete refuses while terms still use the typeTerms assigned this way also become searchable through Tier 1's catalog_search using the Column.term:"<term name>" facet on the datasets index, which returns the tables carrying a term without resolving individual columns.
Example configurations are provided in the examples/ folder. Below are quick-start snippets for common clients.
Tip — open the endpoint in a browser. In HTTP mode, pointing a browser at the MCP URL (e.g.
http://localhost:8134/mcp, orhttps://<host>/mcpfor a deployed server) shows a landing page instead of a bare401: what the server is, which SAS Viya it talks to, the tool tiers this deployment exposes with a one-line summary per tool, and ready-to-copy configuration for Claude Code, VS Code, Cursor, Claude connectors and genericmcp.jsonclients — with the deployment's real URL already filled in. Only a plain browserGET(Accept: text/html) is answered this way; MCP clients andcurlsee exactly what they saw before. The page is unauthenticated and shows deployment shape only (never user data); administrators can turn it off withMCP_LANDING_PAGE=false.
.vscode/mcp.json)HTTP mode (requires uv run app running separately):
Stdio mode (starts the server on demand):
.gemini/settings.json)Gemini CLI only supports stdio mode. Add to your ~/.gemini/settings.json or project-level .gemini/settings.json:
Note: The
timeoutfield (in milliseconds) is important — SAS Viya API calls can take longer than the Gemini CLI default of 10 seconds. A value of60000(60s) is recommended. Setcwdto the absolute path of yoursas-mcp-servercheckout.
Execute SAS code through the MCP tool:
For more details, configuration options, and deployment options, please refer to the examples folder and follow the instructions listed there.
An opt-in, off-by-default mode that records how the server is actually used — which tools, for what goals, with what inputs, and where they fall short. It serves two audiences:
It is implemented as a FastMCP middleware wrapper (telemetry.py + usage_logger.py) and requires no changes to any tool.
🔒 Nothing is ever sent anywhere automatically. Collection mode only appends to a local log file on the machine running the server. It is disabled unless you explicitly enable it, and even when enabled the data stays on your disk — sharing it with anyone (including the maintainers) is a deliberate, manual step you take by sending the file yourself. There is no phone-home, no network transmission, and no third party involved.
When enabled it does two things:
Injects a required goal parameter into every tool's schema, asking the model to state in one sentence why it chose that tool for the current
request. The goal is stripped from the arguments before the real tool runs, so tools never see it.
Appends one JSON line per tool call (JSON Lines / NDJSON, schema v3) to a local log file: timestamp, run id, per-run sequence number, tool
name, goal, arguments (plus a stable args_hash for retry analysis), result, status, error, latency, and the calling client's
client_name / client_version. When a tool declares a failure as data
(e.g. {"status": "apply_failed"}, which the MCP layer sees as success), the record also carries tool_status / is_tool_error /
tool_message / failed_operation_index — so tool-level failure rates are analyzable in every mode. A run_start header record
(transport, pid, server version, result mode, and an optional COLLECTION_RUN_TAG label for tagging A/B runs) opens the log and is
re-emitted every 1000 records, so rotation cannot leave a stretch of the log with no header to resolve; every emission is
byte-identical, so any one of them will do. Secret-shaped keys and inline Bearer/JWT tokens are redacted, the Viya hostname is masked in
error/result text, and every field is size-capped.
Records group by run_id — one per server process — not by MCP session. The protocol is moving to a sessionless model (FastMCP 4 makes
it the default) in which session_id is absent or minted per request, so grouping on it would shatter every trace into single-call fragments.
Under stdio, one process serves one client, so a run is that client's trace. Under HTTP a run spans every client the process served, and
client_name/client_version are the only thing separating them — two users on the same client software share one run_id and one seq
counter, which is an accepted limitation of dropping the session key, not something a per-process COLLECTION_LOG_PATH can fix (that splits
by process, the axis run_id already covers).
Set the toggle in .env (all options are documented in .env.sample):
Tool results are recorded per COLLECTION_LOG_RESULTS — a tri-state dial: never records only a content-free shape summary (type + key
names, e.g. {"_type":"object","_keys":["status","report_id"]}); failures (the default) records full (capped + redacted) result contents only for calls that
errored or whose tool declared a failure — the middle ground, since failure diagnostics are the highest-value trace data and rarely carry
table rows, and because under never a success and a tool-declared failure are indistinguishable in the log; always records result contents on every call. Arguments, goal, status, error text, and the tool-declared outcome fields are captured in
every mode. (true/false still work as aliases for always/never.)
⚠️ Privacy: when enabled, the log captures your tool inputs (e.g. the SAS code and queries you submit) and — in
failures/alwaysmodes — real result data that may include table rows, SAS listings, and PII. Redaction is heuristic (credential-shaped keys + Bearer/JWT + the Viya hostname) and does not detect PII in data values. Review the log before sharing it. The file is locked to your user (chmod 0600 on POSIX; icacls on Windows, best-effort).
Collection mode is designed to be cheap enough to leave on. Measured on this repo (45 registered tools, FastMCP 3.4.2):
goal field grows the tools/list schema the model sees by roughly +2,400 input tokens (~29%) per turn. Because the tool list is stable within a session it is served from the prompt cache after the first turn (steady-state ≈ +240 tokens/turn), plus ~15–30 output tokens per call for the model to write the goal sentence. This is the only client-visible cost and it applies only while collection mode is enabled.COLLECTION_LOG_RESULTS=always). The JSONL
write is offloaded to a worker thread so it never blocks the event loop. Against real Viya calls (typically hundreds of milliseconds to seconds) this is
negligible — the live integration suite passed identically with collection mode off and on, the overhead lost in normal network variance.COLLECTION_MAX_LOG_BYTES (default 10 MiB, ≈16k calls) and keeps COLLECTION_LOG_BACKUPS (default 3) rotated files, so on-disk growth is bounded.The project includes two layers of tests: unit tests (fast, no credentials required) and integration tests (run against a real SAS Viya instance).
run_tests.shvs. runningpytestdirectly — pick by platform.run_tests.shis a Bash convenience wrapper (it adds the ruff + pyright gates, credential wiring, and JUnit reporting). It runs on Linux/macOS — and on Windows only under Git Bash or WSL. On Windows PowerShell orcmd, use theuv run python -m pytest …commands shown under each mode below. They are cross-platform, do the same test selection, and need no setup beyonduv sync.
Unit tests verify tool schemas, request payloads, and internal logic without making any network calls:
This runs the unit suite and deselects the integration tests, which then show up in the
summary as e.g. 28 deselected. That is expected — those tests are not meant to run in a
unit-only pass. They only execute in the integration modes below, because they need a live
Viya instance; there is no flag that "activates" them in a not integration run.
Integration tests call every tool against a live Viya environment. They require credentials, provided via .env or CLI arguments.
uv sync installs everything the integration suite needs, including openpyxl (used to
build the Excel upload_data fixture). It lives in the test-formats dependency group,
which [tool.uv] default-groups syncs by default — so no extra install step is required.
Full suite (unit + integration) — reads VIYA_ENDPOINT, VIYA_USERNAME, VIYA_PASSWORD from .env:
Passing credentials on the command line (wrapper only):
With the direct pytest command, set the same three variables in .env (or export them in your shell) instead.
Integration tests only (skip unit tests):
The pytest marker is
integration, notintegration-only.--integration-onlyis a flag of therun_tests.shwrapper; the underlying pytest marker is justintegration. Runningpytest -m "integration-only"matches no marker and silently deselects all tests (0 selected). Use-m integration.Why
--no-cov?pytest.inienforces a 90% coverage floor that only the full unit suite reaches. An integration-only run exercises far less code (~65%), so without--no-covpytest exits non-zero with a coverage failure even though every selected test passed.run_tests.sh --integration-onlyadds--no-covfor you; add it yourself when calling pytest directly (or use--cov-fail-under=0).
Binary upload formats. The Excel upload_data integration test generates its .xlsx
fixture with openpyxl, from the test-formats group that uv sync installs by default
(see above). If you deliberately sync without it (e.g. uv sync --no-default-groups), the
test importorskips — you'll see it as skipped, not failed. csv,
tsv, and file_path/data_format coverage needs no extra deps. Generating a
sas7bdat/sashdat fixture requires SAS itself, so those two formats are covered by
unit-level payload tests only, not live.
Every one of the 91 tools and 9 prompt templates has an integration test, enforced by the
test_every_tool_has_integration_coverage / test_every_prompt_has_integration_coverage
guards — adding a new tool or prompt without integration coverage fails the suite. The
resource-dependent tests discover real targets on the instance: score_data scores the most
recently modified MAS module (discovering a real step and its inputs), and run_ml_project
re-runs the most recently modified completed ML project. They skip only if the instance
has no such resource at all. Likewise, test_catalog_agents_workflow skips with "No
discovery agent named 'Public'" on instances where SAS Information Catalog has no discovery
agent named Public configured — an expected skip, not a failure; ask a Viya admin to
configure one if you need that test to run.
In CI: the .github/workflows/integration.yml workflow runs this suite on demand
(manual dispatch, or by adding the run-integration label to a PR) using repository
secrets, and publishes the results back to the PR as a status check, a sticky comment, and
a downloadable JUnit artifact. Result files are written to reports/ (git-ignored) and are
never committed.
Locally (attach results to a PR yourself): run with --report to write the JUnit XML
and a Markdown summary into reports/ (git-ignored), then post them to a PR with the GitHub
CLI — no commit, no CI required:
GitHub has no API/CLI to attach a binary file to a PR (drag-and-drop upload is browser-only), so the summary is posted as a comment and the raw XML is shared via a gist link or pasted in a collapsed
<details>block. To produce the canonical Actions artifact from your machine instead, trigger the workflow remotely:gh workflow run integration.yml.
| File | Description |
|---|---|
tests/test_tool_payloads.py | Payload assertions for all 75 Tier 0-8 tools (URL paths, JSON body, query params, headers) plus error-path coverage |
tests/test_integration.py | End-to-end workflow tests against a real Viya instance |
tests/test_tools.py | Unit tests for the generic Viya REST helpers in viya_client (get_json, post_json, make_client, …) |
tests/test_viya_utils.py | Unit tests for Viya compute session and job orchestration |
tests/test_mcp_server.py | Unit tests for the HTTP auth middleware, health route, and token getter |
tests/test_config.py | Unit tests for configuration loading |
tests/test_config_oauth.py | Unit tests for PermissiveOAuthProxy raw-bearer handling |
tests/test_auth_login.py | Unit tests for the sas-mcp-login OAuth/PKCE helper |
tests/test_stdio_server.py | Unit tests for stdio token resolution and the device-code flow |
tests/test_env.py | Unit tests for the env_bool helper |
tests/test_prompts.py | Unit tests for prompt template rendering |
Maintainers are accepting patches and contributions to this project. Please read CONTRIBUTING.md for details about submitting contributions to this project.
Except for the the contents of the /static folder, this project is licensed under the Apache 2.0 License.
Elements in the /static folder are owned by SAS and are not released under an open source license.
SAS and all other SAS Institute Inc. product or service names are registered trademarks or trademarks of SAS Institute Inc. in the USA and other countries. ® indicates USA registration.
Separate commercial licenses for SAS software (e.g., SAS Viya) are not included and are required to use these capabilities with SAS software.
As with any container image, direct and indirect dependencies are governed by their own licenses. Users of the published container image are responsible for ensuring that their use complies with all applicable licenses.
All third-party trademarks referenced belong to their respective owners and are only used here for identification and reference purposes, and not to imply any affiliation or endorsement by the trademark owners.
This project requires the following dependencies.
| Dependency | License |
|---|---|
| Python | Python Software License |
| FastMCP | Apache License 2.0 |
| uvicorn | BSD 3-Clause License |
| starlette | BSD 3-Clause License |
| httpx | MIT License |