@strixgov/mcp-proxy 0.2.0 → 0.2.2
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/CHANGELOG.md +18 -0
- package/examples/youspot/README.md +58 -0
- package/examples/youspot-proxy-config.json +236 -0
- package/package.json +8 -2
- package/scripts/preflight.mjs +109 -0
- package/scripts/run-tests.mjs +56 -0
- package/test/cli.test.mjs +46 -0
- package/test/config.test.mjs +138 -0
- package/test/nvidia-nooa-profile.test.mjs +69 -0
- package/test/package-contract.test.mjs +20 -0
- package/test/proxy-end-to-end.test.mjs +715 -0
- package/test/shadow-discovery-e2e.test.mjs +185 -0
- package/test/shadow-discovery.test.mjs +137 -0
- package/test/webhook-approver.test.mjs +197 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.2]
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- The published package now ships its test suite (`test/`, except the monorepo-only
|
|
14
|
+
`installed-tarball.test.mjs`) and `scripts/preflight.mjs`, so `npm test` runs in an installed
|
|
15
|
+
copy. `scripts/run-tests.mjs` exits non-zero if no test files are found, if the runner reports
|
|
16
|
+
zero or skipped-only tests, or if fewer than 60 tests run (Node counts an empty test file as one
|
|
17
|
+
passing test).
|
|
18
|
+
|
|
19
|
+
## [0.2.1]
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- `npm test` in the published package crashed with a missing
|
|
23
|
+
`scripts/preflight.mjs` (the tarball did not ship `scripts/`). `test` now runs
|
|
24
|
+
`scripts/run-tests.mjs`, which runs the suite in a source checkout and, in an
|
|
25
|
+
installed copy, exits non-zero with an explanation rather than crashing or
|
|
26
|
+
passing on zero tests. Reported by an external tester.
|
|
27
|
+
|
|
10
28
|
## [0.2.0]
|
|
11
29
|
|
|
12
30
|
Minor, not a patch: this release carries the already-merged shadow-discovery
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Govern YouSpot with the Strix MCP proxy
|
|
2
|
+
|
|
3
|
+
This example uses YouSpot's official stdio bridge as the upstream server:
|
|
4
|
+
|
|
5
|
+
`MCP host -> @strixgov/mcp-proxy -> @youspot/mcp -> https://youspot.com/mcp/v1`
|
|
6
|
+
|
|
7
|
+
No YouSpot tool implementation is changed.
|
|
8
|
+
|
|
9
|
+
## 1. Provide the YouSpot token to the host environment
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
export YOUSPOT_TOKEN="..."
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`@youspot/mcp` requires the token for `tools/call`. The example config intentionally does not store it; the proxy passes the process environment through to the upstream when `upstream.env` is omitted.
|
|
16
|
+
|
|
17
|
+
## 2. Point the MCP host at Strix
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"youspot-governed": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": [
|
|
25
|
+
"-y",
|
|
26
|
+
"@strixgov/mcp-proxy",
|
|
27
|
+
"--config",
|
|
28
|
+
"/absolute/path/to/youspot-proxy-config.json"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The same shape works in stdio-capable hosts that accept command/args MCP definitions. Use the host's normal secure environment mechanism for `YOUSPOT_TOKEN`.
|
|
36
|
+
|
|
37
|
+
## 3. What the starter policy does
|
|
38
|
+
|
|
39
|
+
- reviewed read tools -> explicit ALLOW rules
|
|
40
|
+
- every other LOW/MEDIUM/HIGH tool -> APPROVAL_REQUIRED
|
|
41
|
+
- CRITICAL -> DENY
|
|
42
|
+
- policy default -> DENY
|
|
43
|
+
|
|
44
|
+
The explicit rules are intentional. `@strixgov/mcp-proxy` documents name heuristics as a discovery fallback, not a production security boundary. A YouSpot tool added after this example was reviewed therefore does not inherit an automatic LOW/ALLOW path.
|
|
45
|
+
- bundled approval channel -> disabled
|
|
46
|
+
|
|
47
|
+
With approval disabled, any call that evaluates to `APPROVAL_REQUIRED` is refused rather than auto-approved. Configure a separate approver only when the response channel is not writable by the acting agent.
|
|
48
|
+
|
|
49
|
+
## 4. Verify
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx @strixgov/verifier chain ~/.strix-mcp-proxy/youspot/receipts.jsonl \
|
|
53
|
+
--jwks ~/.strix-mcp-proxy/youspot/keys/public-jwk.json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Coverage boundary
|
|
57
|
+
|
|
58
|
+
Only traffic routed through this local proxy is governed. Direct `https://youspot.com/mcp/v1` connections remain outside this example. Remote-HTTP-only clients require a hosted Strix gateway.
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
{
|
|
2
|
+
"serverId": "youspot",
|
|
3
|
+
"upstream": {
|
|
4
|
+
"command": "npx",
|
|
5
|
+
"args": [
|
|
6
|
+
"-y",
|
|
7
|
+
"@youspot/mcp"
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
"capabilities": [
|
|
11
|
+
{
|
|
12
|
+
"id": "mcp.youspot.get_graph_object",
|
|
13
|
+
"name": "get_graph_object",
|
|
14
|
+
"risk": "LOW",
|
|
15
|
+
"mode": "READ",
|
|
16
|
+
"description": "Read one object from the YouSpot graph."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "mcp.youspot.list_graph_objects",
|
|
20
|
+
"name": "list_graph_objects",
|
|
21
|
+
"risk": "LOW",
|
|
22
|
+
"mode": "READ",
|
|
23
|
+
"description": "List objects from the YouSpot graph."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "mcp.youspot.search_graph_objects",
|
|
27
|
+
"name": "search_graph_objects",
|
|
28
|
+
"risk": "LOW",
|
|
29
|
+
"mode": "READ",
|
|
30
|
+
"description": "Search the YouSpot graph."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "mcp.youspot.get_gmail_message",
|
|
34
|
+
"name": "get_gmail_message",
|
|
35
|
+
"risk": "LOW",
|
|
36
|
+
"mode": "READ",
|
|
37
|
+
"description": "Read one Gmail message."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "mcp.youspot.search_gmail_messages",
|
|
41
|
+
"name": "search_gmail_messages",
|
|
42
|
+
"risk": "LOW",
|
|
43
|
+
"mode": "READ",
|
|
44
|
+
"description": "Search connected Gmail mailboxes."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"id": "mcp.youspot.list_gmail_labels",
|
|
48
|
+
"name": "list_gmail_labels",
|
|
49
|
+
"risk": "LOW",
|
|
50
|
+
"mode": "READ",
|
|
51
|
+
"description": "List Gmail labels."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "mcp.youspot.get_calendar_events",
|
|
55
|
+
"name": "get_calendar_events",
|
|
56
|
+
"risk": "LOW",
|
|
57
|
+
"mode": "READ",
|
|
58
|
+
"description": "Read calendar events."
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "mcp.youspot.list_google_calendars",
|
|
62
|
+
"name": "list_google_calendars",
|
|
63
|
+
"risk": "LOW",
|
|
64
|
+
"mode": "READ",
|
|
65
|
+
"description": "List Google calendars."
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "mcp.youspot.list_slack_channels",
|
|
69
|
+
"name": "list_slack_channels",
|
|
70
|
+
"risk": "LOW",
|
|
71
|
+
"mode": "READ",
|
|
72
|
+
"description": "List Slack channels known to YouSpot."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "mcp.youspot.get_slack_thread",
|
|
76
|
+
"name": "get_slack_thread",
|
|
77
|
+
"risk": "LOW",
|
|
78
|
+
"mode": "READ",
|
|
79
|
+
"description": "Read a saved Slack thread."
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"id": "mcp.youspot.search_slack_messages",
|
|
83
|
+
"name": "search_slack_messages",
|
|
84
|
+
"risk": "LOW",
|
|
85
|
+
"mode": "READ",
|
|
86
|
+
"description": "Search saved Slack messages."
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"id": "mcp.youspot.list_integrations",
|
|
90
|
+
"name": "list_integrations",
|
|
91
|
+
"risk": "LOW",
|
|
92
|
+
"mode": "READ",
|
|
93
|
+
"description": "List connected integrations."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"id": "mcp.youspot.add_slack_reaction",
|
|
97
|
+
"name": "add_slack_reaction",
|
|
98
|
+
"risk": "MEDIUM",
|
|
99
|
+
"mode": "WRITE",
|
|
100
|
+
"description": "Add a reaction to a Slack message."
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"id": "mcp.youspot.archive_gmail_message",
|
|
104
|
+
"name": "archive_gmail_message",
|
|
105
|
+
"risk": "MEDIUM",
|
|
106
|
+
"mode": "WRITE",
|
|
107
|
+
"description": "Archive a Gmail message."
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"id": "mcp.youspot.connect_graph_objects",
|
|
111
|
+
"name": "connect_graph_objects",
|
|
112
|
+
"risk": "MEDIUM",
|
|
113
|
+
"mode": "WRITE",
|
|
114
|
+
"description": "Create a directed edge between graph objects."
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"id": "mcp.youspot.create_gmail_draft",
|
|
118
|
+
"name": "create_gmail_draft",
|
|
119
|
+
"risk": "MEDIUM",
|
|
120
|
+
"mode": "WRITE",
|
|
121
|
+
"description": "Create a Gmail draft."
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"id": "mcp.youspot.create_graph_object",
|
|
125
|
+
"name": "create_graph_object",
|
|
126
|
+
"risk": "MEDIUM",
|
|
127
|
+
"mode": "WRITE",
|
|
128
|
+
"description": "Create a YouSpot graph object."
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "mcp.youspot.disconnect_graph_objects",
|
|
132
|
+
"name": "disconnect_graph_objects",
|
|
133
|
+
"risk": "MEDIUM",
|
|
134
|
+
"mode": "WRITE",
|
|
135
|
+
"description": "Remove a graph-object edge."
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"id": "mcp.youspot.set_follow_up",
|
|
139
|
+
"name": "set_follow_up",
|
|
140
|
+
"risk": "MEDIUM",
|
|
141
|
+
"mode": "WRITE",
|
|
142
|
+
"description": "Create a dated follow-up fact."
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"id": "mcp.youspot.set_notification_emails",
|
|
146
|
+
"name": "set_notification_emails",
|
|
147
|
+
"risk": "MEDIUM",
|
|
148
|
+
"mode": "WRITE",
|
|
149
|
+
"description": "Change YouSpot notification-email settings."
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"id": "mcp.youspot.update_graph_object",
|
|
153
|
+
"name": "update_graph_object",
|
|
154
|
+
"risk": "MEDIUM",
|
|
155
|
+
"mode": "WRITE",
|
|
156
|
+
"description": "Update fields on a graph object."
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"id": "mcp.youspot.create_calendar_event",
|
|
160
|
+
"name": "create_calendar_event",
|
|
161
|
+
"risk": "HIGH",
|
|
162
|
+
"mode": "WRITE",
|
|
163
|
+
"description": "Create or queue a calendar event."
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"id": "mcp.youspot.send_email",
|
|
167
|
+
"name": "send_email",
|
|
168
|
+
"risk": "HIGH",
|
|
169
|
+
"mode": "WRITE",
|
|
170
|
+
"description": "Send email to the signed-in user's own address."
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"id": "mcp.youspot.send_slack_message",
|
|
174
|
+
"name": "send_slack_message",
|
|
175
|
+
"risk": "HIGH",
|
|
176
|
+
"mode": "WRITE",
|
|
177
|
+
"description": "Post a Slack message or DM."
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"id": "mcp.youspot.send_text_to_user",
|
|
181
|
+
"name": "send_text_to_user",
|
|
182
|
+
"risk": "HIGH",
|
|
183
|
+
"mode": "WRITE",
|
|
184
|
+
"description": "Send an iMessage/SMS to the user's verified phone."
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"id": "mcp.youspot.delete_graph_object",
|
|
188
|
+
"name": "delete_graph_object",
|
|
189
|
+
"risk": "CRITICAL",
|
|
190
|
+
"mode": "WRITE",
|
|
191
|
+
"description": "Delete one graph object."
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
"id": "mcp.youspot.delete_graph_objects",
|
|
195
|
+
"name": "delete_graph_objects",
|
|
196
|
+
"risk": "CRITICAL",
|
|
197
|
+
"mode": "WRITE",
|
|
198
|
+
"description": "Delete all graph objects of one type."
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
"id": "mcp.youspot.disconnect_integration",
|
|
202
|
+
"name": "disconnect_integration",
|
|
203
|
+
"risk": "CRITICAL",
|
|
204
|
+
"mode": "WRITE",
|
|
205
|
+
"description": "Disconnect an integration; may delete synced records."
|
|
206
|
+
}
|
|
207
|
+
],
|
|
208
|
+
"policy": {
|
|
209
|
+
"rules": {
|
|
210
|
+
"mcp.youspot.get_graph_object": "ALLOW",
|
|
211
|
+
"mcp.youspot.list_graph_objects": "ALLOW",
|
|
212
|
+
"mcp.youspot.search_graph_objects": "ALLOW",
|
|
213
|
+
"mcp.youspot.get_gmail_message": "ALLOW",
|
|
214
|
+
"mcp.youspot.search_gmail_messages": "ALLOW",
|
|
215
|
+
"mcp.youspot.list_gmail_labels": "ALLOW",
|
|
216
|
+
"mcp.youspot.get_calendar_events": "ALLOW",
|
|
217
|
+
"mcp.youspot.list_google_calendars": "ALLOW",
|
|
218
|
+
"mcp.youspot.list_slack_channels": "ALLOW",
|
|
219
|
+
"mcp.youspot.get_slack_thread": "ALLOW",
|
|
220
|
+
"mcp.youspot.search_slack_messages": "ALLOW",
|
|
221
|
+
"mcp.youspot.list_integrations": "ALLOW"
|
|
222
|
+
},
|
|
223
|
+
"riskOverrides": {
|
|
224
|
+
"LOW": "APPROVAL_REQUIRED",
|
|
225
|
+
"MEDIUM": "APPROVAL_REQUIRED",
|
|
226
|
+
"HIGH": "APPROVAL_REQUIRED",
|
|
227
|
+
"CRITICAL": "DENY"
|
|
228
|
+
},
|
|
229
|
+
"default": "DENY"
|
|
230
|
+
},
|
|
231
|
+
"approval": {
|
|
232
|
+
"enabled": false
|
|
233
|
+
},
|
|
234
|
+
"storagePath": "~/.strix-mcp-proxy/youspot",
|
|
235
|
+
"keyPath": "~/.strix-mcp-proxy/youspot/keys"
|
|
236
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strixgov/mcp-proxy",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Strix MCP Proxy — the distribution wedge for governed agent tool execution. Point your MCP client at the proxy instead of the underlying server; every callTool is intercepted, evaluated against policy, and emits a signed Ed25519 receipt. Designed so Mode 3 (capability-enforced) is the v0.2.0 upgrade path, not a rewrite.",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
"@strixgov/mcp-adapter": "^0.1.0",
|
|
21
21
|
"@strixgov/tool-gateway": "^0.5.0"
|
|
22
22
|
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"zod": "^3.25.76"
|
|
25
|
+
},
|
|
23
26
|
"keywords": [
|
|
24
27
|
"strix",
|
|
25
28
|
"mcp",
|
|
@@ -40,6 +43,9 @@
|
|
|
40
43
|
"files": [
|
|
41
44
|
"src/",
|
|
42
45
|
"bin/",
|
|
46
|
+
"scripts/",
|
|
47
|
+
"test/",
|
|
48
|
+
"!test/installed-tarball.test.mjs",
|
|
43
49
|
"examples/",
|
|
44
50
|
"README.md",
|
|
45
51
|
"CHANGELOG.md",
|
|
@@ -54,6 +60,6 @@
|
|
|
54
60
|
"node": ">=18.0.0"
|
|
55
61
|
},
|
|
56
62
|
"scripts": {
|
|
57
|
-
"test": "node scripts/
|
|
63
|
+
"test": "node scripts/run-tests.mjs"
|
|
58
64
|
}
|
|
59
65
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fresh-clone guard for @strixgov/mcp-proxy's test suite.
|
|
4
|
+
*
|
|
5
|
+
* Sibling of packages/strixgov-mcp-adapter/scripts/preflight.mjs. The two are
|
|
6
|
+
* deliberately duplicated rather than shared: these packages publish
|
|
7
|
+
* independently, and importing a guard across the workspace would either add a
|
|
8
|
+
* dependency edge that does not exist at runtime or require shipping the guard
|
|
9
|
+
* in the tarball. The REQUIRED list below is this package's own, not a copy.
|
|
10
|
+
*
|
|
11
|
+
* This package resolves its Strix dependencies through pnpm workspace links
|
|
12
|
+
* (`workspace:^` entries resolved in pnpm-lock.yaml as `link:../<pkg>`). In a
|
|
13
|
+
* checkout that has not been installed, those links do not exist, and
|
|
14
|
+
* `node --test` fails with a wall of ERR_MODULE_NOT_FOUND traces that name the
|
|
15
|
+
* missing specifiers but never say what to do about it. Read cold, that looks
|
|
16
|
+
* like a broken package rather than an uninstalled tree.
|
|
17
|
+
*
|
|
18
|
+
* This guard prints the one command that fixes it and exits non-zero instead.
|
|
19
|
+
* It is silent on success, so CI — which runs `pnpm install --frozen-lockfile`
|
|
20
|
+
* first — never sees extra output.
|
|
21
|
+
*
|
|
22
|
+
* Why dynamic import() and not require.resolve():
|
|
23
|
+
* tool-gateway's package.json "exports" map declares only an "import"
|
|
24
|
+
* condition and does not export "./package.json", so BOTH
|
|
25
|
+
* require.resolve("@strixgov/tool-gateway") and
|
|
26
|
+
* require.resolve("@strixgov/tool-gateway/package.json") throw even when the
|
|
27
|
+
* package is correctly installed. import() is also the exact operation src/
|
|
28
|
+
* and the tests perform, which makes a pass here a faithful predicate for
|
|
29
|
+
* "the suite can load" rather than an approximation of it.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Every specifier the suite needs in order to run.
|
|
34
|
+
*
|
|
35
|
+
* - @strixgov/tool-gateway and @strixgov/mcp-adapter are imported by
|
|
36
|
+
* src/proxy.mjs at module scope.
|
|
37
|
+
* - @modelcontextprotocol/sdk is a registry dependency, not a workspace link,
|
|
38
|
+
* but it fails the same way in an uninstalled tree and deserves the same
|
|
39
|
+
* message. The subpath is checked rather than the bare name because the SDK
|
|
40
|
+
* ships an "exports" map with no root export.
|
|
41
|
+
* - @strixgov/capabilities-mcp-common is dynamically imported by
|
|
42
|
+
* resolveCapabilities() in src/config.mjs and exercised by
|
|
43
|
+
* test/config.test.mjs, so its absence is a real test failure.
|
|
44
|
+
*
|
|
45
|
+
* Deliberately NOT listed: @strixgov/mcp-proxy itself. The only import of it
|
|
46
|
+
* lives inside a generated temp project in test/installed-tarball.test.mjs,
|
|
47
|
+
* which resolves it from that project's own install, not from this workspace.
|
|
48
|
+
*/
|
|
49
|
+
const REQUIRED = [
|
|
50
|
+
"@strixgov/tool-gateway",
|
|
51
|
+
"@strixgov/mcp-adapter",
|
|
52
|
+
"@modelcontextprotocol/sdk/client/index.js",
|
|
53
|
+
"@strixgov/capabilities-mcp-common/notion",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} specifier
|
|
58
|
+
* @returns {Promise<boolean>} true if the specifier loads.
|
|
59
|
+
*/
|
|
60
|
+
async function resolves(specifier) {
|
|
61
|
+
try {
|
|
62
|
+
await import(specifier);
|
|
63
|
+
return true;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err?.code === "ERR_MODULE_NOT_FOUND") return false;
|
|
66
|
+
// The package is present but threw while loading (a syntax error inside
|
|
67
|
+
// it, a broken transitive import). That is a real failure, but it is not
|
|
68
|
+
// the one this guard exists to explain — let the test run surface it
|
|
69
|
+
// verbatim rather than mislabelling it as "not installed".
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @type {string[]} */
|
|
75
|
+
const missing = [];
|
|
76
|
+
for (const specifier of REQUIRED) {
|
|
77
|
+
if (!(await resolves(specifier))) missing.push(specifier);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (missing.length > 0) {
|
|
81
|
+
// Deduplicate to package names: reporting several subpaths of one missing
|
|
82
|
+
// package is noise for what is a single missing install.
|
|
83
|
+
const packages = [
|
|
84
|
+
...new Set(missing.map((s) => s.split("/").slice(0, 2).join("/"))),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
process.stderr.write(
|
|
88
|
+
[
|
|
89
|
+
"",
|
|
90
|
+
" @strixgov/mcp-proxy: dependencies are not installed.",
|
|
91
|
+
"",
|
|
92
|
+
" Cannot resolve:",
|
|
93
|
+
...packages.map((p) => ` - ${p}`),
|
|
94
|
+
"",
|
|
95
|
+
" This package resolves its Strix dependencies through the pnpm",
|
|
96
|
+
" workspace (and the rest from the registry), so the repository has",
|
|
97
|
+
" to be installed once before its tests can run:",
|
|
98
|
+
"",
|
|
99
|
+
" pnpm install # from the repo root",
|
|
100
|
+
" pnpm --dir packages/strixgov-mcp-proxy test",
|
|
101
|
+
"",
|
|
102
|
+
" Nothing is wrong with the published package: installing it from npm",
|
|
103
|
+
" pulls these in automatically as regular dependencies. This only",
|
|
104
|
+
" affects running the suite from a source checkout.",
|
|
105
|
+
"",
|
|
106
|
+
].join("\n") + "\n",
|
|
107
|
+
);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `npm test` entry point for @strixgov/mcp-proxy.
|
|
4
|
+
*
|
|
5
|
+
* Works both in a source checkout and in an installed copy of the published
|
|
6
|
+
* tarball (the suite ships in the package; only test/installed-tarball.test.mjs,
|
|
7
|
+
* which needs sibling monorepo packages, is excluded from the tarball).
|
|
8
|
+
*
|
|
9
|
+
* Fails closed: a run that executes ZERO tests, finds no test files, or cannot
|
|
10
|
+
* read a test count exits non-zero. "No tests" must never read as a pass.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
17
|
+
const testDir = fileURLToPath(new URL("../test", import.meta.url));
|
|
18
|
+
const preflight = fileURLToPath(new URL("./preflight.mjs", import.meta.url));
|
|
19
|
+
|
|
20
|
+
// Node counts an empty test file as one passing test, so "> 0" is not enough:
|
|
21
|
+
// an emptied suite would still pass. Require a floor close to the real size (66 at 0.2.2).
|
|
22
|
+
const MIN_TESTS = 60;
|
|
23
|
+
|
|
24
|
+
const fail = (msg) => {
|
|
25
|
+
process.stderr.write(`\n @strixgov/mcp-proxy: ${msg}\n\n`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const files = existsSync(testDir) ? readdirSync(testDir).filter((f) => f.endsWith(".test.mjs")) : [];
|
|
30
|
+
if (files.length === 0 || !existsSync(preflight)) {
|
|
31
|
+
fail("no tests to run (test/*.test.mjs or scripts/preflight.mjs missing). Exiting non-zero: zero tests run must not read as a pass.");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pre = spawnSync(process.execPath, [preflight], { cwd: root, stdio: "inherit" });
|
|
35
|
+
if (pre.status !== 0) process.exit(pre.status ?? 1);
|
|
36
|
+
|
|
37
|
+
// Run via the node test runner and read its own summary so we can refuse a zero count.
|
|
38
|
+
const run = spawnSync(process.execPath, ["--test", ...files.map((f) => `test/${f}`)], {
|
|
39
|
+
cwd: root,
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
42
|
+
});
|
|
43
|
+
process.stdout.write(run.stdout ?? "");
|
|
44
|
+
process.stderr.write(run.stderr ?? "");
|
|
45
|
+
if (run.status !== 0) process.exit(run.status ?? 1);
|
|
46
|
+
|
|
47
|
+
const text = `${run.stdout}\n${run.stderr}`;
|
|
48
|
+
const count = (label) => {
|
|
49
|
+
const m = text.match(new RegExp(`(?:ℹ|#)\\s+${label}\\s+(\\d+)`));
|
|
50
|
+
return m ? Number(m[1]) : null;
|
|
51
|
+
};
|
|
52
|
+
const tests = count("tests");
|
|
53
|
+
const passed = count("pass");
|
|
54
|
+
if (tests === null || passed === null) fail("could not read the test runner's summary; refusing to report success.");
|
|
55
|
+
if (tests === 0 || passed === 0) fail(`test runner executed ${tests} tests (${passed} passed). Exiting non-zero: zero tests run must not read as a pass.`);
|
|
56
|
+
if (tests < MIN_TESTS) fail(`test runner executed only ${tests} tests; expected at least ${MIN_TESTS}. A truncated or emptied suite must not read as a pass.`);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI sanity tests.
|
|
3
|
+
*
|
|
4
|
+
* Doesn't run the full MCP loop (covered by proxy-end-to-end), just
|
|
5
|
+
* confirms the CLI parses args correctly, surfaces --help / --version,
|
|
6
|
+
* and refuses to start without enough configuration.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import url from "node:url";
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
16
|
+
const BIN = path.resolve(__dirname, "..", "bin", "strix-mcp-proxy.mjs");
|
|
17
|
+
|
|
18
|
+
function runCli(args, opts = {}) {
|
|
19
|
+
return spawnSync("node", [BIN, ...args], { encoding: "utf8", timeout: 10_000, ...opts });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("--help prints usage and exits 0", () => {
|
|
23
|
+
const out = runCli(["--help"]);
|
|
24
|
+
assert.equal(out.status, 0);
|
|
25
|
+
assert.match(out.stdout, /strix-mcp-proxy/);
|
|
26
|
+
assert.match(out.stdout, /CONFIG-FILE MODE/);
|
|
27
|
+
assert.match(out.stdout, /FLAG MODE/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("--version prints the package version and exits 0", () => {
|
|
31
|
+
const out = runCli(["--version"]);
|
|
32
|
+
assert.equal(out.status, 0);
|
|
33
|
+
assert.match(out.stdout, /^strix-mcp-proxy \d+\.\d+\.\d+/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("no args: refuses to start with a clear error", () => {
|
|
37
|
+
const out = runCli([], { input: "" });
|
|
38
|
+
assert.notEqual(out.status, 0, "must exit non-zero without enough config");
|
|
39
|
+
assert.match(out.stderr, /missing --config or --upstream-command/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("missing --config file: clear error", () => {
|
|
43
|
+
const out = runCli(["--config", "/nonexistent/strix-proxy-cli-test.json"], { input: "" });
|
|
44
|
+
assert.notEqual(out.status, 0);
|
|
45
|
+
assert.match(out.stderr, /could not read/);
|
|
46
|
+
});
|