agentsentinel-cli 0.7.0__tar.gz → 0.7.1__tar.gz
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.
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/.gitignore +3 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/PKG-INFO +62 -6
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/README.md +61 -5
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/mcp_client.py +135 -11
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/pyproject.toml +1 -1
- agentsentinel_cli-0.7.1/tmp/test-mcp-agent/README.md +134 -0
- agentsentinel_cli-0.7.1/tmp/test-mcp-agent/langchain_agent.py +178 -0
- agentsentinel_cli-0.7.1/tmp/test-mcp-agent/mcp_server.py +245 -0
- agentsentinel_cli-0.7.1/tmp/test-mcp-agent/requirements.txt +16 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/DOCUMENTATION.md +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/LICENSE +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/__init__.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/a2a_report.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/a2a_rules.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/a2a_scanner.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/agent_mode.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/agent_mode_report.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/ai_probe.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/attacks/__init__.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/attacks/library.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/cli.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/discover.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/discover_report.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/fingerprint.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/frameworks.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/inspect.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/inspect_report.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/mcp_report.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/mcp_rules.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/probe.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/probe_report.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/report.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/rules.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/scanner.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/secrets.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/secrets_report.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/secrets_rules.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/supply_chain_ai.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/supply_chain_report.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/supply_chain_rules.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/suppress.py +0 -0
- {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/target.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentsentinel-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Agentic security CLI — AI analyst with memory, supply chain audit, MCP audit, red-team probing, and agent discovery
|
|
5
5
|
Project-URL: Homepage, https://github.com/jaydenaung/agentsentinel-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/jaydenaung/agentsentinel-cli
|
|
@@ -51,7 +51,7 @@ Description-Content-Type: text/markdown
|
|
|
51
51
|
[](LICENSE)
|
|
52
52
|
[](https://pypi.org/project/agentsentinel-cli/)
|
|
53
53
|
|
|
54
|
-
**AI agent security — analyst mode, static rules, red-team probing, and MCP auditing. No server. No Docker. One install.**
|
|
54
|
+
**AI agent security — analyst mode, multi-agent trust analysis, static rules, red-team probing, and MCP auditing. No server. No Docker. One install.**
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
57
|
pipx install "agentsentinel-cli[all]"
|
|
@@ -61,7 +61,7 @@ pipx install "agentsentinel-cli[all]"
|
|
|
61
61
|
|
|
62
62
|
## What it does
|
|
63
63
|
|
|
64
|
-
`sentinel` covers
|
|
64
|
+
`sentinel` covers 9 of the 10 risks in the [OWASP Top 10 for Agentic Applications 2026](https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/).
|
|
65
65
|
|
|
66
66
|
It operates at two levels:
|
|
67
67
|
|
|
@@ -83,6 +83,10 @@ sentinel agentic ./my-agent/
|
|
|
83
83
|
sentinel supply-chain http://localhost:3001
|
|
84
84
|
sentinel supply-chain http://localhost:3001 --ai # + Claude semantic analysis
|
|
85
85
|
|
|
86
|
+
# Multi-agent trust analysis — detect A2A trust violations in your codebase
|
|
87
|
+
sentinel a2a ./agents/
|
|
88
|
+
sentinel a2a multi_agent.py --fail-on HIGH
|
|
89
|
+
|
|
86
90
|
# Static posture scan
|
|
87
91
|
sentinel scan my_agent.py
|
|
88
92
|
sentinel secrets .
|
|
@@ -300,6 +304,54 @@ sentinel discover --format json
|
|
|
300
304
|
|
|
301
305
|
---
|
|
302
306
|
|
|
307
|
+
### `sentinel a2a` — multi-agent trust analysis
|
|
308
|
+
|
|
309
|
+
Scans Python agent source files and builds a call graph showing which agents call which, then audits the trust boundaries between them. Detects the attack paths that single-agent tools miss entirely: injection that propagates across agent boundaries, unbounded agent spawning, and code-execution agents that accept delegated instructions without verification.
|
|
310
|
+
|
|
311
|
+
Supports **LangChain / LangGraph**, **AutoGen**, and **CrewAI**. No API key required.
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
sentinel a2a ./agents/
|
|
315
|
+
sentinel a2a multi_agent.py
|
|
316
|
+
sentinel a2a . --fail-on HIGH
|
|
317
|
+
sentinel a2a . --format json
|
|
318
|
+
sentinel a2a . --ignore-rule A2A01_UNVERIFIED_ORCHESTRATOR # suppress if handled at infra layer
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**Rules:**
|
|
322
|
+
|
|
323
|
+
| Rule | Severity | What it catches |
|
|
324
|
+
|------|----------|-----------------|
|
|
325
|
+
| `A2A03_IMPLICIT_TRUST` | CRITICAL | Code-execution agent accepts calls from other agents with no caller verification |
|
|
326
|
+
| `A2A04_PROMPT_PASSTHROUGH` | HIGH | User input flows directly across an agent boundary without sanitization |
|
|
327
|
+
| `A2A02_UNBOUNDED_SPAWNING` | HIGH | Agent is instantiated inside a loop — unbounded agent creation risk |
|
|
328
|
+
| `A2A06_CIRCULAR_DELEGATION` | HIGH | Cycle in the call graph — agents can loop indefinitely under injection |
|
|
329
|
+
| `A2A05_UNSCOPED_DELEGATION` | MEDIUM | Orchestrator delegates its full tool set to a sub-agent instead of a restricted subset |
|
|
330
|
+
| `A2A01_UNVERIFIED_ORCHESTRATOR` | LOW | Agents receive instructions from other agents with no visible trust verification |
|
|
331
|
+
|
|
332
|
+
**Example output:**
|
|
333
|
+
|
|
334
|
+
```
|
|
335
|
+
2 agents 1 edges 1 max depth acyclic
|
|
336
|
+
|
|
337
|
+
Agent Framework Role Tools
|
|
338
|
+
planner autogen worker —
|
|
339
|
+
executor autogen orchestrator — ⚠ code exec
|
|
340
|
+
|
|
341
|
+
Call graph:
|
|
342
|
+
executor ──► planner initiate_chat passes input
|
|
343
|
+
|
|
344
|
+
● HIGH A2A04_PROMPT_PASSTHROUGH ASI01
|
|
345
|
+
User input flows directly from 'executor' to 'planner'
|
|
346
|
+
without sanitization at the agent boundary.
|
|
347
|
+
|
|
348
|
+
Trust Score 75/100 ███████████████░░░░░ WATCH
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Covers **ASI07** (Insecure Inter-Agent Communication) from OWASP Top 10 for Agentic Applications 2026.
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
303
355
|
## Finding suppression
|
|
304
356
|
|
|
305
357
|
Use `--ignore-rule` to suppress specific findings by rule ID. Suppressed findings are excluded from `--fail-on` evaluation and output — they don't break CI gates.
|
|
@@ -325,7 +377,7 @@ SC03_HIDDEN_NETWORK_FIELDS # webhook field verified safe — used for audit log
|
|
|
325
377
|
NO_AUTH # server is behind an authenticated reverse proxy
|
|
326
378
|
```
|
|
327
379
|
|
|
328
|
-
Supported on: `sentinel scan`, `sentinel mcp scan`, `sentinel supply-chain`, `sentinel secrets`, `sentinel inspect`.
|
|
380
|
+
Supported on: `sentinel scan`, `sentinel a2a`, `sentinel mcp scan`, `sentinel supply-chain`, `sentinel secrets`, `sentinel inspect`.
|
|
329
381
|
|
|
330
382
|
---
|
|
331
383
|
|
|
@@ -339,7 +391,7 @@ Supported on: `sentinel scan`, `sentinel mcp scan`, `sentinel supply-chain`, `se
|
|
|
339
391
|
| **Agentic Supply Chain Compromise** | **ASI04** | **`sentinel supply-chain`** (static + AI), **`sentinel agentic`** |
|
|
340
392
|
| Unexpected Code Execution | ASI05 | `sentinel scan` (CODE_EXECUTION_GRANT), `sentinel mcp scan` |
|
|
341
393
|
| **Memory & Context Poisoning** | **ASI06** | **`sentinel secrets`** (memory contamination), **`sentinel agentic`** |
|
|
342
|
-
| Insecure Inter-Agent Communication | ASI07 | `sentinel agentic` (reasoning
|
|
394
|
+
| **Insecure Inter-Agent Communication** | **ASI07** | **`sentinel a2a`** (call graph + trust rules), `sentinel agentic` (semantic reasoning) |
|
|
343
395
|
| Cascading Agent Failures | ASI08 | `sentinel agentic` (cross-finding chain analysis) |
|
|
344
396
|
| Human-Agent Trust Exploitation | ASI09 | `sentinel agentic` (narrative + evidence standard) |
|
|
345
397
|
| Rogue Agents | ASI10 | `sentinel agentic` (drift detection across sessions) |
|
|
@@ -373,6 +425,9 @@ jobs:
|
|
|
373
425
|
|
|
374
426
|
- name: MCP security audit
|
|
375
427
|
run: sentinel mcp scan http://localhost:3001 --fail-on CRITICAL
|
|
428
|
+
|
|
429
|
+
- name: Multi-agent trust analysis
|
|
430
|
+
run: sentinel a2a ./agents/ --fail-on HIGH
|
|
376
431
|
```
|
|
377
432
|
|
|
378
433
|
Use a `.sentinelignore` file at the repo root to suppress known-accepted findings without weakening the gate threshold:
|
|
@@ -393,6 +448,7 @@ MISSING_RATE_LIMIT # rate limiting handled at infra layer
|
|
|
393
448
|
| Investigating a specific server or codebase | `sentinel agentic` |
|
|
394
449
|
| First assessment of a new MCP server | `sentinel agentic` |
|
|
395
450
|
| Scheduled nightly security check | `sentinel agentic` (memory tracks drift) |
|
|
451
|
+
| Auditing a multi-agent codebase | `sentinel a2a` (call graph + trust rules) |
|
|
396
452
|
| Quick local sanity check | `sentinel mcp scan`, `sentinel scan` |
|
|
397
453
|
| Red-teaming a live agent endpoint | `sentinel ai-probe` |
|
|
398
454
|
|
|
@@ -402,7 +458,7 @@ MISSING_RATE_LIMIT # rate limiting handled at infra layer
|
|
|
402
458
|
|
|
403
459
|
- Python 3.10+
|
|
404
460
|
- `ANTHROPIC_API_KEY` required for: `sentinel agentic`, `sentinel ai-probe`, `sentinel supply-chain --ai`, `sentinel inspect` (AI summary)
|
|
405
|
-
- No API key required for: `sentinel scan`, `sentinel secrets`, `sentinel mcp scan`, `sentinel supply-chain`, `sentinel probe`, `sentinel discover`, `sentinel inspect --no-ai`
|
|
461
|
+
- No API key required for: `sentinel scan`, `sentinel a2a`, `sentinel secrets`, `sentinel mcp scan`, `sentinel supply-chain`, `sentinel probe`, `sentinel discover`, `sentinel inspect --no-ai`
|
|
406
462
|
|
|
407
463
|
---
|
|
408
464
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://pypi.org/project/agentsentinel-cli/)
|
|
6
6
|
|
|
7
|
-
**AI agent security — analyst mode, static rules, red-team probing, and MCP auditing. No server. No Docker. One install.**
|
|
7
|
+
**AI agent security — analyst mode, multi-agent trust analysis, static rules, red-team probing, and MCP auditing. No server. No Docker. One install.**
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
pipx install "agentsentinel-cli[all]"
|
|
@@ -14,7 +14,7 @@ pipx install "agentsentinel-cli[all]"
|
|
|
14
14
|
|
|
15
15
|
## What it does
|
|
16
16
|
|
|
17
|
-
`sentinel` covers
|
|
17
|
+
`sentinel` covers 9 of the 10 risks in the [OWASP Top 10 for Agentic Applications 2026](https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/).
|
|
18
18
|
|
|
19
19
|
It operates at two levels:
|
|
20
20
|
|
|
@@ -36,6 +36,10 @@ sentinel agentic ./my-agent/
|
|
|
36
36
|
sentinel supply-chain http://localhost:3001
|
|
37
37
|
sentinel supply-chain http://localhost:3001 --ai # + Claude semantic analysis
|
|
38
38
|
|
|
39
|
+
# Multi-agent trust analysis — detect A2A trust violations in your codebase
|
|
40
|
+
sentinel a2a ./agents/
|
|
41
|
+
sentinel a2a multi_agent.py --fail-on HIGH
|
|
42
|
+
|
|
39
43
|
# Static posture scan
|
|
40
44
|
sentinel scan my_agent.py
|
|
41
45
|
sentinel secrets .
|
|
@@ -253,6 +257,54 @@ sentinel discover --format json
|
|
|
253
257
|
|
|
254
258
|
---
|
|
255
259
|
|
|
260
|
+
### `sentinel a2a` — multi-agent trust analysis
|
|
261
|
+
|
|
262
|
+
Scans Python agent source files and builds a call graph showing which agents call which, then audits the trust boundaries between them. Detects the attack paths that single-agent tools miss entirely: injection that propagates across agent boundaries, unbounded agent spawning, and code-execution agents that accept delegated instructions without verification.
|
|
263
|
+
|
|
264
|
+
Supports **LangChain / LangGraph**, **AutoGen**, and **CrewAI**. No API key required.
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
sentinel a2a ./agents/
|
|
268
|
+
sentinel a2a multi_agent.py
|
|
269
|
+
sentinel a2a . --fail-on HIGH
|
|
270
|
+
sentinel a2a . --format json
|
|
271
|
+
sentinel a2a . --ignore-rule A2A01_UNVERIFIED_ORCHESTRATOR # suppress if handled at infra layer
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**Rules:**
|
|
275
|
+
|
|
276
|
+
| Rule | Severity | What it catches |
|
|
277
|
+
|------|----------|-----------------|
|
|
278
|
+
| `A2A03_IMPLICIT_TRUST` | CRITICAL | Code-execution agent accepts calls from other agents with no caller verification |
|
|
279
|
+
| `A2A04_PROMPT_PASSTHROUGH` | HIGH | User input flows directly across an agent boundary without sanitization |
|
|
280
|
+
| `A2A02_UNBOUNDED_SPAWNING` | HIGH | Agent is instantiated inside a loop — unbounded agent creation risk |
|
|
281
|
+
| `A2A06_CIRCULAR_DELEGATION` | HIGH | Cycle in the call graph — agents can loop indefinitely under injection |
|
|
282
|
+
| `A2A05_UNSCOPED_DELEGATION` | MEDIUM | Orchestrator delegates its full tool set to a sub-agent instead of a restricted subset |
|
|
283
|
+
| `A2A01_UNVERIFIED_ORCHESTRATOR` | LOW | Agents receive instructions from other agents with no visible trust verification |
|
|
284
|
+
|
|
285
|
+
**Example output:**
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
2 agents 1 edges 1 max depth acyclic
|
|
289
|
+
|
|
290
|
+
Agent Framework Role Tools
|
|
291
|
+
planner autogen worker —
|
|
292
|
+
executor autogen orchestrator — ⚠ code exec
|
|
293
|
+
|
|
294
|
+
Call graph:
|
|
295
|
+
executor ──► planner initiate_chat passes input
|
|
296
|
+
|
|
297
|
+
● HIGH A2A04_PROMPT_PASSTHROUGH ASI01
|
|
298
|
+
User input flows directly from 'executor' to 'planner'
|
|
299
|
+
without sanitization at the agent boundary.
|
|
300
|
+
|
|
301
|
+
Trust Score 75/100 ███████████████░░░░░ WATCH
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Covers **ASI07** (Insecure Inter-Agent Communication) from OWASP Top 10 for Agentic Applications 2026.
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
256
308
|
## Finding suppression
|
|
257
309
|
|
|
258
310
|
Use `--ignore-rule` to suppress specific findings by rule ID. Suppressed findings are excluded from `--fail-on` evaluation and output — they don't break CI gates.
|
|
@@ -278,7 +330,7 @@ SC03_HIDDEN_NETWORK_FIELDS # webhook field verified safe — used for audit log
|
|
|
278
330
|
NO_AUTH # server is behind an authenticated reverse proxy
|
|
279
331
|
```
|
|
280
332
|
|
|
281
|
-
Supported on: `sentinel scan`, `sentinel mcp scan`, `sentinel supply-chain`, `sentinel secrets`, `sentinel inspect`.
|
|
333
|
+
Supported on: `sentinel scan`, `sentinel a2a`, `sentinel mcp scan`, `sentinel supply-chain`, `sentinel secrets`, `sentinel inspect`.
|
|
282
334
|
|
|
283
335
|
---
|
|
284
336
|
|
|
@@ -292,7 +344,7 @@ Supported on: `sentinel scan`, `sentinel mcp scan`, `sentinel supply-chain`, `se
|
|
|
292
344
|
| **Agentic Supply Chain Compromise** | **ASI04** | **`sentinel supply-chain`** (static + AI), **`sentinel agentic`** |
|
|
293
345
|
| Unexpected Code Execution | ASI05 | `sentinel scan` (CODE_EXECUTION_GRANT), `sentinel mcp scan` |
|
|
294
346
|
| **Memory & Context Poisoning** | **ASI06** | **`sentinel secrets`** (memory contamination), **`sentinel agentic`** |
|
|
295
|
-
| Insecure Inter-Agent Communication | ASI07 | `sentinel agentic` (reasoning
|
|
347
|
+
| **Insecure Inter-Agent Communication** | **ASI07** | **`sentinel a2a`** (call graph + trust rules), `sentinel agentic` (semantic reasoning) |
|
|
296
348
|
| Cascading Agent Failures | ASI08 | `sentinel agentic` (cross-finding chain analysis) |
|
|
297
349
|
| Human-Agent Trust Exploitation | ASI09 | `sentinel agentic` (narrative + evidence standard) |
|
|
298
350
|
| Rogue Agents | ASI10 | `sentinel agentic` (drift detection across sessions) |
|
|
@@ -326,6 +378,9 @@ jobs:
|
|
|
326
378
|
|
|
327
379
|
- name: MCP security audit
|
|
328
380
|
run: sentinel mcp scan http://localhost:3001 --fail-on CRITICAL
|
|
381
|
+
|
|
382
|
+
- name: Multi-agent trust analysis
|
|
383
|
+
run: sentinel a2a ./agents/ --fail-on HIGH
|
|
329
384
|
```
|
|
330
385
|
|
|
331
386
|
Use a `.sentinelignore` file at the repo root to suppress known-accepted findings without weakening the gate threshold:
|
|
@@ -346,6 +401,7 @@ MISSING_RATE_LIMIT # rate limiting handled at infra layer
|
|
|
346
401
|
| Investigating a specific server or codebase | `sentinel agentic` |
|
|
347
402
|
| First assessment of a new MCP server | `sentinel agentic` |
|
|
348
403
|
| Scheduled nightly security check | `sentinel agentic` (memory tracks drift) |
|
|
404
|
+
| Auditing a multi-agent codebase | `sentinel a2a` (call graph + trust rules) |
|
|
349
405
|
| Quick local sanity check | `sentinel mcp scan`, `sentinel scan` |
|
|
350
406
|
| Red-teaming a live agent endpoint | `sentinel ai-probe` |
|
|
351
407
|
|
|
@@ -355,7 +411,7 @@ MISSING_RATE_LIMIT # rate limiting handled at infra layer
|
|
|
355
411
|
|
|
356
412
|
- Python 3.10+
|
|
357
413
|
- `ANTHROPIC_API_KEY` required for: `sentinel agentic`, `sentinel ai-probe`, `sentinel supply-chain --ai`, `sentinel inspect` (AI summary)
|
|
358
|
-
- No API key required for: `sentinel scan`, `sentinel secrets`, `sentinel mcp scan`, `sentinel supply-chain`, `sentinel probe`, `sentinel discover`, `sentinel inspect --no-ai`
|
|
414
|
+
- No API key required for: `sentinel scan`, `sentinel a2a`, `sentinel secrets`, `sentinel mcp scan`, `sentinel supply-chain`, `sentinel probe`, `sentinel discover`, `sentinel inspect --no-ai`
|
|
359
415
|
|
|
360
416
|
---
|
|
361
417
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Minimal MCP (Model Context Protocol) client for security scanning.
|
|
2
2
|
|
|
3
|
-
Implements the initialize + tools/list exchange over stdio
|
|
4
|
-
transports. Only what an auditor needs — no full MCP client dependency.
|
|
3
|
+
Implements the initialize + tools/list exchange over stdio, streamable-HTTP,
|
|
4
|
+
and legacy SSE transports. Only what an auditor needs — no full MCP client dependency.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import dataclasses
|
|
@@ -10,6 +10,7 @@ import queue
|
|
|
10
10
|
import shlex
|
|
11
11
|
import subprocess
|
|
12
12
|
import threading
|
|
13
|
+
import urllib.parse
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
15
16
|
from agentsentinel_cli.scanner import classify_tool
|
|
@@ -79,6 +80,9 @@ def scan_http(
|
|
|
79
80
|
) -> McpServerInfo:
|
|
80
81
|
"""Scan an MCP server via streamable HTTP (POST-based) transport.
|
|
81
82
|
|
|
83
|
+
If the server responds with 405 or text/event-stream, automatically falls
|
|
84
|
+
back to the legacy SSE transport (GET /sse + POST /messages).
|
|
85
|
+
|
|
82
86
|
Raises McpAuthRequired if the server requires credentials.
|
|
83
87
|
Raises McpError for all other connection or protocol failures.
|
|
84
88
|
"""
|
|
@@ -105,18 +109,12 @@ def scan_http(
|
|
|
105
109
|
if resp.status_code in (401, 403):
|
|
106
110
|
raise McpAuthRequired(resp.status_code)
|
|
107
111
|
if resp.status_code == 405:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
"This server may use the older SSE transport (GET /sse). "
|
|
111
|
-
"Try scanning it locally with: sentinel mcp scan --stdio 'python server.py'"
|
|
112
|
-
)
|
|
112
|
+
# Old SSE transport — fall back automatically
|
|
113
|
+
return scan_sse(url, extra_headers=extra_headers, timeout=timeout)
|
|
113
114
|
|
|
114
115
|
content_type = resp.headers.get("content-type", "")
|
|
115
116
|
if "text/event-stream" in content_type:
|
|
116
|
-
|
|
117
|
-
"Server responded with SSE stream (older transport). "
|
|
118
|
-
"Try scanning it locally with: sentinel mcp scan --stdio 'python server.py'"
|
|
119
|
-
)
|
|
117
|
+
return scan_sse(url, extra_headers=extra_headers, timeout=timeout)
|
|
120
118
|
|
|
121
119
|
resp.raise_for_status()
|
|
122
120
|
init_data = _parse_rpc_response(resp.text)
|
|
@@ -143,6 +141,132 @@ def scan_http(
|
|
|
143
141
|
)
|
|
144
142
|
|
|
145
143
|
|
|
144
|
+
def scan_sse(
|
|
145
|
+
url: str,
|
|
146
|
+
extra_headers: dict[str, str] | None = None,
|
|
147
|
+
timeout: float = 15.0,
|
|
148
|
+
) -> McpServerInfo:
|
|
149
|
+
"""Scan an MCP server via the legacy SSE transport (GET /sse + POST /messages).
|
|
150
|
+
|
|
151
|
+
FastMCP 0.x / mcp 1.x servers use this transport. The protocol is:
|
|
152
|
+
1. Client opens GET /sse — server streams SSE events back.
|
|
153
|
+
2. First event is event: endpoint / data: /messages/?session_id=xxx
|
|
154
|
+
3. Client POSTs JSON-RPC requests to /messages/?session_id=xxx.
|
|
155
|
+
4. Server sends responses as SSE data: events on the open GET stream.
|
|
156
|
+
|
|
157
|
+
We handle the bidirectional exchange with a background reader thread that
|
|
158
|
+
drains the SSE stream into a queue while the main thread sends requests.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
import httpx
|
|
162
|
+
except ImportError:
|
|
163
|
+
raise McpError("httpx is required: pip install 'agentsentinel-cli[mcp]'")
|
|
164
|
+
|
|
165
|
+
# Normalise: /sse suffix accepted but not required
|
|
166
|
+
base = url.rstrip("/")
|
|
167
|
+
if not base.endswith("/sse"):
|
|
168
|
+
base = f"{base}/sse"
|
|
169
|
+
|
|
170
|
+
parsed = urllib.parse.urlparse(base)
|
|
171
|
+
origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
172
|
+
|
|
173
|
+
sse_headers: dict[str, str] = {"Accept": "text/event-stream"}
|
|
174
|
+
if extra_headers:
|
|
175
|
+
sse_headers.update(extra_headers)
|
|
176
|
+
|
|
177
|
+
event_q: queue.Queue[str | None] = queue.Queue()
|
|
178
|
+
endpoint_q: queue.Queue[str | None] = queue.Queue(maxsize=1)
|
|
179
|
+
|
|
180
|
+
def _sse_reader(client: "httpx.Client") -> None:
|
|
181
|
+
"""Stream GET /sse, push data-line payloads into event_q."""
|
|
182
|
+
try:
|
|
183
|
+
with client.stream("GET", base, headers=sse_headers) as resp:
|
|
184
|
+
if resp.status_code in (401, 403):
|
|
185
|
+
endpoint_q.put(None)
|
|
186
|
+
return
|
|
187
|
+
resp.raise_for_status()
|
|
188
|
+
endpoint_sent = False
|
|
189
|
+
for line in resp.iter_lines():
|
|
190
|
+
if line.startswith("data:"):
|
|
191
|
+
data = line[5:].strip()
|
|
192
|
+
if not endpoint_sent:
|
|
193
|
+
# First data line is the session endpoint path
|
|
194
|
+
endpoint_q.put(data)
|
|
195
|
+
endpoint_sent = True
|
|
196
|
+
else:
|
|
197
|
+
event_q.put(data)
|
|
198
|
+
except Exception:
|
|
199
|
+
endpoint_q.put(None)
|
|
200
|
+
event_q.put(None)
|
|
201
|
+
|
|
202
|
+
def _recv(wait: float) -> dict[str, Any]:
|
|
203
|
+
try:
|
|
204
|
+
payload = event_q.get(timeout=wait)
|
|
205
|
+
except queue.Empty:
|
|
206
|
+
raise McpError(f"No SSE response within {wait}s")
|
|
207
|
+
if payload is None:
|
|
208
|
+
raise McpError("SSE stream closed unexpectedly")
|
|
209
|
+
try:
|
|
210
|
+
return json.loads(payload)
|
|
211
|
+
except json.JSONDecodeError as exc:
|
|
212
|
+
raise McpError(f"Invalid JSON from SSE stream: {exc}") from exc
|
|
213
|
+
|
|
214
|
+
with httpx.Client(timeout=timeout) as client:
|
|
215
|
+
reader = threading.Thread(target=_sse_reader, args=(client,), daemon=True)
|
|
216
|
+
reader.start()
|
|
217
|
+
|
|
218
|
+
# Wait for the session endpoint URL
|
|
219
|
+
try:
|
|
220
|
+
session_path = endpoint_q.get(timeout=timeout)
|
|
221
|
+
except queue.Empty:
|
|
222
|
+
raise McpError("SSE server did not send an endpoint URL in time")
|
|
223
|
+
if session_path is None:
|
|
224
|
+
raise McpAuthRequired(401)
|
|
225
|
+
|
|
226
|
+
# Build the absolute messages URL
|
|
227
|
+
if session_path.startswith("http"):
|
|
228
|
+
messages_url = session_path
|
|
229
|
+
else:
|
|
230
|
+
messages_url = f"{origin}{session_path}"
|
|
231
|
+
|
|
232
|
+
post_headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
233
|
+
if extra_headers:
|
|
234
|
+
post_headers.update(extra_headers)
|
|
235
|
+
|
|
236
|
+
# initialize
|
|
237
|
+
resp = client.post(messages_url, json=_rpc("initialize", {
|
|
238
|
+
"protocolVersion": _PROTOCOL_VERSION,
|
|
239
|
+
"capabilities": {},
|
|
240
|
+
"clientInfo": _CLIENT_INFO,
|
|
241
|
+
}, 1), headers=post_headers)
|
|
242
|
+
if resp.status_code in (401, 403):
|
|
243
|
+
raise McpAuthRequired(resp.status_code)
|
|
244
|
+
resp.raise_for_status()
|
|
245
|
+
init_data = _recv(timeout)
|
|
246
|
+
|
|
247
|
+
# initialized notification — fire and forget
|
|
248
|
+
client.post(messages_url, json={
|
|
249
|
+
"jsonrpc": "2.0",
|
|
250
|
+
"method": "notifications/initialized",
|
|
251
|
+
"params": {},
|
|
252
|
+
}, headers=post_headers)
|
|
253
|
+
|
|
254
|
+
# tools/list
|
|
255
|
+
resp = client.post(messages_url, json=_rpc("tools/list", {}, 2), headers=post_headers)
|
|
256
|
+
resp.raise_for_status()
|
|
257
|
+
tools_data = _recv(timeout)
|
|
258
|
+
|
|
259
|
+
server_meta = init_data.get("result", {}).get("serverInfo", {})
|
|
260
|
+
raw_tools = tools_data.get("result", {}).get("tools", [])
|
|
261
|
+
|
|
262
|
+
return McpServerInfo(
|
|
263
|
+
name=server_meta.get("name", "unknown"),
|
|
264
|
+
version=server_meta.get("version", "unknown"),
|
|
265
|
+
tools=[_make_tool(t) for t in raw_tools],
|
|
266
|
+
transport="sse",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
146
270
|
def _parse_rpc_response(text: str) -> dict[str, Any]:
|
|
147
271
|
"""Parse a JSON-RPC response body, unwrapping SSE data-lines if present."""
|
|
148
272
|
text = text.strip()
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentsentinel-cli"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.1"
|
|
8
8
|
description = "Agentic security CLI — AI analyst with memory, supply chain audit, MCP audit, red-team probing, and agent discovery"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# LangChain Agent + MCP Server — Learning Example
|
|
2
|
+
|
|
3
|
+
Two separate scripts. Run them in two separate terminals to see
|
|
4
|
+
agent → MCP server communication in real time.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
Terminal 1 Terminal 2
|
|
8
|
+
────────────────────────────── ──────────────────────────────
|
|
9
|
+
python mcp_server.py python langchain_agent.py
|
|
10
|
+
↑ shows [TOOL CALL] logs ↑ LLM + agent loop
|
|
11
|
+
↑ runs as HTTP server ↑ connects over HTTP/SSE
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Setup (do this once)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python3 -m venv venv
|
|
20
|
+
source venv/bin/activate
|
|
21
|
+
pip install -r requirements.txt
|
|
22
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
> To deactivate when done: `deactivate`
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## How to run
|
|
30
|
+
|
|
31
|
+
**Terminal 1 — start the MCP server:**
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
source venv/bin/activate
|
|
35
|
+
python mcp_server.py
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You'll see:
|
|
39
|
+
```
|
|
40
|
+
10:30:00 INFO file-browser MCP server starting (SSE/HTTP transport)
|
|
41
|
+
10:30:00 INFO tools: list_directory, get_directory_tree
|
|
42
|
+
10:30:00 INFO listening on http://127.0.0.1:8000/sse
|
|
43
|
+
10:30:00 INFO waiting for agent connection...
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Terminal 2 — run the agent:**
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
source venv/bin/activate
|
|
50
|
+
python langchain_agent.py
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Now ask the agent something:
|
|
54
|
+
```
|
|
55
|
+
You: list /Users/jaydenaung/code
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Watch Terminal 1** — you'll see every tool call the agent makes:
|
|
59
|
+
```
|
|
60
|
+
10:30:05 INFO [TOOL CALL] list_directory path='/Users/jaydenaung/code' show_hidden=False
|
|
61
|
+
10:30:05 INFO [TOOL DONE] list_directory → 42 lines
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## File 1: `mcp_server.py`
|
|
67
|
+
|
|
68
|
+
Standalone HTTP/SSE server exposing two tools: `list_directory` and `get_directory_tree`.
|
|
69
|
+
|
|
70
|
+
Logs every incoming tool call to stderr so you can watch agent→server traffic live.
|
|
71
|
+
|
|
72
|
+
**Custom port:**
|
|
73
|
+
```bash
|
|
74
|
+
python mcp_server.py --port 9000
|
|
75
|
+
# update MCP_SERVER_URL in langchain_agent.py to match
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Test in the MCP Inspector (browser UI):**
|
|
79
|
+
```bash
|
|
80
|
+
mcp dev mcp_server.py
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Connect to Claude Desktop** — edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"mcpServers": {
|
|
87
|
+
"file-browser": {
|
|
88
|
+
"command": "/absolute/path/to/venv/bin/python",
|
|
89
|
+
"args": ["/absolute/path/to/mcp_server.py"]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
Use the venv Python path so it picks up the `mcp` package. Restart Claude Desktop after saving.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## File 2: `langchain_agent.py`
|
|
99
|
+
|
|
100
|
+
LangChain agent that connects to the running MCP server over HTTP/SSE.
|
|
101
|
+
Does NOT spawn the server — the server must be running first.
|
|
102
|
+
|
|
103
|
+
Uses `langchain.agents.create_agent` (the LangChain v1 replacement for the
|
|
104
|
+
removed `AgentExecutor` + `create_tool_calling_agent` API).
|
|
105
|
+
|
|
106
|
+
**Example prompts:**
|
|
107
|
+
```
|
|
108
|
+
> What's in my home directory?
|
|
109
|
+
> List /Users/jaydenaung/code
|
|
110
|
+
> Show me the tree of ~/Downloads
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Key concepts
|
|
116
|
+
|
|
117
|
+
### stdio vs HTTP/SSE transport
|
|
118
|
+
|
|
119
|
+
| | stdio | HTTP/SSE (this example) |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| How server starts | Client spawns it as a subprocess | Runs independently, clients connect |
|
|
122
|
+
| Terminals needed | 1 (server hidden inside agent) | 2 (server + agent separate) |
|
|
123
|
+
| Multiple clients | No — one subprocess per client | Yes — any number of clients |
|
|
124
|
+
| Claude Desktop | Yes (subprocess mode) | Yes (point to URL) |
|
|
125
|
+
| Good for | Embedding tools into one script | Observing, sharing, scaling |
|
|
126
|
+
|
|
127
|
+
### LangChain tool vs MCP server
|
|
128
|
+
|
|
129
|
+
| | LangChain tool | MCP server |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| Scope | Only works inside your Python agent | Works with ANY MCP client |
|
|
132
|
+
| Clients | Your script only | Claude Desktop, Cursor, Claude Code, etc. |
|
|
133
|
+
| Transport | Direct Python function call | JSON-RPC over HTTP or stdio |
|
|
134
|
+
| When to use | Building your own agent in code | Exposing tools to AI assistants generally |
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LangChain Agent — connects to a running MCP server over HTTP/SSE
|
|
3
|
+
=================================================================
|
|
4
|
+
The agent connects to mcp_server.py over HTTP — it does NOT spawn it.
|
|
5
|
+
Start the server in one terminal first, then run this in another.
|
|
6
|
+
|
|
7
|
+
HOW TO RUN (two separate terminals):
|
|
8
|
+
Terminal 1: python mcp_server.py
|
|
9
|
+
Terminal 2: python langchain_agent.py
|
|
10
|
+
|
|
11
|
+
The server terminal will show [TOOL CALL] / [TOOL DONE] logs for
|
|
12
|
+
every request the agent sends.
|
|
13
|
+
|
|
14
|
+
WHAT THIS TEACHES:
|
|
15
|
+
- How an agent calls tools over MCP (HTTP/SSE transport)
|
|
16
|
+
- The full flow: LLM decides → MCP client sends request → server executes
|
|
17
|
+
- How the agent and server are truly decoupled processes
|
|
18
|
+
|
|
19
|
+
NOTE (LangChain v1.x):
|
|
20
|
+
AgentExecutor and create_tool_calling_agent were removed in LangChain v1.
|
|
21
|
+
The modern API is langchain.agents.create_agent (backed by LangGraph).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
from dotenv import load_dotenv
|
|
29
|
+
from mcp import ClientSession
|
|
30
|
+
from mcp.client.sse import sse_client
|
|
31
|
+
|
|
32
|
+
load_dotenv()
|
|
33
|
+
|
|
34
|
+
from langchain_anthropic import ChatAnthropic
|
|
35
|
+
from langchain_core.tools import StructuredTool
|
|
36
|
+
from langchain_core.messages import HumanMessage
|
|
37
|
+
from langchain.agents import create_agent
|
|
38
|
+
from pydantic import BaseModel, Field
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ─────────────────────────────────────────────
|
|
42
|
+
# Server URL — change port if you started mcp_server.py with --port
|
|
43
|
+
# ─────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
MCP_SERVER_URL = "http://127.0.0.1:8000/sse"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ─────────────────────────────────────────────
|
|
49
|
+
# STEP 1: Pydantic schemas — tell the LLM what arguments each tool takes
|
|
50
|
+
# These mirror the inputSchema defined in mcp_server.py
|
|
51
|
+
# ─────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
class ListDirectoryInput(BaseModel):
|
|
54
|
+
path: str = Field(description="Absolute path to the directory. Examples: /Users/jaydenaung/code, ~/Documents")
|
|
55
|
+
show_hidden: bool = Field(default=False, description="Include hidden files (starting with '.')")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GetDirectoryTreeInput(BaseModel):
|
|
59
|
+
path: str = Field(description="Absolute path to the root directory")
|
|
60
|
+
max_depth: int = Field(default=2, description="How many levels deep to recurse")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ─────────────────────────────────────────────
|
|
64
|
+
# STEP 2: Wrap MCP tools as LangChain tools
|
|
65
|
+
# Each async function forwards the call to the MCP server over the live session.
|
|
66
|
+
# ─────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def make_mcp_tools(session: ClientSession) -> list:
|
|
69
|
+
async def list_directory(path: str, show_hidden: bool = False) -> str:
|
|
70
|
+
result = await session.call_tool(
|
|
71
|
+
"list_directory", {"path": path, "show_hidden": show_hidden}
|
|
72
|
+
)
|
|
73
|
+
return "\n".join(b.text for b in result.content if hasattr(b, "text"))
|
|
74
|
+
|
|
75
|
+
async def get_directory_tree(path: str, max_depth: int = 2) -> str:
|
|
76
|
+
result = await session.call_tool(
|
|
77
|
+
"get_directory_tree", {"path": path, "max_depth": max_depth}
|
|
78
|
+
)
|
|
79
|
+
return "\n".join(b.text for b in result.content if hasattr(b, "text"))
|
|
80
|
+
|
|
81
|
+
return [
|
|
82
|
+
StructuredTool.from_function(
|
|
83
|
+
coroutine=list_directory,
|
|
84
|
+
name="list_directory",
|
|
85
|
+
description="List all files and folders in a directory. Returns names, types, and sizes.",
|
|
86
|
+
args_schema=ListDirectoryInput,
|
|
87
|
+
),
|
|
88
|
+
StructuredTool.from_function(
|
|
89
|
+
coroutine=get_directory_tree,
|
|
90
|
+
name="get_directory_tree",
|
|
91
|
+
description="Get a recursive tree view of a directory up to a specified depth.",
|
|
92
|
+
args_schema=GetDirectoryTreeInput,
|
|
93
|
+
),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ─────────────────────────────────────────────
|
|
98
|
+
# STEP 3: LLM setup
|
|
99
|
+
# ─────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
llm = ChatAnthropic(model="claude-sonnet-4-6", temperature=0, max_tokens=4096)
|
|
102
|
+
|
|
103
|
+
SYSTEM_PROMPT = (
|
|
104
|
+
"You are a helpful file system assistant running on macOS. "
|
|
105
|
+
"You have tools to list directories and get directory trees. "
|
|
106
|
+
"When a user asks about files or folders, use the appropriate tool. "
|
|
107
|
+
"Be concise and helpful."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ─────────────────────────────────────────────
|
|
112
|
+
# STEP 4: Connect to the MCP server and run the agent loop
|
|
113
|
+
#
|
|
114
|
+
# sse_client connects to the running HTTP server — it does NOT spawn a process.
|
|
115
|
+
# The session stays open for the entire conversation so all tool calls
|
|
116
|
+
# go through the same persistent connection.
|
|
117
|
+
# ─────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
async def main():
|
|
120
|
+
print("\n" + "=" * 55)
|
|
121
|
+
print(" LangChain Agent — File System Assistant (MCP)")
|
|
122
|
+
print(" Type 'exit' to quit")
|
|
123
|
+
print("=" * 55)
|
|
124
|
+
print(f"\nConnecting to MCP server at {MCP_SERVER_URL} ...")
|
|
125
|
+
print("(Start mcp_server.py first if you haven't already)\n")
|
|
126
|
+
|
|
127
|
+
api_key = os.environ.get("MCP_API_KEY", "")
|
|
128
|
+
if not api_key:
|
|
129
|
+
print("Error: MCP_API_KEY is not set. Add it to .env or export it.")
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
|
|
132
|
+
auth_headers = {"Authorization": f"Bearer {api_key}"}
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
async with sse_client(MCP_SERVER_URL, headers=auth_headers) as (read, write):
|
|
136
|
+
async with ClientSession(read, write) as session:
|
|
137
|
+
await session.initialize()
|
|
138
|
+
|
|
139
|
+
tools = make_mcp_tools(session)
|
|
140
|
+
agent = create_agent(llm, tools, system_prompt=SYSTEM_PROMPT)
|
|
141
|
+
|
|
142
|
+
print(f"Connected! Tools: {[t.name for t in tools]}")
|
|
143
|
+
print("\nExample prompts:")
|
|
144
|
+
print(" > What's in my home directory?")
|
|
145
|
+
print(" > List /Users/jaydenaung/code")
|
|
146
|
+
print(" > Show me the tree of ~/Downloads")
|
|
147
|
+
print()
|
|
148
|
+
|
|
149
|
+
messages = []
|
|
150
|
+
|
|
151
|
+
while True:
|
|
152
|
+
user_input = await asyncio.to_thread(input, "You: ")
|
|
153
|
+
user_input = user_input.strip()
|
|
154
|
+
|
|
155
|
+
if not user_input:
|
|
156
|
+
continue
|
|
157
|
+
if user_input.lower() in ("exit", "quit", "q"):
|
|
158
|
+
print("Bye!")
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
messages.append(HumanMessage(content=user_input))
|
|
162
|
+
result = await agent.ainvoke({"messages": messages})
|
|
163
|
+
|
|
164
|
+
ai_message = result["messages"][-1]
|
|
165
|
+
print(f"\nAgent: {ai_message.content}\n")
|
|
166
|
+
messages = result["messages"]
|
|
167
|
+
|
|
168
|
+
except* Exception as eg:
|
|
169
|
+
for exc in eg.exceptions:
|
|
170
|
+
if "connection refused" in str(exc).lower() or "connect" in str(exc).lower():
|
|
171
|
+
print(f"\nError: could not connect to {MCP_SERVER_URL}")
|
|
172
|
+
print("Make sure mcp_server.py is running in another terminal.")
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple MCP Server — Directory Lister
|
|
3
|
+
=====================================
|
|
4
|
+
Exposes two tools over HTTP/SSE: list_directory and get_directory_tree.
|
|
5
|
+
|
|
6
|
+
Runs as a standalone HTTP server so ANY MCP client can connect to it —
|
|
7
|
+
langchain_agent.py, Claude Desktop, Cursor, or the MCP inspector.
|
|
8
|
+
|
|
9
|
+
HOW TO RUN:
|
|
10
|
+
source venv/bin/activate
|
|
11
|
+
python mcp_server.py # starts on http://127.0.0.1:8000
|
|
12
|
+
python mcp_server.py --port 9000 # custom port
|
|
13
|
+
|
|
14
|
+
HOW TO CONNECT:
|
|
15
|
+
- langchain_agent.py connects automatically (run it in a separate terminal)
|
|
16
|
+
- MCP inspector: mcp dev mcp_server.py
|
|
17
|
+
- Claude Desktop: see README for config
|
|
18
|
+
|
|
19
|
+
WHAT THIS TEACHES:
|
|
20
|
+
- How an MCP server is structured with FastMCP
|
|
21
|
+
- How tools are defined (decorated functions — docstring = description)
|
|
22
|
+
- HTTP/SSE transport: the server is a proper long-running HTTP service,
|
|
23
|
+
not a subprocess spawned per-client like stdio mode
|
|
24
|
+
- The difference between MCP server vs LangChain tool:
|
|
25
|
+
LangChain tool = only works inside your Python agent
|
|
26
|
+
MCP server = works with ANY MCP-compatible client
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import logging
|
|
33
|
+
|
|
34
|
+
import uvicorn
|
|
35
|
+
from dotenv import load_dotenv
|
|
36
|
+
from mcp.server.fastmcp import FastMCP
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
load_dotenv()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ─────────────────────────────────────────────
|
|
43
|
+
# Logging — stderr keeps it out of the HTTP response stream
|
|
44
|
+
# ─────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
logging.basicConfig(
|
|
47
|
+
stream=sys.stderr,
|
|
48
|
+
level=logging.INFO,
|
|
49
|
+
format="%(asctime)s %(levelname)-7s %(message)s",
|
|
50
|
+
datefmt="%H:%M:%S",
|
|
51
|
+
)
|
|
52
|
+
log = logging.getLogger("file-browser")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ─────────────────────────────────────────────
|
|
56
|
+
# Bearer token auth middleware (raw ASGI)
|
|
57
|
+
#
|
|
58
|
+
# BaseHTTPMiddleware buffers the full response before sending, which breaks
|
|
59
|
+
# SSE streaming. Raw ASGI middleware passes the scope/receive/send directly
|
|
60
|
+
# so SSE streams flow through without buffering.
|
|
61
|
+
# ─────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
class BearerAuthMiddleware:
|
|
64
|
+
def __init__(self, app, token: str):
|
|
65
|
+
self.app = app
|
|
66
|
+
self._token = f"Bearer {token}".encode()
|
|
67
|
+
|
|
68
|
+
async def __call__(self, scope, receive, send):
|
|
69
|
+
if scope["type"] in ("http", "websocket"):
|
|
70
|
+
headers = dict(scope.get("headers", []))
|
|
71
|
+
auth = headers.get(b"authorization", b"")
|
|
72
|
+
if auth != self._token:
|
|
73
|
+
log.warning(f"[AUTH] rejected connection from {scope.get('client', ('?', 0))[0]}")
|
|
74
|
+
if scope["type"] == "http":
|
|
75
|
+
body = b'{"error":"Unauthorized: invalid or missing Bearer token"}'
|
|
76
|
+
await send({"type": "http.response.start", "status": 401,
|
|
77
|
+
"headers": [(b"content-type", b"application/json")]})
|
|
78
|
+
await send({"type": "http.response.body", "body": body})
|
|
79
|
+
return
|
|
80
|
+
log.info(f"[AUTH] accepted connection from {scope.get('client', ('?', 0))[0]}")
|
|
81
|
+
await self.app(scope, receive, send)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ─────────────────────────────────────────────
|
|
85
|
+
# STEP 1: Create the FastMCP server instance
|
|
86
|
+
# ─────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
mcp = FastMCP("file-browser")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ─────────────────────────────────────────────
|
|
92
|
+
# STEP 2: Define tools with @mcp.tool()
|
|
93
|
+
# FastMCP reads the function signature for the input schema and
|
|
94
|
+
# the docstring for the description — no manual schema required.
|
|
95
|
+
# ─────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
@mcp.tool()
|
|
98
|
+
def list_directory(path: str, show_hidden: bool = False) -> str:
|
|
99
|
+
"""
|
|
100
|
+
List all files and folders in a given directory.
|
|
101
|
+
Returns names, types (file/folder), and sizes.
|
|
102
|
+
Use this to browse the local file system.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
path: Absolute path to the directory. Examples: /Users/jaydenaung/code, ~/Documents
|
|
106
|
+
show_hidden: Whether to include hidden files (starting with '.'). Defaults to false.
|
|
107
|
+
"""
|
|
108
|
+
log.info(f"[TOOL CALL] list_directory path={path!r} show_hidden={show_hidden}")
|
|
109
|
+
result = handle_list_directory(path, show_hidden)
|
|
110
|
+
log.info(f"[TOOL DONE] list_directory → {result.count(chr(10)) + 1} lines")
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@mcp.tool()
|
|
115
|
+
def get_directory_tree(path: str, max_depth: int = 2) -> str:
|
|
116
|
+
"""
|
|
117
|
+
Get a recursive tree view of a directory up to a specified depth.
|
|
118
|
+
Useful for understanding a project's folder structure at a glance.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
path: Absolute path to the root directory.
|
|
122
|
+
max_depth: How many levels deep to recurse. Default is 2.
|
|
123
|
+
"""
|
|
124
|
+
log.info(f"[TOOL CALL] get_directory_tree path={path!r} max_depth={max_depth}")
|
|
125
|
+
result = handle_directory_tree(path, max_depth)
|
|
126
|
+
log.info(f"[TOOL DONE] get_directory_tree → {result.count(chr(10)) + 1} lines")
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ─────────────────────────────────────────────
|
|
131
|
+
# STEP 3: Business logic (pure Python, no MCP dependency)
|
|
132
|
+
# ─────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def handle_list_directory(path: str, show_hidden: bool = False) -> str:
|
|
135
|
+
try:
|
|
136
|
+
expanded = os.path.expanduser(path)
|
|
137
|
+
dir_path = Path(expanded)
|
|
138
|
+
|
|
139
|
+
if not dir_path.exists():
|
|
140
|
+
return f"❌ Path does not exist: {expanded}"
|
|
141
|
+
if not dir_path.is_dir():
|
|
142
|
+
return f"❌ Not a directory: {expanded}"
|
|
143
|
+
|
|
144
|
+
entries = sorted(dir_path.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
|
|
145
|
+
if not show_hidden:
|
|
146
|
+
entries = [e for e in entries if not e.name.startswith(".")]
|
|
147
|
+
if not entries:
|
|
148
|
+
return f"Directory is empty: {expanded}"
|
|
149
|
+
|
|
150
|
+
lines = [f"📂 {expanded}\n", f"{'Name':<40} {'Type':<8} {'Size':>10}", "-" * 62]
|
|
151
|
+
for entry in entries:
|
|
152
|
+
try:
|
|
153
|
+
if entry.is_dir():
|
|
154
|
+
lines.append(f"📁 {entry.name:<38} {'folder':<8} {'':>10}")
|
|
155
|
+
else:
|
|
156
|
+
size_str = format_size(entry.stat().st_size)
|
|
157
|
+
lines.append(f"📄 {entry.name:<38} {'file':<8} {size_str:>10}")
|
|
158
|
+
except PermissionError:
|
|
159
|
+
lines.append(f"🔒 {entry.name:<38} (permission denied)")
|
|
160
|
+
|
|
161
|
+
lines.append(f"\nTotal: {len(entries)} item(s)")
|
|
162
|
+
return "\n".join(lines)
|
|
163
|
+
|
|
164
|
+
except PermissionError:
|
|
165
|
+
return f"❌ Permission denied: {path}"
|
|
166
|
+
except Exception as e:
|
|
167
|
+
return f"❌ Error: {str(e)}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def handle_directory_tree(path: str, max_depth: int = 2) -> str:
|
|
171
|
+
try:
|
|
172
|
+
expanded = os.path.expanduser(path)
|
|
173
|
+
root = Path(expanded)
|
|
174
|
+
|
|
175
|
+
if not root.exists():
|
|
176
|
+
return f"❌ Path does not exist: {expanded}"
|
|
177
|
+
if not root.is_dir():
|
|
178
|
+
return f"❌ Not a directory: {expanded}"
|
|
179
|
+
|
|
180
|
+
lines = [f"📂 {root.name}/"]
|
|
181
|
+
_build_tree(root, lines, prefix="", depth=0, max_depth=max_depth)
|
|
182
|
+
lines.append(f"\n(max depth: {max_depth})")
|
|
183
|
+
return "\n".join(lines)
|
|
184
|
+
|
|
185
|
+
except Exception as e:
|
|
186
|
+
return f"❌ Error: {str(e)}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _build_tree(directory: Path, lines: list, prefix: str, depth: int, max_depth: int):
|
|
190
|
+
if depth >= max_depth:
|
|
191
|
+
return
|
|
192
|
+
try:
|
|
193
|
+
entries = sorted(directory.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
|
|
194
|
+
entries = [e for e in entries if not e.name.startswith(".")]
|
|
195
|
+
except PermissionError:
|
|
196
|
+
lines.append(f"{prefix}└── 🔒 (permission denied)")
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
for i, entry in enumerate(entries):
|
|
200
|
+
is_last = (i == len(entries) - 1)
|
|
201
|
+
connector = "└── " if is_last else "├── "
|
|
202
|
+
icon = "📁" if entry.is_dir() else "📄"
|
|
203
|
+
lines.append(f"{prefix}{connector}{icon} {entry.name}")
|
|
204
|
+
if entry.is_dir():
|
|
205
|
+
extension = " " if is_last else "│ "
|
|
206
|
+
_build_tree(entry, lines, prefix + extension, depth + 1, max_depth)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def format_size(size_bytes: int) -> str:
|
|
210
|
+
for unit in ["B", "KB", "MB", "GB"]:
|
|
211
|
+
if size_bytes < 1024:
|
|
212
|
+
return f"{size_bytes:.0f} {unit}"
|
|
213
|
+
size_bytes /= 1024
|
|
214
|
+
return f"{size_bytes:.1f} TB"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ─────────────────────────────────────────────
|
|
218
|
+
# STEP 4: Run as a standalone HTTP/SSE server
|
|
219
|
+
#
|
|
220
|
+
# SSE transport = the server is a real HTTP service.
|
|
221
|
+
# Any MCP client connects to http://host:port/sse — no subprocess spawning.
|
|
222
|
+
# This is what lets you run the server and agent in two separate terminals.
|
|
223
|
+
# ─────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
parser = argparse.ArgumentParser(description="file-browser MCP server")
|
|
227
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
228
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
229
|
+
args = parser.parse_args()
|
|
230
|
+
|
|
231
|
+
api_key = os.environ.get("MCP_API_KEY", "")
|
|
232
|
+
if not api_key:
|
|
233
|
+
log.error("MCP_API_KEY is not set — refusing to start without auth.")
|
|
234
|
+
log.error("Set it in .env or export MCP_API_KEY=<secret>")
|
|
235
|
+
sys.exit(1)
|
|
236
|
+
|
|
237
|
+
log.info("file-browser MCP server starting (SSE/HTTP transport)")
|
|
238
|
+
log.info("tools: list_directory, get_directory_tree")
|
|
239
|
+
log.info(f"listening on http://{args.host}:{args.port}/sse [auth: Bearer token]")
|
|
240
|
+
log.info("waiting for agent connection...")
|
|
241
|
+
|
|
242
|
+
# Wrap the Starlette SSE app with bearer token auth before handing to uvicorn.
|
|
243
|
+
# Using raw ASGI (not mcp.run) so we control the middleware stack.
|
|
244
|
+
app = BearerAuthMiddleware(mcp.sse_app(), token=api_key)
|
|
245
|
+
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# LangChain Agent + MCP Server
|
|
2
|
+
# Python 3.11+ recommended
|
|
3
|
+
|
|
4
|
+
# LangChain core
|
|
5
|
+
langchain>=0.3.0
|
|
6
|
+
langchain-core>=0.3.0
|
|
7
|
+
langchain-anthropic>=0.3.0
|
|
8
|
+
|
|
9
|
+
# MCP server + inspector (cli extras required for mcp dev)
|
|
10
|
+
mcp[cli]>=1.0.0
|
|
11
|
+
|
|
12
|
+
# Anthropic SDK (pulled in by langchain-anthropic, pinned for safety)
|
|
13
|
+
anthropic>=0.40.0
|
|
14
|
+
|
|
15
|
+
# Env file loading
|
|
16
|
+
python-dotenv>=1.0.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/supply_chain_report.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|