@attackforge/mcp-server 0.2.9 → 0.3.1

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.
Files changed (3) hide show
  1. package/README.md +51 -32
  2. package/dist/index.js +75 -42
  3. package/package.json +11 -5
package/README.md CHANGED
@@ -1,24 +1,13 @@
1
1
  # AttackForge MCP Server
2
2
 
3
- An MCP (Model Context Protocol) server that enables Claude AI to interact with AttackForge. This server acts as a proxy, forwarding tool requests from Claude to your AttackForge instance.
3
+ An MCP (Model Context Protocol) server that gives any MCP client access to AttackForge. It acts as a proxy, forwarding the client's tool requests to your AttackForge instance.
4
4
 
5
5
  ## Installation
6
6
 
7
- ### From npm
8
-
9
7
  ```bash
10
8
  npm install -g @attackforge/mcp-server
11
9
  ```
12
10
 
13
- ### From source
14
-
15
- ```bash
16
- git clone https://github.com/AttackForge/af-mcp-server.git
17
- cd af-mcp-server
18
- npm install
19
- npm run build
20
- ```
21
-
22
11
  ## Configuration
23
12
 
24
13
  The server requires the following environment variables:
@@ -28,15 +17,18 @@ The server requires the following environment variables:
28
17
  | `AF_HOSTNAME` | Yes | Your AttackForge server hostname (e.g., `your-instance.attackforge.com`) |
29
18
  | `AF_USER_KEY` | Yes | Your AttackForge API user key for authentication |
30
19
  | `AF_PORT` | No | Custom port if your AttackForge server uses a non-standard HTTPS port |
20
+ | `AF_TIMEOUT_MS` | No | How long to wait on an AttackForge request before giving up (default `60000`) |
31
21
 
32
22
  ## Usage
33
23
 
24
+ The server communicates over stdio, so any MCP client that launches local (stdio) servers can use it. Configure the client to run the `af-mcp-server` command with the environment variables above. Configuration for two common clients is shown below; for other clients, see their documentation on adding a stdio server.
25
+
34
26
  ### Claude Desktop
35
27
 
36
28
  Add to your Claude Desktop configuration file:
37
29
 
38
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
39
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
30
+ - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
31
+ - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
40
32
 
41
33
  ```json
42
34
  {
@@ -54,20 +46,8 @@ Add to your Claude Desktop configuration file:
54
46
 
55
47
  ### Claude Code
56
48
 
57
- Add to your Claude Code settings:
58
-
59
- ```json
60
- {
61
- "mcpServers": {
62
- "attackforge": {
63
- "command": "af-mcp-server",
64
- "env": {
65
- "AF_HOSTNAME": "your-instance.attackforge.com",
66
- "AF_USER_KEY": "your-api-key"
67
- }
68
- }
69
- }
70
- }
49
+ ```bash
50
+ claude mcp add attackforge -e AF_HOSTNAME=your-instance.attackforge.com -e AF_USER_KEY=your-api-key -- af-mcp-server
71
51
  ```
72
52
 
73
53
  ### Running directly
@@ -76,16 +56,41 @@ Add to your Claude Code settings:
76
56
  AF_HOSTNAME=your-instance.attackforge.com AF_USER_KEY=your-api-key af-mcp-server
77
57
  ```
78
58
 
59
+ The server then waits for an MCP client on stdin and stdout; this is mainly useful for checking the configuration starts cleanly.
60
+
79
61
  ## How It Works
80
62
 
81
63
  This MCP server implements the [Model Context Protocol](https://modelcontextprotocol.io/) and proxies requests to your AttackForge instance:
82
64
 
83
- 1. Claude connects to the server via stdio transport
84
- 2. When Claude lists available tools, the server fetches them from `https://{AF_HOSTNAME}/mcp/tools/list`
85
- 3. When Claude calls a tool, the server forwards the request to `https://{AF_HOSTNAME}/mcp/tools/call`
65
+ 1. The MCP client starts the server and connects to it over stdio
66
+ 2. When the client lists available tools, the server fetches them from `https://{AF_HOSTNAME}/mcp/tools/list`
67
+ 3. When the client calls a tool, the server forwards the request to `https://{AF_HOSTNAME}/mcp/tools/call`
86
68
  4. All requests are authenticated using the `x-user-key` header
87
69
 
88
- The tools available to Claude are determined by your AttackForge instance configuration.
70
+ The tools available are determined by your AttackForge instance configuration and the permissions of the user whose key is configured.
71
+
72
+ ### Protocol versions
73
+
74
+ The server is built on the v2 MCP TypeScript SDK (`@modelcontextprotocol/server`) and serves both protocol eras from the same connection entry point:
75
+
76
+ - **Modern** — the `2026-07-28` revision, negotiated with `server/discover`
77
+ - **Legacy** — the `2024-10-07` through `2025-11-25` revisions, negotiated with the `initialize` handshake
78
+
79
+ The era is decided once per connection and every tool behaves identically on both, so no configuration is needed to support either kind of client.
80
+
81
+ ### Errors
82
+
83
+ Failures reaching AttackForge are relayed as MCP protocol errors naming the actual cause:
84
+
85
+ | Status | Meaning |
86
+ |--------|---------|
87
+ | `400` | AttackForge rejected the request; the validation detail is included in the message |
88
+ | `401` | `AF_USER_KEY` is not a valid AttackForge API key |
89
+ | `404` | Local MCP access is disabled on the instance, or the user has no access to the MCP module |
90
+ | other | Relayed with the status and response body |
91
+ | none | AttackForge could not be reached, or did not respond within `AF_TIMEOUT_MS` |
92
+
93
+ A tool that runs but fails (for example, a record that does not exist) returns an ordinary tool result marked `isError`, which is passed through unchanged for the model to read.
89
94
 
90
95
  ## Development
91
96
 
@@ -103,6 +108,20 @@ npm run clean
103
108
  npm run rebuild
104
109
  ```
105
110
 
111
+ ### Testing
112
+
113
+ ```bash
114
+ # Contract tests: the built server against a mock AttackForge, on both protocol eras
115
+ npm test
116
+
117
+ # Integration tests against a real AttackForge instance
118
+ npm run test:integration
119
+ ```
120
+
121
+ The tests are TypeScript run directly by Node, which needs Node.js 22.18 or later.
122
+
123
+ Integration tests read `AF_HOSTNAME`, `AF_USER_KEY` and optionally `AF_PORT` and `AF_TIMEOUT_MS` from a gitignored `.env.integration` - copy `.env.integration.example` to start. They are skipped when those are not set, and are read-only unless `AF_TEST_ALLOW_WRITES=true`, which creates a tagged testsuite per protocol era on each run.
124
+
106
125
  ## Requirements
107
126
 
108
127
  - Node.js 22 or later
package/dist/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
2
+ import { ProtocolError, ProtocolErrorCode, Server } from '@modelcontextprotocol/server';
3
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
5
4
  import { readFile } from 'node:fs/promises';
6
5
  if (!process.env.AF_HOSTNAME) {
7
6
  throw new Error('Environment variable AF_HOSTNAME is required');
@@ -9,6 +8,7 @@ if (!process.env.AF_HOSTNAME) {
9
8
  if (!process.env.AF_USER_KEY) {
10
9
  throw new Error('Environment variable AF_USER_KEY is required');
11
10
  }
11
+ const DEFAULT_TIMEOUT_MS = 60_000;
12
12
  let packageJson;
13
13
  try {
14
14
  const filePath = new URL('../package.json', import.meta.url);
@@ -18,50 +18,83 @@ try {
18
18
  catch (err) {
19
19
  throw new Error('Failed to parse package.json');
20
20
  }
21
- const mcpServer = new McpServer({
22
- name: packageJson.name,
23
- version: packageJson.version,
24
- }, {
25
- capabilities: {
26
- tools: {
27
- listChanged: false
28
- }
21
+ function timeoutMs() {
22
+ const configured = Number(process.env.AF_TIMEOUT_MS);
23
+ return (Number.isFinite(configured) && configured > 0) ? configured : DEFAULT_TIMEOUT_MS;
24
+ }
25
+ // The AttackForge endpoints report failures through the HTTP status, so map each
26
+ // one back to a message that names the actual cause instead of a generic error.
27
+ function describeFailure(status, body) {
28
+ switch (status) {
29
+ case 400:
30
+ return `AttackForge rejected the request${body ? `: ${body}` : ''}`;
31
+ case 401:
32
+ return 'AttackForge rejected the credentials - AF_USER_KEY is not a valid API key';
33
+ case 404:
34
+ return 'The AttackForge MCP module is unavailable - local MCP access is disabled on this instance, or this user has no access to the module';
35
+ default:
36
+ return `AttackForge returned ${status}${body ? `: ${body}` : ''}`;
29
37
  }
30
- });
31
- mcpServer.server.setRequestHandler(CallToolRequestSchema, async (request) => {
32
- const url = new URL('/mcp/tools/call', `https://${process.env.AF_HOSTNAME}`);
38
+ }
39
+ async function forward(path, request) {
40
+ const url = new URL(path, `https://${process.env.AF_HOSTNAME}`);
33
41
  if (process.env.AF_PORT) {
34
42
  url.port = process.env.AF_PORT;
35
43
  }
36
- const response = await fetch(url, {
37
- method: 'POST',
38
- body: JSON.stringify(request),
39
- headers: {
40
- 'content-type': 'application/json',
41
- 'x-user-key': process.env.AF_USER_KEY,
42
- }
43
- });
44
- if (response.status === 200) {
45
- return await response.json();
44
+ let response;
45
+ try {
46
+ response = await fetch(url, {
47
+ method: 'POST',
48
+ body: JSON.stringify(request),
49
+ headers: {
50
+ 'content-type': 'application/json',
51
+ 'x-user-key': process.env.AF_USER_KEY,
52
+ },
53
+ signal: AbortSignal.timeout(timeoutMs()),
54
+ });
46
55
  }
47
- throw new Error('Tool not found');
48
- });
49
- mcpServer.server.setRequestHandler(ListToolsRequestSchema, async (request) => {
50
- const url = new URL('/mcp/tools/list', `https://${process.env.AF_HOSTNAME}`);
51
- if (process.env.AF_PORT) {
52
- url.port = process.env.AF_PORT;
56
+ catch (err) {
57
+ throw new ProtocolError(ProtocolErrorCode.InternalError, `Could not reach AttackForge at ${url.host}: ${(err instanceof Error) ? err.message : String(err)}`);
58
+ }
59
+ if (!response.ok) {
60
+ throw new ProtocolError(ProtocolErrorCode.InternalError, describeFailure(response.status, (await response.text()).trim()));
53
61
  }
54
- const response = await fetch(url, {
55
- method: 'POST',
56
- body: JSON.stringify(request),
57
- headers: {
58
- 'content-type': 'application/json',
59
- 'x-user-key': process.env.AF_USER_KEY,
62
+ return await response.json();
63
+ }
64
+ // The low-level Server is @deprecated in favour of McpServer, but its note carves
65
+ // out advanced use cases and a pure proxy is one: the tools come from AttackForge
66
+ // at runtime, so there is nothing to registerTool and nothing to derive a schema
67
+ // from. What the low-level path owes the SDK is projectCallToolResult, which
68
+ // McpServer would otherwise call for us - the AttackForge REST endpoints return
69
+ // tool results unprojected, so this proxy is the only place the wire codec runs.
70
+ function createServer() {
71
+ const server = new Server({
72
+ name: packageJson.name,
73
+ version: packageJson.version,
74
+ }, {
75
+ capabilities: {
76
+ tools: {}
60
77
  }
61
78
  });
62
- if (response.status === 200) {
63
- return await response.json();
64
- }
65
- throw new Error('Unexpected response');
66
- });
67
- mcpServer.connect(new StdioServerTransport());
79
+ // tools/list is grant-filtered per principal upstream, so the advertised output
80
+ // schemas belong to this connection and are cached alongside it.
81
+ const outputSchemas = new Map();
82
+ server.setRequestHandler('tools/list', async (request) => {
83
+ const result = await forward('/mcp/tools/list', request);
84
+ for (const tool of result.tools) {
85
+ outputSchemas.set(tool.name, tool.outputSchema);
86
+ }
87
+ return result;
88
+ });
89
+ server.setRequestHandler('tools/call', async (request) => {
90
+ const result = await forward('/mcp/tools/call', request);
91
+ return server.projectCallToolResult(result, outputSchemas.get(request.params.name));
92
+ });
93
+ return server;
94
+ }
95
+ const handle = serveStdio(createServer);
96
+ for (const signal of ['SIGINT', 'SIGTERM']) {
97
+ process.on(signal, () => {
98
+ void handle.close();
99
+ });
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@attackforge/mcp-server",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "description": "AttackForge MCP Server",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -14,17 +14,23 @@
14
14
  "build": "tsc && shx chmod +x dist/index.js",
15
15
  "clean": "rm -rf dist",
16
16
  "prepare": "npm run rebuild",
17
- "rebuild": "npm run clean && npm run build"
17
+ "rebuild": "npm run clean && npm run build",
18
+ "test": "npm run build && tsc -p test && node --test test/proxy.test.ts",
19
+ "test:integration": "npm run build && tsc -p test && node --env-file-if-exists=.env.integration --test test/integration.test.ts"
18
20
  },
19
21
  "author": "AttackForge",
20
22
  "license": "MIT",
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
21
26
  "devDependencies": {
22
- "@types/node": "^22.16.5",
27
+ "@modelcontextprotocol/client": "^2.1.0",
28
+ "@tsconfig/node22": "^22.0.6",
29
+ "@types/node": "^22.20.4",
23
30
  "shx": "^0.4.0",
24
31
  "typescript": "^6.0.2"
25
32
  },
26
33
  "dependencies": {
27
- "@modelcontextprotocol/sdk": "^1.21.0",
28
- "@tsconfig/node22": "^22.0.2"
34
+ "@modelcontextprotocol/server": "^2.1.0"
29
35
  }
30
36
  }