Skip to main content

sas-mcp

An MCP server that lets coding agents — Claude Code, Claude Desktop, GitHub Copilot, Cursor — write, run, and validate SAS 9.4 code through SASPy.

It runs locally as a stdio subprocess on your own machine. Nothing is hosted, and your code and data never leave your environment: SASPy connects to whatever SAS you already have.

Why not just let the agent call sas.submit()?

Wrapping submit() is thirty lines. The reason this package exists is the three layers around it:

  • Log triage. SAS logs are enormous and the signal is buried. Worse, SAS routinely succeeds while being semantically wrong. A misspelled variable, a many-to-many merge, a silent character-to-numeric conversion — all of these return zero errors and a wrong answer. run_sas returns status: "suspicious" for exactly that class, so an agent can't mistake it for success.
  • Schema discovery. Hallucinated column names are the most common way an LLM writes broken SAS. describe_dataset removes the guessing.
  • Guardrails. An agent improvising proc datasets lib=prod kill; against a production libref is a career event. Writes are restricted to WORK by default.

Install

pip install sas-mcp     # or: uv tool install sas-mcp

First run: create your SAS configuration

SASPy needs a sascfg_personal.py describing where your SAS lives. If you already run SASPy you have one and can skip ahead — this uses it as-is.

If you don't, don't hand-write it. Run:

sas-mcp init

It asks where your SAS runs, finds a working Java runtime for you, and writes a correct config to the right place:

Where does your SAS run?
  1. SAS OnDemand for Academics (free, cloud)
  2. SAS installed locally on this Linux/UNIX machine
  3. SAS installed locally on this Windows machine
  4. SAS server on my network (IOM / Workspace Server)
  5. SAS on a remote UNIX host over SSH
Choice [1-5]: 1

Which ODA home region? (shown at welcome.oda.sas.com)
  1. United States (Home Region 1)
  ...
  Found a working Java runtime: /Library/Java/.../bin/java

Wrote /Users/you/.config/saspy/sascfg_personal.py
Save your SAS credentials to /Users/you/.authinfo now? [Y/n]:

For ODA and intranet IOM servers it also offers to write your credentials to ~/.authinfo (_authinfo on Windows). The password is prompted without echo and the file is created owner-only — SASPy needs it in the clear, so permissions are the only thing protecting it, and init sets them rather than trusting you to remember.

Then verify:

sas-mcp doctor   # is the configuration sane? (never connects)
sas-mcp check    # does it actually work? (starts a real SAS session)

doctor is deliberately offline, so it still works when the connection is exactly what's broken. check runs those same checks and then connects:

[PASS] connect: Connected (SAS 9.04.01M8P02222023, encoding utf-8)
[PASS] submit: DATA step ran; WORK._SASMCP_PROBE = 19 rows.
[PASS] log_notes: Log triage is working: flagged suspicious
       (missing_values_generated, uninitialized_variable).
[PASS] schema: Schema discovery works (5 columns, 19 rows).
[PASS] encoding: Non-ASCII round-trip is clean ('café').

The log_notes probe is the important one. It submits code with a misspelled variable — code that must be flagged — and fails if it isn't. A session with NONOTES set runs everything successfully while the triage layer sees nothing, so results look like clean successes while being wrong. A passing submit does not prove triage works; only this does.

The encoding probe round-trips a non-ASCII string, since an encoding mismatch corrupts character data silently rather than raising.

check uses a real SAS session, which counts against concurrency limits on ODA and licensed servers, and it cleans up the WORK tables it creates. sas-mcp doctor --connect is the same thing.

It can also run unattended, for scripted or team setup:

sas-mcp init --deployment oda --region us1

Regions are us1, us2, eu1, ap1, ap2; your home region is shown at welcome.oda.sas.com. init never overwrites an existing config or credential entry without --force.

Where the config goes

init writes to your home directory, which works the same on every platform:

Platform Location
macOS / Linux ~/.config/saspy/sascfg_personal.py
Windows %USERPROFILE%\.config\saspy\sascfg_personal.py

SASPy calls expanduser("~/.config/saspy/") on all platforms, so Windows uses that same .config folder under your user directory — not AppData.

Be aware that this is the lowest-priority location SASPy searches:

  1. An explicit cfgfile path (what --config-file passes)
  2. The saspy package directory inside site-packages
  3. The working directory (sys.path[0])
  4. ~/.config/saspy/

So a sascfg_personal.py left in site-packages or in your project folder will silently win over your home copy. Two consequences worth knowing:

  • A config inside site-packages is deleted when you rebuild your virtualenv or reinstall saspy. Keep it in your home directory instead.

  • There is no environment variable for this. SASPy reads none when resolving a config, so --config-file is the only unambiguous way to pin it:

    sas-mcp serve --config-file ~/.config/saspy/sascfg_personal.py --config oda
    

sas-mcp doctor reports which file actually wins, warns when several exist, and flags a config living somewhere a reinstall will destroy.

Check your setup first

sas-mcp doctor

This is the fastest way past the usual configuration problems, and it runs without connecting to SAS — so it still works when the connection is what's broken. It checks the config file and access method, the Java runtime that IOM requires, ~/.authinfo presence and permissions, ODA hostname validity, network reachability, and encoding. Every failure comes with the fix.

[FAIL] java: /usr/bin/java exists but no Java runtime is installed.
         fix: On macOS /usr/bin/java is only a stub. Install a real JRE, e.g.
              `brew install --cask temurin`, then set 'java' to the full path
              from `/usr/libexec/java_home`.
[FAIL] authinfo_permissions: ~/.authinfo is readable by group or others
       (mode 0o644). SASPy refuses to use it and your SAS password is exposed
       to other local accounts.
         fix: chmod 600 ~/.authinfo

When anything fails, the report also links SASPy's troubleshooting guide, which covers the IOM, Java, and encryption problems this tool can detect but not fix for you.

If your password "doesn't work"

SASPy parses ~/.authinfo by requiring a line to split into exactly five whitespace-separated fields:

<key> user <username> password <password>

A password containing a space produces six fields, so SASPy skips the line entirely and reports "did not find key" — which sends you looking for a missing entry rather than a mis-parsed one. Trailing comments break it the same way.

Two fixes:

  • Use a password with no spaces, or
  • Store a SAS PWENCODE value instead — {SAS004}... is always a single token, so it sidesteps the problem. These authenticate fine against both ODA and intranet IOM servers. Note it's obfuscation, not encryption: the file still needs chmod 600.

sas-mcp doctor checks the field count directly and names this cause.

On Windows, also check the file's encoding. SASPy opens _authinfo with open(pwf, mode='r') — the locale default, typically cp1252, not UTF-8. If your editor saved it as UTF-8 and the password contains a non-ASCII character, the bytes decode into something longer: à becomes à + \xa0, and \xa0 (NBSP) counts as whitespace to split(), so the line gains a field and is skipped. Save the file as ANSI/cp1252, or use an ASCII-only password.

SAS encryption jars (needed for ODA)

SASPy does not ship the SAS encryption jarssas.rutil.jar, sas.rutil.nls.jar, and sastpj.rutil.jar are absent from a clean install, though SASPy puts them on the IOM classpath regardless. They are a manual download.

Whether you need them depends on the server:

  • SAS ODA always requires an encrypted connection. Without these jars it fails even when everything else is correct — as a Java error that never mentions a missing file. sas-mcp doctor reports this as a failure.
  • An intranet IOM server may not require encryption, so doctor reports it as information rather than treating a fresh install as broken.

Either way, doctor names the missing files, links the SAS download, and prints the exact destination. They must go in SASPy's own saspy/java/iomclient/ directory — that path is hardcoded where SASPy builds the IOM classpath, so no other location will be found. Doctor prints the resolved absolute path for your install.

Connect your agent

Claude Code

claude mcp add sas -- sas-mcp serve

Claude Desktop — in claude_desktop_config.json:

{
  "mcpServers": {
    "sas": { "command": "sas-mcp", "args": ["serve"] }
  }
}

VS Code / GitHub Copilot — in .vscode/mcp.json:

{
  "servers": {
    "sas": { "type": "stdio", "command": "sas-mcp", "args": ["serve"] }
  }
}

Add policy flags to args as needed, e.g. ["serve", "--config", "oda", "--writable-libs", "STAGE"].

Choosing between multiple SAS environments

If your sascfg_personal.py defines more than one configuration, decide who picks:

Pin it in the client config — the agent uses this one and cannot switch:

{
  "servers": {
    "sas": { "command": "sas-mcp", "args": ["serve", "--config", "oda"] }
  }
}

Add --allow-config-switch to make it a starting point the agent may change instead of a restriction.

Or leave --config off, and the agent chooses: list_sas_configs shows what's available with each one's access method and target server, and use_sas_config selects one. With exactly one configuration defined it's used automatically and nothing is asked.

Either way the server never blocks waiting for an answer. Left to itself, SASPy prompts on stdin for a configuration name — and on a stdio MCP server stdin is the JSON-RPC stream, so the prompt would consume protocol bytes and hang the client. sas-mcp disables SASPy's prompting entirely and returns the choice as data instead.

Tools

Tool Purpose
sas_doctor Diagnose configuration without connecting
session_status Connection, SAS version, librefs, WORK contents, policy
run_sas Submit code; returns triaged status, findings, row counts, output
get_last_log Full raw log, when triage isn't enough
reset_session Clear WORK
list_libraries Assigned librefs with paths and writability
list_datasets Tables in a library with row/column counts
describe_dataset Columns with type, length, format, label
sample_rows First N rows as records
compare_datasets PROC COMPARE as a structured diff
run_sas_tests Run code with assertion macros; report pass/fail

What run_sas returns

Not a log — a verdict:

{
  "status": "suspicious",
  "summary": "Ran, but results may be wrong: 2 suspicious notes; created WORK.B=19 obs.",
  "suspicious_notes": [
    {
      "rule": "uninitialized_variable",
      "text": "NOTE: Variable weigth is uninitialized.",
      "explanation": "Variable was read before being assigned, so it evaluates to missing. Almost always a misspelled variable name.",
      "line_no": 5
    }
  ],
  "steps": [{"step": "DATA statement", "dataset": "WORK.B", "obs_out": 19}],
  "output": "..."
}

status is ok, suspicious, or error. suspicious means the code ran and the answer is probably wrong — it is not a success.

Every result also carries log_file: a path to a saved file holding the findings and the complete SAS log, so "check the log" is something you can actually act on. Logs go to a temporary directory by default; --log-dir PATH puts them somewhere you choose, and the 25 most recent are kept.

Validating code

Rather than teach an agent a niche SAS test framework, this builds on the tool SAS developers already use — and which happens to be machine-readable. PROC COMPARE sets &SYSINFO to a bitmask where each bit names a specific kind of difference, so compare_datasets can distinguish "the values disagree" from "only a format differs":

{
  "identical": false,
  "data_differs": true,
  "metadata_only": false,
  "findings": [
    {"code": "base_obs", "meaning": "Base data set has observations not in comparison"},
    {"code": "value",    "meaning": "At least one value comparison was unequal"}
  ],
  "summary": "Data sets differ: ..."
}

That makes it a real assertion an agent can iterate against — the natural way to verify that a rewritten step reproduces the original result.

run_sas_tests adds a small assertion macro library, loaded into the session on first use:

%assert_exists(work.out);
%assert_rows(work.out, 19);
%assert_not_empty(work.out);
%assert_no_missing(work.out, age);
%assert_unique(work.out, id);
%assert_equal_datasets(work.expected, work.out);
%assert_condition(&n > 0, detail=n must be positive);

Each writes a marker to the log that comes back as structured pass/fail. A failed assertion sets status: "assertions_failed" even when the log itself is clean — a green log with a red assertion is not a pass.

Getting files out of SAS

SAS ODA runs in AWS and an intranet SAS server sits on another machine, so neither can see your local disk. SASPy transfers over the SAS connection itself, which works regardless:

run_sas:           proc export data=sashelp.class
                     outfile="~/report.xlsx" dbms=xlsx replace; run;
list_sas_files:    ~            -> report.xlsx
download_from_sas: ~/report.xlsx -> /tmp/sas-mcp-files-xxxx/report.xlsx

upload_to_sas goes the other way, for sending a CSV or workbook in.

Everything the server writes for you lands in your working folder, so it shows up in the editor's file tree:

sas-mcp/
  .gitignore        excludes this directory automatically
  files/            downloads land here; uploads may only read from here
  logs/             one annotated log per submission

Override with --file-dir PATH and --log-dir PATH. If the client starts the server somewhere unwritable, both fall back to a temporary directory.

Transfers are confined to that one directory, so the agent can neither overwrite arbitrary local files nor send arbitrary local files to a remote server. To upload something else, copy it in first.

Safety

Writes are restricted to WORK by default. Also blocked unless you opt in:

  • OS escapesX, %SYSEXEC, SYSTASK COMMAND, CALL SYSTEM, FILENAME PIPE, PROC PYTHON/LUA/GROOVY
  • Destructive DDLPROC DATASETS KILL/DELETE, DROP TABLE, PROC DELETE, FDELETE
  • Libref rebinding — re-pointing an allowlisted libref somewhere else

Reads are never restricted; an agent can set prod.sales freely.

sas-mcp serve --writable-libs STAGE,SCRATCH   # widen the write allowlist
sas-mcp serve --allow-destructive             # permit DROP/KILL/DELETE
sas-mcp serve --allow-os-escape               # permit X, PIPE, etc.

Scope of the guarantee

This is a defense against model error, not a security boundary. SAS can generate code at run time through CALL EXECUTE, DOSUBL, and macro expansion, so no static scan can be complete, and a determined bypass is always possible. It reliably stops the common accident. Do not rely on it as your only control on a system where an agent could do real damage — use a SAS account whose own permissions match what you want to allow.

Two known gaps, stated plainly:

  • Filesystem writes (PROC EXPORT ... OUTFILE=, ODS to a path) are not restricted, only SAS library writes.
  • Code assembled at run time from fragments that are individually innocuous will not be caught.

Supported deployments

The SAS 9.4 setups SASPy handles, selected by your sascfg_personal.py:

Deployment Access method Needs Verified
SAS OnDemand for Academics IOM Java, ~/.authinfo ✅ macOS + Windows
Intranet SAS server IOM Java, ~/.authinfo ✅ Windows
Intranet SAS server SSH SSH keys not yet
Local Windows install COM pywin32 (no Java) not yet
Local Windows install IOM Java not yet
Local Linux/UNIX install STDIO saspath not yet

"Not yet" means generated and unit-tested but not exercised against a running SAS — run sas-mcp check and it will tell you whether your setup works. If one of these fails for you, that's a bug worth reporting.

SAS ODA is free but its terms are academic and non-commercial use only.

Note that each MCP client starts its own server process and therefore its own SAS session, which counts against concurrent-session limits on both ODA and licensed servers.

Development

uv pip install -e ".[dev]"
pytest

The log parser and guardrails are pure functions with no SAS dependency, and the server tests run against a fake session — so the full suite runs anywhere. CI runs them on Linux, macOS, and Windows against Python 3.10, 3.12, and 3.14.

Testing on Windows

CI runs the suite on Windows, but that cannot reach a SAS installation. To test against real Windows SAS, on the Windows machine:

# No PyPI release needed -- install straight from the repo
pip install git+https://github.com/matise-joe-norc/sas-mcp

sas-mcp init      # choose the COM option first; it needs no Java
sas-mcp doctor

Local Windows SAS has two access methods, and COM is worth trying first: it needs no Java at all, which removes the most common Windows setup failure. It does need pip install pywin32. The IOM option is the fallback if COM gives trouble.

Then exercise the server end to end:

python -c "from sas_mcp.session import SASSessionManager as M; m=M(cfgname='wincom'); r=m.submit('data work.a; set sashelp.class; run;'); print(r.triage.status, r.triage.summary)"

Expect ok Ran: created WORK.A=19 obs. A result of ok with no steps reported means the log has no NOTEs — see the options notes note below.

The two things most likely to differ from the verified Linux/ODA path:

  • Encoding. Windows SAS 9.4 typically runs wlatin1, not UTF-8. A mismatch shows up as mojibake in character columns rather than an error. sas-mcp doctor reports the configured value.
  • NONOTES. SASPy's IOM sessions suppress the NOTE: lines that log triage depends on. The session manager sets options notes source; on connect; if a Windows session somehow overrides that, run_sas would return ok with empty steps and no suspicious_notes.

To confirm triage is really working rather than silently blind, run something that should be flagged:

python -c "from sas_mcp.session import SASSessionManager as M; m=M(cfgname='wincom'); r=m.submit('data work.b; set sashelp.class; bmi=weigth/height; run;'); print(r.triage.status, [n.rule for n in r.triage.suspicious_notes])"

Expect suspicious ['uninitialized_variable', 'missing_values_generated']. If that returns ok with an empty list, triage is not seeing NOTEs and the result is untrustworthy — report it as a bug.

Releasing

The version lives in exactly one place: __version__ in src/sas_mcp/__init__.py. Packaging metadata reads it from there, so the two can't drift.

  1. Bump __version__ and add a CHANGELOG.md entry.
  2. Commit, and confirm CI is green on main.
  3. Publish a GitHub Release tagged vX.Y.Z.

That triggers release.yml, which builds, runs twine check, installs the built wheel into a clean virtualenv to confirm it actually runs, verifies the tag matches __version__, and only then publishes. The tag check matters because PyPI will not let you re-upload a filename — a mismatched tag is not recoverable.

workflow_dispatch runs the same build and verification without publishing, if you want a dry run first.

One-time PyPI setup

Publishing uses Trusted Publishing, so there is no API token to store or rotate. Before the first release, add a pending publisher at pypi.org/manage/account/publishing:

Field Value
PyPI project name sas-mcp
Owner matise-joe-norc
Repository name sas-mcp
Workflow name release.yml
Environment name pypi

Then create a pypi environment under the repository's Settings → Environments. Adding a required reviewer there gives you a manual approval gate before anything reaches PyPI.

License

MIT

Release files for sas-mcp 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sas-mcp 0.2.0
File Size Uploaded
sas_mcp-0.2.0.tar.gz 90.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sas-mcp 0.2.0
File Interpreter ABI Platform
sas_mcp-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 156.6 kB

Release files / sas_mcp-0.2.0.tar.gz

Download URL sas_mcp-0.2.0.tar.gz
Size 90.0 kB
Tags Source
SHA-256 checksum
How to use checksums
e6646048491287d4155630f4b1dc1fd5552ff6a2970de79c9f0c45ac28178055
BLAKE2b-256 checksum
How to use checksums
b2053d90ca25524741e98b3124598b4387161cecc98e87bf2d269e1c2415971f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

Release files / sas_mcp-0.2.0-py3-none-any.whl

Download URL sas_mcp-0.2.0-py3-none-any.whl
Size 66.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d5c8c0e9a1f841cb52daf49b85a94ac40c6168d4b8d49feabd79ee5b4a8f1d79
BLAKE2b-256 checksum
How to use checksums
55f804f3041d66886d2b6edc44d5cf8ce1ebf11d5ee1f0966133b9eb64acdb97
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page