@access-mcp/shared 0.3.3 → 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
+ });
@@ -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,42 +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>;
31
34
  /**
32
35
  * Helper method to create a standard error response (MCP 2025 compliant)
33
36
  * @param message The error message
34
37
  * @param hint Optional suggestion for how to fix the error
35
38
  */
36
- protected errorResponse(message: string, hint?: string): {
37
- content: {
38
- type: "text";
39
- text: string;
40
- }[];
41
- isError: boolean;
42
- };
39
+ protected errorResponse(message: string, hint?: string): CallToolResult;
43
40
  /**
44
41
  * Helper method to create a JSON resource response
45
42
  * @param uri The resource URI
46
43
  * @param data The data to return as JSON
47
44
  */
48
- protected createJsonResource(uri: string, data: any): {
45
+ protected createJsonResource(uri: string, data: unknown): {
49
46
  contents: {
50
47
  uri: string;
51
48
  mimeType: string;
@@ -83,13 +80,17 @@ export declare abstract class BaseAccessServer {
83
80
  httpPort?: number;
84
81
  }): Promise<void>;
85
82
  /**
86
- * Start HTTP service layer for inter-server communication
83
+ * Start HTTP service layer with SSE support for remote MCP connections
87
84
  */
88
85
  private startHttpService;
86
+ /**
87
+ * Set up MCP handlers on a server instance
88
+ */
89
+ private setupServerHandlers;
89
90
  /**
90
91
  * Call a tool on another ACCESS-CI MCP server via HTTP
91
92
  */
92
- 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>;
93
94
  /**
94
95
  * Get service endpoint from environment configuration
95
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;
@@ -135,13 +137,13 @@ export class BaseAccessServer {
135
137
  * Handle resource read requests - override in subclasses
136
138
  */
137
139
  async handleResourceRead(request) {
138
- throw new Error("Resource reading not supported by this server");
140
+ throw new Error(`Resource reading not supported by this server: ${request.params.uri}`);
139
141
  }
140
142
  /**
141
143
  * Handle get prompt requests - override in subclasses
142
144
  */
143
145
  async handleGetPrompt(request) {
144
- throw new Error("Prompt not found");
146
+ throw new Error(`Prompt not found: ${request.params.name}`);
145
147
  }
146
148
  /**
147
149
  * Helper method to create a standard error response (MCP 2025 compliant)
@@ -228,7 +230,7 @@ export class BaseAccessServer {
228
230
  }
229
231
  }
230
232
  /**
231
- * Start HTTP service layer for inter-server communication
233
+ * Start HTTP service layer with SSE support for remote MCP connections
232
234
  */
233
235
  async startHttpService() {
234
236
  if (!this._httpPort)
@@ -244,7 +246,43 @@ export class BaseAccessServer {
244
246
  timestamp: new Date().toISOString()
245
247
  });
246
248
  });
247
- // 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)
248
286
  this._httpServer.get('/tools', (req, res) => {
249
287
  try {
250
288
  const tools = this.getTools();
@@ -254,7 +292,7 @@ export class BaseAccessServer {
254
292
  res.status(500).json({ error: 'Failed to list tools' });
255
293
  }
256
294
  });
257
- // Tool execution endpoint
295
+ // Tool execution endpoint (for inter-server communication)
258
296
  this._httpServer.post('/tools/:toolName', async (req, res) => {
259
297
  try {
260
298
  const { toolName } = req.params;
@@ -263,10 +301,12 @@ export class BaseAccessServer {
263
301
  const tools = this.getTools();
264
302
  const tool = tools.find(t => t.name === toolName);
265
303
  if (!tool) {
266
- return res.status(404).json({ error: `Tool '${toolName}' not found` });
304
+ res.status(404).json({ error: `Tool '${toolName}' not found` });
305
+ return;
267
306
  }
268
307
  // Execute the tool
269
308
  const request = {
309
+ method: "tools/call",
270
310
  params: {
271
311
  name: toolName,
272
312
  arguments: args
@@ -287,6 +327,83 @@ export class BaseAccessServer {
287
327
  }).on('error', reject);
288
328
  });
289
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
+ }
290
407
  /**
291
408
  * Call a tool on another ACCESS-CI MCP server via HTTP
292
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/utils.d.ts CHANGED
@@ -1,6 +1,6 @@
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
4
  /**
5
5
  * LLM-Friendly Response Utilities
6
6
  *
@@ -11,15 +11,15 @@ export interface NextStep {
11
11
  action: string;
12
12
  description: string;
13
13
  tool?: string;
14
- parameters?: Record<string, any>;
14
+ parameters?: Record<string, unknown>;
15
15
  }
16
- export interface LLMResponse<T = any> {
16
+ export interface LLMResponse<T = unknown> {
17
17
  data?: T;
18
18
  count?: number;
19
19
  next_steps?: NextStep[];
20
20
  suggestions?: string[];
21
21
  related_tools?: string[];
22
- context?: Record<string, any>;
22
+ context?: Record<string, unknown>;
23
23
  }
24
24
  export interface LLMError {
25
25
  error: string;
package/dist/utils.js CHANGED
@@ -8,13 +8,17 @@ export function formatApiUrl(version, endpoint) {
8
8
  return `/${version}/${endpoint}`;
9
9
  }
10
10
  export function handleApiError(error) {
11
- if (error.response?.data?.message) {
12
- return error.response.data.message;
11
+ const axiosError = error;
12
+ if (axiosError.response?.data?.message) {
13
+ return axiosError.response.data.message;
13
14
  }
14
- if (error.response?.status) {
15
- return `API error: ${error.response.status} ${error.response.statusText}`;
15
+ if (axiosError.response?.status) {
16
+ return `API error: ${axiosError.response.status} ${axiosError.response.statusText}`;
16
17
  }
17
- return error.message || "Unknown API error";
18
+ if (error instanceof Error) {
19
+ return error.message;
20
+ }
21
+ return "Unknown API error";
18
22
  }
19
23
  /**
20
24
  * Add helpful next steps to a successful response
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@access-mcp/shared",
3
- "version": "0.3.3",
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",