@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.
- package/README.md +51 -32
- package/dist/index.js +75 -42
- 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
|
|
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
|
-
|
|
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.
|
|
84
|
-
2. When
|
|
85
|
-
3. When
|
|
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
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
const url = new URL(
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
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
|
-
"@
|
|
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/
|
|
28
|
-
"@tsconfig/node22": "^22.0.2"
|
|
34
|
+
"@modelcontextprotocol/server": "^2.1.0"
|
|
29
35
|
}
|
|
30
36
|
}
|