@access-mcp/shared 0.3.2 → 0.5.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { BaseAccessServer } from "../base-server.js";
3
+ // Concrete implementation for testing
4
+ class TestServer extends BaseAccessServer {
5
+ constructor() {
6
+ super("test-server", "1.0.0", "https://api.example.com");
7
+ }
8
+ getTools() {
9
+ return [
10
+ {
11
+ name: "test_tool",
12
+ description: "A test tool",
13
+ inputSchema: {
14
+ type: "object",
15
+ properties: {
16
+ message: { type: "string" },
17
+ },
18
+ },
19
+ },
20
+ ];
21
+ }
22
+ getResources() {
23
+ return [
24
+ {
25
+ uri: "test://resource",
26
+ name: "Test Resource",
27
+ mimeType: "application/json",
28
+ },
29
+ ];
30
+ }
31
+ async handleToolCall(request) {
32
+ if (request.params.name === "test_tool") {
33
+ const args = request.params.arguments;
34
+ return {
35
+ content: [
36
+ {
37
+ type: "text",
38
+ text: JSON.stringify({ echo: args.message || "no message" }),
39
+ },
40
+ ],
41
+ };
42
+ }
43
+ return this.errorResponse(`Unknown tool: ${request.params.name}`);
44
+ }
45
+ }
46
+ describe("BaseAccessServer HTTP Mode", () => {
47
+ let server;
48
+ let port;
49
+ let baseUrl;
50
+ beforeEach(async () => {
51
+ server = new TestServer();
52
+ port = 3100 + Math.floor(Math.random() * 100);
53
+ baseUrl = `http://localhost:${port}`;
54
+ await server.start({ httpPort: port });
55
+ });
56
+ afterEach(async () => {
57
+ // Server cleanup handled by process exit
58
+ });
59
+ describe("Health endpoint", () => {
60
+ it("should return health status", async () => {
61
+ const response = await fetch(`${baseUrl}/health`);
62
+ const data = await response.json();
63
+ expect(response.status).toBe(200);
64
+ expect(data.server).toBe("test-server");
65
+ expect(data.version).toBe("1.0.0");
66
+ expect(data.status).toBe("healthy");
67
+ expect(data.timestamp).toBeDefined();
68
+ });
69
+ });
70
+ describe("Tools endpoint", () => {
71
+ it("should list available tools", async () => {
72
+ const response = await fetch(`${baseUrl}/tools`);
73
+ const data = await response.json();
74
+ expect(response.status).toBe(200);
75
+ expect(data.tools).toHaveLength(1);
76
+ expect(data.tools[0].name).toBe("test_tool");
77
+ });
78
+ });
79
+ describe("Tool execution endpoint", () => {
80
+ it("should execute a tool via POST", async () => {
81
+ const response = await fetch(`${baseUrl}/tools/test_tool`, {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({ arguments: { message: "hello" } }),
85
+ });
86
+ const data = await response.json();
87
+ expect(response.status).toBe(200);
88
+ expect(data.content).toBeDefined();
89
+ const result = JSON.parse(data.content[0].text);
90
+ expect(result.echo).toBe("hello");
91
+ });
92
+ it("should return 404 for unknown tool", async () => {
93
+ const response = await fetch(`${baseUrl}/tools/unknown_tool`, {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/json" },
96
+ body: JSON.stringify({ arguments: {} }),
97
+ });
98
+ expect(response.status).toBe(404);
99
+ const data = await response.json();
100
+ expect(data.error).toContain("not found");
101
+ });
102
+ });
103
+ describe("SSE endpoint", () => {
104
+ it("should accept SSE connections", async () => {
105
+ const controller = new AbortController();
106
+ const timeout = setTimeout(() => controller.abort(), 1000);
107
+ try {
108
+ const response = await fetch(`${baseUrl}/sse`, {
109
+ signal: controller.signal,
110
+ });
111
+ expect(response.status).toBe(200);
112
+ expect(response.headers.get("content-type")).toContain("text/event-stream");
113
+ }
114
+ catch (e) {
115
+ // AbortError is expected when we timeout
116
+ if (e instanceof Error && e.name !== "AbortError") {
117
+ throw e;
118
+ }
119
+ }
120
+ finally {
121
+ clearTimeout(timeout);
122
+ }
123
+ });
124
+ });
125
+ });
126
+ describe("BaseAccessServer helper methods", () => {
127
+ let server;
128
+ beforeEach(() => {
129
+ server = new TestServer();
130
+ });
131
+ describe("errorResponse", () => {
132
+ it("should create error response without hint", () => {
133
+ const result = server["errorResponse"]("Something went wrong");
134
+ expect(result.isError).toBe(true);
135
+ const textContent = result.content[0];
136
+ const content = JSON.parse(textContent.text);
137
+ expect(content.error).toBe("Something went wrong");
138
+ expect(content.hint).toBeUndefined();
139
+ });
140
+ it("should create error response with hint", () => {
141
+ const result = server["errorResponse"]("Invalid input", "Try using a number");
142
+ expect(result.isError).toBe(true);
143
+ const textContent = result.content[0];
144
+ const content = JSON.parse(textContent.text);
145
+ expect(content.error).toBe("Invalid input");
146
+ expect(content.hint).toBe("Try using a number");
147
+ });
148
+ });
149
+ describe("createJsonResource", () => {
150
+ it("should create JSON resource response", () => {
151
+ const result = server["createJsonResource"]("test://data", { foo: "bar" });
152
+ expect(result.contents).toHaveLength(1);
153
+ expect(result.contents[0].uri).toBe("test://data");
154
+ expect(result.contents[0].mimeType).toBe("application/json");
155
+ expect(JSON.parse(result.contents[0].text)).toEqual({ foo: "bar" });
156
+ });
157
+ });
158
+ describe("createMarkdownResource", () => {
159
+ it("should create Markdown resource response", () => {
160
+ const result = server["createMarkdownResource"]("test://doc", "# Hello");
161
+ expect(result.contents).toHaveLength(1);
162
+ expect(result.contents[0].uri).toBe("test://doc");
163
+ expect(result.contents[0].mimeType).toBe("text/markdown");
164
+ expect(result.contents[0].text).toBe("# Hello");
165
+ });
166
+ });
167
+ describe("createTextResource", () => {
168
+ it("should create plain text resource response", () => {
169
+ const result = server["createTextResource"]("test://text", "Hello world");
170
+ expect(result.contents).toHaveLength(1);
171
+ expect(result.contents[0].uri).toBe("test://text");
172
+ expect(result.contents[0].mimeType).toBe("text/plain");
173
+ expect(result.contents[0].text).toBe("Hello world");
174
+ });
175
+ });
176
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { spawn } from "child_process";
3
+ import path from "path";
4
+ /**
5
+ * Integration tests for inter-server HTTP communication
6
+ * These tests start actual MCP servers and test tool execution via HTTP
7
+ */
8
+ describe("Inter-server Communication Integration Tests", () => {
9
+ let nsfServer;
10
+ const NSF_PORT = 3199;
11
+ const NSF_URL = `http://localhost:${NSF_PORT}`;
12
+ beforeAll(async () => {
13
+ // Start NSF Awards server in HTTP mode
14
+ const serverPath = path.resolve(process.cwd(), "packages/nsf-awards/dist/index.js");
15
+ nsfServer = spawn("node", [serverPath], {
16
+ env: {
17
+ ...process.env,
18
+ PORT: String(NSF_PORT),
19
+ },
20
+ stdio: ["ignore", "pipe", "pipe"],
21
+ });
22
+ // Wait for server to be ready
23
+ await new Promise((resolve, reject) => {
24
+ const timeout = setTimeout(() => {
25
+ reject(new Error("Server startup timeout"));
26
+ }, 10000);
27
+ nsfServer.stdout?.on("data", (data) => {
28
+ if (data.toString().includes("HTTP server running")) {
29
+ clearTimeout(timeout);
30
+ resolve();
31
+ }
32
+ });
33
+ nsfServer.on("error", (err) => {
34
+ clearTimeout(timeout);
35
+ reject(err);
36
+ });
37
+ });
38
+ }, 15000);
39
+ afterAll(async () => {
40
+ if (nsfServer) {
41
+ nsfServer.kill("SIGTERM");
42
+ // Wait for process to exit
43
+ await new Promise((resolve) => {
44
+ nsfServer.on("exit", () => resolve());
45
+ setTimeout(resolve, 1000); // Fallback timeout
46
+ });
47
+ }
48
+ });
49
+ describe("NSF Awards Server HTTP API", () => {
50
+ it("should respond to health check", async () => {
51
+ const response = await fetch(`${NSF_URL}/health`);
52
+ const data = await response.json();
53
+ expect(response.status).toBe(200);
54
+ expect(data.status).toBe("healthy");
55
+ expect(data.server).toBe("access-mcp-nsf-awards");
56
+ });
57
+ it("should list available tools", async () => {
58
+ const response = await fetch(`${NSF_URL}/tools`);
59
+ const data = await response.json();
60
+ expect(response.status).toBe(200);
61
+ expect(data.tools).toBeDefined();
62
+ expect(Array.isArray(data.tools)).toBe(true);
63
+ const toolNames = data.tools.map((t) => t.name);
64
+ expect(toolNames).toContain("search_nsf_awards");
65
+ });
66
+ it("should execute search_nsf_awards tool via HTTP", async () => {
67
+ const response = await fetch(`${NSF_URL}/tools/search_nsf_awards`, {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json" },
70
+ body: JSON.stringify({
71
+ arguments: {
72
+ keyword: "machine learning",
73
+ limit: 5,
74
+ },
75
+ }),
76
+ });
77
+ const data = await response.json();
78
+ expect(response.status).toBe(200);
79
+ expect(data.content).toBeDefined();
80
+ expect(data.content[0].type).toBe("text");
81
+ // Parse the result
82
+ const result = JSON.parse(data.content[0].text);
83
+ expect(result).toBeDefined();
84
+ }, 15000);
85
+ it("should return 404 for unknown tool", async () => {
86
+ const response = await fetch(`${NSF_URL}/tools/nonexistent_tool`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify({ arguments: {} }),
90
+ });
91
+ expect(response.status).toBe(404);
92
+ });
93
+ });
94
+ describe("SSE Endpoint", () => {
95
+ it("should accept SSE connections on /sse", async () => {
96
+ const controller = new AbortController();
97
+ const timeout = setTimeout(() => controller.abort(), 2000);
98
+ try {
99
+ const response = await fetch(`${NSF_URL}/sse`, {
100
+ signal: controller.signal,
101
+ });
102
+ expect(response.status).toBe(200);
103
+ expect(response.headers.get("content-type")).toContain("text/event-stream");
104
+ }
105
+ catch (e) {
106
+ // AbortError is expected
107
+ if (e instanceof Error && e.name !== "AbortError") {
108
+ throw e;
109
+ }
110
+ }
111
+ finally {
112
+ clearTimeout(timeout);
113
+ }
114
+ });
115
+ });
116
+ });
@@ -8,8 +8,8 @@ describe("Utils", () => {
8
8
  test("should keep valid characters", () => {
9
9
  expect(sanitizeGroupId("test.group-123")).toBe("test.group-123");
10
10
  });
11
- test("should handle empty string", () => {
12
- expect(sanitizeGroupId("")).toBe("");
11
+ test("should throw error for empty string", () => {
12
+ expect(() => sanitizeGroupId("")).toThrow("groupId parameter is required and cannot be null or undefined");
13
13
  });
14
14
  });
15
15
  describe("formatApiUrl", () => {
@@ -38,12 +38,12 @@ describe("Utils", () => {
38
38
  };
39
39
  expect(handleApiError(error)).toBe("API error: 404 Not Found");
40
40
  });
41
- test("should handle error message", () => {
42
- const error = { message: "Network error" };
41
+ test("should handle Error instance", () => {
42
+ const error = new Error("Network error");
43
43
  expect(handleApiError(error)).toBe("Network error");
44
44
  });
45
45
  test("should handle unknown error", () => {
46
- const error = {};
46
+ const error = "some string error";
47
47
  expect(handleApiError(error)).toBe("Unknown API error");
48
48
  });
49
49
  });
@@ -1,6 +1,8 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { Tool, Resource, Prompt, CallToolResult, ReadResourceResult, GetPromptResult, CallToolRequest, ReadResourceRequest, GetPromptRequest } from "@modelcontextprotocol/sdk/types.js";
3
4
  import { AxiosInstance } from "axios";
5
+ export type { Tool, Resource, Prompt, CallToolResult, ReadResourceResult, GetPromptResult };
4
6
  export declare abstract class BaseAccessServer {
5
7
  protected serverName: string;
6
8
  protected version: string;
@@ -10,30 +12,37 @@ export declare abstract class BaseAccessServer {
10
12
  private _httpClient?;
11
13
  private _httpServer?;
12
14
  private _httpPort?;
15
+ private _sseTransports;
13
16
  constructor(serverName: string, version: string, baseURL?: string);
14
17
  protected get httpClient(): AxiosInstance;
15
18
  private setupHandlers;
16
- protected abstract getTools(): any[];
17
- protected abstract getResources(): any[];
18
- protected abstract handleToolCall(request: any): Promise<any>;
19
+ protected abstract getTools(): Tool[];
20
+ protected abstract getResources(): Resource[];
21
+ protected abstract handleToolCall(request: CallToolRequest): Promise<CallToolResult>;
19
22
  /**
20
23
  * Get available prompts - override in subclasses to provide prompts
21
24
  */
22
- protected getPrompts(): any[];
25
+ protected getPrompts(): Prompt[];
23
26
  /**
24
27
  * Handle resource read requests - override in subclasses
25
28
  */
26
- protected handleResourceRead(request: any): Promise<any>;
29
+ protected handleResourceRead(request: ReadResourceRequest): Promise<ReadResourceResult>;
27
30
  /**
28
31
  * Handle get prompt requests - override in subclasses
29
32
  */
30
- protected handleGetPrompt(request: any): Promise<any>;
33
+ protected handleGetPrompt(request: GetPromptRequest): Promise<GetPromptResult>;
34
+ /**
35
+ * Helper method to create a standard error response (MCP 2025 compliant)
36
+ * @param message The error message
37
+ * @param hint Optional suggestion for how to fix the error
38
+ */
39
+ protected errorResponse(message: string, hint?: string): CallToolResult;
31
40
  /**
32
41
  * Helper method to create a JSON resource response
33
42
  * @param uri The resource URI
34
43
  * @param data The data to return as JSON
35
44
  */
36
- protected createJsonResource(uri: string, data: any): {
45
+ protected createJsonResource(uri: string, data: unknown): {
37
46
  contents: {
38
47
  uri: string;
39
48
  mimeType: string;
@@ -71,13 +80,17 @@ export declare abstract class BaseAccessServer {
71
80
  httpPort?: number;
72
81
  }): Promise<void>;
73
82
  /**
74
- * Start HTTP service layer for inter-server communication
83
+ * Start HTTP service layer with SSE support for remote MCP connections
75
84
  */
76
85
  private startHttpService;
86
+ /**
87
+ * Set up MCP handlers on a server instance
88
+ */
89
+ private setupServerHandlers;
77
90
  /**
78
91
  * Call a tool on another ACCESS-CI MCP server via HTTP
79
92
  */
80
- protected callRemoteServer(serviceName: string, toolName: string, args?: Record<string, any>): Promise<any>;
93
+ protected callRemoteServer(serviceName: string, toolName: string, args?: Record<string, unknown>): Promise<unknown>;
81
94
  /**
82
95
  * Get service endpoint from environment configuration
83
96
  * Expected format: ACCESS_MCP_SERVICES=nsf-awards=http://localhost:3001,xdmod-metrics=http://localhost:3002
@@ -1,5 +1,6 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
3
4
  import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
5
  import axios from "axios";
5
6
  import express from "express";
@@ -12,6 +13,7 @@ export class BaseAccessServer {
12
13
  _httpClient;
13
14
  _httpServer;
14
15
  _httpPort;
16
+ _sseTransports = new Map();
15
17
  constructor(serverName, version, baseURL = "https://support.access-ci.org/api") {
16
18
  this.serverName = serverName;
17
19
  this.version = version;
@@ -78,9 +80,12 @@ export class BaseAccessServer {
78
80
  content: [
79
81
  {
80
82
  type: "text",
81
- text: `Error: ${errorMessage}`,
83
+ text: JSON.stringify({
84
+ error: errorMessage
85
+ }),
82
86
  },
83
87
  ],
88
+ isError: true,
84
89
  };
85
90
  }
86
91
  });
@@ -132,13 +137,32 @@ export class BaseAccessServer {
132
137
  * Handle resource read requests - override in subclasses
133
138
  */
134
139
  async handleResourceRead(request) {
135
- throw new Error("Resource reading not supported by this server");
140
+ throw new Error(`Resource reading not supported by this server: ${request.params.uri}`);
136
141
  }
137
142
  /**
138
143
  * Handle get prompt requests - override in subclasses
139
144
  */
140
145
  async handleGetPrompt(request) {
141
- throw new Error("Prompt not found");
146
+ throw new Error(`Prompt not found: ${request.params.name}`);
147
+ }
148
+ /**
149
+ * Helper method to create a standard error response (MCP 2025 compliant)
150
+ * @param message The error message
151
+ * @param hint Optional suggestion for how to fix the error
152
+ */
153
+ errorResponse(message, hint) {
154
+ return {
155
+ content: [
156
+ {
157
+ type: "text",
158
+ text: JSON.stringify({
159
+ error: message,
160
+ ...(hint && { hint }),
161
+ }),
162
+ },
163
+ ],
164
+ isError: true,
165
+ };
142
166
  }
143
167
  /**
144
168
  * Helper method to create a JSON resource response
@@ -206,7 +230,7 @@ export class BaseAccessServer {
206
230
  }
207
231
  }
208
232
  /**
209
- * Start HTTP service layer for inter-server communication
233
+ * Start HTTP service layer with SSE support for remote MCP connections
210
234
  */
211
235
  async startHttpService() {
212
236
  if (!this._httpPort)
@@ -222,7 +246,43 @@ export class BaseAccessServer {
222
246
  timestamp: new Date().toISOString()
223
247
  });
224
248
  });
225
- // List available tools endpoint
249
+ // SSE endpoint for MCP remote connections
250
+ this._httpServer.get('/sse', async (req, res) => {
251
+ console.log(`[${this.serverName}] New SSE connection`);
252
+ const transport = new SSEServerTransport('/messages', res);
253
+ const sessionId = transport.sessionId;
254
+ this._sseTransports.set(sessionId, transport);
255
+ // Clean up on disconnect
256
+ res.on('close', () => {
257
+ console.log(`[${this.serverName}] SSE connection closed: ${sessionId}`);
258
+ this._sseTransports.delete(sessionId);
259
+ });
260
+ // Create a new server instance for this SSE connection
261
+ const sseServer = new Server({
262
+ name: this.serverName,
263
+ version: this.version,
264
+ }, {
265
+ capabilities: {
266
+ resources: {},
267
+ tools: {},
268
+ prompts: {},
269
+ },
270
+ });
271
+ // Set up handlers for the SSE server (same as main server)
272
+ this.setupServerHandlers(sseServer);
273
+ await sseServer.connect(transport);
274
+ });
275
+ // Messages endpoint for SSE POST messages
276
+ this._httpServer.post('/messages', async (req, res) => {
277
+ const sessionId = req.query.sessionId;
278
+ const transport = this._sseTransports.get(sessionId);
279
+ if (!transport) {
280
+ res.status(404).json({ error: 'Session not found' });
281
+ return;
282
+ }
283
+ await transport.handlePostMessage(req, res, req.body);
284
+ });
285
+ // List available tools endpoint (for inter-server communication)
226
286
  this._httpServer.get('/tools', (req, res) => {
227
287
  try {
228
288
  const tools = this.getTools();
@@ -232,7 +292,7 @@ export class BaseAccessServer {
232
292
  res.status(500).json({ error: 'Failed to list tools' });
233
293
  }
234
294
  });
235
- // Tool execution endpoint
295
+ // Tool execution endpoint (for inter-server communication)
236
296
  this._httpServer.post('/tools/:toolName', async (req, res) => {
237
297
  try {
238
298
  const { toolName } = req.params;
@@ -241,10 +301,12 @@ export class BaseAccessServer {
241
301
  const tools = this.getTools();
242
302
  const tool = tools.find(t => t.name === toolName);
243
303
  if (!tool) {
244
- return res.status(404).json({ error: `Tool '${toolName}' not found` });
304
+ res.status(404).json({ error: `Tool '${toolName}' not found` });
305
+ return;
245
306
  }
246
307
  // Execute the tool
247
308
  const request = {
309
+ method: "tools/call",
248
310
  params: {
249
311
  name: toolName,
250
312
  arguments: args
@@ -265,6 +327,83 @@ export class BaseAccessServer {
265
327
  }).on('error', reject);
266
328
  });
267
329
  }
330
+ /**
331
+ * Set up MCP handlers on a server instance
332
+ */
333
+ setupServerHandlers(server) {
334
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
335
+ try {
336
+ return { tools: this.getTools() };
337
+ }
338
+ catch (error) {
339
+ return { tools: [] };
340
+ }
341
+ });
342
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
343
+ try {
344
+ return { resources: this.getResources() };
345
+ }
346
+ catch (error) {
347
+ return { resources: [] };
348
+ }
349
+ });
350
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
351
+ try {
352
+ return await this.handleToolCall(request);
353
+ }
354
+ catch (error) {
355
+ const errorMessage = error instanceof Error ? error.message : String(error);
356
+ console.error("Error handling tool call:", errorMessage);
357
+ return {
358
+ content: [
359
+ {
360
+ type: "text",
361
+ text: JSON.stringify({
362
+ error: errorMessage
363
+ }),
364
+ },
365
+ ],
366
+ isError: true,
367
+ };
368
+ }
369
+ });
370
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
371
+ try {
372
+ return await this.handleResourceRead(request);
373
+ }
374
+ catch (error) {
375
+ const errorMessage = error instanceof Error ? error.message : String(error);
376
+ console.error("Error reading resource:", errorMessage);
377
+ return {
378
+ contents: [
379
+ {
380
+ uri: request.params.uri,
381
+ mimeType: "text/plain",
382
+ text: `Error: ${errorMessage}`,
383
+ },
384
+ ],
385
+ };
386
+ }
387
+ });
388
+ server.setRequestHandler(ListPromptsRequestSchema, async () => {
389
+ try {
390
+ return { prompts: this.getPrompts() };
391
+ }
392
+ catch (error) {
393
+ return { prompts: [] };
394
+ }
395
+ });
396
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
397
+ try {
398
+ return await this.handleGetPrompt(request);
399
+ }
400
+ catch (error) {
401
+ const errorMessage = error instanceof Error ? error.message : String(error);
402
+ console.error("Error getting prompt:", errorMessage);
403
+ throw error;
404
+ }
405
+ });
406
+ }
268
407
  /**
269
408
  * Call a tool on another ACCESS-CI MCP server via HTTP
270
409
  */
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Authentication provider for Drupal JSON:API using cookie-based auth.
3
+ *
4
+ * This is a temporary implementation for development/testing.
5
+ * Production should use Key Auth with the access_mcp_author module.
6
+ *
7
+ * @see ../../../access-qa-planning/06-mcp-authentication.md
8
+ */
9
+ export declare class DrupalAuthProvider {
10
+ private baseUrl;
11
+ private username;
12
+ private password;
13
+ private sessionCookie?;
14
+ private csrfToken?;
15
+ private logoutToken?;
16
+ private userUuid?;
17
+ private httpClient;
18
+ private isAuthenticated;
19
+ constructor(baseUrl: string, username: string, password: string);
20
+ /**
21
+ * Ensure we have a valid session, logging in if necessary
22
+ */
23
+ ensureAuthenticated(): Promise<void>;
24
+ /**
25
+ * Login to Drupal and store session cookie + CSRF token
26
+ */
27
+ login(): Promise<void>;
28
+ /**
29
+ * Get headers required for authenticated JSON:API requests
30
+ */
31
+ getAuthHeaders(): Record<string, string>;
32
+ /**
33
+ * Get the authenticated user's UUID
34
+ */
35
+ getUserUuid(): string | undefined;
36
+ /**
37
+ * Invalidate the current session
38
+ */
39
+ invalidate(): void;
40
+ /**
41
+ * Make an authenticated GET request to JSON:API
42
+ */
43
+ get(path: string): Promise<any>;
44
+ /**
45
+ * Make an authenticated POST request to JSON:API
46
+ */
47
+ post(path: string, data: any): Promise<any>;
48
+ /**
49
+ * Make an authenticated PATCH request to JSON:API
50
+ */
51
+ patch(path: string, data: any): Promise<any>;
52
+ /**
53
+ * Make an authenticated DELETE request to JSON:API
54
+ */
55
+ delete(path: string): Promise<any>;
56
+ /**
57
+ * Handle JSON:API response, throwing on errors
58
+ */
59
+ private handleResponse;
60
+ }
@@ -0,0 +1,188 @@
1
+ import axios from "axios";
2
+ /**
3
+ * Authentication provider for Drupal JSON:API using cookie-based auth.
4
+ *
5
+ * This is a temporary implementation for development/testing.
6
+ * Production should use Key Auth with the access_mcp_author module.
7
+ *
8
+ * @see ../../../access-qa-planning/06-mcp-authentication.md
9
+ */
10
+ export class DrupalAuthProvider {
11
+ baseUrl;
12
+ username;
13
+ password;
14
+ sessionCookie;
15
+ csrfToken;
16
+ logoutToken;
17
+ userUuid;
18
+ httpClient;
19
+ isAuthenticated = false;
20
+ constructor(baseUrl, username, password) {
21
+ this.baseUrl = baseUrl;
22
+ this.username = username;
23
+ this.password = password;
24
+ this.httpClient = axios.create({
25
+ baseURL: this.baseUrl,
26
+ timeout: 30000,
27
+ validateStatus: () => true,
28
+ });
29
+ }
30
+ /**
31
+ * Ensure we have a valid session, logging in if necessary
32
+ */
33
+ async ensureAuthenticated() {
34
+ if (!this.isAuthenticated) {
35
+ await this.login();
36
+ }
37
+ }
38
+ /**
39
+ * Login to Drupal and store session cookie + CSRF token
40
+ */
41
+ async login() {
42
+ const response = await this.httpClient.post("/user/login?_format=json", {
43
+ name: this.username,
44
+ pass: this.password,
45
+ }, {
46
+ headers: {
47
+ "Content-Type": "application/json",
48
+ },
49
+ });
50
+ if (response.status !== 200) {
51
+ throw new Error(`Drupal login failed: ${response.status} ${response.statusText}`);
52
+ }
53
+ // Extract session cookie from Set-Cookie header
54
+ const setCookie = response.headers["set-cookie"];
55
+ if (setCookie && setCookie.length > 0) {
56
+ // Parse the session cookie (format: SESS...=value; path=/; ...)
57
+ const cookieParts = setCookie[0].split(";")[0];
58
+ this.sessionCookie = cookieParts;
59
+ }
60
+ // Store CSRF token and logout token from response
61
+ this.csrfToken = response.data.csrf_token;
62
+ this.logoutToken = response.data.logout_token;
63
+ this.userUuid = response.data.current_user?.uuid;
64
+ if (!this.sessionCookie || !this.csrfToken) {
65
+ throw new Error("Login succeeded but missing session cookie or CSRF token");
66
+ }
67
+ this.isAuthenticated = true;
68
+ }
69
+ /**
70
+ * Get headers required for authenticated JSON:API requests
71
+ */
72
+ getAuthHeaders() {
73
+ if (!this.isAuthenticated || !this.sessionCookie || !this.csrfToken) {
74
+ throw new Error("Not authenticated. Call ensureAuthenticated() first.");
75
+ }
76
+ return {
77
+ "Cookie": this.sessionCookie,
78
+ "X-CSRF-Token": this.csrfToken,
79
+ "Content-Type": "application/vnd.api+json",
80
+ "Accept": "application/vnd.api+json",
81
+ };
82
+ }
83
+ /**
84
+ * Get the authenticated user's UUID
85
+ */
86
+ getUserUuid() {
87
+ return this.userUuid;
88
+ }
89
+ /**
90
+ * Invalidate the current session
91
+ */
92
+ invalidate() {
93
+ this.sessionCookie = undefined;
94
+ this.csrfToken = undefined;
95
+ this.logoutToken = undefined;
96
+ this.userUuid = undefined;
97
+ this.isAuthenticated = false;
98
+ }
99
+ /**
100
+ * Make an authenticated GET request to JSON:API
101
+ */
102
+ async get(path) {
103
+ await this.ensureAuthenticated();
104
+ const response = await this.httpClient.get(path, {
105
+ headers: this.getAuthHeaders(),
106
+ });
107
+ if (response.status === 401 || response.status === 403) {
108
+ // Session may have expired, try re-authenticating
109
+ this.invalidate();
110
+ await this.ensureAuthenticated();
111
+ const retryResponse = await this.httpClient.get(path, {
112
+ headers: this.getAuthHeaders(),
113
+ });
114
+ return this.handleResponse(retryResponse);
115
+ }
116
+ return this.handleResponse(response);
117
+ }
118
+ /**
119
+ * Make an authenticated POST request to JSON:API
120
+ */
121
+ async post(path, data) {
122
+ await this.ensureAuthenticated();
123
+ const response = await this.httpClient.post(path, data, {
124
+ headers: this.getAuthHeaders(),
125
+ });
126
+ if (response.status === 401 || response.status === 403) {
127
+ this.invalidate();
128
+ await this.ensureAuthenticated();
129
+ const retryResponse = await this.httpClient.post(path, data, {
130
+ headers: this.getAuthHeaders(),
131
+ });
132
+ return this.handleResponse(retryResponse);
133
+ }
134
+ return this.handleResponse(response);
135
+ }
136
+ /**
137
+ * Make an authenticated PATCH request to JSON:API
138
+ */
139
+ async patch(path, data) {
140
+ await this.ensureAuthenticated();
141
+ const response = await this.httpClient.patch(path, data, {
142
+ headers: this.getAuthHeaders(),
143
+ });
144
+ if (response.status === 401 || response.status === 403) {
145
+ this.invalidate();
146
+ await this.ensureAuthenticated();
147
+ const retryResponse = await this.httpClient.patch(path, data, {
148
+ headers: this.getAuthHeaders(),
149
+ });
150
+ return this.handleResponse(retryResponse);
151
+ }
152
+ return this.handleResponse(response);
153
+ }
154
+ /**
155
+ * Make an authenticated DELETE request to JSON:API
156
+ */
157
+ async delete(path) {
158
+ await this.ensureAuthenticated();
159
+ const response = await this.httpClient.delete(path, {
160
+ headers: this.getAuthHeaders(),
161
+ });
162
+ if (response.status === 401 || response.status === 403) {
163
+ this.invalidate();
164
+ await this.ensureAuthenticated();
165
+ const retryResponse = await this.httpClient.delete(path, {
166
+ headers: this.getAuthHeaders(),
167
+ });
168
+ return this.handleResponse(retryResponse);
169
+ }
170
+ return this.handleResponse(response);
171
+ }
172
+ /**
173
+ * Handle JSON:API response, throwing on errors
174
+ */
175
+ handleResponse(response) {
176
+ if (response.status >= 200 && response.status < 300) {
177
+ return response.data;
178
+ }
179
+ // JSON:API error format
180
+ if (response.data?.errors) {
181
+ const errors = response.data.errors
182
+ .map((e) => e.detail || e.title || "Unknown error")
183
+ .join("; ");
184
+ throw new Error(`Drupal API error (${response.status}): ${errors}`);
185
+ }
186
+ throw new Error(`Drupal API error: ${response.status} ${response.statusText}`);
187
+ }
188
+ }
package/dist/index.d.ts CHANGED
@@ -2,3 +2,4 @@ export * from "./base-server.js";
2
2
  export * from "./types.js";
3
3
  export * from "./utils.js";
4
4
  export * from "./taxonomies.js";
5
+ export * from "./drupal-auth.js";
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ export * from "./base-server.js";
2
2
  export * from "./types.js";
3
3
  export * from "./utils.js";
4
4
  export * from "./taxonomies.js";
5
+ export * from "./drupal-auth.js";
package/dist/types.d.ts CHANGED
@@ -1,58 +1,25 @@
1
- import { z } from "zod";
2
- export declare const AffinityGroupSchema: z.ZodObject<{
3
- group_id: z.ZodString;
4
- name: z.ZodOptional<z.ZodString>;
5
- description: z.ZodOptional<z.ZodString>;
6
- }, "strip", z.ZodTypeAny, {
7
- group_id: string;
8
- name?: string | undefined;
9
- description?: string | undefined;
10
- }, {
11
- group_id: string;
12
- name?: string | undefined;
13
- description?: string | undefined;
14
- }>;
15
- export declare const EventSchema: z.ZodObject<{
16
- id: z.ZodString;
17
- title: z.ZodString;
18
- description: z.ZodOptional<z.ZodString>;
19
- start_date: z.ZodOptional<z.ZodString>;
20
- end_date: z.ZodOptional<z.ZodString>;
21
- location: z.ZodOptional<z.ZodString>;
22
- }, "strip", z.ZodTypeAny, {
23
- title: string;
24
- id: string;
25
- description?: string | undefined;
26
- start_date?: string | undefined;
27
- end_date?: string | undefined;
28
- location?: string | undefined;
29
- }, {
30
- title: string;
31
- id: string;
32
- description?: string | undefined;
33
- start_date?: string | undefined;
34
- end_date?: string | undefined;
35
- location?: string | undefined;
36
- }>;
37
- export declare const KnowledgeBaseResourceSchema: z.ZodObject<{
38
- id: z.ZodString;
39
- title: z.ZodString;
40
- content: z.ZodOptional<z.ZodString>;
41
- category: z.ZodOptional<z.ZodString>;
42
- tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
43
- }, "strip", z.ZodTypeAny, {
44
- title: string;
45
- id: string;
46
- content?: string | undefined;
47
- category?: string | undefined;
48
- tags?: string[] | undefined;
49
- }, {
50
- title: string;
51
- id: string;
52
- content?: string | undefined;
53
- category?: string | undefined;
54
- tags?: string[] | undefined;
55
- }>;
56
- export type AffinityGroup = z.infer<typeof AffinityGroupSchema>;
57
- export type Event = z.infer<typeof EventSchema>;
58
- export type KnowledgeBaseResource = z.infer<typeof KnowledgeBaseResourceSchema>;
1
+ export interface UniversalSearchParams {
2
+ query?: string;
3
+ id?: string;
4
+ type?: string;
5
+ tags?: string[];
6
+ date?: "today" | "upcoming" | "past" | "this_week" | "this_month";
7
+ limit?: number;
8
+ offset?: number;
9
+ sort?: string;
10
+ order?: "asc" | "desc";
11
+ }
12
+ export interface UniversalResponse<T> {
13
+ total: number;
14
+ items: T[];
15
+ }
16
+ export interface UniversalResponseWithHints<T> extends UniversalResponse<T> {
17
+ hints?: {
18
+ next?: string;
19
+ tags?: string[];
20
+ };
21
+ }
22
+ export interface StandardErrorResponse {
23
+ error: string;
24
+ hint?: string;
25
+ }
package/dist/types.js CHANGED
@@ -1,21 +1 @@
1
- import { z } from "zod";
2
- export const AffinityGroupSchema = z.object({
3
- group_id: z.string(),
4
- name: z.string().optional(),
5
- description: z.string().optional(),
6
- });
7
- export const EventSchema = z.object({
8
- id: z.string(),
9
- title: z.string(),
10
- description: z.string().optional(),
11
- start_date: z.string().optional(),
12
- end_date: z.string().optional(),
13
- location: z.string().optional(),
14
- });
15
- export const KnowledgeBaseResourceSchema = z.object({
16
- id: z.string(),
17
- title: z.string(),
18
- content: z.string().optional(),
19
- category: z.string().optional(),
20
- tags: z.array(z.string()).optional(),
21
- });
1
+ export {};
package/dist/utils.d.ts CHANGED
@@ -1,3 +1,74 @@
1
1
  export declare function sanitizeGroupId(groupId: string): string;
2
2
  export declare function formatApiUrl(version: string, endpoint: string): string;
3
- export declare function handleApiError(error: any): string;
3
+ export declare function handleApiError(error: unknown): string;
4
+ /**
5
+ * LLM-Friendly Response Utilities
6
+ *
7
+ * These utilities help create responses that guide LLMs through multi-step workflows,
8
+ * provide clear next actions, and improve error handling.
9
+ */
10
+ export interface NextStep {
11
+ action: string;
12
+ description: string;
13
+ tool?: string;
14
+ parameters?: Record<string, unknown>;
15
+ }
16
+ export interface LLMResponse<T = unknown> {
17
+ data?: T;
18
+ count?: number;
19
+ next_steps?: NextStep[];
20
+ suggestions?: string[];
21
+ related_tools?: string[];
22
+ context?: Record<string, unknown>;
23
+ }
24
+ export interface LLMError {
25
+ error: string;
26
+ error_type: "validation" | "not_found" | "api_error" | "invalid_parameter";
27
+ suggestions?: string[];
28
+ next_steps?: NextStep[];
29
+ did_you_mean?: string[];
30
+ related_queries?: string[];
31
+ }
32
+ /**
33
+ * Add helpful next steps to a successful response
34
+ */
35
+ export declare function addNextSteps<T>(data: T, nextSteps: NextStep[]): LLMResponse<T>;
36
+ /**
37
+ * Create an LLM-friendly error response with suggestions
38
+ */
39
+ export declare function createLLMError(error: string, errorType: LLMError["error_type"], options?: {
40
+ suggestions?: string[];
41
+ nextSteps?: NextStep[];
42
+ didYouMean?: string[];
43
+ relatedQueries?: string[];
44
+ }): LLMError;
45
+ /**
46
+ * Add discovery suggestions when returning empty results
47
+ */
48
+ export declare function addDiscoverySuggestions<T>(data: T[], discoverySteps: NextStep[]): LLMResponse<T[]>;
49
+ /**
50
+ * Common next step templates for cross-server consistency
51
+ */
52
+ export declare const CommonNextSteps: {
53
+ discoverResources: {
54
+ action: string;
55
+ description: string;
56
+ tool: string;
57
+ parameters: {
58
+ include_resource_ids: boolean;
59
+ };
60
+ };
61
+ narrowResults: (currentCount: number, suggestedFilters: string[]) => {
62
+ action: string;
63
+ description: string;
64
+ };
65
+ exploreRelated: (relatedTool: string, description: string) => {
66
+ action: string;
67
+ description: string;
68
+ tool: string;
69
+ };
70
+ refineSearch: (suggestions: string[]) => {
71
+ action: string;
72
+ description: string;
73
+ };
74
+ };
package/dist/utils.js CHANGED
@@ -1,15 +1,84 @@
1
1
  export function sanitizeGroupId(groupId) {
2
+ if (!groupId) {
3
+ throw new Error("groupId parameter is required and cannot be null or undefined");
4
+ }
2
5
  return groupId.replace(/[^a-zA-Z0-9.-]/g, "");
3
6
  }
4
7
  export function formatApiUrl(version, endpoint) {
5
8
  return `/${version}/${endpoint}`;
6
9
  }
7
10
  export function handleApiError(error) {
8
- if (error.response?.data?.message) {
9
- return error.response.data.message;
11
+ const axiosError = error;
12
+ if (axiosError.response?.data?.message) {
13
+ return axiosError.response.data.message;
14
+ }
15
+ if (axiosError.response?.status) {
16
+ return `API error: ${axiosError.response.status} ${axiosError.response.statusText}`;
17
+ }
18
+ if (error instanceof Error) {
19
+ return error.message;
10
20
  }
11
- if (error.response?.status) {
12
- return `API error: ${error.response.status} ${error.response.statusText}`;
21
+ return "Unknown API error";
22
+ }
23
+ /**
24
+ * Add helpful next steps to a successful response
25
+ */
26
+ export function addNextSteps(data, nextSteps) {
27
+ return {
28
+ data,
29
+ next_steps: nextSteps,
30
+ };
31
+ }
32
+ /**
33
+ * Create an LLM-friendly error response with suggestions
34
+ */
35
+ export function createLLMError(error, errorType, options = {}) {
36
+ return {
37
+ error,
38
+ error_type: errorType,
39
+ ...options,
40
+ };
41
+ }
42
+ /**
43
+ * Add discovery suggestions when returning empty results
44
+ */
45
+ export function addDiscoverySuggestions(data, discoverySteps) {
46
+ if (data.length === 0) {
47
+ return {
48
+ data,
49
+ count: 0,
50
+ next_steps: discoverySteps,
51
+ suggestions: [
52
+ "No results found. Try the suggested next steps to discover available options.",
53
+ ],
54
+ };
13
55
  }
14
- return error.message || "Unknown API error";
56
+ return {
57
+ data,
58
+ count: data.length,
59
+ };
15
60
  }
61
+ /**
62
+ * Common next step templates for cross-server consistency
63
+ */
64
+ export const CommonNextSteps = {
65
+ discoverResources: {
66
+ action: "discover_resources",
67
+ description: "Find available compute resources to filter by",
68
+ tool: "search_resources",
69
+ parameters: { include_resource_ids: true },
70
+ },
71
+ narrowResults: (currentCount, suggestedFilters) => ({
72
+ action: "narrow_results",
73
+ description: `Currently showing ${currentCount} results. Add filters to narrow down: ${suggestedFilters.join(", ")}`,
74
+ }),
75
+ exploreRelated: (relatedTool, description) => ({
76
+ action: "explore_related",
77
+ description,
78
+ tool: relatedTool,
79
+ }),
80
+ refineSearch: (suggestions) => ({
81
+ action: "refine_search",
82
+ description: `Try these refinements: ${suggestions.join(", ")}`,
83
+ }),
84
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@access-mcp/shared",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "Shared utilities for ACCESS-CI MCP servers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",