@sassoftware/sas-score-mcp-serverjs 0.1.0 → 0.2.1-0

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.
package/.env CHANGED
@@ -1,9 +1,16 @@
1
1
 
2
2
  PORT=8080
3
- HTTPS=TRUE
3
+ HTTPS=true
4
4
  MCPTYPE=http
5
+ USELOGON=FALSE
6
+ USETOKEN=TRUE
7
+ APPNAME=mcpserver
8
+ APPHOST=localhost
9
+ APPPORT=8080
10
+
11
+ CLIENTID=mcpserver
12
+ CLIENTSECRET=jellico
5
13
 
6
- # Sample values shown below
7
14
  AUTHFLOW=sascli
8
15
  SAS_CLI_PROFILE=00m
9
16
  SAS_CLI_CONFIG=c:\Users\kumar
@@ -11,4 +18,6 @@ SSLCERT=c:\Users\kumar\.tls
11
18
  VIYACERT=c:\Users\kumar\viyaCert
12
19
  CAS_SERVER=cas-shared-default
13
20
  COMPUTECONTEXT=SAS Job Execution compute context
21
+ SAMESITE=Lax,false
22
+ AUTOSTART=TRUE
14
23
 
package/CHANGES.md CHANGED
@@ -1,2 +1,8 @@
1
1
  # Changes
2
2
  All notable changes to this project will be documented in this file in accordance with semantic versioning.
3
+
4
+ ## V 0.2.0
5
+
6
+ 1. Added support for authorization_code flow.(AUTHFLOW=code)
7
+ 2. Logon dialog is auto started.
8
+ 3. Marked as experimental until further testing is completed.
@@ -0,0 +1,70 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>sas-score-mcp-server</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: Arial, sans-serif;
16
+ height: 100vh;
17
+ overflow: hidden;
18
+ }
19
+
20
+ dialog {
21
+ position: fixed;
22
+ left: 50%;
23
+ right: auto;
24
+ top: 0;
25
+ transform: translateX(-50%);
26
+ width: fit-content;
27
+ height: fit-content;
28
+ border: none;
29
+ border-radius: 4px;
30
+ padding: 16px;
31
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
32
+ }
33
+
34
+ dialog::backdrop {
35
+ background-color: rgba(0, 0, 0, 0.1);
36
+ }
37
+
38
+ dialog h2 {
39
+ font-size: 18px;
40
+ margin-bottom: 12px;
41
+ color: #000;
42
+ }
43
+
44
+ dialog p {
45
+ font-family: 'Courier New', monospace;
46
+ font-size: 12px;
47
+ width: fit-content;
48
+ line-height: 1.6;
49
+ color: #333;
50
+ word-wrap: break-word;
51
+ white-space: pre-wrap;
52
+ }
53
+
54
+ /* Window styling to show it's 10px larger than dialog */
55
+ body::before {
56
+ display: none;
57
+ }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <dialog open>
62
+ <h2>sas-score-mcp-server</h2>
63
+ <p>The mcp server is now ready for use. </p>
64
+ <p>You can close this window</p>
65
+ <p>For information on the tools see this documentation link:</p>
66
+ <a href="https://github.com/sassoftware/sas-score-mcp-serverjs/wiki " target="_blank">Summary of tools </a>
67
+ </p>
68
+ </dialog>
69
+ </body>
70
+ </html>
package/README.md CHANGED
@@ -19,10 +19,10 @@ This MCP server was developed for two types of SAS users.
19
19
 
20
20
  ### SAS users
21
21
  SAS users who want to use natural language("chat") to execute prebuilt SAS code and models.
22
- See this [quick reference](../sas-mcp-tools-reference.md) for details.
22
+ See this [quick reference](sas-mcp-tools-reference.md) for details.
23
23
 
24
24
  ### MCP tool developers
25
- SAS developers who want to extend the capabilities of the server with their own tools. See the [guide](../tool-developer-guide.md) for details.
25
+ SAS developers who want to extend the capabilities of the server with their own tools. See the [guide](tool-developer-guide.md) for details.
26
26
 
27
27
  ## Configuration Variables
28
28
  Typically these are set either in the .env file or as environment variables (or both). This is full list of the configuration variables used the mcp server.
@@ -65,7 +65,7 @@ VIYA_SERVER= your Viya server url
65
65
  TOKENFILE=
66
66
 
67
67
  # if password flow specify these
68
- CLIENTIDPW=
68
+ CLIENTID=
69
69
  CLIENTSECRET=
70
70
  PASSWORD=
71
71
 
@@ -130,8 +130,66 @@ Set the env TOKENFILE to a file containing the token.
130
130
  There seems to be a pattern of using a long-lived token.
131
131
  If this is your use case, set the TOKENFILE to a file containing this token.
132
132
 
133
- ### Oauth
134
- This is under development.
133
+ ### Oauth - (experimental) Authentication handled by the mcp server
134
+
135
+ In this approach, the mcp client does not participate in the Oauth authentication process. It is handled by the mcp server at startup.
136
+
137
+ > This is marked as experimental since there can be timing issues between the mcp client and server. This needs to be investigated further.
138
+
139
+ #### SAS viya setup.
140
+
141
+ Create a Oauth client with the following properties
142
+
143
+ ```js
144
+ {
145
+ auth flow: authorization_code
146
+ clientid: <your client id>
147
+ clientsecret: <some client secret - pkce not supported at this time>
148
+ redirect: https://localhost:8080/mcpserver
149
+ }
150
+
151
+ #### Use an .env file as follows(sample values shown)
152
+
153
+ ```env
154
+
155
+ PORT=8080
156
+ HTTPS=true
157
+ MCPTYPE=http
158
+ USELOGON=FALSE
159
+ USETOKEN=TRUE
160
+ APPNAME=mcpserver
161
+ APPHOST=localhost
162
+ APPPORT=8080
163
+
164
+ CLIENTID=mcpserver
165
+ CLIENTSECRET=jellico
166
+ AUTHFLOW=code
167
+ SSLCERT=c:\Users\kumar\.tls
168
+ VIYACERT=c:\Users\kumar\viyaCert
169
+ CAS_SERVER=cas-shared-default
170
+ COMPUTECONTEXT=SAS Job Execution compute context
171
+ SAMESITE=Lax,false
172
+
173
+ ```
174
+
175
+ #### Usage
176
+
177
+ Start the server with this command:
178
+
179
+ ```sh
180
+ npx @sassoftware/sas-score-mcp-serverjs@latest
181
+ ```
182
+
183
+ Then visit this site on your browser:
184
+
185
+ ```sh
186
+ https://localhost:8080/mcpserver
187
+ ```
188
+
189
+ You will be prompted to logon to SAS Viya.
190
+ A dialog will be displayed if the logon was successful.
191
+ Icon this window and proceed to your mcp client
192
+
135
193
 
136
194
  ## Transport Methods
137
195
  This server supports both stdio and http transport methods.
@@ -154,7 +212,7 @@ The env variables can be specified in two ways:
154
212
  "command": "npx",
155
213
  "args": [
156
214
  "-y",
157
- "@sassoftware/sas-app-mcp-serverjs@latest",
215
+ "@sassoftware/sas-score-mcp-serverjs@latest",
158
216
  ],
159
217
  "env": {
160
218
  "MCPTYPE": "stdio",
@@ -166,8 +224,8 @@ The env variables can be specified in two ways:
166
224
  "VIYA_SERVER": "viya server if AUTHFLOW=password|token|refresh",
167
225
  "PASSWORD": "password if AUTHFLOW is password",
168
226
  "USERNAME": "username if AUTHFLOW is password",
169
- "CLIENTIDPW": "client password if AUTHFLOW is password",
170
- "CLIENTSECRETPW": "client id if AUTHFLOW is password",
227
+ "CLIENTID": "client password if AUTHFLOW is password",
228
+ "CLIENTSECRET": "client id if AUTHFLOW is password",
171
229
  "TOKENFILE": "file if AUTHFLOW is token",
172
230
  "COMPUTECONTEXT": "SAS Job Execution compute context",
173
231
  "CASSERVER": "cas-shared-default",
@@ -222,7 +280,7 @@ But this step is necessary of using http transport.
222
280
 
223
281
 
224
282
  ```sh
225
- npx @sassoftware/sas-app-mcp-serverjs@latest
283
+ npx @sassoftware/sas-score-mcp-serverjs@latest
226
284
  ```
227
285
 
228
286
  Make sure that the .env file is in the current working directory
package/SECURITY.md CHANGED
@@ -1,10 +1,7 @@
1
- <!--
2
- A SECURITY.md outlines your project's security policy. It includes instructions on how to report a security vulnerability in your project.
3
- If your project contains this file, link to it from the project's README.
4
- -->
5
1
 
6
- # PROJECT_NAME Security Policy
7
- <!-- Replace PROJECT_NAME with the official name of your SAS-sanctioned open source project. -->
2
+
3
+ # sas-score-mcp-serverjs Security Policy
4
+
8
5
  Project maintainers and community contributors take security issues seriously.
9
6
  Efforts to disclose potential issues responsibly are appreciated, and viable contributions will be acknowledged.
10
7
  To aid investigation of any reported vulnerabilities, please follow the [reporting guidelines](#reporting-guidelines) when submitting your findings.
package/cli.js CHANGED
@@ -8,7 +8,9 @@
8
8
 
9
9
 
10
10
  import coreSSE from './src/coreSSE.js';
11
- import corehttp from './src/corehttp.js';
11
+ import expressMcpServer from './src/expressMcpServer.js';
12
+ import hapiMcpServer from './src/hapiMcpServer.js';
13
+
12
14
  import createMcpServer from './src/createMcpServer.js';
13
15
  // import dotenvExpand from 'dotenv-expand';
14
16
  import fs from 'fs';
@@ -38,7 +40,7 @@ if (process.env.ENVFILE === 'NONE') {
38
40
  let e = iconfig(envf); // avoid dotenv since it writes to console.log
39
41
  console.error('[Note]: Environment variables loaded from .env file...');
40
42
  console.error('Loaded env variables:', e);
41
- // dotenvExpand.expand(e);
43
+ // dotenvExpand.expand(e);
42
44
  } else {
43
45
  console.error(
44
46
  '[Note]: No .env file found, Using default environment variables...'
@@ -90,14 +92,16 @@ if (process.env.SUBCLASS != null) {
90
92
  // setup base appEnv
91
93
  // for stdio this is the _appContext
92
94
  // for http each session a copy of this as appEnvTemplate is created in corehttp
95
+
96
+ // backward compability variables
97
+ let clientID = process.env.CLIENTID || process.env.CLIENTIDPW || null;
98
+ let clientSecret = process.env.CLIENTSECRET || process.env.CLIENTSECRETPW || null;
99
+ let https = process.env.HTTPS != null ? process.env.HTTPS.toUpperCase() : "FALSE";
93
100
  const appEnvBase = {
94
101
  version: version,
95
- mcpType: mcpType,
102
+ mcpType: mcpType,
96
103
  brand: (process.env.BRAND == null) ? BRAND : process.env.BRAND,
97
- HTTPS:
98
- process.env.HTTPS != null && process.env.HTTPS.toUpperCase() === 'TRUE'
99
- ? true
100
- : false,
104
+ HTTPS: https,
101
105
  SAS_CLI_PROFILE: process.env.SAS_CLI_PROFILE || 'Default',
102
106
  SAS_CLI_CONFIG: process.env.SAS_CLI_CONFIG || process.env.HOME, // default to user home directory
103
107
  SSLCERT: process.env.SSLCERT || null,
@@ -108,8 +112,10 @@ const appEnvBase = {
108
112
  PORT: process.env.PORT || 8080,
109
113
  USERNAME: process.env.USERNAME || null,
110
114
  PASSWORD: process.env.PASSWORD || null,
111
- CLIENTIDPW: process.env.CLIENTIDPW || null,
112
- CLIENTSECRET: process.env.CLIENTSECRETPW || null,
115
+ CLIENTID: clientID,
116
+ CLIENTSECRET: clientSecret,
117
+ PKCE: process.env.PKCE || null,
118
+
113
119
  TOKEN: process.env.TOKEN || null,
114
120
  REFRESH_TOKEN: process.env.REFRESH_TOKEN || null,
115
121
  TOKENFILE: process.env.TOKENFILE || null,
@@ -136,9 +142,19 @@ const appEnvBase = {
136
142
  logonPayload: null,
137
143
  bearerToken: null,
138
144
  tlsOpts: null,
145
+ oauthInfo: null,
139
146
  contexts: {
147
+ host: process.env.VIYA_SERVER,
148
+ APPHOST: process.env.APPHOST || 'localhost',
149
+ APPNAME: process.env.APPNAME || 'mcpServer',
150
+ PORT: process.env.APPPORT || 8080,
151
+ HTTPS: https,
140
152
  store: null, /* for restaf users */
141
153
  storeConfig: {},
154
+ oauthInfo: null,
155
+ CLIENTID: clientID,
156
+ CLIENTSECRET: clientSecret,
157
+ pkce: process.env.PKCE || null,
142
158
  casSession: null, /* restaf cas session object */
143
159
  computeSession: null, /* restaf compute session object */
144
160
  viyaCert: null, /* ssl/tsl certificates to connect to viya */
@@ -192,14 +208,8 @@ if (appEnvBase.REFRESH_TOKEN != null) {
192
208
  }
193
209
  }
194
210
 
195
- // if authflow is cli, postpone getting logonPayload until needed
211
+ // if authflow is cli or code, postpone getting logonPayload until needed
196
212
 
197
- /*
198
- if(appEnvBase.AUTHFLOW ==='sascli') {
199
- let logonPayload = await getLogonPayload(appEnvBase);
200
- appEnvBase.logonPayload = logonPayload;
201
- }
202
- */
203
213
 
204
214
  // setup mcpServer (both http and stdio use this)
205
215
  // this is singleton - best practices recommend this
@@ -216,6 +226,7 @@ sessionCache.set('transports', transports);
216
226
 
217
227
  // set this for stdio transport use
218
228
  // dummy sessionId for use in the tools
229
+ let useHapi = process.env.AUTHFLOW === 'code' ? true : false;
219
230
  if (mcpType === 'stdio') {
220
231
  let sessionId = randomUUID();
221
232
  sessionCache.set('currentId', sessionId);
@@ -226,31 +237,36 @@ if (mcpType === 'stdio') {
226
237
 
227
238
  } else {
228
239
  console.error('[Note] Starting HTTP MCP server...');
229
- await corehttp(mcpServer, sessionCache, appEnvBase);
230
- console.error('[Note] MCP HTTP server started on port ' + appEnvBase.PORT);
240
+ if (useHapi === true) {
241
+ await hapiMcpServer(mcpServer, sessionCache, appEnvBase);
242
+ console.error('[Note] Using HAPI HTTP server...')
243
+ } else {
244
+ await expressMcpServer(mcpServer, sessionCache, appEnvBase);
245
+ console.error('[Note] MCP HTTP server started on port ' + appEnvBase.PORT);
246
+ }
231
247
  }
232
248
 
233
249
  // custom reader for .env file to avoid dotenv logging to console
234
250
  function iconfig(envFile) {
235
- try {
236
- let data = fs.readFileSync(envFile, 'utf8');
237
- let d = data.split(/\r?\n/);
251
+ try {
252
+ let data = fs.readFileSync(envFile, 'utf8');
253
+ let d = data.split(/\r?\n/);
238
254
  let envData = {};
239
- d.forEach(l => {
240
- if (l.length > 0 && l.indexOf('#') === -1) {
241
- let la = l.split('=');
242
- let envName = la[0];
243
- if (la.length === 2 && la[1].length > 0) {
244
- let t = la[1].trim();
245
- process.env[envName] = t;
255
+ d.forEach(l => {
256
+ if (l.length > 0 && l.indexOf('#') === -1) {
257
+ let la = l.split('=');
258
+ let envName = la[0];
259
+ if (la.length === 2 && la[1].length > 0) {
260
+ let t = la[1].trim();
261
+ process.env[envName] = t;
246
262
  envData[envName] = t;
247
- }
248
- }
249
- });
263
+ }
264
+ }
265
+ });
250
266
  return envData;
251
- } catch (err) {
252
- console.log(err);
253
- process.exit(0);
254
- }
267
+ } catch (err) {
268
+ console.log(err);
269
+ process.exit(0);
270
+ }
255
271
  }
256
272
 
@@ -4,11 +4,11 @@
4
4
  "type": "stdio",
5
5
  "command": "npx",
6
6
  "args": [
7
- "@sassoftware/sas-score-mcp-serverjs@alpha"
7
+ "@sassoftware/sas-score-mcp-serverjs@latest"
8
8
  ],
9
9
  "env": {
10
10
  "MCPTYPE": "stdio",
11
- "AUTHFLOW": "sascli",
11
+ "AUTHFLOW": "password",
12
12
  "SAS_CLI_PROFILE": "cis",
13
13
  "SAS_CLI_CONFIG": "c:\\Users\\kumar",
14
14
  "SSLCERT": "c:\\Users\\kumar\\.tls",
@@ -4,7 +4,7 @@
4
4
  "type": "stdio",
5
5
  "command": "node",
6
6
  "args": [
7
- "c:\\dev\\gitlab\\mcp-serverjs\\cli.js"
7
+ "c:\\dev\\github\\mcp-serverjs\\cli.js"
8
8
  ],
9
9
  "env": {
10
10
  "MCPTYPE": "stdio",
@@ -12,6 +12,7 @@
12
12
  "SAS_CLI_PROFILE": "00m",
13
13
  "SAS_CLI_CONFIG": "c:\\Users\\kumar",
14
14
  "SSLCERT": "c:\\Users\\kumar\\.tls",
15
+ "VIYACERT": "c:\\Users\\kumar\\.tls",
15
16
  "COMPUTECONTEXT": "SAS Job Execution compute context",
16
17
  "CAS_SERVER": "cas-shared-default"
17
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sassoftware/sas-score-mcp-serverjs",
3
- "version": "0.1.0",
3
+ "version": "0.2.1-0",
4
4
  "description": "A mcp server for SAS Viya",
5
5
  "author": "Deva Kumar <deva.kumar@sas.com>",
6
6
  "license": "Apache-2.0",
@@ -40,6 +40,7 @@
40
40
  "@sassoftware/restaf": "^5.6.0",
41
41
  "@sassoftware/restafedit": "^3.11.1-10",
42
42
  "@sassoftware/restaflib": "^5.6.0",
43
+ "@sassoftware/viya-serverjs": "^0.6.1-5",
43
44
  "axios": "^1.13.2",
44
45
  "body-parser": "^2.2.1",
45
46
  "cors": "^2.8.5",
@@ -51,6 +52,8 @@
51
52
  "helmet": "^8.1.0",
52
53
  "mcp-framework": "^0.2.16",
53
54
  "node-cache": "^5.1.2",
55
+ "open": "^11.0.0",
56
+ "puppeteer": "^24.34.0",
54
57
  "selfsigned": "^5.2.0",
55
58
  "undici": "^7.16.0",
56
59
  "uuid": "^13.0.0",
@@ -16,7 +16,7 @@ import makeTools from "./toolSet/makeTools.js";
16
16
  import getLogonPayload from "./toolHelpers/getLogonPayload.js";
17
17
 
18
18
  async function createMcpServer(cache, _appContext) {
19
-
19
+
20
20
  let mcpServer = new McpServer(
21
21
  {
22
22
  name: "sasmcp",
@@ -40,20 +40,31 @@ async function createMcpServer(cache, _appContext) {
40
40
  let _appContext = cache.get(currentId);
41
41
  let params;
42
42
  // get Viya token
43
+
44
+ let errorStatus = cache.get('errorStatus');
45
+ if (errorStatus) {
46
+ return { isError: true, content: [{ type: 'text', text: errorStatus }] }
47
+ };
48
+ if (_appContext.AUTHFLOW === 'code' && _appContext.contexts.oauthInfo == null) {
49
+ return { isError: true, content: [{ type: 'text', text: 'Please visit https://localhost:8080/mcpserver to connect to Viya. Then try again.' }] }
50
+ }
51
+ console.error("Getting logon payload for tool with session ID:", currentId);
43
52
  _appContext.contexts.logonPayload = await getLogonPayload(_appContext);
44
53
  if (_appContext.contexts.logonPayload == null) {
45
54
  return { isError: true, content: [{ type: 'text', text: 'Unable to get authentication token for SAS Viya. Please check your configuration.' }] }
55
+
46
56
  }
47
57
 
48
- // create enhanced appContext for tool
49
- if (args == null) {
50
- params = {_appContext: _appContext.contexts};
51
- } else {
52
- params = Object.assign({}, args, {_appContext: _appContext.contexts});
53
- }
54
-
55
- // call the actual tool handler
56
- let r = await builtin(params);
58
+ // create enhanced appContext for tool
59
+ if (args == null) {
60
+ params = { _appContext: _appContext.contexts };
61
+ } else {
62
+ params = Object.assign({}, args, { _appContext: _appContext.contexts });
63
+ }
64
+
65
+ // call the actual tool handler
66
+ debugger;
67
+ let r = await builtin(params);
57
68
  return r;
58
69
  }
59
70
 
@@ -62,9 +73,9 @@ async function createMcpServer(cache, _appContext) {
62
73
  let toolNames = [];
63
74
  toolSet.forEach((tool, i) => {
64
75
  let toolName = _appContext.brand + '-' + tool.name;
65
- // console.error(`\n[Note] Registering tool ${i + 1} : ${toolName}`);
76
+ // console.error(`\n[Note] Registering tool ${i + 1} : ${toolName}`);
66
77
  let toolHandler = wrapf(cache, tool.handler);
67
-
78
+
68
79
  mcpServer.tool(toolName, tool.description, tool.schema, toolHandler);
69
80
  toolNames.push(toolName);
70
81
  });
@@ -21,7 +21,7 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
21
21
 
22
22
  // setup express server
23
23
 
24
- async function corehttp(mcpServer, cache, currentAppEnvContext) {
24
+ async function expressMcpServer(mcpServer, cache, currentAppEnvContext) {
25
25
  // setup for change to persistence session
26
26
  let headerCache = {};
27
27
 
@@ -334,4 +334,4 @@ async function corehttp(mcpServer, cache, currentAppEnvContext) {
334
334
  }
335
335
  }
336
336
 
337
- export default corehttp;
337
+ export default expressMcpServer;
@@ -0,0 +1,31 @@
1
+ /*
2
+ * Copyright © 2025, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ async function handleGetDelete(mcpServer, cache, req, h) {
7
+ const sessionId = req.headers["mcp-session-id"];
8
+ console.error("Handling GET/DELETE for session ID:", sessionId);
9
+ let transports = cache.get("transports");
10
+ let transport = transports[sessionId];
11
+ if (!sessionId || transport == null) {
12
+ console.error('[Note] Looks like a fresh start - no session id or transport found');
13
+ h.abandon;
14
+ }
15
+
16
+ if (req.method === "GET") {
17
+ // You can customize the response as needed
18
+ await transport.handleRequest(req.raw.req, req.raw.res, req.payload);
19
+ return h.abandon;
20
+ }
21
+
22
+ if (req.method === "DELETE") {
23
+ console.error("Deleting transport and cache for session ID:", sessionId);
24
+ delete transports[sessionId];
25
+ cache.del(sessionId);
26
+ return h.response(`[Info] In DELETE: Session ID ${sessionId} deleted`).code(201);
27
+ }
28
+
29
+
30
+ }
31
+ export default handleGetDelete;
@@ -0,0 +1,110 @@
1
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
2
+ import { randomUUID } from "node:crypto";
3
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
4
+
5
+ async function handleRequest(mcpServer, cache, req, h, credentials) {
6
+ let headerCache = {};
7
+ let transport;
8
+ let transports = cache.get("transports");
9
+ try {
10
+
11
+ headerCache = customHeaders(req, h);
12
+ let sessionId = req.headers["mcp-session-id"];
13
+
14
+ // we have session id, get existing transport
15
+
16
+ if (sessionId != null) {
17
+ /* existing transport */
18
+ transport = transports[sessionId];
19
+ if (transport == null) {
20
+ h.response({ isError: true, content: [{ type: 'text', text: 'Session not found. Please re-initialize the MCP client.' }] }).code(400).type('application/json');
21
+ return h.abandon;
22
+ }
23
+ }
24
+
25
+ if (sessionId != null && transport != null) {
26
+ // post the curren session - used to pass _appContext to tools
27
+ cache.set("currentId", sessionId);
28
+
29
+ // get app context for session
30
+ let _appContext = cache.get(sessionId);
31
+
32
+ //if first prompt on a sessionid, create app context
33
+
34
+ if (_appContext == null) {
35
+ console.error("[Note] Creating new app context for session ID:", sessionId);
36
+ let appEnvTemplate = cache.get("appEnvTemplate");
37
+ _appContext = Object.assign({}, appEnvTemplate, headerCache);
38
+ _appContext.contexts.oauthInfo = credentials;
39
+ cache.set(sessionId, _appContext);
40
+ }
41
+ console.error("[Note] Using existing transport for session ID:", sessionId);
42
+ debugger;
43
+ console.error("calling transport.handleRequest");
44
+ return await transport.handleRequest(req.raw.req, req.raw.res, req.payload);
45
+ }
46
+
47
+ // initialize request
48
+ else if (!sessionId && isInitializeRequest(req.payload)) {
49
+ // create transport
50
+ console.error("[Note] Initializing new transport for MCP request...");
51
+ transport = new StreamableHTTPServerTransport({
52
+ sessionIdGenerator: () => randomUUID(),
53
+ enableJsonResponse: true,
54
+ onsessioninitialized: (sessionId) => {
55
+ // Store the transport by session ID
56
+ transports[sessionId] = transport;
57
+ },
58
+ });
59
+ // Clean up transport when closed
60
+ transport.onclose = () => {
61
+ if (transport.sessionId) {
62
+ delete transports[transport.sessionId];
63
+ }
64
+ };
65
+ console.error("[Note] Connecting mcpServer to new transport...");
66
+ await mcpServer.connect(transport);
67
+
68
+ // Save transport data and app context for use in tools
69
+ cache.set("transports", transports);
70
+ return await transport.handleRequest(req.raw.req, req.raw.res, req.payload);
71
+ // cache transport
72
+
73
+
74
+ }
75
+ }
76
+ catch (error) {
77
+ console.error("Error handling MCP request:", error);
78
+ let r = { isError: true, content: [{ type: 'text', text: 'Internal server error occurred while processing the request.' }] };
79
+ return h.response(r).code(500).type('application/json');
80
+ }
81
+ function customHeaders(req, h) {
82
+
83
+ // process any new header information
84
+
85
+ // Allow different VIYA server per sessionid(user)
86
+ let headerCache = {};
87
+ if (req.headers["X-VIYA-SERVER"] != null) {
88
+ console.error("[Note] Using user supplied VIYA server");
89
+ headerCache.VIYA_SERVER = req.header("X-VIYA-SERVER");
90
+ }
91
+
92
+ // used when doing autorization via mcp client
93
+ // ideal for production use
94
+ const hdr = req.headers["Authorization"];
95
+ if (hdr != null) {
96
+ headerCache.bearerToken = hdr.slice(7);
97
+ headerCache.AUTHFLOW = "bearer";
98
+ }
99
+
100
+ // faking out api key since Viya does not support
101
+ // not ideal for production
102
+ const hdr2 = req.headers["X-REFRESH-TOKEN"];
103
+ if (hdr2 != null) {
104
+ headerCache.refreshToken = hdr2;
105
+ headerCache.AUTHFLOW = "refresh";
106
+ }
107
+ return headerCache;
108
+ }
109
+ };
110
+ export default handleRequest;
@@ -0,0 +1,89 @@
1
+ /*
2
+ * Copyright © 2025, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import appServer from "@sassoftware/viya-serverjs";
6
+ import handleRequest from "./handleRequest.js";
7
+ import handleGetDelete from "./handleGetDelete.js";
8
+ import urlOpen from "./urlOpen.js";
9
+ //import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
10
+
11
+ async function hapiMcpServer(mcpServer, cache, baseAppEnvContext) {
12
+
13
+ console.error(appServer);
14
+ appServer(mcpHandlers, true, 'app', null);
15
+ if (process.env.AUTOSTART === 'TRUE') {
16
+ await urlOpen();
17
+ }
18
+
19
+ function mcpHandlers() {
20
+ let routes = [
21
+ {
22
+ method: ["GET"],
23
+ path: "/health",
24
+ options: {
25
+ handler: async (req, h) => {
26
+ let health = {
27
+ name: "@sassoftware/mcp-server",
28
+ version: baseAppEnvContext.version,
29
+ description: "SAS Viya Sample MCP Server",
30
+ endpoints: {
31
+ mcp: "/mcp",
32
+ health: "/health",
33
+ },
34
+ usage:
35
+ "Use with MCP Inspector or compatible MCP clients like vscode or your own MCP client",
36
+ };
37
+ console.error("Health check requested, returning:", health);
38
+ return h.response(health).code(200).type('application/json');
39
+ },
40
+ auth: false,
41
+ description: "Help",
42
+ notes: "Help",
43
+ tags: ["app"],
44
+ }
45
+ },
46
+ {
47
+ method: ["POST"],
48
+ path: `/mcp`,
49
+ options: {
50
+ handler: async (req, h) => {
51
+ let precontext = req.pre.context;
52
+ let oauthInfo = (precontext != null) ? precontext.credentials : null;
53
+ await handleRequest(mcpServer, cache, req, h, oauthInfo);
54
+ return h.abandon;
55
+ },
56
+
57
+ auth: {
58
+ strategy: "session",
59
+ mode: 'try'
60
+ },
61
+ description: "The main route for MCP requests",
62
+ notes: "Requires a valid session",
63
+ tags: ["mcp"],
64
+ },
65
+ },
66
+ {
67
+ method: ["GET", "DELETE"],
68
+ path: `/mcp`,
69
+
70
+ options: {
71
+ handler: async (req, h) => {
72
+ await handleGetDelete(mcpServer, cache, req, h);
73
+ return h.abandon;
74
+ },
75
+ auth: {
76
+ strategy: "session",
77
+ mode: 'try'
78
+ },
79
+ description: "Handle GET and DELETE requests",
80
+ notes: "Will fail if no valid session",
81
+ tags: ["mcp"],
82
+ },
83
+ }
84
+
85
+ ];
86
+ return routes;
87
+ }
88
+ }
89
+ export default hapiMcpServer;
@@ -5,6 +5,7 @@
5
5
 
6
6
  import getToken from "./getToken.js";
7
7
  import refreshToken from "./refreshToken.js";
8
+ import refreshTokenOauth from "./refreshTokenOauth.js";
8
9
 
9
10
  async function getLogonPayload(_appContext) {
10
11
  _appContext.contexts.logonPayload = await igetLogonPayload(_appContext);
@@ -15,14 +16,32 @@ async function igetLogonPayload(_appContext) {
15
16
 
16
17
  // Use cached logonPayload if available
17
18
  // This will cause timeouts if the token expires
18
- if (_appContext.logonPayload != null && _appContext.tokenRefresh !== true) {
19
+ if (_appContext.contexts.logonPayload != null && _appContext.tokenRefresh !== true) {
19
20
  console.error("[Note] Using cached logonPayload information");
20
21
  return _appContext.contexts.logonPayload;
21
22
  }
22
23
 
24
+ if (_appContext.AUTHFLOW === 'code') {
25
+ let oauthInfo = _appContext.contexts.oauthInfo;
26
+ if (oauthInfo == null) {
27
+ return null;
28
+ }
29
+ _appContext.contexts.oauthInfo = await refreshTokenOauth(_appContext, oauthInfo);
30
+ if (_appContext.contexts.oauthInfo == null) {
31
+ return null;
32
+ }
33
+ let logonPayload = {
34
+ host: _appContext.VIYA_SERVER,
35
+ authType: "server",
36
+ token: _appContext.contexts.oauthInfo.accessToken,
37
+ tokenType: "Bearer",
38
+ }
39
+ return logonPayload;
40
+ }
41
+
23
42
  // Use user supplied bearer token
24
43
  if (_appContext.AUTHFLOW === "bearer") {
25
- console.error("[Note] Using user suplied bearer token ");
44
+ console.error("[Note] Using user supplied bearer token ");
26
45
  let logonPayload = {
27
46
  host: _appContext.VIYA_SERVER,
28
47
  authType: "server",
@@ -68,7 +87,7 @@ async function igetLogonPayload(_appContext) {
68
87
  return logonPayload;
69
88
  }
70
89
 
71
- if (_appContext.PASSWORDAUTHFLOW === "password") {
90
+ if (_appContext.AUTHFLOW === "password") {
72
91
  let logonPayload = {
73
92
  host: _appContext.VIYA_SERVER,
74
93
  authType: "password",
@@ -84,7 +103,7 @@ async function igetLogonPayload(_appContext) {
84
103
  // sascli auth flow - create from credentials file
85
104
  try {
86
105
  let { host, token } = await getToken(_appContext)
87
- console.error("[Note] got refresh token from getToken() for host ", host);
106
+ console.error("[Note] Token refreshed ", host);
88
107
  let logonPayload = {
89
108
  host: host,
90
109
  authType: "server",
@@ -9,12 +9,19 @@
9
9
  * if this function return a null, coreehttp will create unsigned certs
10
10
  * @param {Object} _appContext - Application context containing SSLCERT property
11
11
  */
12
- import fs from 'fs';
12
+
13
+ import readCerts from './readCerts.js';
13
14
  function getOpts(_appContext) {
14
-
15
- if (_appContext.tlsOpts != null) {
15
+
16
+ if (_appContext.tlsOpts != null) {
16
17
  return _appContext.tlsOpts;
17
18
  }
19
+ let r = readCerts(_appContext.SSLCERT);
20
+ _appContext.tlsOpts = r;
21
+ return r;
22
+
23
+
24
+ /*
18
25
  let tlsdir = _appContext.SSLCERT;
19
26
  if (tlsdir == null || tlsdir === 'NONE') {
20
27
  return null;
@@ -38,6 +45,7 @@ function getOpts(_appContext) {
38
45
  console.error('TLS FILES', Object.keys(options));
39
46
  _appContext.tlsOpts = options;
40
47
  return options;
41
-
48
+ */
49
+
42
50
  }
43
51
  export default getOpts;
@@ -2,13 +2,18 @@
2
2
  * Copyright © 2025, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
- import fs from 'fs';
5
+ import readCerts from './readCerts.js';
6
6
  function getOptsViya(_appContext) {
7
-
8
- if (_appContext.contexts.viyaCert != null) {
7
+
8
+ if (_appContext.contexts.viyaCert != null) {
9
9
  console.error('[Note] Using cached viyaOpts');
10
10
  return _appContext.contexts.viyaCert;
11
11
  }
12
+ let r = readCerts(_appContext.VIYACERT);
13
+ _appContext.contexts.viyaCert = r;
14
+ return r;
15
+
16
+ /*
12
17
  let tlsdir = _appContext.VIYACERT;
13
18
  if (tlsdir == null || tlsdir === 'NONE') {
14
19
  return {};
@@ -33,6 +38,7 @@ function getOptsViya(_appContext) {
33
38
  console.error('VIYACERT FILES', Object.keys(options));
34
39
  _appContext.contexts.viyaCert = options;
35
40
  return options;
36
-
41
+ */
42
+
37
43
  }
38
44
  export default getOptsViya;
@@ -7,8 +7,6 @@ import getOptsViya from './getOptsViya.js';
7
7
  function getStoreOpts(_appContext) {
8
8
 
9
9
  let opts = getOptsViya(_appContext);
10
-
11
-
12
10
  let storeOpts = {
13
11
  casProxy: true,
14
12
  httpOptions: { ...opts, rejectUnauthorized: true }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Copyright © 2025, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import fs from 'fs';
6
+ function getCerts(tlsdir) {
7
+
8
+ if (tlsdir == null || tlsdir === 'NONE') {
9
+ return null;
10
+ }
11
+
12
+ console.log(`[Note] Reading certs from directory: ` + tlsdir);
13
+ if (fs.existsSync(tlsdir) === false) {
14
+ console.error("[Warning] Specified cert dir does not exist: " + tlsdir);
15
+ return null;
16
+ }
17
+
18
+ let listOfFiles = fs.readdirSync(tlsdir);
19
+ console.log("[Note] TLS/SSL files found: " + listOfFiles);
20
+ let options = {};
21
+ for(let i=0; i < listOfFiles.length; i++) {
22
+ let fname = listOfFiles[i];
23
+ let name = tlsdir + '/' + listOfFiles[i];
24
+ let key = fname.split('.')[0];
25
+ console.log('Reading TLS file: ' + name + ' as key: ' + key);
26
+ options[key] = fs.readFileSync(name, { encoding: 'utf8' });
27
+ }
28
+ console.log('cert files', Object.keys(options));
29
+
30
+ return options;
31
+
32
+ }
33
+ export default getCerts;
@@ -0,0 +1,53 @@
1
+ /*
2
+ * Copyright © 2025, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import { Agent, fetch } from 'undici';
6
+ import getOpts from './getOpts.js';
7
+ async function refreshTokenOauth(_appContext, oauthInfo ){
8
+
9
+ const url = `${process.env.VIYA_SERVER}/SASLogon/oauth/token`;
10
+ let opts = getOpts(_appContext);
11
+
12
+ const agent = new Agent({
13
+ connect: opts
14
+ });
15
+
16
+ let bodyObject = {
17
+ grant_type: 'refresh_token',
18
+ refresh_token: oauthInfo.refreshToken,
19
+ client_id: _appContext.CLIENTID,
20
+ client_secret: _appContext.CLIENTSECRET
21
+ }
22
+ const body = new URLSearchParams(bodyObject);
23
+ try {
24
+ const response = await fetch(url, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'Accept': 'application/json',
28
+ 'Content-Type': 'application/x-www-form-urlencoded',
29
+ dispatcher: agent
30
+ },
31
+ body: body.toString()
32
+ });
33
+
34
+ if (!response.ok) {
35
+ const error = await response.text();
36
+ console.error('[Error] Failed to refresh token: ', error);
37
+ throw new Error(error);
38
+ }
39
+
40
+ const data = await response.json();
41
+ let newauthInfo = {
42
+ accessToken: data.access_token,
43
+ refreshToken: data.refresh_token,
44
+ expiresIn: data.expires_in
45
+ }
46
+ return newauthInfo;
47
+ } catch (err) {
48
+ console.error('[Error] Failed to refresh token: ', err);
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export default refreshTokenOauth;
@@ -9,6 +9,7 @@ function findJob(_appContext) {
9
9
  "purpose": "Map natural language requests to find a job in SAS Viya and return structured results.",
10
10
  "param_mapping": {
11
11
  "name": "required - single name. If missing, ask 'Which job name would you like to find?'.",
12
+ "_userPrompt": "the original user prompt that triggered this tool."
12
13
 
13
14
  },
14
15
  "response_schema": "{ jobs: Array<string|object> }",
@@ -77,7 +78,8 @@ function findJob(_appContext) {
77
78
  aliases: ['findJob','find job','find_job'],
78
79
  description: description,
79
80
  schema: {
80
- name: z.string()
81
+ name: z.string(),
82
+ _userPrompt: z.string()
81
83
  },
82
84
  required: ['name'],
83
85
  handler: async (params) => {
package/src/urlOpen.js ADDED
@@ -0,0 +1,12 @@
1
+ import open from 'open';
2
+
3
+ async function urlOpen(contexts) {
4
+ let appHost = process.env.APPHOST || 'localhost';
5
+ let appPort = process.env.PORT || '8080';
6
+ let appName = process.env.APPNAME || 'mcpserver';
7
+ let protocol = (process.env.HTTPS != null && process.env.HTTPS.toUpperCase() === 'TRUE') ? 'https' : 'http';
8
+ let urlx = `${protocol}://${appHost}:${appPort}/${appName}`;
9
+ console.log(`Opening URL: ${urlx}`);
10
+ await open(urlx, {wait:true});
11
+ }
12
+ export default urlOpen;