#
tokens: 3313/50000 2/2 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── external-recon.py
└── README.md
```

# Files

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
 1 | # mcp-vscode-template
 2 | MCP server template for VS Code Agent   
 3 | 
 4 | ###  Setup  
 5 | Install uv however you like. May options available.  
 6 | https://docs.astral.sh/uv/getting-started/installation/  
 7 | 
 8 | Project setup is heavily based off of Renae Schilg's work. I didn't even bother to change the project name as an homage although I did deviate on a few things, namely not using Claude Desktop, but also modified `external-recon.py` fairly heavily.  
 9 | https://nae-bo.medium.com/building-your-first-offensive-security-mcp-server-dd655e258d5f  
10 | 
11 | ```bash
12 | # Initialize project
13 | uv init external-recon
14 | cd external-recon
15 | ```
16 | 
17 | I had to modify the python versions in `.python-version` to `3.11` or something above 3.8 or 3.10  
18 | I also had to modify the line `requires-python = ">=3.11"` in `pyroject.toml` to something above 3.8 or 3.10
19 | Mileage will vary... It may not be necessary.  
20 | 
21 | ```bash
22 | # Create virtual environment and activate it
23 | uv venv --python 3.11
24 | source .venv/bin/activate
25 | 
26 | # Install mcp
27 | uv add "mcp[cli]"
28 | 
29 | # Install dnspython (dependency for external-recon.py, not necessary for all projects, but for this one yes)
30 | uv pip install dnspython
31 | 
32 | # Create MCP server external-recon.py file or empty and rename main.py
33 | touch external-recon.py
34 | ```
35 | 
36 | The VS Code `settings.json` should be modified.  
37 | Use `which uv` to find the path to uv.  
38 | The `"/path/to/project/external-recon"` should refer to the project path, where the MCP server .py file is located (use absoulte path).  
39 | 
40 | settings.json
41 | ```json
42 |     "mcp": {
43 |         "servers": {
44 |                 "external-recon": {
45 |                  "command": "/path/to/uv",
46 |                  "args": [
47 |                   "--directory",
48 |                   "/path/to/project/external-recon",
49 |                   "run",
50 |                   "external-recon.py"
51 |                  ]
52 |                 }
53 |         }
54 |     },
55 | ```
56 | 
57 | One of the main differences between Renae's work and mine is I used `@mcp.tool()` instead of `@mcp.prompt()` in `external-recon.py`
58 | 
59 | ### Start the server  
60 | From the venv of the project, start the server with `uv run external-recon.py`  
61 | Example:  
62 | ```bash
63 | (external-recon) user@workstation external-recon % uv run external-recon.py
64 | ```
65 | 
```

--------------------------------------------------------------------------------
/external-recon.py:
--------------------------------------------------------------------------------

```python
  1 | from mcp.server.fastmcp import FastMCP
  2 | import subprocess
  3 | # import httpx
  4 | # import os
  5 | # import json
  6 | import socket
  7 | import dns.resolver
  8 | import re
  9 | from typing import Dict, Any
 10 | # from typing import List, Dict, Any, Optional
 11 | 
 12 | 
 13 | # Initialise FastMCP server
 14 | mcp = FastMCP("external-recon")
 15 | 
 16 | ## Prompt to initialise the AI model to the task
 17 | @mcp.tool()
 18 | def setup_prompt(domainname: str) -> str:
 19 |     """
 20 |     setup external reconnaissance by domain name
 21 | 
 22 |     :param domainname: domain name to target
 23 |     :type domainname: str
 24 |     :return:
 25 |     :rtype: str
 26 |     """
 27 | 
 28 |     return f"""
 29 | Your role is a highly skilled penetration tester specialising in network reconnaissance. Your primary objective is to enumerate the {domainname} domain and report on discovered IP addresses, subdomains, and email security.
 30 | 
 31 | Observer carefully the output of the tools in inform next steps
 32 | 
 33 | Your objective is to perform reconnaissance against the organisation's domain name, identify IP addresses, discover subdomains, report on the ownership of the domains, and assess the email security measures. When you find new IP addresses or subdomains I want you to repeat enumeration steps.
 34 | 
 35 | First, reflect on the objective, then execute any tools you have access to on the target domain {domainname} and report your findings on all IP addresses and subdomains discovered.
 36 | """
 37 | 
 38 | @mcp.tool()
 39 | def dns_lookup(domain: str) -> Dict[str, Any]:
 40 |     """
 41 |     Perform DNS lookups for A, MX, NS, TXT, and SOA records
 42 | 
 43 |     :param domain: The domain to look up
 44 |     :type domain: str
 45 |     :return: Dictionary containing different DNS records
 46 |     :rtype: Dict[str, Any]
 47 |     """
 48 |     results = {}
 49 |     
 50 |     try:
 51 |         # A records (IPv4 addresses)
 52 |         try:
 53 |             a_records = dns.resolver.resolve(domain, 'A')
 54 |             results['a_records'] = [record.to_text() for record in a_records]
 55 |         except Exception as e:
 56 |             results['a_records'] = f"Error: {str(e)}"
 57 |             
 58 |         # MX records (Mail servers)
 59 |         try:
 60 |             mx_records = dns.resolver.resolve(domain, 'MX')
 61 |             results['mx_records'] = [record.to_text() for record in mx_records]
 62 |         except Exception as e:
 63 |             results['mx_records'] = f"Error: {str(e)}"
 64 |             
 65 |         # NS records (Name servers)
 66 |         try:
 67 |             ns_records = dns.resolver.resolve(domain, 'NS')
 68 |             results['ns_records'] = [record.to_text() for record in ns_records]
 69 |         except Exception as e:
 70 |             results['ns_records'] = f"Error: {str(e)}"
 71 |             
 72 |         # TXT records (Text records, includes SPF usually)
 73 |         try:
 74 |             txt_records = dns.resolver.resolve(domain, 'TXT')
 75 |             results['txt_records'] = [record.to_text() for record in txt_records]
 76 |         except Exception as e:
 77 |             results['txt_records'] = f"Error: {str(e)}"
 78 |             
 79 |         # SOA records (Start of Authority)
 80 |         try:
 81 |             soa_records = dns.resolver.resolve(domain, 'SOA')
 82 |             results['soa_records'] = [record.to_text() for record in soa_records]
 83 |         except Exception as e:
 84 |             results['soa_records'] = f"Error: {str(e)}"
 85 |             
 86 |     except Exception as e:
 87 |         results['error'] = f"General error: {str(e)}"
 88 |         
 89 |     return results
 90 | 
 91 | @mcp.tool()
 92 | def check_email_security(domain: str) -> Dict[str, Any]:
 93 |     """
 94 |     Check for email security measures like SPF, DMARC and DKIM
 95 | 
 96 |     :param domain: The domain to check
 97 |     :type domain: str
 98 |     :return: Dictionary containing email security findings
 99 |     :rtype: Dict[str, Any]
100 |     """
101 |     results = {"spf": None, "dmarc": None, "dkim_configured": False}
102 |     
103 |     # Check SPF (Sender Policy Framework)
104 |     try:
105 |         txt_records = dns.resolver.resolve(domain, 'TXT')
106 |         for record in txt_records:
107 |             if "v=spf1" in record.to_text():
108 |                 results["spf"] = record.to_text()
109 |                 break
110 |     except Exception as e:
111 |         results["spf_error"] = str(e)
112 |     
113 |     # Check DMARC (Domain-based Message Authentication, Reporting & Conformance)
114 |     try:
115 |         dmarc_records = dns.resolver.resolve(f"_dmarc.{domain}", 'TXT')
116 |         for record in dmarc_records:
117 |             if "v=DMARC1" in record.to_text():
118 |                 results["dmarc"] = record.to_text()
119 |                 break
120 |     except Exception as e:
121 |         results["dmarc_error"] = str(e)
122 |     
123 |     # Check DKIM (DomainKeys Identified Mail)
124 |     # We can't check directly as DKIM selectors are custom
125 |     # But we can try common selectors
126 |     common_selectors = ["default", "mail", "email", "selector1", "selector2", "k1", "dkim"]
127 |     for selector in common_selectors:
128 |         try:
129 |             dkim_records = dns.resolver.resolve(f"{selector}._domainkey.{domain}", 'TXT')
130 |             results["dkim_configured"] = True
131 |             results["dkim_selector"] = selector
132 |             results["dkim"] = dkim_records[0].to_text()
133 |             break
134 |         except:
135 |             pass
136 |     
137 |     return results
138 | 
139 | @mcp.tool()
140 | def scan_ports(target: str, ports: str = "21,22,23,25,53,80,443,8080,8443") -> Dict[str, Any]:
141 |     """
142 |     Scan for open ports on a target IP or domain
143 | 
144 |     :param target: IP or domain to scan
145 |     :type target: str
146 |     :param ports: Comma-separated list of ports to scan (defaults to common ports)
147 |     :type ports: str
148 |     :return: Dictionary containing open ports and their services
149 |     :rtype: Dict[str, Any]
150 |     """
151 |     results = {"open_ports": []}
152 |     
153 |     # First resolve the target if it's a domain
154 |     try:
155 |         if not re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", target):
156 |             ip = socket.gethostbyname(target)
157 |             results["resolved_ip"] = ip
158 |         else:
159 |             ip = target
160 |     except Exception as e:
161 |         results["error"] = f"Could not resolve hostname: {str(e)}"
162 |         return results
163 |         
164 |     port_list = [int(p) for p in ports.split(",")]
165 |     
166 |     for port in port_list:
167 |         try:
168 |             sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
169 |             sock.settimeout(1)
170 |             result = sock.connect_ex((ip, port))
171 |             if result == 0:
172 |                 # Try to identify the service
173 |                 service = "unknown"
174 |                 try:
175 |                     # Common port to service mapping
176 |                     services = {
177 |                         21: "FTP", 22: "SSH", 23: "Telnet", 25: "SMTP", 
178 |                         53: "DNS", 80: "HTTP", 443: "HTTPS", 3389: "RDP",
179 |                         8080: "HTTP-Alt", 8443: "HTTPS-Alt"
180 |                     }
181 |                     if port in services:
182 |                         service = services[port]
183 |                 except:
184 |                     pass
185 |                     
186 |                 results["open_ports"].append({"port": port, "service": service})
187 |             sock.close()
188 |         except:
189 |             pass
190 |             
191 |     return results
192 | 
193 | @mcp.tool()
194 | def whois_lookup(domain: str) -> Dict[str, str]:
195 |     """
196 |     Perform a WHOIS lookup on a domain
197 | 
198 |     :param domain: The domain to look up
199 |     :type domain: str
200 |     :return: Dictionary containing WHOIS information
201 |     :rtype: Dict[str, str]
202 |     """
203 |     results = {}
204 |     
205 |     try:
206 |         # Use the whois command line tool
207 |         command = ["whois", domain]
208 |         result = subprocess.run(command, capture_output=True, text=True, timeout=30)
209 |         
210 |         # Extract useful information from the output
211 |         output = result.stdout
212 |         
213 |         # Extract registrar information
214 |         registrar_match = re.search(r"Registrar:\s*(.*)", output)
215 |         if registrar_match:
216 |             results["registrar"] = registrar_match.group(1).strip()
217 |             
218 |         # Extract creation date
219 |         creation_date_match = re.search(r"Creation Date:\s*(.*)", output)
220 |         if creation_date_match:
221 |             results["creation_date"] = creation_date_match.group(1).strip()
222 |             
223 |         # Extract expiration date
224 |         expiration_date_match = re.search(r"Registry Expiry Date:\s*(.*)", output)
225 |         if expiration_date_match:
226 |             results["expiration_date"] = expiration_date_match.group(1).strip()
227 |             
228 |         # Include the full output
229 |         results["raw_data"] = output
230 |         
231 |     except Exception as e:
232 |         results["error"] = f"WHOIS lookup failed: {str(e)}"
233 |         
234 |     return results
235 | 
236 | if __name__ == "__main__":
237 |     # Initialise and run the server
238 |     mcp.run(transport='stdio')
239 | 
```