Repository navigation
Releases: sassoftware/sas-mcp-server
Release list
v1.19.0 — SAS Intelligent Decisioning end to end, and Microsoft Copilot
A minor release. Before, the SAS Intelligent Decisioning tools covered rule sets and decisions built only from rule sets. They now cover the whole product: every decision step kind, lookup tables, segmentation trees, value lists, code files and custom functions. Every asset is stored in a folder. Decisioning is split into two tiers, and the server reaches Microsoft 365 Copilot and Copilot Studio. (#69, #68)
Highlights
🧩 Decisions with every step kind
create_decision_flow / update_decision_flow take steps of every kind, nested freely: rule set, segmentation tree, code file, sub-decision, model, assignment, condition, and range, equals or like branches. The server wires each step: it picks the newest locked revision, maps terms by name and adds a decision variable for every unmapped term, so the decision validates and publishes. It refuses combinations the service would accept but could not run. The tool description has a worked example and spells out the rules that are easy to get wrong.
📖 Lookup tables
get_lookup_table, create_lookup_table, update_lookup_table and activate_lookup_table manage the keys and values that rules consult.
- Versions: entries are edited in versions, and an active version is never changed.
- Activation: it reports
active,runningorfailed, with Viya's reason, and also handles rollback and republish. - Already-published decisions: they use newly activated entries without being published again (verified live).
- Rules: rule tools resolve a lookup table named in a
lookupcondition or alookupValueaction to its ID. They report conditions and actions Viya would store as broken inproblems. - Delete guard: deleting a lookup table that rule sets still use is refused, and the refusal names those rule sets. Viya itself would delete it and leave them unable to generate code.
🌳 Trees, value lists, code files and custom functions
These are segmentation trees (including create_segmentation_tree_from_table, which turns a FICO-style strategy table into one tree), value lists, DS2/Python/SQL code files and custom functions. Custom functions are compiled before they are saved. One shared life cycle covers every kind: list, get, copy and delete_decisioning_asset, plus get_decisioning_asset_code, list_decisioning_asset_revisions and lock_decisioning_asset_revision. validate_decision_expression checks a DS2 condition or action before it goes into a rule set or decision.
🗂️ Two decisioning tiers
- Tier 7, Decisioning Foundation (34 tools): everything it had, plus lookup tables, the life-cycle tools and expression validation.
- Tier 10, Decisioning Advanced (10 tools, new): trees, value lists, code files and custom functions.
- Tier 10 needs Tier 7, which puts that content into decisions and reads, locks and deletes it. The server logs a warning at startup when Tier 10 is enabled without it.
📁 Every decisioning asset is stored in a folder
Rule sets and decision flows used to be created outside any folder, so no folder view showed them. Every decisioning create now takes folder, which can be:
- a path such as
/Public/Content/Decisions My Folder, or a path below it- a folder ID
- a folder URI
Without folder, the create uses the new MCP_DEFAULT_FOLDER. With neither, the call is refused before anything is created and the model is told to ask the user. Updates never move an asset, and an asset created earlier is filed by its next update. The new find_folder tool (Tier 2, read-only) resolves the same references.
🚀 Publishing for MAS
publish_decision_flow now generates the decision's code for MAS, as the Intelligent Decisioning UI does. Before, decisions that use a lookup table could not be published, and now they can. Modules keep the decision's own input names, without the UI's trailing underscores.
In a 50-case comparison against the modules published before, every output matched except PathID, which MAS code leaves empty. score_data now also accepts plain input names for modules published from the UI, and reports the mapping in inputs_renamed.
🪟 Microsoft 365 Copilot and Copilot Studio
examples/m365-copilot/: a Microsoft 365 Copilot declarative agent.examples/copilot-studio/: a Copilot Studio how-to.examples/copilot-studio/certified-connector/: a draft package for Microsoft's MCP certification.examples/microsoft-copilot.md: a guide to choosing between the two routes.
Two new settings make the integrations work:
MCP_OAUTH_CLIENT_SECRETS=truelets Microsoft 365 Copilot register itself again.MCP_TOOL_RENAME=catalog_=infocat_works around Copilot Studio's runtime, which fails on tool names that start withcatalog_.
🔢 Tool count
115 tools in 11 tiers (was 92 in 10 tiers): 57 read-only and 28 destructive. Tier 7 goes from 22 to 34 tools, the new Tier 10 has 10, and Tier 2 gains find_folder.
🐳 Container image
ghcr.io/sassoftware/sas-mcp-server:1.19.0
Also published as 1.19, 1 and latest. Helm chart 0.6.0 (appVersion 1.19.0) is a minor chart bump for three new values: server.defaultFolder, server.toolRename and server.oauthClientSecrets.
⚠️ Upgrading
- Decisioning creates need a folder. Set
MCP_DEFAULT_FOLDER(chart:server.defaultFolder) or passfolderon each call. Without either,create_business_rulesetandcreate_decision_floware refused. - Trees, value lists, code files and custom functions are in the new Tier 10. A deployment that limits
MCP_TIERSadds10, alongside7, to get them. publish_decision_flownow publishes MAS code. Outputs are unchanged exceptPathID, which is now empty.
📄 Details
v1.18.0 — User ID in the server log and configurable client timeout
A minor release. The server log now names the SAS Viya user behind every tool call, and the timeout of every Viya REST call is configurable. (#65)
Highlights
👤 The Viya user of every tool call, in the server log
A shared HTTP deployment serves many people through one process, and its log said which tool ran but not for whom, so a field report could not be matched to the person who hit it. Each tool call now logs one line:
Tool call: execute_sas_code (user: sasdemo)
The id comes from GET /identities/users/@currentUser, asked with the caller's own token, so it is exactly the identity Viya authorizes the call against. For a client-credentials token, it is the client's id.
- One lookup per token, not per call. Results are cached by a hash of the token, never the token itself.
- Never in the way. The lookup has its own 10-second timeout. A failure logs
user: unknown, is retried after five minutes, and never fails the tool. - Both transports. It hooks in where each tool obtains its token, so HTTP and stdio both log it, and stdio never starts a second sign-in.
- Server log only. The opt-in collection log is unchanged.
⏱️ VIYA_CLIENT_TIMEOUT
The timeout of every Viya REST call was fixed at 300 seconds. It is now VIYA_CLIENT_TIMEOUT (chart: server.clientTimeout), with 300 as the default. Lower it to fail fast behind a proxy that drops idle connections; raise it for compute contexts that are slow to start. A value that is not a number greater than 0 stops the server at startup with the reason.
🔢 Tool count
92 tools, unchanged: 51 read-only and 21 destructive.
🐳 Container image
ghcr.io/sassoftware/sas-mcp-server:1.18.0
Also published as 1.18, 1 and latest. Helm chart 0.5.0 (appVersion 1.18.0), a minor chart bump for the new server.clientTimeout value.
⚠️ Upgrading
- One extra request per sign-in. Each new token makes a single
GET /identities/users/@currentUser. It needs no permission beyond a signed-in user. - The server log grows by one line per tool call. If you ship server logs somewhere, the user id now appears in them.
📄 Details
v1.17.0 — HTTP Debug Environment Variables
A minor release. HTTP_DEBUG=true writes every request this server sends to the SAS Viya APIs, and each response, to a separate file, so a failing tool call can be traced to the exact request behind it. (#64)
Highlights
🔍 HTTP_DEBUG traces every SAS Viya API request
A tool failure reaches the person as the model's summary of an error. The request that caused it — a wrong path, a missing media type, a body of the wrong shape — is nowhere to be seen, and one tool call can make several requests: paging, polling a compute job, fetching its log.
With HTTP_DEBUG=true in .env, each request sent through the server's Viya client and its response are appended as JSON lines to HTTP_DEBUG_LOG_PATH (default ~/.sas-mcp-server/http-debug.log), never to the server log, so stdio's protocol stream is untouched. Two records per exchange share an id: a request (method, URL, headers, body) and a response (status, elapsed_ms, headers, body). A request with no matching response failed in transit; a body read that failed midway is a response with an error, and the caller still gets that error.
What the trace keeps out and what it keeps in:
- Redacted with the collection log's rules:
Authorization, cookies and other credential-shaped headers, query parameters and body keys, plus inline Bearer and JWT strings. Not redacted: data values. Table rows and SAS code appear in bodies, so review the file before sharing it. - Bounded. Bodies are capped at
HTTP_DEBUG_MAX_BODY_BYTES(default 4096;0records none), binary bodies are described rather than recorded, multipart uploads are not buffered, bodies over 1 MiB are clipped without being decoded or parsed whole, and the file rotates (HTTP_DEBUG_MAX_LOG_BYTES,HTTP_DEBUG_LOG_BACKUPS). - Faithful. A URL is recorded exactly as sent unless a parameter had to be masked, so it can be pasted into curl to reproduce the call.
- Off the event loop. Records are written from a worker thread, as the collection log's are.
Off by default. The server warns at startup while it is on; it is meant for troubleshooting, not for a shared deployment. An unusable path turns tracing off with a warning rather than stopping the server. OAuth sign-in and token-refresh calls are not traced.
🔢 Tool count
92 tools, unchanged: 51 read-only and 21 destructive.
🐳 Container image
ghcr.io/sassoftware/sas-mcp-server:1.17.0
Also published as 1.17, 1 and latest. Helm chart 0.4.2 (appVersion 1.17.0); no chart values changed. To trace a deployment, pass HTTP_DEBUG through the chart's extraEnv.
📄 Details
v1.16.1 — Fix for new Claude Desktop behavior that led to unnecessary report creations
A patch release. apply_report_operations edits a report in place unless told otherwise: save_as is now the only switch to a new report. (#63)
Highlights
🛠️ apply_report_operations edits in place unless save_as is set
Until now the mode was inferred: any result_report_name or result_folder turned the call into a save-as clone. A client layer that must send every parameter cannot leave a name out, and a model asked to fill a required slot invents one, so every edit through such a layer quietly became a new report — the shape of a September field report against Claude Desktop 2.110, where a "change the title" request left the original untouched and a copy behind.
Now the flag alone decides:
save_as=false(the default) edits the report in place, whatever else is passed. Aresult_report_nameorresult_foldergiven alongside it is refused with a message that says which to choose, never acted on. Blank values count as not given, and the earlier rejection of blank names is gone with the ambiguity that justified it.save_as=trueapplies the operations to a new report named byresult_report_nameand/or placed byresult_folder; at least one is required, and the source is left untouched.
Every result now carries mode: in_place or save_as, so a caller can see which happened without reading the rest.
🔢 Tool count
92 tools, unchanged: 51 read-only and 21 destructive. apply_report_operations gains the save_as parameter.
🐳 Container image
ghcr.io/sassoftware/sas-mcp-server:1.16.1
Also published as 1.16, 1 and latest. Helm chart 0.4.1 (appVersion 1.16.1); no chart values changed.
⚠️ Upgrading
- A bare
result_report_nameno longer clones. A caller that relied on it to save the result as a new report must addsave_as=true; until it does, the call is refused with a message that says so, and nothing is created or edited.
📄 Details
v1.16.0 — ODS HTML Results
A minor release. execute_sas_code now returns the HTML results as SAS Data and AI Studio would show them, graphs included; the interactive views keep rendering on current Claude hosts; and one host path that broke on Anthropic's side is documented as a known issue. (#62)
Highlights
📊 execute_sas_code returns the HTML results
The compute context opens no HTML destination of its own — SAS Data and AI Studio's page comes from ODS statements it adds to every submit — so a job submitted through the API yielded a listing and nothing else, and a graph could not be seen at all.
The code now runs between an ods html5 statement and its close. Before the close comes ;*';*";*/;run;quit;, which ends whatever the code left open — an unterminated statement, quote or comment, or a step without run; — so the close still runs; none of it appears in the log the model reads. When the code printed something, the page is saved to the Viya Files service and the result gains html_results_url: opening it asks for a SAS Viya sign-in, and the file sits in no folder, so only the person who ran the code (and administrators) can open it. html_results_error reports a page that could not be saved without failing the call; html_results=false submits the code exactly as given.
The page — 40 to 70 KB, mostly the ODS stylesheet — never enters the model's context; only the link does. In the SAS log view it is the new Results tab, shown first when the code ran cleanly, with an "Open in browser" link.
Two details learned against a live Viya: the guard is run;quit;, not the quit;run; of SAS Enterprise Guide, because quit; inside an unfinished DATA step is an error on a line the caller never wrote and once left the session unable to run the next job; and when the guard's run; ends a step the code left open, the log splits the echo of the closing line around the step's notes, so the wrapper is stripped by line number, not by text.
🪟 Views on current Claude hosts
Three changes, each forced by a host:
- The view binding is repeated where hosts look for it — on the
tools/callresult, in both spellings the extension has used, and as_meta.ui.cspon theresources/readitem — not only on thetools/listentry. - View URIs carry a fingerprint of the page (
ui://sas-viya/sas-log/execute_sas_code.<hash>.html, from the ui package's files, the version and the tier and read-only selection), because hosts cache a page by its URI — claude.ai even across a connector being removed and added again — so a changed page was served stale. The plain URI is served too, for hosts holding an older tool listing. MCP_STATELESS_HTTP(chart:server.statelessHttp) serves requests without an MCP session. The 2026-07-28 protocol revision has no session and opens withserver/discover, which the default stateful transport refuses; claude.ai's custom-connector backend speaks only that revision. Verified end to end: the views, Results tab included, render in Claude Desktop through a custom connector.
The README's "Interactive views" section now has a table of clients and how they must be connected.
⚠️ Known issue: Claude Desktop and local servers
Since 22 September 2026, Claude Desktop 2.2553 no longer renders views from a server configured in claude_desktop_config.json (which accepts stdio servers only): Desktop passes a local server's tools to the chat through a bridge that rebuilds each tool's metadata and drops the view binding, so the chat never asks for the page. The tools work as before and execute_sas_code still returns its HTML results link. Reported as anthropics/claude-ai-mcp#1069. Until it is fixed, the views render when the HTTP server is added to Claude as a custom connector with MCP_STATELESS_HTTP=true.
🏷️ Product names
SAS Studio is SAS Data and AI Studio and SAS Information Catalog is SAS Data Governance, throughout tool descriptions, docs and tests. API paths and tool names are unchanged.
🔢 Tool count
92 tools, unchanged: 51 read-only and 21 destructive. execute_sas_code gains the html_results parameter.
🐳 Container image
ghcr.io/sassoftware/sas-mcp-server:1.16.0
Also published as 1.16, 1 and latest. Helm chart 0.4.0 (appVersion 1.16.0), a minor chart bump for the new server.statelessHttp value.
⚠️ Upgrading
execute_sas_codewraps the code by default. Every call now runs between anods html5open and close with the guard above, and a call that prints something stores one HTML page in the Viya Files service, kept until deleted. Sethtml_results=falseon a call to submit the code exactly as given. Nothing else in the result changes.- View URIs changed shape (
…/<tool>.<hash>.html). Hosts pick the new URI up fromtools/list; the old plain URI keeps working as an alias. - Stateless HTTP is off by default. Turn it on (
MCP_STATELESS_HTTP=true, chartserver.statelessHttp) for a deployment that will be added to Claude as a custom connector.
📄 Details
v1.15.0 — interactive views for data, SAS logs and the glossary
A minor release, and the first in which a result can be more than text. Ten tools now carry an interactive view — a data grid, a SAS log viewer and three glossary views — that MCP Apps clients render beside the result. What the tools return is unchanged. (#61)
Highlights
🪟 Interactive views (MCP Apps)
Some results read badly as text however well the tool shapes them: a hundred rows of a table, a SAS log, a form's worth of typed attributes. Clients that support the MCP Apps extension now render a small HTML view in a sandboxed frame beside the result.
| View | Tools | What you get |
|---|---|---|
| Data grid | query_data, get_castable_data, get_compute_table_data |
Sort, filter and page. Paging calls the tool again from the view, so a person walks a large table while the model holds only the first page. |
| SAS log | execute_sas_code, get_job_log, submit_batch_job |
ERROR / WARNING / NOTE lines coloured and counted, jump to the next problem, problems-only and hide-notes filters, the listing on its own tab. A batch job is polled from the view until it ends, so the model spends no turns polling. |
| Glossary term editor | get_glossary_term_type, get_glossary_term |
A form generated from the term type: a real checkbox for a boolean, a date picker, a multi-select. The attribute wire-format mistakes fixed in v1.14.0 cannot be made from here at all. A draft can be published. |
| Glossary browser | list_glossary_terms |
The whole glossary as a tree beside a detail pane: search, type and has-assets filters, breadcrumbs, attributes, assigned assets and a sunburst of the hierarchy. |
| Glossary import | import_glossary_terms |
With the new dry_run=true, the rows exactly as they would be sent, with an Import button. |
Every view opens fullscreen from a ⤢ button in its header wherever the host offers that mode. That is where a wide table or a long log is meant to be read.
What keeps this safe:
- Purely additive. Every tool returns exactly what it did. A client without the extension (Claude Code, a script, an older client) sees no difference.
- The browser never talks to Viya. Every request a view makes travels through the host to this server as an ordinary tool call, carrying the session's token.
- A view offers nothing the deployment withheld. A tool removed by
MCP_TIERSorMCP_READ_ONLYhas no view, and no control inside another view calls it. In read-only mode the glossary browser opens without its Publish button, rather than showing one that can only fail. - Fully inlined. The official
@modelcontextprotocol/ext-appsbridge (1.7.5, Apache-2.0) is vendored and inlined. Hosts build the frame's Content-Security-Policy from what a server declares, and Claude.ai ignores that declaration, so only inline pages render everywhere. The same choice makes the views work on an air-gapped Viya. No new Python dependency; the image grows by about a third of a megabyte.
Verified in Claude Desktop, including fullscreen on every view. The other clients listed in the README support the extension but have not yet been tested against this server.
📋 get_compute_table_data: rows from a SAS library table
This is the compute-tier counterpart of get_castable_data, which only ever covered CAS. It returns a page of rows from libref.table through the compute session, with values formatted as SAS displays them, the total row count and a truncated flag. It is read-only and runs in the same session as execute_sas_code, so a WORK table from an earlier call can be browsed.
🔎 import_glossary_terms(dry_run=true)
A dry run validates the batch, resolves every parent into its path and orders the rows exactly as the import would, then returns them without sending anything. A real import runs as a job that can fail partway, after earlier rows are already committed, so a preview a person can confirm is worth the extra call.
🏷️ The server says which version it is
The first line of the log, in both transports, now reads:
sas-mcp-server 1.15.0 (fastmcp 4.0.0, stdio) - connecting to SAS Viya at https://…
Until now nothing at startup named the version. The MCP handshake also reported FastMCP's version instead of ours, because FastMCP(...) was never given one, so a client asked what it was connected to answered 4.0.0. The FastMCP version stays in the log line on purpose: both sign-in regressions after the 4.0 upgrade (#54, #58) were specific to it.
🔢 Tool count
92 tools, up from 91: 51 read-only (up from 50, since get_compute_table_data is read-only) and 21 destructive.
🐳 Container image
ghcr.io/sassoftware/sas-mcp-server:1.15.0
Also published as 1.15, 1 and latest. Helm chart 0.3.0 (appVersion 1.15.0), a minor chart bump for the new server.apps value.
⚠️ Upgrading
- Views are on by default. Set
MCP_APPS=false(Helm:server.apps: false) to register none. The tools are unaffected either way. - The startup log line changed. It used to read
Connecting to SAS Viya at …; it now beginssas-mcp-server <version> (…) - connecting to SAS Viya at …. Anything that matched the old line, such as a log-based readiness check or alert, needs updating.
📄 Details
v1.14.2 — Copilot CLI sign-in, and failures that explain themselves
A patch release driven by three field reports. One fixes interactive sign-in from GitHub Copilot CLI; the other two fix failures that happened for a real reason while the output said nothing useful about it.
Highlights
🔐 GitHub Copilot CLI can sign in (#58)
copilot mcp auth failed before SAS Viya was ever contacted, with Redirect URI 'http://127.0.0.1:<port>/' does not match CIMD redirect_uris. An MCP client opens a fresh loopback port for every sign-in, and FastMCP 4.0 enables CIMD (Client ID Metadata Document) by default: a client whose client_id is an HTTPS URL — Copilot CLI is one — has its callback checked against the fixed redirect URIs in its own published document, with no exemption for a varying loopback port. The server now sets enable_cimd=False, which puts those clients on the ordinary dynamic-registration path every other MCP client already uses, and which does let loopback ports vary (RFC 8252 §7.3).
The trade, stated plainly: the server no longer advertises client_id_metadata_document_supported, and private_key_jwt drops out of the advertised token-endpoint auth methods. Both are accurate — with CIMD off it supports neither.
🧾 execute_sas_code says why a job failed (#59, fixes #55)
A refused job submission surfaced as Error calling tool 'execute_sas_code': 'id' — a KeyError leaking out, because the submit never checked its HTTP status and parsed Viya's error body as though it were a job. Viya's actual reason was thrown away, so a model driving the tool retried simpler and simpler SAS code against a failure that had nothing to do with the code. It now reports what Viya said.
The same sweep found two quieter defects in the same path:
- A log that could not be read came back as an empty log rather than an error — for audit-style work, silently losing the deliverable.
- A compute session dying mid-job left the tool polling forever, every two seconds, because the error body was read as a job state that was never terminal. Deliberately no wall-clock cap was added alongside the fix: legitimate SAS jobs can run for hours.
🗄️ A caslib 409 points at the data source, not your permissions (#59, fixes #56)
casManagement answers a caslib read with HTTP 409 when CAS cannot connect to the library's underlying data source — verified against postgres (password authentication failed) and Oracle (ORA-00257: archiver error) caslibs, both arriving as the same 409. On its own the status reads as an authorization problem, so the natural next step is to check caslib permissions, find them correct, and be stuck. list_castables, list_source_tables, get_castable_info and get_castable_columns now keep Viya's own reason and add what it means, with the one step that settles it independently of MCP: confirm the caslib opens directly in SAS Viya.
🔢 Tool count
91 tools (50 read-only, 21 destructive) — unchanged from v1.14.1. This release changes error handling and authentication configuration only.
🐳 Container image
ghcr.io/sassoftware/sas-mcp-server:1.14.2
Also published as 1.14, 1 and latest. Helm chart 0.2.6 (appVersion 1.14.2).
📄 Details
Thanks to @bteleuca for diagnosing and fixing the Copilot CLI sign-in (#58), and to @nishah2005 for the reports behind #55 and #56.
v1.14.0 — Business Glossary, corrected by the field
Highlights
Tier 9 shipped in v1.13.0 and was immediately used to build a real 122-term glossary. This release is what that use found — thirteen fixes and four new tools, all from two rounds of field feedback against the same live deployment. (#52)
The Business Glossary service publishes no OpenAPI document, so every wire format below was established by probing a running Viya. That is the theme of the release: the API accepts a value, stores something else, and says nothing.
🐛 Values that were accepted and silently stored wrong
Three of these produce no error at all — which makes them the ones worth reading.
| What you sent | What was stored | Now |
|---|---|---|
attributes={"Approved": True} |
the term type's default — never your value | a JSON boolean, stored as sent |
attributes={"Regions": ["EMEA", "APAC"]} |
rejected: The value "['EMEA', 'APAC']" … is invalid |
EMEA,APAC — the one form the glossary takes |
a time attribute of 09:15:00+02:00 |
09:15:00Z — two hours out, no warning |
07:15:00Z |
an imported row's definition |
in the description; the definition held the term's own name | both columns written from their own field |
That last one is the sharpest. The importer's Description column is the description; Definition is a separate system column, and with no Definition column at all the service fills the field in with the term's name. A 122-term import therefore came back with every definition reading like a label and every description holding the text meant for the definition — the field a reader actually sees, wrong on every row, on every term.
✍️ import_glossary_terms — a whole hierarchy in one call
Creating terms one at a time costs a call per term and a wait between levels, because a child needs its parent's id from the previous response. The reported 122-term hierarchy took 122 sequential calls.
This wraps the glossary's own bulk import, so parents are named rather than threaded by id:
[{"name": "Exposure", "term_type": "Business Term"},
{"name": "Net Exposure", "term_type": "Business Term", "parent": "Exposure"},
{"name": "Counterparty", "term_type": "Business Term", "parent": "Exposure\\Net Exposure"}]Rows may be given in any order and are sorted so every parent precedes its children. Attribute values keep the same forms as create_glossary_term and are validated per term type before the CSV is built, so a bad value fails locally rather than as one line of a job log.
The result names the term each row became. terms carries {name, path, term_id, existed} per row, resolved in one filtered request per 40 distinct names — sibling names are unique, so a (parentId, name) index over the candidates rebuilds the hierarchy and every row's path walks down it. On a 122-term import that is about four requests, not 122.
The same pass fixes what created was hiding. A row naming a term that already exists at that path is not created, but the job counts it successful either way — so a re-import of a 122-term hierarchy reported 122 created and changed nothing. The job's own creationTimeStamp separates the two: a term older than the job was already there. new and already_existed now report the split, and existed says which is which per row.
🧱 Term types can be designed, not only read
create_glossary_term_type, update_glossary_term_type and delete_glossary_term_type complete the tier. A glossary could previously be read and populated through these tools but never designed through them, so any deployment wanting a custom type needed a SAS UI step first.
- An edit keeps each attribute's identifier, which matters more than it looks: every term's stored values are filed under it, and re-minting one orphans them all.
- To rename an attribute, give its
attribute_idalongside the new label. Matching by label alone means a new label matches nothing, mints a fresh attribute, and leaves every stored value behind under the old one — readable as nothing. A rename onto a label another attribute already holds is refused rather than producing two of the same name. - A
single-selectwith noallowed_valuesis refused, since nothing could ever be stored in it — the service accepts that definition happily.
📝 A draft is a separate resource
create_glossary_term(publish=false) produced a term that could not be edited and could never be published. /glossary/terms/{id} answers GET and DELETE for a draft, so it looked reachable — but a PUT there is a flat 404.
The draft's own links say where it actually lives: PUT …/draft to edit, PUT …/draft/state?action=publish to promote. Neither is documented anywhere. Updates now route to the draft when the term is one, and update_glossary_term(publish=true) publishes it — on its own, or alongside a final edit.
🔍 Attributes in listings, and filtering on them
include_attributes returns each term's attribute values from list_glossary_terms and search_glossary_terms; attribute_filter keeps only the terms that match — {"Used in Risk": true}, {"Regions": "EMEA"}.
On the listing, attributes are free: the list representation already carries the raw UUID-keyed map, so decoding costs one call per distinct term type on the page — normally one — and none per term.
The glossary cannot filter on attribute values, so this filters here, and says how far it got: scanned, scan_complete, and a next_start when the scan was capped. A short list is otherwise indistinguishable from a complete answer. Narrowing with term_type narrows the scan too — on a live 1,720-term glossary, one term type answered in 1.3 s against 8.3 s unscoped.
💬 Errors that say what went wrong
Every Viya failure used to lead with httpx's status phrase, the full URL and a link to the MDN page for the status code, ahead of the one line that says what was actually wrong. Errors now read:
HTTP 400 from POST /glossary/terms — Viya reported: The value "true" for the field "Approved" is invalid
This one is not glossary-specific — it affects every tier.
🔢 Tool count
91 tools across tiers 0–9 — up from 87. 50 read-only (unchanged: all four new tools write), 21 destructive. Tier 9 goes from 12 tools to 16.
No tool was removed or renamed. import_glossary_terms' created keeps its old meaning — rows the job accepted — with new and already_existed added beside it.
🐳 Container image
docker pull ghcr.io/sassoftware/sas-mcp-server:1.14.0Helm chart 0.2.4 (appVersion 1.14.0); both sample manifests and the pull/mirror instructions point at 1.14.0.
⚠️ Upgrading
Drop-in. Container and Kubernetes deployments pick it up with the new image tag; a source checkout wants the usual uv sync.
One thing to check if you already authored terms with v1.13.0: boolean and multi-select attributes written through the old tools did not store what you sent — a boolean fell back to the term type's default, and a multi-select was rejected outright. Re-read any term carrying one before trusting it.
📄 Details
- CHANGELOG — 1.14.0
- Full changelog: v1.13.0...v1.14.0
v1.13.0 — Business Glossary
Highlights
📖 Tier 9 — Business Glossary: twelve new tools (#51)
Tier 1 can tell you a column is named CD_NAC_RSK, that it is a varchar(3), and which table it lives in. It cannot tell you what it means, who decided that, or whether anyone has decided at all. That meaning lives in the SAS Business Glossary, and this release is the server learning to read and write it.
The gap matters most for exactly the questions people bring to an MCP server. "Which tables carry counterparty exposure?" is a glossary question wearing a data-discovery costume — the answer depends on which columns a governance team attached a term to, not on column names, which are frequently unhelpful (CD_NAC_RSK) or actively misleading (two AMOUNT columns meaning different things).
| Reading | Linking | Authoring |
|---|---|---|
search_glossary_terms — ranked free text over names and definitions |
list_term_assets — where is this term used? |
create_glossary_term |
list_glossary_terms — structural: by type, parent, name |
list_table_terms — what does every column of this table mean? |
update_glossary_term |
get_glossary_term |
delete_glossary_term |
|
list_glossary_term_types / get_glossary_term_type |
assign_glossary_term / unassign_glossary_term |
Enable the tier alone with MCP_TIERS=9. Seven of the twelve survive MCP_READ_ONLY.
🧩 Three sharp edges the tier absorbs
The Glossary REST API can answer all of the above, but three of its properties make a direct wrapping quietly wrong rather than loudly broken.
A term has two identities, and the bridge between them is invisible by default. Every term exists twice — as a Glossary object and as an Information Catalog entity, under different ids. The glossary id is what you read and write; the catalog id is what asset relationships point at and what catalog search returns. Get them the wrong way round and the call 404s.
They are joined by the catalog entity's resourceId, and that field is absent from the default representation. Read the entity as anything other than application/vnd.sas.metadata.instance.entity+json and resourceId is simply not in the body — no error, no warning, just a missing key. This is the failure mode worth knowing about, because it does not look like a failure: code reading resourceId from the default representation gets None for every term and therefore reports "this table has no glossary terms" for a table that is fully governed. Every tool here returns both ids explicitly, as term_id and catalog_entity_id.
Custom attributes are keyed by UUID, with the labels held elsewhere. A term's attributes come back as {"8f3c…e21": "Group"}; the human label (Scope) lives on the term type, a separate resource. These tools resolve UUID→label on read and label→UUID on write, so a caller works in {"Scope": "Group"} throughout. They also drop the empty values the glossary stores for every declared attribute, validate single-select values and required attributes before calling Viya — naming the valid labels, which is a mistake a model can correct where an HTTP 400 is not — and coerce booleans to the "true"/"false" strings the glossary stores, since passing a real bool is accepted and then displays as True forever.
Term↔asset links are catalog relationships, and their endpoints are representation-dependent too. endpoint1Id/endpoint2Id appear only under application/vnd.sas.metadata.instance.relationship+json. Supplying that as the collection's Accept-Item returns the endpoints straight from a filtered query, instead of one round trip per link. On a live 13-column table, measured end to end:
| requests | wall clock | |
|---|---|---|
| per-link traversal | 27 | 4.83 s |
batched Accept-Item |
4 | 0.89 s |
Identical answers.
✍️ Two authoring defaults worth knowing
create_glossary_termpublishes by default. The API's?publishparameter defaults tofalse, so a plain POST creates a draft visible to nobody but its author — almost never what "create a term" means when a model does it on your behalf.publish=falsestill gets you the draft.update_glossary_termmerges rather than replaces. The API's update is a whole-resource PUT, so the naive wrapping blanks every field you did not mention. This reads the current term first and merges; attributes merge one at a time.
📄 Reading every asset of a heavily used term
list_term_assets takes limit (default 100, ceiling 500 — the catalog's page size) and start, and reports the term's total asset count alongside the page, plus truncated and the exact next_start to pass. A term governing thousands of columns is read in full by paging, rather than being cut off at a fixed ceiling with no way past it.
🔎 Also reachable from Tier 1
Terms assigned this way become searchable through catalog_search using the Column.term:"<term name>" facet on the datasets index, which returns the tables carrying a term without resolving individual columns. Matching is case-insensitive. (The catalog's own index for terms is terms, plural — singular term returns HTTP 400.)
🔢 Tool count
87 tools across tiers 0–9 — up from 75. 50 read-only (was 43), 18 destructive. Nothing existing changed shape: no tool was removed, renamed, or altered in behaviour.
🐳 Container image
docker pull ghcr.io/sassoftware/sas-mcp-server:1.13.0Helm chart 0.2.3 (appVersion 1.13.0); both sample manifests and the pull/mirror instructions point at 1.13.0.
⚠️ Upgrading
Drop-in. Container and Kubernetes deployments pick it up with the new image tag; a source checkout wants the usual uv sync. Nothing in .env, the chart values, or any client configuration needs to change — though if you set MCP_TIERS explicitly, Tier 9 is not included unless you add it.
📄 Details
- CHANGELOG — 1.13.0
- Full changelog: v1.12.0...v1.13.0
v1.12.0 — FastMCP 4.0
Highlights
⬆️ FastMCP 4.0 is now the minimum (#50)
The pin moves from fastmcp>=3.0.0,<4.0.0 to fastmcp>=4.0.0,<5.0.0, bringing mcp 2.x and FastMCP 4's HTTP client, httpx2, alongside the httpx this project uses directly.
Nothing about how this server is written had to change. Tools, prompts, middleware, the tier system, read-only mode and the OAuth proxy all work on 4.0 unaltered — the entire test suite passes without a single change to server logic, and the live integration suite passes against a real SAS Viya deployment.
It is not a drop-in for an existing checkout, though. uv sync — or a container rebuild — is required, and running this code against FastMCP 3.x is no longer supported. The next section is why that combination has to be ruled out rather than merely discouraged.
🏷️ Tool annotations renamed to the MCP SDK v2 snake_case fields
MCP SDK v2, which ships with FastMCP 4, renamed the annotation fields: readOnlyHint → read_only_hint, and likewise destructive_hint, idempotent_hint, open_world_hint, plus input_schema in the tests. This release follows that rename.
Clients are unaffected. The wire format is still the spec's camelCase, because SDK v2 keeps those names as serialization aliases. Verified against a real tools/list response: all 75 tools carry the camelCase keys, and 43 report readOnlyHint: true, exactly as before.
🛡️ Guarding the failure mode that has no symptoms
The rename above is exactly the kind of change that can go wrong without anything appearing to break, and two independent versions of that were closed here.
Mixing this code with FastMCP 3.x. On the older mcp 1.x, ToolAnnotations accepts unknown fields, and its real fields are camelCase. Pass read_only_hint=True there and pydantic files it as an extra: the model reads the value back correctly, tests pass, and the real camelCase field stays None. Proven on a live tools/list against 3.4.6 with this source — zero tools advertise read-only instead of 43, so every read-only tool quietly looks writable to a client deciding whether it needs to ask permission. A hard floor of 4.0 is what makes that unreachable.
A future rename doing the same thing to the landing page. The badges were read with getattr(annotations, "readOnlyHint", None), which cannot distinguish "this tool declares nothing" from "this field no longer exists". The next rename would have blanked every badge on the page with nothing failing anywhere. Four tests now close both halves: two assert the field names exist on ToolAnnotations, and two assert the serialized payload uses the spec's camelCase keys and that every read-only and destructive tool still advertises itself as such. Each was confirmed to actually fail when the old name is restored.
🔐 SSL_VERIFY=false still covers FastMCP's own HTTPS calls
FastMCP 4 dropped httpx in favour of httpx2. A TLS patch reaching only httpx would have silently stopped covering the JWKS fetch and the OAuth token exchange — surfacing inside the auth layer as a 401 or a connect error, a long way from its cause.
ssl_patch already patched both, so no change was needed. This release confirms it against 4.0 rather than a pre-release: on a live run with SSL_VERIFY=false, all four client classes carry the patch marker.
🔢 Tool count
Unchanged: 75 tools across tiers 0–8 — 43 read-only, 15 destructive. This release changes the dependency floor and internal field names; no tool was added, removed or renamed, and no tool's behaviour changed.
🐳 Container image
docker pull ghcr.io/sassoftware/sas-mcp-server:1.12.0Helm chart 0.2.2 (appVersion 1.12.0); both sample manifests and the pull/mirror instructions point at 1.12.0.
⚠️ Upgrading
uv sync # required — this is not a drop-in for an existing checkoutContainer and Kubernetes deployments pick it up with the new image tag. Nothing in .env, the chart values, or any client configuration needs to change.
📄 Details
- CHANGELOG — 1.12.0
- Full changelog: v1.11.2...v1.12.0