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.
Files changed (42) hide show
  1. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/.gitignore +3 -0
  2. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/PKG-INFO +62 -6
  3. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/README.md +61 -5
  4. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/mcp_client.py +135 -11
  5. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/pyproject.toml +1 -1
  6. agentsentinel_cli-0.7.1/tmp/test-mcp-agent/README.md +134 -0
  7. agentsentinel_cli-0.7.1/tmp/test-mcp-agent/langchain_agent.py +178 -0
  8. agentsentinel_cli-0.7.1/tmp/test-mcp-agent/mcp_server.py +245 -0
  9. agentsentinel_cli-0.7.1/tmp/test-mcp-agent/requirements.txt +16 -0
  10. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/DOCUMENTATION.md +0 -0
  11. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/LICENSE +0 -0
  12. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/__init__.py +0 -0
  13. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/a2a_report.py +0 -0
  14. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/a2a_rules.py +0 -0
  15. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/a2a_scanner.py +0 -0
  16. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/agent_mode.py +0 -0
  17. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/agent_mode_report.py +0 -0
  18. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/ai_probe.py +0 -0
  19. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/attacks/__init__.py +0 -0
  20. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/attacks/library.py +0 -0
  21. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/cli.py +0 -0
  22. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/discover.py +0 -0
  23. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/discover_report.py +0 -0
  24. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/fingerprint.py +0 -0
  25. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/frameworks.py +0 -0
  26. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/inspect.py +0 -0
  27. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/inspect_report.py +0 -0
  28. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/mcp_report.py +0 -0
  29. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/mcp_rules.py +0 -0
  30. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/probe.py +0 -0
  31. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/probe_report.py +0 -0
  32. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/report.py +0 -0
  33. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/rules.py +0 -0
  34. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/scanner.py +0 -0
  35. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/secrets.py +0 -0
  36. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/secrets_report.py +0 -0
  37. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/secrets_rules.py +0 -0
  38. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/supply_chain_ai.py +0 -0
  39. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/supply_chain_report.py +0 -0
  40. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/supply_chain_rules.py +0 -0
  41. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/suppress.py +0 -0
  42. {agentsentinel_cli-0.7.0 → agentsentinel_cli-0.7.1}/agentsentinel_cli/target.py +0 -0
@@ -39,3 +39,6 @@ Thumbs.db
39
39
  .vscode/
40
40
  *.swp
41
41
  ~$*
42
+
43
+ # Local documentation (not for commit)
44
+ doc/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentsentinel-cli
3
- Version: 0.7.0
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](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
52
52
  [![Python](https://img.shields.io/pypi/pyversions/agentsentinel-cli)](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 8 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/).
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 layer) |
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](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
5
5
  [![Python](https://img.shields.io/pypi/pyversions/agentsentinel-cli)](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 8 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/).
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 layer) |
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 and streamable-HTTP
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
- raise McpError(
109
- "Server returned 405 Method Not Allowed. "
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
- raise McpError(
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.0"
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