strest-mcp 0.0.0-stage → 1.0.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/LICENSE +22 -0
- package/README.md +150 -2
- package/package.json +36 -4
- package/server.json +25 -0
- package/smithery.yaml +6 -0
- package/src/cli/index.mjs +117 -0
- package/src/core/assert.mjs +71 -0
- package/src/core/executor.mjs +171 -0
- package/src/core/guard.mjs +108 -0
- package/src/core/interpolator.mjs +118 -0
- package/src/index.mjs +19 -0
- package/src/mcp/server.mjs +183 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christopher Hailfinger (Diamantenschmiede)
|
|
4
|
+
Original work Copyright (c) 2018-2023 Eyke Rehbein
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,151 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ⚡ Strest MCP (Strest Orchestrator)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](https://glama.ai/mcp/servers/tastenkasperle/strest-mcp)
|
|
5
|
+
[](https://modelcontextprotocol.io)
|
|
6
|
+
[](tests/)
|
|
7
|
+
[](src/core/guard.mjs)
|
|
8
|
+
[](package.json)
|
|
9
|
+
|
|
10
|
+
> **Headless Declarative Multi-Step REST API Test & Chaining Engine for Autonomous AI Agents and DevSecOps.**
|
|
11
|
+
> *Execute complex, multi-stage API workflows (Auth ➔ Token Extraction ➔ Resource Creation ➔ Assertions) in a single deterministic MCP tool call.*
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 💡 The Problem
|
|
16
|
+
|
|
17
|
+
Autonomous AI agents (Claude Code, Cursor, Antigravity, AutoGen, CrewAI) and CI pipelines frequently need to interact with multi-step REST APIs.
|
|
18
|
+
When executing chained requests step-by-step with individual `curl` commands:
|
|
19
|
+
|
|
20
|
+
1. **Context Window Waste & Token Bloat:** Passing whole JSON payloads back and forth across multiple LLM turns wastes hundreds of tokens.
|
|
21
|
+
2. **Fragile State Propagation:** Extracting tokens, session IDs, and foreign keys across separate prompts introduces hallucinations and state loss.
|
|
22
|
+
3. **SSRF & Security Hazards:** AI agents given raw network access can be coerced into probing internal cloud metadata endpoints (`169.254.169.254`).
|
|
23
|
+
|
|
24
|
+
**Strest MCP solves this:** You pass a declarative multi-step scenario definition to the engine. It executes all steps sequentially with in-memory variable chaining, enforces strict assertions, applies active SSRF defense, and returns a clean, structured execution report in a single round-trip.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## ⚡ Key Capabilities
|
|
29
|
+
|
|
30
|
+
* 🔗 **Declarative Multi-Step Chaining:** Chain outputs from previous steps into downstream headers, query params, or JSON bodies using `<$ steps.<step_name>.body.<property> $>`.
|
|
31
|
+
* 🛡️ **Built-in SSRF & Cloud Metadata Shield:** Proactively blocks attacks against AWS/GCP/Azure instance metadata endpoints (`169.254.169.254`, `metadata.google.internal`), local loopbacks, and dangerous protocols (`file://`, `ftp://`).
|
|
32
|
+
* 🪶 **Zero External Dependencies:** Built entirely with native Node.js 20+ `fetch`, keeping the package footprint lightweight and lightning-fast.
|
|
33
|
+
* 🤖 **Model Context Protocol (MCP) Native:** Integrates seamlessly via stdio JSON-RPC 2.0 with Claude Desktop, Cursor, Antigravity, and any MCP-compliant client.
|
|
34
|
+
* 🎯 **Rich Assertions:** Status code validation, regex header checks, deep JSON body assertions, and timing thresholds.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 🤖 Model Context Protocol (MCP) Setup
|
|
39
|
+
|
|
40
|
+
### Add to Claude Desktop / Cursor / Antigravity
|
|
41
|
+
|
|
42
|
+
Add the following to your `claude_desktop_config.json` or MCP settings:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"strest-mcp": {
|
|
48
|
+
"command": "npx",
|
|
49
|
+
"args": ["-y", "strest-mcp"]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or run directly from a local clone:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"mcpServers": {
|
|
60
|
+
"strest-mcp": {
|
|
61
|
+
"command": "node",
|
|
62
|
+
"args": ["/path/to/strest_orchestrator/src/mcp/server.mjs"]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Available MCP Tools
|
|
69
|
+
|
|
70
|
+
| Tool | Description | Input Parameters |
|
|
71
|
+
| :--- | :--- | :--- |
|
|
72
|
+
| `strest_run_scenario` | Executes a declarative multi-step REST API workflow scenario with automatic variable chaining and schema assertions. | `scenario` (object) OR `scenarioPath` (string) |
|
|
73
|
+
| `strest_template` | Generates a ready-to-use template for multi-step authentication and CRUD chaining. | `baseUrl` (optional string, e.g. `http://localhost:5000`) |
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 💻 CLI Usage
|
|
78
|
+
|
|
79
|
+
You can also run Strest Orchestrator as a standalone CLI tool:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Display help and usage
|
|
83
|
+
npx strest help
|
|
84
|
+
|
|
85
|
+
# Run an API workflow scenario from a JSON file
|
|
86
|
+
npx strest run scenarios/auth_and_crud.json
|
|
87
|
+
|
|
88
|
+
# Generate a starter template
|
|
89
|
+
npx strest template > scenario.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 📋 Scenario Example (Chaining in Action)
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"name": "Auth and User Project Creation",
|
|
99
|
+
"steps": [
|
|
100
|
+
{
|
|
101
|
+
"name": "login",
|
|
102
|
+
"url": "https://api.example.com/v1/auth/login",
|
|
103
|
+
"method": "POST",
|
|
104
|
+
"headers": { "Content-Type": "application/json" },
|
|
105
|
+
"body": { "username": "admin", "password": "SuperSecretPassword" },
|
|
106
|
+
"assert": {
|
|
107
|
+
"status": 200,
|
|
108
|
+
"body": { "token": "defined" }
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"name": "create_project",
|
|
113
|
+
"url": "https://api.example.com/v1/projects",
|
|
114
|
+
"method": "POST",
|
|
115
|
+
"headers": {
|
|
116
|
+
"Content-Type": "application/json",
|
|
117
|
+
"Authorization": "Bearer <$ steps.login.body.token $>"
|
|
118
|
+
},
|
|
119
|
+
"body": {
|
|
120
|
+
"title": "Autonomous Agent Workspace",
|
|
121
|
+
"requestId": "<$ faker.uuid $>"
|
|
122
|
+
},
|
|
123
|
+
"assert": {
|
|
124
|
+
"status": 201,
|
|
125
|
+
"body": { "id": "defined" }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 🛡️ Security & Hardening (Raptor Guard v2.0)
|
|
135
|
+
|
|
136
|
+
* **SSRF Prevention:** Blocks requests to IPv4/IPv6 link-local addresses, link-local multicast, and known cloud metadata IP ranges (`169.254.169.254`, `100.100.100.200`).
|
|
137
|
+
* **Path Traversal Shield:** Resolves and validates scenario file paths against path traversal sequences (`../`, null bytes, system files).
|
|
138
|
+
* **Hermetic Execution:** 100% offline self-testing suite with embedded ephemeral mock servers.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 📜 Origin & Credits
|
|
143
|
+
|
|
144
|
+
* Based on the concept of [`eykrehbein/strest`](https://github.com/eykrehbein/strest) (1.7k+ ⭐, MIT).
|
|
145
|
+
* Modernized, decoupled, SSRF-hardened, and transformed into an autonomous Model Context Protocol server by Christopher Hailfinger ([@tastenkasperle](https://github.com/tastenkasperle)) as part of the **Diamantenschmiede** initiative.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## 📄 License
|
|
150
|
+
|
|
151
|
+
MIT License — see [LICENSE](LICENSE) for details.
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strest-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Declarative Multi-Step REST API Test & Chaining Engine for AI Agents and DevSecOps",
|
|
5
|
+
"main": "src/index.mjs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"strest": "src/cli/index.mjs",
|
|
8
|
+
"strest-mcp": "src/mcp/server.mjs"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node tests/run_all.mjs",
|
|
13
|
+
"start": "node src/mcp/server.mjs",
|
|
14
|
+
"run": "node src/cli/index.mjs run"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"strest",
|
|
20
|
+
"api-testing",
|
|
21
|
+
"rest-client",
|
|
22
|
+
"chaining",
|
|
23
|
+
"ai-agents",
|
|
24
|
+
"devsecops",
|
|
25
|
+
"ssrf-protection",
|
|
26
|
+
"automation"
|
|
27
|
+
],
|
|
28
|
+
"author": "Christopher Hailfinger (Diamantenschmiede)",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/tastenkasperle/strest-mcp.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/tastenkasperle/strest-mcp/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/tastenkasperle/strest-mcp#readme"
|
|
38
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"name": "strest-mcp",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Declarative Multi-Step REST API Test & Chaining Engine for AI Agents and DevSecOps",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Diamantenschmiede",
|
|
8
|
+
"url": "https://github.com/tastenkasperle"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/tastenkasperle/strest-mcp",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/tastenkasperle/strest-mcp.git"
|
|
14
|
+
},
|
|
15
|
+
"capabilities": {
|
|
16
|
+
"tools": true
|
|
17
|
+
},
|
|
18
|
+
"transport": {
|
|
19
|
+
"type": "stdio",
|
|
20
|
+
"command": "node",
|
|
21
|
+
"args": [
|
|
22
|
+
"src/mcp/server.mjs"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
}
|
package/smithery.yaml
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Strest Standalone CLI Interface
|
|
4
|
+
* Part of Diamantenschmiede (AYA Protocol)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import { executeScenario } from '../core/executor.mjs';
|
|
9
|
+
import { SecurityGuard } from '../core/guard.mjs';
|
|
10
|
+
|
|
11
|
+
function printHelp() {
|
|
12
|
+
console.log(`
|
|
13
|
+
⚡ Strest Orchestrator CLI – Headless Multi-Step REST Engine
|
|
14
|
+
|
|
15
|
+
VERWENDUNG:
|
|
16
|
+
strest <command> [options]
|
|
17
|
+
|
|
18
|
+
BEFEHLE:
|
|
19
|
+
run <scenario.json> Führt ein mehrstufiges API-Szenario aus
|
|
20
|
+
Optionen: --bail (Stop bei erstem Fehler, Standard: true)
|
|
21
|
+
|
|
22
|
+
template Gibt ein Beispiel-Szenario auf stdout aus
|
|
23
|
+
|
|
24
|
+
help Zeigt diese Hilfe an
|
|
25
|
+
`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function handleRun(args) {
|
|
29
|
+
const filePath = args[0];
|
|
30
|
+
if (!filePath) {
|
|
31
|
+
console.error('❌ Fehler: Bitte Pfad zur scenario.json angeben.');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const safePath = SecurityGuard.validateSafePath(filePath);
|
|
36
|
+
if (!fs.existsSync(safePath)) {
|
|
37
|
+
console.error(`❌ Fehler: Datei nicht gefunden: ${safePath}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const raw = fs.readFileSync(safePath, 'utf-8');
|
|
42
|
+
const scenario = JSON.parse(raw);
|
|
43
|
+
|
|
44
|
+
console.log(`🚀 Führe Szenario aus: '${scenario.name || safePath}'...`);
|
|
45
|
+
const result = await executeScenario(scenario);
|
|
46
|
+
|
|
47
|
+
console.log('\n--- ERGEBNIS ---');
|
|
48
|
+
console.log(`Status: ${result.success ? '✅ ERFOLGREICH' : '❌ FEHLGESCHLAGEN'}`);
|
|
49
|
+
console.log(`Schritte: ${result.executedSteps} / ${result.totalSteps}`);
|
|
50
|
+
console.log(`Dauer: ${result.durationMs}ms\n`);
|
|
51
|
+
|
|
52
|
+
for (const step of result.steps) {
|
|
53
|
+
const symbol = step.success ? '✔' : '✖';
|
|
54
|
+
console.log(`${symbol} [${step.request.method}] ${step.stepName} (${step.response?.status || 'ERR'} in ${step.response?.durationMs || 0}ms)`);
|
|
55
|
+
if (!step.success && step.errors) {
|
|
56
|
+
for (const err of step.errors) {
|
|
57
|
+
console.log(` └─ 🚨 ${err}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!result.success) {
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function handleTemplate() {
|
|
68
|
+
const template = {
|
|
69
|
+
name: "Sample Multi-Step Flow",
|
|
70
|
+
steps: [
|
|
71
|
+
{
|
|
72
|
+
name: "login",
|
|
73
|
+
url: "http://localhost:3000/api/login",
|
|
74
|
+
method: "POST",
|
|
75
|
+
body: { username: "admin", password: "password" },
|
|
76
|
+
assert: { status: 200, body: { token: "defined" } }
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "get_user",
|
|
80
|
+
url: "http://localhost:3000/api/users/me",
|
|
81
|
+
method: "GET",
|
|
82
|
+
headers: { authorization: "Bearer <$ steps.login.body.token $>" },
|
|
83
|
+
assert: { status: 200 }
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
};
|
|
87
|
+
console.log(JSON.stringify(template, null, 2));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function main() {
|
|
91
|
+
const argv = process.argv.slice(2);
|
|
92
|
+
const command = argv[0];
|
|
93
|
+
const args = argv.slice(1);
|
|
94
|
+
|
|
95
|
+
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
96
|
+
printHelp();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
switch (command) {
|
|
101
|
+
case 'run':
|
|
102
|
+
await handleRun(args);
|
|
103
|
+
break;
|
|
104
|
+
case 'template':
|
|
105
|
+
handleTemplate();
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
console.error(`❌ Unbekannter Befehl: ${command}`);
|
|
109
|
+
printHelp();
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
main().catch((err) => {
|
|
115
|
+
console.error('❌ Fehler:', err.message);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strest Assertion Validator
|
|
3
|
+
* Part of Diamantenschmiede (AYA Protocol)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getByPath } from './interpolator.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validates step response against assertions
|
|
10
|
+
* @param {Object} response { status: number, headers: {}, body: any }
|
|
11
|
+
* @param {Object} assertions
|
|
12
|
+
* @returns {{ passed: boolean, errors: string[] }}
|
|
13
|
+
*/
|
|
14
|
+
export function validateAssertions(response, assertions = {}) {
|
|
15
|
+
const errors = [];
|
|
16
|
+
|
|
17
|
+
// 1. Status Code Check
|
|
18
|
+
if (assertions.status !== undefined) {
|
|
19
|
+
if (response.status !== assertions.status) {
|
|
20
|
+
errors.push(`Expected HTTP status ${assertions.status} but got ${response.status}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 2. Status Range Check (e.g. statusRange: [200, 299])
|
|
25
|
+
if (Array.isArray(assertions.statusRange) && assertions.statusRange.length === 2) {
|
|
26
|
+
const [min, max] = assertions.statusRange;
|
|
27
|
+
if (response.status < min || response.status > max) {
|
|
28
|
+
errors.push(`Status ${response.status} outside expected range [${min}, ${max}]`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 3. Header Checks
|
|
33
|
+
if (assertions.headers && typeof assertions.headers === 'object') {
|
|
34
|
+
for (const [key, expectedVal] of Object.entries(assertions.headers)) {
|
|
35
|
+
const actualVal = response.headers[key.toLowerCase()];
|
|
36
|
+
if (actualVal === undefined) {
|
|
37
|
+
errors.push(`Expected header '${key}' is missing`);
|
|
38
|
+
} else if (expectedVal !== '*' && !String(actualVal).includes(String(expectedVal))) {
|
|
39
|
+
errors.push(`Header '${key}' value '${actualVal}' does not match '${expectedVal}'`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 4. Body Property Assertions (e.g. body: { "token": "string", "user.id": 42 })
|
|
45
|
+
if (assertions.body && typeof assertions.body === 'object') {
|
|
46
|
+
for (const [propPath, expectedVal] of Object.entries(assertions.body)) {
|
|
47
|
+
const actualVal = getByPath(response.body, propPath);
|
|
48
|
+
|
|
49
|
+
if (expectedVal === 'defined') {
|
|
50
|
+
if (actualVal === undefined || actualVal === null) {
|
|
51
|
+
errors.push(`Body property '${propPath}' must be defined`);
|
|
52
|
+
}
|
|
53
|
+
} else if (expectedVal === 'string') {
|
|
54
|
+
if (typeof actualVal !== 'string') {
|
|
55
|
+
errors.push(`Body property '${propPath}' must be a string, got ${typeof actualVal}`);
|
|
56
|
+
}
|
|
57
|
+
} else if (expectedVal === 'number') {
|
|
58
|
+
if (typeof actualVal !== 'number') {
|
|
59
|
+
errors.push(`Body property '${propPath}' must be a number, got ${typeof actualVal}`);
|
|
60
|
+
}
|
|
61
|
+
} else if (actualVal !== expectedVal) {
|
|
62
|
+
errors.push(`Body property '${propPath}' expected '${expectedVal}' but got '${actualVal}'`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
passed: errors.length === 0,
|
|
69
|
+
errors
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strest Headless Execution Engine
|
|
3
|
+
* Part of Diamantenschmiede (AYA Protocol)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { interpolateDeep } from './interpolator.mjs';
|
|
7
|
+
import { validateAssertions } from './assert.mjs';
|
|
8
|
+
import { SecurityGuard } from './guard.mjs';
|
|
9
|
+
|
|
10
|
+
const MAX_REDIRECTS = 5;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Executes a single HTTP step with SSRF-safe redirect handling and timeout protection
|
|
14
|
+
* @param {Object} stepDefinition
|
|
15
|
+
* @param {Object} context { steps: {}, env: {} }
|
|
16
|
+
* @param {Object} options
|
|
17
|
+
* @returns {Promise<Object>} stepResult
|
|
18
|
+
*/
|
|
19
|
+
export async function executeStep(stepDefinition, context = {}, options = {}) {
|
|
20
|
+
// 1. Interpolate request definition with accumulated context
|
|
21
|
+
const step = interpolateDeep(stepDefinition, context);
|
|
22
|
+
|
|
23
|
+
const url = step.url;
|
|
24
|
+
const method = (step.method || 'GET').toUpperCase();
|
|
25
|
+
const headers = step.headers || {};
|
|
26
|
+
const body = step.body;
|
|
27
|
+
const allowLocalhost = options.allowLocalhost ?? true;
|
|
28
|
+
const timeoutMs = options.timeoutMs || step.timeoutMs || 15000;
|
|
29
|
+
|
|
30
|
+
// Validate initial URL with SSRF Guard
|
|
31
|
+
SecurityGuard.validateUrl(url, { allowLocalhost });
|
|
32
|
+
|
|
33
|
+
const fetchOptions = {
|
|
34
|
+
method,
|
|
35
|
+
headers: { ...headers },
|
|
36
|
+
redirect: 'manual',
|
|
37
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
|
41
|
+
if (typeof body === 'object') {
|
|
42
|
+
fetchOptions.body = JSON.stringify(body);
|
|
43
|
+
const hasContentType = Object.keys(fetchOptions.headers).some(
|
|
44
|
+
k => k.toLowerCase() === 'content-type'
|
|
45
|
+
);
|
|
46
|
+
if (!hasContentType) {
|
|
47
|
+
fetchOptions.headers['content-type'] = 'application/json';
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
fetchOptions.body = String(body);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const startTime = Date.now();
|
|
55
|
+
let response;
|
|
56
|
+
let parsedBody;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
let currentUrl = url;
|
|
60
|
+
let currentOptions = { ...fetchOptions };
|
|
61
|
+
|
|
62
|
+
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
|
|
63
|
+
response = await fetch(currentUrl, currentOptions);
|
|
64
|
+
|
|
65
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
66
|
+
const location = response.headers.get('location');
|
|
67
|
+
if (!location) break;
|
|
68
|
+
if (redirectCount === MAX_REDIRECTS) {
|
|
69
|
+
throw new Error(`Maximum redirect limit (${MAX_REDIRECTS}) exceeded`);
|
|
70
|
+
}
|
|
71
|
+
const resolvedRedirect = new URL(location, currentUrl).toString();
|
|
72
|
+
// Validate every redirect hop against SSRF Guard
|
|
73
|
+
SecurityGuard.validateUrl(resolvedRedirect, { allowLocalhost });
|
|
74
|
+
currentUrl = resolvedRedirect;
|
|
75
|
+
if (response.status === 303 || ((response.status === 301 || response.status === 302) && currentOptions.method === 'POST')) {
|
|
76
|
+
currentOptions = { ...currentOptions, method: 'GET', body: undefined };
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const durationMs = Date.now() - startTime;
|
|
84
|
+
|
|
85
|
+
const responseHeaders = {};
|
|
86
|
+
for (const [k, v] of response.headers.entries()) {
|
|
87
|
+
responseHeaders[k.toLowerCase()] = v;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const contentType = responseHeaders['content-type'] || '';
|
|
91
|
+
if (contentType.includes('application/json')) {
|
|
92
|
+
parsedBody = await response.json();
|
|
93
|
+
} else {
|
|
94
|
+
parsedBody = await response.text();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const responseObj = {
|
|
98
|
+
status: response.status,
|
|
99
|
+
statusText: response.statusText,
|
|
100
|
+
headers: responseHeaders,
|
|
101
|
+
body: parsedBody,
|
|
102
|
+
durationMs
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// 2. Validate Assertions
|
|
106
|
+
const assertionResult = validateAssertions(responseObj, step.assert || {});
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
stepName: step.name || step.id || 'anonymous_step',
|
|
110
|
+
success: assertionResult.passed,
|
|
111
|
+
errors: assertionResult.errors,
|
|
112
|
+
request: { method, url, headers: fetchOptions.headers, body },
|
|
113
|
+
response: responseObj
|
|
114
|
+
};
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return {
|
|
117
|
+
stepName: step.name || step.id || 'anonymous_step',
|
|
118
|
+
success: false,
|
|
119
|
+
errors: [err.message],
|
|
120
|
+
request: { method, url, headers: fetchOptions.headers, body },
|
|
121
|
+
response: null
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Executes a multi-step scenario
|
|
128
|
+
* @param {Object} scenario { name: string, steps: Array<Object>, env: {} }
|
|
129
|
+
* @param {Object} options
|
|
130
|
+
* @returns {Promise<Object>} scenarioResult
|
|
131
|
+
*/
|
|
132
|
+
export async function executeScenario(scenario, options = {}) {
|
|
133
|
+
const steps = scenario.steps || [];
|
|
134
|
+
const context = {
|
|
135
|
+
steps: {},
|
|
136
|
+
env: { ...(scenario.env || {}), ...(options.env || {}) },
|
|
137
|
+
allowProcessEnv: options.allowProcessEnv === true
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const results = [];
|
|
141
|
+
let scenarioSuccess = true;
|
|
142
|
+
const startTime = Date.now();
|
|
143
|
+
|
|
144
|
+
for (const stepDef of steps) {
|
|
145
|
+
const stepResult = await executeStep(stepDef, context, options);
|
|
146
|
+
results.push(stepResult);
|
|
147
|
+
|
|
148
|
+
// Save step response into context for downstream steps
|
|
149
|
+
const stepKey = stepDef.name || stepDef.id;
|
|
150
|
+
if (stepKey) {
|
|
151
|
+
context.steps[stepKey] = stepResult.response;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!stepResult.success) {
|
|
155
|
+
scenarioSuccess = false;
|
|
156
|
+
if (options.bail !== false) {
|
|
157
|
+
// Stop execution on first failed step (bail mode)
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
scenarioName: scenario.name || 'Unnamed Scenario',
|
|
165
|
+
success: scenarioSuccess,
|
|
166
|
+
totalSteps: steps.length,
|
|
167
|
+
executedSteps: results.length,
|
|
168
|
+
durationMs: Date.now() - startTime,
|
|
169
|
+
steps: results
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strest SecurityGuard v2.0
|
|
3
|
+
* SSRF & Malicious Input Defense (AYA Protocol / Fire Team Elite Certified)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'path';
|
|
7
|
+
|
|
8
|
+
// Dangerous SSRF / Cloud Metadata Hosts
|
|
9
|
+
const METADATA_HOSTS = new Set([
|
|
10
|
+
'169.254.169.254', // AWS / Azure / OpenStack IMDSv1/v2
|
|
11
|
+
'metadata.google.internal', // GCP Metadata
|
|
12
|
+
'metadata',
|
|
13
|
+
'100.100.100.200', // Alibaba Cloud Metadata
|
|
14
|
+
'instance-data',
|
|
15
|
+
'[fd00:ec2::254]', // AWS IPv6 IMDS
|
|
16
|
+
'fd00:ec2::254'
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export class SecurityGuard {
|
|
20
|
+
/**
|
|
21
|
+
* Validates target URL against SSRF and Cloud Metadata Exfiltration
|
|
22
|
+
* @param {string} rawUrl
|
|
23
|
+
* @param {Object} options
|
|
24
|
+
* @param {boolean} [options.allowLocalhost=true] Allow testing local dev servers (e.g. localhost:5000, 127.0.0.1)
|
|
25
|
+
* @returns {URL} Validated URL object
|
|
26
|
+
*/
|
|
27
|
+
static validateUrl(rawUrl, options = { allowLocalhost: true }) {
|
|
28
|
+
if (!rawUrl || typeof rawUrl !== 'string') {
|
|
29
|
+
throw new Error('Security Violation: Invalid URL provided');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = new URL(rawUrl);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
throw new Error(`Security Violation: Malformed URL: ${rawUrl}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
40
|
+
throw new Error(`Security Violation: Prohibited protocol '${parsed.protocol}'. Only http/https allowed.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
44
|
+
|
|
45
|
+
// Check Cloud Metadata Endpoints & Link-Local Ranges (Strict block, cannot be bypassed)
|
|
46
|
+
if (
|
|
47
|
+
METADATA_HOSTS.has(hostname) ||
|
|
48
|
+
hostname.startsWith('169.254.') ||
|
|
49
|
+
hostname.includes('ffff:169.254.') ||
|
|
50
|
+
hostname.includes('ffff:a9fe:a9fe')
|
|
51
|
+
) {
|
|
52
|
+
throw new Error(`Security Violation: Access to cloud instance metadata service (${hostname}) is blocked.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!options.allowLocalhost) {
|
|
56
|
+
if (
|
|
57
|
+
hostname === 'localhost' ||
|
|
58
|
+
hostname === '127.0.0.1' ||
|
|
59
|
+
hostname === '::1' ||
|
|
60
|
+
hostname === '[::1]' ||
|
|
61
|
+
hostname === '0.0.0.0' ||
|
|
62
|
+
hostname.startsWith('127.')
|
|
63
|
+
) {
|
|
64
|
+
throw new Error('Security Violation: Localhost access restricted in this environment.');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validates safe file path against path traversal and sensitive credential exposure
|
|
73
|
+
* @param {string} targetPath
|
|
74
|
+
* @param {string} allowedBaseDir
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
static validateSafePath(targetPath, allowedBaseDir = process.cwd()) {
|
|
78
|
+
if (!targetPath || typeof targetPath !== 'string') {
|
|
79
|
+
throw new Error('Security Violation: Invalid path argument');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (targetPath.includes('\0')) {
|
|
83
|
+
throw new Error('Security Violation: Null byte detected in path');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const base = path.resolve(allowedBaseDir);
|
|
87
|
+
const resolved = path.resolve(base, targetPath);
|
|
88
|
+
const basename = path.basename(resolved);
|
|
89
|
+
|
|
90
|
+
// Block sensitive secrets first
|
|
91
|
+
if (
|
|
92
|
+
/^\.env(\..+)?$/i.test(basename) ||
|
|
93
|
+
/^id_(rsa|ed25519|ecdsa)/i.test(basename) ||
|
|
94
|
+
/masterpw/i.test(basename) ||
|
|
95
|
+
/credentials/i.test(basename)
|
|
96
|
+
) {
|
|
97
|
+
throw new Error(`Security Violation: Protected credential file blocked: ${basename}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Strict containment check inside allowedBaseDir
|
|
101
|
+
const rel = path.relative(base, resolved);
|
|
102
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
103
|
+
throw new Error(`Security Violation: Path traversal outside allowed workspace blocked: ${targetPath}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return resolved;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strest Data Interpolator & Chaining Engine
|
|
3
|
+
* Part of Diamantenschmiede (AYA Protocol)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
|
|
8
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolves dot-notated path in an object (e.g. "steps.login.body.token")
|
|
12
|
+
* @param {Object} obj
|
|
13
|
+
* @param {string} path
|
|
14
|
+
* @returns {any}
|
|
15
|
+
*/
|
|
16
|
+
export function getByPath(obj, path) {
|
|
17
|
+
if (!obj || !path || typeof path !== 'string') return undefined;
|
|
18
|
+
const parts = path.split('.');
|
|
19
|
+
let current = obj;
|
|
20
|
+
for (const part of parts) {
|
|
21
|
+
if (current === null || current === undefined) return undefined;
|
|
22
|
+
if (FORBIDDEN_KEYS.has(part)) return undefined;
|
|
23
|
+
if (typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, part)) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
current = current[part];
|
|
27
|
+
}
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generates synthetic helper data
|
|
33
|
+
*/
|
|
34
|
+
export const SYNTHETIC_HELPERS = {
|
|
35
|
+
uuid: () => crypto.randomUUID(),
|
|
36
|
+
timestamp: () => Date.now(),
|
|
37
|
+
isoDate: () => new Date().toISOString(),
|
|
38
|
+
randomInt: (min = 1, max = 1000) => Math.floor(Math.random() * (max - min + 1)) + min
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Interpolates string with `<$ expression $>` patterns
|
|
43
|
+
* @param {string} template
|
|
44
|
+
* @param {Object} context { steps: {}, env: {} }
|
|
45
|
+
* @returns {any}
|
|
46
|
+
*/
|
|
47
|
+
export function interpolateString(template, context = {}) {
|
|
48
|
+
if (typeof template !== 'string') return template;
|
|
49
|
+
|
|
50
|
+
// Single exact match replacement (preserving numbers/objects/booleans)
|
|
51
|
+
const singleMatch = template.match(/^<\$\s*([a-zA-Z0-9_.]+)\s*\$>$/);
|
|
52
|
+
if (singleMatch) {
|
|
53
|
+
const expr = singleMatch[1];
|
|
54
|
+
return resolveExpression(expr, context);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Multi-match substring replacement
|
|
58
|
+
return template.replace(/<\$\s*([a-zA-Z0-9_.]+)\s*\$>/g, (_, expr) => {
|
|
59
|
+
const val = resolveExpression(expr, context);
|
|
60
|
+
return val !== undefined ? String(val) : '';
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolves expression against context or helpers
|
|
66
|
+
* @param {string} expr
|
|
67
|
+
* @param {Object} context
|
|
68
|
+
* @returns {any}
|
|
69
|
+
*/
|
|
70
|
+
function resolveExpression(expr, context) {
|
|
71
|
+
if (expr.startsWith('faker.') || expr.startsWith('helper.')) {
|
|
72
|
+
const helperName = expr.split('.')[1];
|
|
73
|
+
if (Object.prototype.hasOwnProperty.call(SYNTHETIC_HELPERS, helperName)) {
|
|
74
|
+
return SYNTHETIC_HELPERS[helperName]();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (expr.startsWith('env.')) {
|
|
79
|
+
const envKey = expr.slice(4);
|
|
80
|
+
if (FORBIDDEN_KEYS.has(envKey)) return undefined;
|
|
81
|
+
if (context?.env && Object.prototype.hasOwnProperty.call(context.env, envKey)) {
|
|
82
|
+
return context.env[envKey];
|
|
83
|
+
}
|
|
84
|
+
if (context?.allowProcessEnv === true) {
|
|
85
|
+
return process.env[envKey];
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return getByPath(context, expr);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Recursively interpolates objects, arrays, and strings
|
|
95
|
+
* @param {any} target
|
|
96
|
+
* @param {Object} context
|
|
97
|
+
* @returns {any}
|
|
98
|
+
*/
|
|
99
|
+
export function interpolateDeep(target, context = {}) {
|
|
100
|
+
if (typeof target === 'string') {
|
|
101
|
+
return interpolateString(target, context);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (Array.isArray(target)) {
|
|
105
|
+
return target.map(item => interpolateDeep(item, context));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (target !== null && typeof target === 'object') {
|
|
109
|
+
const result = {};
|
|
110
|
+
for (const [key, value] of Object.entries(target)) {
|
|
111
|
+
if (FORBIDDEN_KEYS.has(key)) continue;
|
|
112
|
+
result[key] = interpolateDeep(value, context);
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return target;
|
|
118
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strest Orchestrator - Main Package Entry Point
|
|
3
|
+
* Part of Diamantenschmiede (AYA Protocol)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { executeScenario, executeStep } from './core/executor.mjs';
|
|
7
|
+
export {
|
|
8
|
+
interpolateDeep,
|
|
9
|
+
interpolateDeep as interpolate,
|
|
10
|
+
interpolateString,
|
|
11
|
+
getByPath,
|
|
12
|
+
SYNTHETIC_HELPERS
|
|
13
|
+
} from './core/interpolator.mjs';
|
|
14
|
+
export {
|
|
15
|
+
validateAssertions,
|
|
16
|
+
validateAssertions as assertResponse
|
|
17
|
+
} from './core/assert.mjs';
|
|
18
|
+
export { SecurityGuard } from './core/guard.mjs';
|
|
19
|
+
export { handleRequest } from './mcp/server.mjs';
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Strest MCP Server (Model Context Protocol)
|
|
4
|
+
* Native Stdio JSON-RPC 2.0 Server for Local AI Agents
|
|
5
|
+
* Part of Diamantenschmiede (AYA Protocol / Gang 3)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import readline from 'readline';
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import { executeScenario } from '../core/executor.mjs';
|
|
11
|
+
import { SecurityGuard } from '../core/guard.mjs';
|
|
12
|
+
|
|
13
|
+
const SERVER_NAME = 'strest-mcp';
|
|
14
|
+
const SERVER_VERSION = '1.0.0';
|
|
15
|
+
|
|
16
|
+
const TOOLS = [
|
|
17
|
+
{
|
|
18
|
+
name: 'strest_run_scenario',
|
|
19
|
+
description: 'Executes a declarative multi-step REST API workflow scenario with automatic variable chaining and schema assertions.',
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
scenario: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
description: 'The scenario definition object containing steps and assertions'
|
|
26
|
+
},
|
|
27
|
+
scenarioPath: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Path to a scenario JSON file on disk'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'strest_template',
|
|
36
|
+
description: 'Generates a ready-to-use template for multi-step authentication and CRUD chaining.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
baseUrl: { type: 'string', description: 'Base URL for the scenario (e.g. http://localhost:5000)' }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export async function handleRequest(request) {
|
|
47
|
+
const { id, method, params } = request;
|
|
48
|
+
|
|
49
|
+
if (
|
|
50
|
+
method === 'notifications/initialized' ||
|
|
51
|
+
method === 'initialized' ||
|
|
52
|
+
(typeof method === 'string' && method.startsWith('notifications/')) ||
|
|
53
|
+
id === undefined
|
|
54
|
+
) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (method === 'initialize') {
|
|
59
|
+
return {
|
|
60
|
+
jsonrpc: '2.0',
|
|
61
|
+
id,
|
|
62
|
+
result: {
|
|
63
|
+
protocolVersion: '2024-11-05',
|
|
64
|
+
capabilities: { tools: {} },
|
|
65
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (method === 'ping') {
|
|
71
|
+
return {
|
|
72
|
+
jsonrpc: '2.0',
|
|
73
|
+
id,
|
|
74
|
+
result: {}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (method === 'tools/list') {
|
|
79
|
+
return {
|
|
80
|
+
jsonrpc: '2.0',
|
|
81
|
+
id,
|
|
82
|
+
result: { tools: TOOLS }
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (method === 'tools/call') {
|
|
87
|
+
const { name, arguments: args = {} } = params;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
if (name === 'strest_run_scenario') {
|
|
91
|
+
let scenarioObj;
|
|
92
|
+
if (args.scenario) {
|
|
93
|
+
scenarioObj = args.scenario;
|
|
94
|
+
} else if (args.scenarioPath) {
|
|
95
|
+
const safePath = SecurityGuard.validateSafePath(args.scenarioPath);
|
|
96
|
+
scenarioObj = JSON.parse(fs.readFileSync(safePath, 'utf-8'));
|
|
97
|
+
} else {
|
|
98
|
+
throw new Error('Either scenario object or scenarioPath must be provided');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = await executeScenario(scenarioObj);
|
|
102
|
+
return {
|
|
103
|
+
jsonrpc: '2.0',
|
|
104
|
+
id,
|
|
105
|
+
result: {
|
|
106
|
+
content: [{
|
|
107
|
+
type: 'text',
|
|
108
|
+
text: JSON.stringify(result, null, 2)
|
|
109
|
+
}]
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (name === 'strest_template') {
|
|
115
|
+
const base = args.baseUrl || 'http://127.0.0.1:5000';
|
|
116
|
+
const template = {
|
|
117
|
+
name: "Generated Multi-Step Scenario",
|
|
118
|
+
steps: [
|
|
119
|
+
{
|
|
120
|
+
name: "auth_step",
|
|
121
|
+
url: `${base}/api/auth/login`,
|
|
122
|
+
method: "POST",
|
|
123
|
+
body: { username: "admin", password: "password123" },
|
|
124
|
+
assert: { status: 200, body: { token: "defined" } }
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "query_step",
|
|
128
|
+
url: `${base}/api/data`,
|
|
129
|
+
method: "GET",
|
|
130
|
+
headers: { authorization: "Bearer <$ steps.auth_step.body.token $>" },
|
|
131
|
+
assert: { status: 200 }
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
jsonrpc: '2.0',
|
|
137
|
+
id,
|
|
138
|
+
result: {
|
|
139
|
+
content: [{
|
|
140
|
+
type: 'text',
|
|
141
|
+
text: JSON.stringify(template, null, 2)
|
|
142
|
+
}]
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return {
|
|
150
|
+
jsonrpc: '2.0',
|
|
151
|
+
id,
|
|
152
|
+
error: { code: -32000, message: err.message }
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
jsonrpc: '2.0',
|
|
159
|
+
id,
|
|
160
|
+
error: { code: -32601, message: `Method not found: ${method}` }
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (process.argv[1] && process.argv[1].endsWith('server.mjs')) {
|
|
165
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
166
|
+
|
|
167
|
+
rl.on('line', async (line) => {
|
|
168
|
+
if (!line.trim()) return;
|
|
169
|
+
try {
|
|
170
|
+
const request = JSON.parse(line);
|
|
171
|
+
const response = await handleRequest(request);
|
|
172
|
+
if (response !== null && response !== undefined) {
|
|
173
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
process.stdout.write(JSON.stringify({
|
|
177
|
+
jsonrpc: '2.0',
|
|
178
|
+
id: null,
|
|
179
|
+
error: { code: -32700, message: 'Parse error: ' + err.message }
|
|
180
|
+
}) + '\n');
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|