@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.
- package/dist/__tests__/base-server.test.d.ts +1 -0
- package/dist/__tests__/base-server.test.js +176 -0
- package/dist/__tests__/interserver.integration.test.d.ts +1 -0
- package/dist/__tests__/interserver.integration.test.js +116 -0
- package/dist/__tests__/utils.test.js +3 -3
- package/dist/base-server.d.ts +17 -16
- package/dist/base-server.js +123 -6
- package/dist/drupal-auth.d.ts +60 -0
- package/dist/drupal-auth.js +188 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/utils.d.ts +4 -4
- package/dist/utils.js +9 -5
- package/package.json +1 -1
|
@@ -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
|
|
42
|
-
const 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
|
});
|
package/dist/base-server.d.ts
CHANGED
|
@@ -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():
|
|
17
|
-
protected abstract getResources():
|
|
18
|
-
protected abstract handleToolCall(request:
|
|
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():
|
|
25
|
+
protected getPrompts(): Prompt[];
|
|
23
26
|
/**
|
|
24
27
|
* Handle resource read requests - override in subclasses
|
|
25
28
|
*/
|
|
26
|
-
protected handleResourceRead(request:
|
|
29
|
+
protected handleResourceRead(request: ReadResourceRequest): Promise<ReadResourceResult>;
|
|
27
30
|
/**
|
|
28
31
|
* Handle get prompt requests - override in subclasses
|
|
29
32
|
*/
|
|
30
|
-
protected handleGetPrompt(request:
|
|
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:
|
|
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
|
|
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,
|
|
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
|
package/dist/base-server.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
package/dist/index.js
CHANGED
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:
|
|
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,
|
|
14
|
+
parameters?: Record<string, unknown>;
|
|
15
15
|
}
|
|
16
|
-
export interface LLMResponse<T =
|
|
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,
|
|
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
|
-
|
|
12
|
-
|
|
11
|
+
const axiosError = error;
|
|
12
|
+
if (axiosError.response?.data?.message) {
|
|
13
|
+
return axiosError.response.data.message;
|
|
13
14
|
}
|
|
14
|
-
if (
|
|
15
|
-
return `API error: ${
|
|
15
|
+
if (axiosError.response?.status) {
|
|
16
|
+
return `API error: ${axiosError.response.status} ${axiosError.response.statusText}`;
|
|
16
17
|
}
|
|
17
|
-
|
|
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
|