@sassoftware/sas-score-mcp-serverjs 0.4.1-5 → 0.4.1-7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ ---
2
+ name: sas-score-workflow
3
+ description: >
4
+ Guide the full model scoring workflow: validate model familiarity, route to appropriate scoring tool
5
+ based on model type, invoke scoring with scenario data, and present merged results. Use this skill
6
+ when the user wants to run predictions on data (already fetched or user-supplied). Supports generic
7
+ syntax: "score with model <name>.<type> scenario =<params>" where type is job|jobdef|mas|scr|sas.
8
+ Trigger phrases: "score these records", "predict using model", "run model on", "score with model X.mas".
9
+ ---
10
+
11
+ # SAS Score Workflow
12
+
13
+ Orchestrates model validation, type-based routing, scoring invocation, and result presentation.
14
+ Handles both MAS models and alternative scoring engines (jobs, jobdefs, SCR, SAS programs).
15
+
16
+ ---
17
+
18
+ ## Generic Scoring Syntax
19
+
20
+ Users can invoke scoring with a unified syntax that automatically routes to the correct tool:
21
+
22
+ ```
23
+ score with model <name>.<type> [scenario =<key=value pairs>]
24
+ score <name>.<type> [scenario =<key=value pairs>]
25
+ ```
26
+
27
+ **Type determines the routing:**
28
+ - `.job` → route to `run-job` with scoring parameters
29
+ - `.jobdef` → route to `run-jobdef` with scoring parameters
30
+ - `.mas` → route to `model-score` (Model Analytical Service — default)
31
+ - `.scr` → route to `scr-score` (SAS Container Runtime)
32
+ - `.sas` → route to `run-sas-program` to run sas program in folder
33
+
34
+ If no type is specified (bare model name), assume `.mas` (MAS model).
35
+
36
+ ---
37
+
38
+ ## Type-Based Routing
39
+
40
+ ### Parse and Strip Model Type
41
+
42
+ When a user provides a model name with a type suffix (e.g., `simplejon.job`, `churn.mas`):
43
+
44
+ 1. **Extract the type:** Split on the last dot to identify the type suffix
45
+ - `simplejon.job` → type = `job`, base name = `simplejon`
46
+ - `churn.mas` → type = `mas`, base name = `churn`
47
+ - `fraud_detector.jobdef` → type = `jobdef`, base name = `fraud_detector`
48
+
49
+ 2. **Validate the type:** Confirm it matches one of the supported types: `job`, `jobdef`, `mas`, `scr`, `sas`
50
+ - If type is unrecognized, assume `.mas` (default MAS model) and treat the entire input as the model name
51
+
52
+ 3. **Strip the type suffix:** Remove the `.type` from the model name before passing to the routing tool
53
+ - **Critical:** Always pass the base name (without the dot and type) to the invoked tool
54
+ - `simplejon.job` → pass `simplejon` to `run-job`
55
+ - `churn.mas` → pass `churn` to `model-score`
56
+ - `fraud_detector.jobdef` → pass `fraud_detector` to `run-jobdef`
57
+
58
+ ### Type: `.mas` (Model Aggregation Service)
59
+ - **Tool**: `model-score`
60
+ - **Use for**: Standard MAS-deployed predictive models
61
+ - **Example**: `score with model churn.mas scenario =age=45,income=60000`
62
+ - **Invocation**: `model-score({ model: "churn", scenario: {...} })`
63
+
64
+ ### Type: `.job` (SAS Viya Job)
65
+ - **Tool**: `run-job`
66
+ - **Use for**: Pre-built scoring jobs with parameters
67
+ - **Example**: `score with model monthly_scorer.job scenario =month=10,year=2025`
68
+ - **Invocation**: `run-job({ name: "monthly_scorer", scenario: {...} })`
69
+
70
+ ### Type: `.jobdef` (SAS Viya Job Definition)
71
+ - **Tool**: `run-jobdef`
72
+ - **Use for**: Job definitions that perform scoring logic
73
+ - **Example**: `score with model fraud_detector.jobdef using amount=500,merchant=online`
74
+ - **Invocation**: `run-jobdef({ name: "fraud_detector", scenario: {...} })`
75
+
76
+ ### Type: `.scr` (Score Code Runtime)
77
+ - **Tool**: `scr-score`
78
+ - **Use for**: Models deployed in SCR containers (REST endpoints)
79
+ - **Example**: `score https://scr-host/models/loan.scr using age=45,credit=700`
80
+ - **Invocation**: `scr-score({ url: "https://scr-host/models/loan", scenario: {...} })`
81
+
82
+ ### Type: `.sas` (SAS Program / SQL)
83
+ - **Tool**: `run-sas-program`
84
+ - **Use for**: Custom SAS or SQL scoring code
85
+ - **Example**: `score my_scoring_code.sas using x=1,y=2`
86
+ - **Invocation**: `run-sas-program({ folder: "my_scoring_code", scenario: {...} })`
87
+
88
+
89
+
90
+ ---
91
+
92
+ ## Scenario Parsing
93
+
94
+ The scenario parameter (comma-separated key=value pairs) is parsed into an object:
95
+
96
+ ```
97
+ scenario =age=45,income=60000,region=South
98
+ ↓ parsed as:
99
+ { age: "45", income: "60000", region: "South" }
100
+ ```
101
+
102
+ Accepted formats:
103
+ - **String**: `age=45,income=60000`
104
+ - **Object**: `{ age: 45, income: 60000 }`
105
+ - **Array** (batch): `[ {age:45, income:60000}, {age:50, income:75000} ]`
106
+
107
+ ---
108
+
109
+ ## Step 1 — Check model familiarity before scoring
110
+
111
+ Score immediately if:
112
+ - The user names a specific model they've used before in this session, OR
113
+ - The model name matches a previously confirmed model in the conversation
114
+
115
+ Pause and suggest investigation if:
116
+ - The model name is new, vague, or misspelled-looking (e.g. "the churn one", "that cancer model")
117
+ - The user seems unsure of the required input variable names
118
+
119
+ **Suggested message:**
120
+ > "I don't recognize that model — want me to run `find-model` to confirm it exists,
121
+ > or `model-info` to check its required inputs first?"
122
+
123
+ ---
124
+
125
+ ## Step 2 — Prepare the scenario data
126
+
127
+ **For a single record** (one object):
128
+ ```javascript
129
+ scenario = { field1: value1, field2: value2, ... }
130
+ ```
131
+
132
+ **For batch scoring** (multiple records — the typical case):
133
+ ```javascript
134
+ scenario = [
135
+ { field1: val1, field2: val2, ... },
136
+ { field1: val3, field2: val4, ... },
137
+ ...
138
+ ]
139
+ ```
140
+
141
+ **Critical rules:**
142
+ - Loop or call model-score **once per row**.
143
+ - Field names in the scenario must match the model's expected input variable names **exactly**.
144
+ - If table column names differ from model input names, **flag this to the user** and ask for confirmation before scoring.
145
+ - Example: Table has `age_years`, but model expects `age` → ask user which column maps to which input.
146
+ - Do not add units, labels, or extra metadata — raw field values only.
147
+
148
+ ---
149
+
150
+ ## Step 3 — Invoke the appropriate scoring tool
151
+
152
+ Based on the type extracted from the model name, invoke the corresponding tool:
153
+
154
+ **For `.mas` (default):**
155
+ ```javascript
156
+ model-score({
157
+ model: "<modelname>",
158
+ scenario: scenario, // object or array
159
+ uflag: false // set true if you need field names prefixed with _
160
+ })
161
+ ```
162
+
163
+ **For `.job`:**
164
+ ```javascript
165
+ run-job({
166
+ name: "<jobname>",
167
+ scenario: scenario
168
+ })
169
+ ```
170
+
171
+ **For `.jobdef`:**
172
+ ```javascript
173
+ run-jobdef({
174
+ name: "<jobdefname>",
175
+ scenario: scenario
176
+ })
177
+ ```
178
+
179
+ **For `.scr`:**
180
+ ```javascript
181
+ scr-score({
182
+ url: "<scr_endpoint_url>",
183
+ scenario: scenario
184
+ })
185
+ ```
186
+
187
+ **For `.sas`:**
188
+ ```javascript
189
+ run-sas-program({
190
+ src: "<sas_or_sql_code>",
191
+ scenario: scenario
192
+ })
193
+ ```
194
+
195
+ **Rules:**
196
+ - Pass the full batch in one call; do not loop over rows
197
+ - If scoring fails, return the structured error and suggest troubleshooting
198
+ - For MAS models, include uflag parameter if underscore-prefixed output is needed
199
+ - For jobs/jobdefs, scenario becomes parameter arguments
200
+ - For SCR, include full URL endpoint
201
+
202
+ ---
203
+
204
+ ## Step 4 — Present the results
205
+
206
+ Merge the scoring output back with the input records and present as a table where possible.
207
+
208
+ **Always surface:**
209
+ - The key prediction/score field(s) (e.g. `P_churn`, `score`, `prediction`, `P_risk`)
210
+ - Any probability/confidence fields for classification models (e.g. `P_class0`, `P_class1`)
211
+ - Selected input fields that drove the prediction, so the user can see context
212
+
213
+ **Formatting:**
214
+ - Present results in a table for clarity
215
+ - If results exceed 10 rows, show the first 10 and ask: *"Want to see more results or export the full set?"*
216
+ - Round numeric predictions to 2–4 decimal places for readability
217
+
218
+ ---
219
+
220
+ ## Common flows
221
+
222
+ **Flow A — Score rows with MAS model**
223
+ > "Score the first 10 customers in Public.customers with the churn model"
224
+
225
+ 1. `read-table` → { table: "Public.customers", limit: 10 }
226
+ 2. `model-score` → { model: "churn", scenario: [ ...10 row objects ] }
227
+ 3. Present merged results with prediction + key inputs
228
+
229
+ **Flow B — Score with a scoring job**
230
+ > "Score December sales with the monthly_scorer job using month=12,year=2025"
231
+
232
+ 1. `run-job` → { name: "monthly_scorer", scenario: { month: "12", year: "2025" } }
233
+ 2. Capture job output and tables
234
+ 3. Present results
235
+
236
+ **Flow C — Score with a job definition**
237
+ > "Run fraud detection jobdef on transaction amount=500, merchant=online"
238
+
239
+ 1. `run-jobdef` → { name: "fraud_detection", scenario: { amount: "500", merchant: "online" } }
240
+ 2. Capture log, listings, and tables
241
+ 3. Present results
242
+
243
+ **Flow D — Score with SCR endpoint**
244
+ > "Score with the loan model at https://scr-host/models/loan using age=45, credit_score=700"
245
+
246
+ 1. `scr-score` → { url: "https://scr-host/models/loan", scenario: { age: "45", credit_score: "700" } }
247
+ 2. Capture prediction response
248
+ 3. Present result
249
+
250
+ **Flow E — Score results of an analytical query with MAS**
251
+ > "Score high-value customers (spend > 5000) in mylib.sales with the fraud model"
252
+
253
+ 1. `sas-query` → { table: "mylib.sales", sql: "SELECT * FROM mylib.sales WHERE spend > 5000" }
254
+ 2. `model-score` → { model: "fraud", scenario: [ ...result rows ] }
255
+ 3. Present merged results
256
+
257
+ **Flow F — User supplies scenario data directly**
258
+ > "Score age=45, income=60000, region=South with the churn model"
259
+
260
+ 1. Skip read step
261
+ 2. `model-score` → { model: "churn", scenario: { age: "45", income: "60000", region: "South" } }
262
+ 3. Present result
263
+
264
+ **Flow G — Model unfamiliar, need to confirm**
265
+ > "Score Public.applicants with the creditRisk2 model"
266
+
267
+ 1. Pause — "creditRisk2" is new
268
+ 2. Suggest: `find-model` to confirm it exists, `model-info` to get input variables
269
+ 3. Once confirmed → `read-table` + `model-score`
270
+
271
+ **Flow H — Generic score syntax with type routing**
272
+ > "score with model churn.mas scenario =age=45,income=60000"
273
+ > "score fraud_detector.jobdef where scenario =amount=500"
274
+ > "score monthly_report.job using month=10,year=2025"
275
+
276
+ 1. Parse model name to extract type (.mas, .job, .jobdef, .scr, .sas)
277
+ 2. Route to appropriate tool based on type
278
+ 3. Parse scenario and invoke tool with parameters
279
+ 4. Present results from routed tool
280
+
281
+ ---
282
+
283
+ ## Error handling
284
+
285
+ | Problem | Action |
286
+ |---|---|
287
+ | Model not found | Suggest `find-model` to verify the model is deployed |
288
+ | Input field name mismatch | Show the mismatch (table has X, model expects Y), ask user to confirm mapping |
289
+ | Scoring error / invalid inputs | Return structured error, suggest `model-info` to check required inputs and data types |
290
+ | Empty read result | Tell user, ask if they want to adjust the query/filter before scoring |
291
+ | Missing input fields | Ask which table columns map to the required model inputs |
292
+
293
+ ---
294
+
295
+ ## Tips
296
+
297
+ - **Batch is better:** Always pass the full set of records in one `model-score` call. Do not loop.
298
+ - **Confirm mappings:** If column names don't match model inputs, ask before scoring.
299
+ - **Show context:** Include key input columns in the result output so predictions make sense.
300
+ - **Limit output:** For large result sets (>10 rows), ask before showing all.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * SAS Viya PKCE Authorization Flow
3
+ *
4
+ * Uses the Authorization Code flow with PKCE (RFC 7636).
5
+ * Starts a local HTTP server to capture the redirect callback.
6
+ *
7
+ * Usage:
8
+ * node sas-viya-pkce-auth.js
9
+ *
10
+ * Prerequisites:
11
+ * - A SAS Viya client registered with:
12
+ * grant_types: authorization_code
13
+ * redirect_uri: http://localhost:3000/callback
14
+ * PKCE enabled (no client_secret required)
15
+ */
16
+
17
+ import http from "http";
18
+ import https from "https";
19
+ import crypto from "crypto";
20
+ import url from "url";
21
+
22
+ function authpkce(_appContext) {
23
+ let {VIYA_SERVER,CLIENTID, PORT} = _appContext;
24
+ // ── Configuration ────────────────────────────────────────────────────────────
25
+
26
+ const CONFIG = {
27
+ authBaseUrl: `${VIYA_SERVER}/oauth/SASLogon`,
28
+ clientId: CLIENTID, // <-- replace with your client ID
29
+ redirectUri: `http://localhost:${PORT}/callback`,
30
+ scopes: "openid profile",
31
+ localPort: PORT,
32
+ };
33
+
34
+ main().catch((err) => {
35
+ console.error("Unexpected error:", err);
36
+ process.exit(1);
37
+ });
38
+
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+
41
+ // Generate a cryptographically random code verifier (43–128 chars, base64url)
42
+ function generateCodeVerifier() {
43
+ return crypto.randomBytes(64).toString("base64url");
44
+ }
45
+
46
+ // Derive the code challenge: BASE64URL(SHA-256(verifier))
47
+ function generateCodeChallenge(verifier) {
48
+ return crypto.createHash("sha256").update(verifier).digest("base64url");
49
+ }
50
+
51
+ // Build the SASLogon authorization URL
52
+ function buildAuthUrl(codeChallenge, state) {
53
+ const params = new URLSearchParams({
54
+ response_type: "code",
55
+ client_id: CONFIG.clientId,
56
+ redirect_uri: CONFIG.redirectUri,
57
+ scope: CONFIG.scopes,
58
+ state,
59
+ code_challenge: codeChallenge,
60
+ code_challenge_method: "S256",
61
+ });
62
+ return `${CONFIG.authBaseUrl}/authorize?${params}`;
63
+ }
64
+
65
+ // Exchange the authorization code for tokens
66
+ function exchangeCodeForTokens(code, codeVerifier) {
67
+ return new Promise((resolve, reject) => {
68
+ const body = new URLSearchParams({
69
+ grant_type: "authorization_code",
70
+ client_id: CONFIG.clientId,
71
+ redirect_uri: CONFIG.redirectUri,
72
+ code,
73
+ code_verifier: codeVerifier,
74
+ }).toString();
75
+
76
+ const tokenUrl = new URL(`${CONFIG.authBaseUrl}/token`);
77
+
78
+ const options = {
79
+ hostname: tokenUrl.hostname,
80
+ port: tokenUrl.port || 443,
81
+ path: tokenUrl.pathname,
82
+ method: "POST",
83
+ headers: {
84
+ "Content-Type": "application/x-www-form-urlencoded",
85
+ "Content-Length": Buffer.byteLength(body),
86
+ },
87
+ // Remove the line below if your Viya instance has a valid TLS cert
88
+ rejectUnauthorized: false,
89
+ };
90
+
91
+ const req = https.request(options, (res) => {
92
+ let data = "";
93
+ res.on("data", (chunk) => (data += chunk));
94
+ res.on("end", () => {
95
+ try {
96
+ const parsed = JSON.parse(data);
97
+ if (res.statusCode >= 400) {
98
+ reject(new Error(`Token error (${res.statusCode}): ${data}`));
99
+ } else {
100
+ resolve(parsed);
101
+ }
102
+ } catch {
103
+ reject(new Error(`Failed to parse token response: ${data}`));
104
+ }
105
+ });
106
+ });
107
+
108
+ req.on("error", reject);
109
+ req.write(body);
110
+ req.end();
111
+ });
112
+ }
113
+
114
+ // Wait for the browser redirect and extract code + state
115
+ function waitForCallback(expectedState) {
116
+ return new Promise((resolve, reject) => {
117
+ const server = http.createServer((req, res) => {
118
+ const parsed = url.parse(req.url, true);
119
+
120
+ if (parsed.pathname !== "/callback") {
121
+ res.writeHead(404);
122
+ res.end("Not found");
123
+ return;
124
+ }
125
+
126
+ const { code, state, error, error_description } = parsed.query;
127
+
128
+ res.writeHead(200, { "Content-Type": "text/html" });
129
+ res.end(`
130
+ <html><body>
131
+ <h2>${error ? "Authorization failed" : "Authorization successful!"}</h2>
132
+ <p>You may close this tab.</p>
133
+ </body></html>
134
+ `);
135
+
136
+ server.close();
137
+
138
+ if (error) {
139
+ reject(new Error(`OAuth error: ${error} — ${error_description}`));
140
+ return;
141
+ }
142
+ if (state !== expectedState) {
143
+ reject(new Error("State mismatch — possible CSRF attack"));
144
+ return;
145
+ }
146
+
147
+ resolve(code);
148
+ });
149
+
150
+ server.listen(CONFIG.localPort, () => {
151
+ console.error(`Listening on http://localhost:${CONFIG.localPort}/callback`);
152
+ });
153
+
154
+ server.on("error", reject);
155
+ });
156
+ }
157
+
158
+ // Main flow
159
+ async function main() {
160
+ const codeVerifier = generateCodeVerifier();
161
+ const codeChallenge = generateCodeChallenge(codeVerifier);
162
+ const state = crypto.randomBytes(16).toString("hex");
163
+
164
+ const authUrl = buildAuthUrl(codeChallenge, state);
165
+
166
+ console.error("\nOpen this URL in your browser to log in:\n");
167
+ console.error(authUrl);
168
+ console.error();
169
+
170
+ // Try to auto-open in the default browser (best-effort)
171
+ try {
172
+ const { exec } = require("child_process");
173
+ const cmd =
174
+ process.platform === "win32"
175
+ ? `start "" "${authUrl}"`
176
+ : process.platform === "darwin"
177
+ ? `open "${authUrl}"`
178
+ : `xdg-open "${authUrl}"`;
179
+ exec(cmd);
180
+ } catch {
181
+ // ignore — user can open manually
182
+ }
183
+
184
+ let code;
185
+ try {
186
+ code = await waitForCallback(state);
187
+ } catch (err) {
188
+ console.error("Callback error:", err.message);
189
+ return null;
190
+ }
191
+
192
+ console.error("Authorization code received. Exchanging for tokens...");
193
+
194
+ let tokens;
195
+ try {
196
+ tokens = await exchangeCodeForTokens(code, codeVerifier);
197
+ } catch (err) {
198
+ console.error("Token exchange error:", err.message);
199
+ return null;
200
+ }
201
+
202
+ console.error("\nTokens received:");
203
+ console.error(" access_token :", tokens.access_token?.slice(0, 40) + "...");
204
+ console.error(" token_type :", tokens.token_type);
205
+ console.error(" expires_in :", tokens.expires_in, "seconds");
206
+ if (tokens.refresh_token) {
207
+ console.error(" refresh_token:", tokens.refresh_token.slice(0, 20) + "...");
208
+ }
209
+ if (tokens.id_token) {
210
+ console.error(" id_token :", tokens.id_token.slice(0, 40) + "...");
211
+ }
212
+
213
+ // tokens.access_token is ready to use as a Bearer token for Viya REST APIs
214
+ return tokens.access_token
215
+ }
216
+
217
+
218
+
219
+
220
+ }