From aacc88bc19282c4d680b3fd4d58cf7d060c05d82 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Thu, 14 Aug 2025 19:14:19 +0100 Subject: [PATCH 1/6] Start to add an MCP server still need to add tools --- compose/production/mcp-server/Dockerfile | 23 +++++ local.yml | 24 ++++++ mcp-server/ghostwriter_mcp_server/__init__.py | 0 .../resources/__init__.py | 0 .../resources/report_finding.py | 45 ++++++++++ .../ghostwriter_mcp_server/tools/__init__.py | 0 .../tools/generate_executive_summary.py | 86 +++++++++++++++++++ .../ghostwriter_mcp_server/utils/__init__.py | 0 .../ghostwriter_mcp_server/utils/auth.py | 25 ++++++ .../ghostwriter_mcp_server/utils/graphql.py | 29 +++++++ mcp-server/server.py | 60 +++++++++++++ requirements/mcp_requirements.txt | 7 ++ 12 files changed, 299 insertions(+) create mode 100755 compose/production/mcp-server/Dockerfile create mode 100755 mcp-server/ghostwriter_mcp_server/__init__.py create mode 100755 mcp-server/ghostwriter_mcp_server/resources/__init__.py create mode 100755 mcp-server/ghostwriter_mcp_server/resources/report_finding.py create mode 100755 mcp-server/ghostwriter_mcp_server/tools/__init__.py create mode 100755 mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py create mode 100755 mcp-server/ghostwriter_mcp_server/utils/__init__.py create mode 100755 mcp-server/ghostwriter_mcp_server/utils/auth.py create mode 100755 mcp-server/ghostwriter_mcp_server/utils/graphql.py create mode 100755 mcp-server/server.py create mode 100755 requirements/mcp_requirements.txt diff --git a/compose/production/mcp-server/Dockerfile b/compose/production/mcp-server/Dockerfile new file mode 100755 index 000000000..4366a6864 --- /dev/null +++ b/compose/production/mcp-server/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.10.9-alpine3.17 + +ENV PYTHONUNBUFFERED=1 + +ENV PYTHONPATH="$PYTHONPATH:/app/config" + +RUN apk --no-cache add build-base curl \ + && addgroup -S mcp \ + && adduser -S -G mcp mcp \ + && pip install --no-cache-dir -U setuptools pip + +COPY ./requirements /requirements + +RUN pip install --no-cache-dir -r /requirements/mcp_requirements.txt \ + && rm -rf /requirements + +COPY . /app + +USER mcp + +WORKDIR /app + +ENTRYPOINT [ "python", "server.py", "--host", "0.0.0.0", "--port", "3001" ] \ No newline at end of file diff --git a/local.yml b/local.yml index 06360c3f5..db09c770e 100644 --- a/local.yml +++ b/local.yml @@ -210,3 +210,27 @@ services: timeout: ${HEALTHCHECK_TIMEOUT} retries: ${HEALTHCHECK_RETRIES} start_period: ${HEALTHCHECK_START} + + mcp-server: + build: + context: . + dockerfile: ./compose/production/mcp-server/Dockerfile + image: ghostwriter_local_mcp_server + depends_on: + - graphql_engine + ports: + - "8081:3001" + volumes: + - ./mcp-server:/app + labels: + name: ghostwriter_mcp_server + environment: + - GHOSTWRITER_URL=http://${DJANGO_HOST}:${DJANGO_PORT} + - GRAPHQL_URL=http://${HASURA_GRAPHQL_SERVER_HOSTNAME}:${HASURA_GRAPHQL_SERVER_PORT}/v1/graphql + - JWT_SECRET_KEY=${DJANGO_JWT_SECRET_KEY} + healthcheck: + test: curl --insecure --fail http://mcp-server:8000/healthz || exit 1 + interval: ${HEALTHCHECK_INTERVAL} + timeout: ${HEALTHCHECK_TIMEOUT} + retries: ${HEALTHCHECK_RETRIES} + start_period: ${HEALTHCHECK_START} \ No newline at end of file diff --git a/mcp-server/ghostwriter_mcp_server/__init__.py b/mcp-server/ghostwriter_mcp_server/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/mcp-server/ghostwriter_mcp_server/resources/__init__.py b/mcp-server/ghostwriter_mcp_server/resources/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/mcp-server/ghostwriter_mcp_server/resources/report_finding.py b/mcp-server/ghostwriter_mcp_server/resources/report_finding.py new file mode 100755 index 000000000..e201f21bc --- /dev/null +++ b/mcp-server/ghostwriter_mcp_server/resources/report_finding.py @@ -0,0 +1,45 @@ +# 3rd Party Libraries +from mcp.server.fastmcp import Context, FastMCP + +# Ghostwriter MCP Server Imports +from ghostwriter_mcp_server.utils.graphql import graphql_request + +class ReportFindingResource: + """Resource for a reported finding.""" + + def __init__(self, mcp: FastMCP): + """Initialize the ReportFindingResource.""" + mcp.resource(name='report_finding', uri="reportedFinding://{finding_id}")(self.report_finding_resource) + + async def report_finding_resource( + self, + finding_id: int, + ctx: Context + ) -> str: + """Get a reported finding by its ID. + Args: + finding_id (int): The ID of the reported finding. + Returns: + dict: The reported finding data. + """ + await ctx.info(f'Getting reported finding for ID {finding_id}') + graphql_query = '''query GetReportedFinding($id: bigint!) { + reportedFinding(where: {id: {_eq: $id}}) { + title + cweId + cweName + findingType { + findingType + } + severity { + severity + } + cvssScore + cvssVector + description + replication_steps + mitigation + references + } + }''' + return await graphql_request(graphql_query, ctx, variables={"id": finding_id}) diff --git a/mcp-server/ghostwriter_mcp_server/tools/__init__.py b/mcp-server/ghostwriter_mcp_server/tools/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py b/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py new file mode 100755 index 000000000..03db140e6 --- /dev/null +++ b/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py @@ -0,0 +1,86 @@ +# 3rd Party Libraries +from mcp.server.fastmcp import Context, FastMCP +from mcp.types import SamplingMessage, TextContent + +# Ghostwriter MCP Server Imports +from ghostwriter_mcp_server.utils.graphql import graphql_request + +class GenerateExecutiveSummaryTool: + """Tool to generate executive summaries for reports.""" + + def __init__(self, mcp: FastMCP): + """Initialize the GenerateExecutiveSummaryTool.""" + mcp.tool(name='generate_executive_summary')(self.generate_executive_summary_tool) + + async def generate_executive_summary_tool( + self, + ctx: Context, + report_id: int + ) -> str: + """ + Generate an executive summary for a report. + + Args: + report_id (int): The ID of the report to generate a summary for. + + Returns: + dict: The response from Ghostwriter containing a list of `reportedFinding` containing the title of the finding and the report name it was found on. + """ + await ctx.info(f'Getting executive summary for report {report_id}') + + # Query the findings on the current report + graphql_query = '''query SearchReportFindings($reportId: bigint!) { + reportedFinding( + where: { + reportId: {_eq: $reportId}, + severity: {severity: {_in: ["Critical", "High", "Medium", "Low", "Informational"]}} + }, + order_by: {severity: {weight: asc}} + ) { + title + severity { + severity + } + description + mitigation + } + }''' + response = await graphql_request(graphql_query, ctx, variables={"reportId": report_id}) + + # Format the response into markdown for the LLM to interpret + findings = [] + for finding in response.get("data", {}).get("reportedFinding", []): + title = finding["title"] + severity = finding["severity"]["severity"] + description = finding["description"] + mitigation = finding["mitigation"] + findings.append(f"# {title} ({severity})\n## Description\n{description}\n## Recommendation\n{mitigation}\n") + findings_str = "\n".join(findings) + + # Send as a prompt to the LLM + prompt = f"""You are a cybersecurity analyst tasked with generating an executive summary for a penetration test report. The summary should be concise, non-technical and it should be between 1 and 2 paragraphs, do not use bullet points. + Use the findings provided between the tags to: + 1. Summarize the overall security posture. + 2. Highlight the number and severity of findings (e.g., how many critical, high, medium, low, and informational issues were found). + 3. Mention the general nature of the vulnerabilities discovered (e.g., misconfigurations, outdated software, weak authentication). + 4. Emphasize the importance of remediation and outline a general prioritization strategy. + 5. Maintain a professional tone and avoid deep technical jargon. + + + {findings_str} + """ + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt) + ) + ], + max_tokens=1000, + ) + + # Return the generated executive summary + if result.content.type == "text": + return result.content.text + return str(result.content) diff --git a/mcp-server/ghostwriter_mcp_server/utils/__init__.py b/mcp-server/ghostwriter_mcp_server/utils/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/mcp-server/ghostwriter_mcp_server/utils/auth.py b/mcp-server/ghostwriter_mcp_server/utils/auth.py new file mode 100755 index 000000000..da4c39f2e --- /dev/null +++ b/mcp-server/ghostwriter_mcp_server/utils/auth.py @@ -0,0 +1,25 @@ +# Standard Libraries +import jwt + +# 3rd Party Libraries +import environ +from mcp.server.auth.provider import AccessToken, TokenVerifier + +env = environ.Env() +JWT_SECRET_KEY = env("JWT_SECRET_KEY", default="secret") + +class GhostwriterTokenVerifier(TokenVerifier): + """Simple token verifier for demonstration.""" + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify the JWT token and return the access token.""" + try: + decoded = jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"], audience="Ghostwriter") + return AccessToken( + token=token, + client_id=decoded.get("client_id", ""), + scopes=decoded.get("scopes", ["user"]), + expires_at=decoded.get("exp"), + ) + except Exception as e: + return None \ No newline at end of file diff --git a/mcp-server/ghostwriter_mcp_server/utils/graphql.py b/mcp-server/ghostwriter_mcp_server/utils/graphql.py new file mode 100755 index 000000000..04a17e931 --- /dev/null +++ b/mcp-server/ghostwriter_mcp_server/utils/graphql.py @@ -0,0 +1,29 @@ +# Standard Libraries +import httpx + +# 3rd Party Libraries +import environ +from starlette.requests import Request +from mcp.server.fastmcp import Context + +env = environ.Env() +GRAPHQL_URL = env("GRAPHQL_URL", default="http://localhost:8080") + +async def graphql_request(query: str, context: Context, variables: dict = None) -> dict: + """Helper function to make async GraphQL requests.""" + request: Request = context.request_context.request + token = request.headers.get("Authorization") + if not token: + raise Exception("Unauthorized: No Authorization header found") + headers = {"Content-Type": "application/json", "Authorization": token} + async with httpx.AsyncClient() as client: + response = await client.post( + GRAPHQL_URL, + json={"query": query, "variables": variables}, + headers=headers + ) + response_json = response.json() + if "errors" in response_json: + raise Exception(f"GraphQL query failed with errors: {response_json['errors']}") + else: + return response_json \ No newline at end of file diff --git a/mcp-server/server.py b/mcp-server/server.py new file mode 100755 index 000000000..334c8658e --- /dev/null +++ b/mcp-server/server.py @@ -0,0 +1,60 @@ +# Standard Libraries +import sys +import argparse + +# 3rd Party Libraries +import environ +from pydantic import AnyHttpUrl +from mcp.server.fastmcp import FastMCP +from mcp.server.auth.settings import AuthSettings +from loguru import logger + +# Ghostwriter MCP Server Imports +from ghostwriter_mcp_server.utils.auth import GhostwriterTokenVerifier +# from ghostwriter_mcp_server.resources.report_finding import ReportFindingResource +from ghostwriter_mcp_server.tools.generate_executive_summary import GenerateExecutiveSummaryTool + +def main() -> int: + """Entry point for the Ghostwriter MCP server. + + Returns: + int: Exit code (0 for success, non-zero for failure) + """ + env = environ.Env() + FASTMCP_LOG_LEVEL = env("FASTMCP_LOG_LEVEL", default="WARNING") + + logger.remove() + logger.add(sys.stderr, level=FASTMCP_LOG_LEVEL) + + parser = argparse.ArgumentParser(description='Ghostwriter MCP Server') + parser.add_argument('--host', default='localhost', help='Host for the MCP server') + parser.add_argument('--port', type=int, default=8000, help='Port for the MCP server') + + args = parser.parse_args() + + mcp = FastMCP( + "Ghostwriter MCP Server", + token_verifier=GhostwriterTokenVerifier(), + host=args.host, + port=args.port, + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("http://localhost:3001"), + required_scopes=["user"], + ), + ) + + # ReportFindingResource(mcp) + + GenerateExecutiveSummaryTool(mcp) + + try: + logger.info(f'Starting Ghostwriter MCP Server on {args.host}:{args.port}') + mcp.run(transport="streamable-http") + return 0 + except Exception as e: + logger.error(f'Error starting Ghostwriter MCP Server: {e}') + return 1 + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/requirements/mcp_requirements.txt b/requirements/mcp_requirements.txt new file mode 100755 index 000000000..cdf0a6c17 --- /dev/null +++ b/requirements/mcp_requirements.txt @@ -0,0 +1,7 @@ +django-environ==0.10.0 # https://github.com/joke2k/django-environ +httpx==0.28.1 +pydantic==2.11.7 +starlette==0.47.2 +loguru==0.7.3 +pyjwt==2.10.1 +mcp==1.12.4 \ No newline at end of file From 55b71e17a284d771a555e69410504ab1cfc4a460 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Sat, 16 Aug 2025 15:06:48 +0100 Subject: [PATCH 2/6] Modified --- compose/production/mcp-server/Dockerfile | 2 - mcp-server/README.md | 14 ++++++ mcp-server/ghostwriter_mcp_server/__init__.py | 0 .../resources/__init__.py | 0 .../resources/report_finding.py | 45 ----------------- .../ghostwriter_mcp_server/tools/__init__.py | 0 .../tools/generate_executive_summary.py | 50 +++++++------------ .../ghostwriter_mcp_server/utils/__init__.py | 0 .../ghostwriter_mcp_server/utils/auth.py | 4 +- .../ghostwriter_mcp_server/utils/graphql.py | 0 .../utils/load_config.py | 6 +++ mcp-server/prompts.yaml | 13 +++++ mcp-server/server.py | 21 +++----- requirements/mcp_requirements.txt | 2 +- 14 files changed, 64 insertions(+), 93 deletions(-) create mode 100644 mcp-server/README.md mode change 100755 => 100644 mcp-server/ghostwriter_mcp_server/__init__.py delete mode 100755 mcp-server/ghostwriter_mcp_server/resources/__init__.py delete mode 100755 mcp-server/ghostwriter_mcp_server/resources/report_finding.py mode change 100755 => 100644 mcp-server/ghostwriter_mcp_server/tools/__init__.py mode change 100755 => 100644 mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py mode change 100755 => 100644 mcp-server/ghostwriter_mcp_server/utils/__init__.py mode change 100755 => 100644 mcp-server/ghostwriter_mcp_server/utils/auth.py mode change 100755 => 100644 mcp-server/ghostwriter_mcp_server/utils/graphql.py create mode 100644 mcp-server/ghostwriter_mcp_server/utils/load_config.py create mode 100644 mcp-server/prompts.yaml diff --git a/compose/production/mcp-server/Dockerfile b/compose/production/mcp-server/Dockerfile index 4366a6864..22887330d 100755 --- a/compose/production/mcp-server/Dockerfile +++ b/compose/production/mcp-server/Dockerfile @@ -14,8 +14,6 @@ COPY ./requirements /requirements RUN pip install --no-cache-dir -r /requirements/mcp_requirements.txt \ && rm -rf /requirements -COPY . /app - USER mcp WORKDIR /app diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 000000000..fd3de0e77 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,14 @@ +# MCP Server + +Edit any files python files inside `mcp-server/` and you must run `./ghostwriter-cli containers restart --dev`. +A tool will only load the `prompts.yaml` file when it is called so editing this file does not require a container restart. + +# Authentication + +The MCP server uses the JWT from ghostwriter for authentication and is required when calling any MCP methods. This token is used when querying the graphql endpoint over the local docker network. + +# Tools + +## GenerateExecutiveSummaryTool + +This tool will query the findings on a given report ID and provide a prompt to the LLM to generate a natural language response. When the tool is called it loads the `executive_summary_prompt` from `prompts.yaml` and places your findings into the `{findings}` variable \ No newline at end of file diff --git a/mcp-server/ghostwriter_mcp_server/__init__.py b/mcp-server/ghostwriter_mcp_server/__init__.py old mode 100755 new mode 100644 diff --git a/mcp-server/ghostwriter_mcp_server/resources/__init__.py b/mcp-server/ghostwriter_mcp_server/resources/__init__.py deleted file mode 100755 index e69de29bb..000000000 diff --git a/mcp-server/ghostwriter_mcp_server/resources/report_finding.py b/mcp-server/ghostwriter_mcp_server/resources/report_finding.py deleted file mode 100755 index e201f21bc..000000000 --- a/mcp-server/ghostwriter_mcp_server/resources/report_finding.py +++ /dev/null @@ -1,45 +0,0 @@ -# 3rd Party Libraries -from mcp.server.fastmcp import Context, FastMCP - -# Ghostwriter MCP Server Imports -from ghostwriter_mcp_server.utils.graphql import graphql_request - -class ReportFindingResource: - """Resource for a reported finding.""" - - def __init__(self, mcp: FastMCP): - """Initialize the ReportFindingResource.""" - mcp.resource(name='report_finding', uri="reportedFinding://{finding_id}")(self.report_finding_resource) - - async def report_finding_resource( - self, - finding_id: int, - ctx: Context - ) -> str: - """Get a reported finding by its ID. - Args: - finding_id (int): The ID of the reported finding. - Returns: - dict: The reported finding data. - """ - await ctx.info(f'Getting reported finding for ID {finding_id}') - graphql_query = '''query GetReportedFinding($id: bigint!) { - reportedFinding(where: {id: {_eq: $id}}) { - title - cweId - cweName - findingType { - findingType - } - severity { - severity - } - cvssScore - cvssVector - description - replication_steps - mitigation - references - } - }''' - return await graphql_request(graphql_query, ctx, variables={"id": finding_id}) diff --git a/mcp-server/ghostwriter_mcp_server/tools/__init__.py b/mcp-server/ghostwriter_mcp_server/tools/__init__.py old mode 100755 new mode 100644 diff --git a/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py b/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py old mode 100755 new mode 100644 index 03db140e6..c00d721ef --- a/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py +++ b/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py @@ -1,9 +1,9 @@ # 3rd Party Libraries from mcp.server.fastmcp import Context, FastMCP -from mcp.types import SamplingMessage, TextContent # Ghostwriter MCP Server Imports from ghostwriter_mcp_server.utils.graphql import graphql_request +from ghostwriter_mcp_server.utils.load_config import load_config class GenerateExecutiveSummaryTool: """Tool to generate executive summaries for reports.""" @@ -24,18 +24,20 @@ async def generate_executive_summary_tool( report_id (int): The ID of the report to generate a summary for. Returns: - dict: The response from Ghostwriter containing a list of `reportedFinding` containing the title of the finding and the report name it was found on. + str: A system prompt to generate an executive summary """ - await ctx.info(f'Getting executive summary for report {report_id}') + await ctx.info(f'Loading the most up to date prompt template...') + prompts = load_config("prompts.yaml") - # Query the findings on the current report + await ctx.info(f'Querying the findings for report {report_id}') graphql_query = '''query SearchReportFindings($reportId: bigint!) { reportedFinding( where: { reportId: {_eq: $reportId}, severity: {severity: {_in: ["Critical", "High", "Medium", "Low", "Informational"]}} }, - order_by: {severity: {weight: asc}} + order_by: {severity: {weight: asc}}, + limit: 5 ) { title severity { @@ -45,9 +47,12 @@ async def generate_executive_summary_tool( mitigation } }''' + # Execute the GraphQL query response = await graphql_request(graphql_query, ctx, variables={"reportId": report_id}) + if "errors" in response: + Exception(response) - # Format the response into markdown for the LLM to interpret + await ctx.info(f'Formatting the findings into a markdown string') findings = [] for finding in response.get("data", {}).get("reportedFinding", []): title = finding["title"] @@ -57,30 +62,13 @@ async def generate_executive_summary_tool( findings.append(f"# {title} ({severity})\n## Description\n{description}\n## Recommendation\n{mitigation}\n") findings_str = "\n".join(findings) - # Send as a prompt to the LLM - prompt = f"""You are a cybersecurity analyst tasked with generating an executive summary for a penetration test report. The summary should be concise, non-technical and it should be between 1 and 2 paragraphs, do not use bullet points. - Use the findings provided between the tags to: - 1. Summarize the overall security posture. - 2. Highlight the number and severity of findings (e.g., how many critical, high, medium, low, and informational issues were found). - 3. Mention the general nature of the vulnerabilities discovered (e.g., misconfigurations, outdated software, weak authentication). - 4. Emphasize the importance of remediation and outline a general prioritization strategy. - 5. Maintain a professional tone and avoid deep technical jargon. + if not findings: + raise Exception("No findings found for the report.") - - {findings_str} - """ + await ctx.info(f'Generating the executive summary prompt') + prompt_template = prompts['executive_summary_prompt'] + if "{findings}" not in prompt_template: + Exception("The `executive_summary_prompt` prompt template must contain the {findings} variable") + prompt = prompt_template.format(findings=findings_str) - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt) - ) - ], - max_tokens=1000, - ) - - # Return the generated executive summary - if result.content.type == "text": - return result.content.text - return str(result.content) + return prompt diff --git a/mcp-server/ghostwriter_mcp_server/utils/__init__.py b/mcp-server/ghostwriter_mcp_server/utils/__init__.py old mode 100755 new mode 100644 diff --git a/mcp-server/ghostwriter_mcp_server/utils/auth.py b/mcp-server/ghostwriter_mcp_server/utils/auth.py old mode 100755 new mode 100644 index da4c39f2e..d6627b442 --- a/mcp-server/ghostwriter_mcp_server/utils/auth.py +++ b/mcp-server/ghostwriter_mcp_server/utils/auth.py @@ -9,10 +9,11 @@ JWT_SECRET_KEY = env("JWT_SECRET_KEY", default="secret") class GhostwriterTokenVerifier(TokenVerifier): - """Simple token verifier for demonstration.""" + """Verify the token from Ghostwriter.""" async def verify_token(self, token: str) -> AccessToken | None: """Verify the JWT token and return the access token.""" + print("Validating authentication token...") try: decoded = jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"], audience="Ghostwriter") return AccessToken( @@ -22,4 +23,5 @@ async def verify_token(self, token: str) -> AccessToken | None: expires_at=decoded.get("exp"), ) except Exception as e: + print(e) return None \ No newline at end of file diff --git a/mcp-server/ghostwriter_mcp_server/utils/graphql.py b/mcp-server/ghostwriter_mcp_server/utils/graphql.py old mode 100755 new mode 100644 diff --git a/mcp-server/ghostwriter_mcp_server/utils/load_config.py b/mcp-server/ghostwriter_mcp_server/utils/load_config.py new file mode 100644 index 000000000..4398babfc --- /dev/null +++ b/mcp-server/ghostwriter_mcp_server/utils/load_config.py @@ -0,0 +1,6 @@ +import yaml + +def load_config(file_path: str) -> dict: + with open(file_path, 'r') as file: + config = yaml.safe_load(file) + return config \ No newline at end of file diff --git a/mcp-server/prompts.yaml b/mcp-server/prompts.yaml new file mode 100644 index 000000000..3c00676f1 --- /dev/null +++ b/mcp-server/prompts.yaml @@ -0,0 +1,13 @@ +# Configure the prompt that the executive summary tool sends to the LLM to generate an executive summary from the report findings (Must include a {findings} variable) +executive_summary_prompt: | + You are a cybersecurity analyst tasked with generating an executive summary for a penetration test report. The summary should be concise, non-technical and it should be between 1 and 2 paragraphs, do not use bullet points. + Use the findings provided between the tags to: + 1. Summarize the overall security posture. + 2. Highlight the number and severity of findings (e.g., how many critical, high, medium, low, and informational issues were found). + 3. Mention the general nature of the vulnerabilities discovered (e.g., misconfigurations, outdated software, weak authentication). + 4. Emphasize the importance of remediation and outline a general prioritization strategy. + 5. Maintain a professional tone and avoid deep technical jargon. + + + {findings} + \ No newline at end of file diff --git a/mcp-server/server.py b/mcp-server/server.py index 334c8658e..4e5a09934 100755 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -1,4 +1,5 @@ # Standard Libraries +import os import sys import argparse @@ -7,12 +8,10 @@ from pydantic import AnyHttpUrl from mcp.server.fastmcp import FastMCP from mcp.server.auth.settings import AuthSettings -from loguru import logger # Ghostwriter MCP Server Imports -from ghostwriter_mcp_server.utils.auth import GhostwriterTokenVerifier -# from ghostwriter_mcp_server.resources.report_finding import ReportFindingResource from ghostwriter_mcp_server.tools.generate_executive_summary import GenerateExecutiveSummaryTool +from ghostwriter_mcp_server.utils.auth import GhostwriterTokenVerifier def main() -> int: """Entry point for the Ghostwriter MCP server. @@ -21,10 +20,8 @@ def main() -> int: int: Exit code (0 for success, non-zero for failure) """ env = environ.Env() - FASTMCP_LOG_LEVEL = env("FASTMCP_LOG_LEVEL", default="WARNING") - - logger.remove() - logger.add(sys.stderr, level=FASTMCP_LOG_LEVEL) + GHOSTWRITER_URL = env("GHOSTWRITER_URL", default="http://localhost:8000") + GRAPHQL_URL = env("GRAPHQL_URL", default="http://localhost:8080/v1/graphql") parser = argparse.ArgumentParser(description='Ghostwriter MCP Server') parser.add_argument('--host', default='localhost', help='Host for the MCP server') @@ -38,22 +35,20 @@ def main() -> int: host=args.host, port=args.port, auth=AuthSettings( - issuer_url=AnyHttpUrl("https://auth.example.com"), - resource_server_url=AnyHttpUrl("http://localhost:3001"), + issuer_url=AnyHttpUrl(GHOSTWRITER_URL), + resource_server_url=AnyHttpUrl(GRAPHQL_URL), required_scopes=["user"], ), ) - # ReportFindingResource(mcp) - GenerateExecutiveSummaryTool(mcp) try: - logger.info(f'Starting Ghostwriter MCP Server on {args.host}:{args.port}') + print(f'Starting Ghostwriter MCP Server on {args.host}:{args.port}') mcp.run(transport="streamable-http") return 0 except Exception as e: - logger.error(f'Error starting Ghostwriter MCP Server: {e}') + print(f'Error starting Ghostwriter MCP Server: {e}') return 1 if __name__ == '__main__': diff --git a/requirements/mcp_requirements.txt b/requirements/mcp_requirements.txt index cdf0a6c17..8da280149 100755 --- a/requirements/mcp_requirements.txt +++ b/requirements/mcp_requirements.txt @@ -2,6 +2,6 @@ django-environ==0.10.0 # https://github.com/joke2k/django-environ httpx==0.28.1 pydantic==2.11.7 starlette==0.47.2 -loguru==0.7.3 +pyyaml==6.0.1 pyjwt==2.10.1 mcp==1.12.4 \ No newline at end of file From 42bc56321c79337e1b43afb25cfc788472d66849 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Sat, 16 Aug 2025 16:29:44 +0100 Subject: [PATCH 3/6] Raise an exception when no findings on the report are found --- .../tools/generate_executive_summary.py | 19 +++++++------------ mcp-server/server.py | 1 - 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py b/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py index c00d721ef..17db17fb7 100644 --- a/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py +++ b/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py @@ -49,26 +49,21 @@ async def generate_executive_summary_tool( }''' # Execute the GraphQL query response = await graphql_request(graphql_query, ctx, variables={"reportId": report_id}) - if "errors" in response: - Exception(response) await ctx.info(f'Formatting the findings into a markdown string') - findings = [] - for finding in response.get("data", {}).get("reportedFinding", []): - title = finding["title"] - severity = finding["severity"]["severity"] - description = finding["description"] - mitigation = finding["mitigation"] - findings.append(f"# {title} ({severity})\n## Description\n{description}\n## Recommendation\n{mitigation}\n") - findings_str = "\n".join(findings) + findings_list = response.get("data", {}).get("reportedFinding", []) + findings_str = "\n".join( + f"# {f['title']} ({f['severity']['severity']})\n## Description\n{f['description']}\n## Recommendation\n{f['mitigation']}\n" + for f in findings_list + ) - if not findings: + if not findings_list: raise Exception("No findings found for the report.") await ctx.info(f'Generating the executive summary prompt') prompt_template = prompts['executive_summary_prompt'] if "{findings}" not in prompt_template: - Exception("The `executive_summary_prompt` prompt template must contain the {findings} variable") + raise Exception("The `executive_summary_prompt` prompt template must contain the {findings} variable") prompt = prompt_template.format(findings=findings_str) return prompt diff --git a/mcp-server/server.py b/mcp-server/server.py index 4e5a09934..9499cffc9 100755 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -1,5 +1,4 @@ # Standard Libraries -import os import sys import argparse From 19522bfaed8465d63107ae01f776d6be90244c0b Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Sun, 17 Aug 2025 15:25:17 +0100 Subject: [PATCH 4/6] Use fastmcp instead --- .../tools/generate_executive_summary.py | 2 +- mcp-server/ghostwriter_mcp_server/utils/auth.py | 2 +- .../ghostwriter_mcp_server/utils/graphql.py | 2 +- mcp-server/server.py | 15 ++------------- requirements/mcp_requirements.txt | 3 +-- 5 files changed, 6 insertions(+), 18 deletions(-) diff --git a/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py b/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py index 17db17fb7..720c2dec4 100644 --- a/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py +++ b/mcp-server/ghostwriter_mcp_server/tools/generate_executive_summary.py @@ -1,5 +1,5 @@ # 3rd Party Libraries -from mcp.server.fastmcp import Context, FastMCP +from fastmcp import FastMCP, Context # Ghostwriter MCP Server Imports from ghostwriter_mcp_server.utils.graphql import graphql_request diff --git a/mcp-server/ghostwriter_mcp_server/utils/auth.py b/mcp-server/ghostwriter_mcp_server/utils/auth.py index d6627b442..8108aecb7 100644 --- a/mcp-server/ghostwriter_mcp_server/utils/auth.py +++ b/mcp-server/ghostwriter_mcp_server/utils/auth.py @@ -3,7 +3,7 @@ # 3rd Party Libraries import environ -from mcp.server.auth.provider import AccessToken, TokenVerifier +from fastmcp.server.auth import AccessToken, TokenVerifier env = environ.Env() JWT_SECRET_KEY = env("JWT_SECRET_KEY", default="secret") diff --git a/mcp-server/ghostwriter_mcp_server/utils/graphql.py b/mcp-server/ghostwriter_mcp_server/utils/graphql.py index 04a17e931..540dc51a1 100644 --- a/mcp-server/ghostwriter_mcp_server/utils/graphql.py +++ b/mcp-server/ghostwriter_mcp_server/utils/graphql.py @@ -4,7 +4,7 @@ # 3rd Party Libraries import environ from starlette.requests import Request -from mcp.server.fastmcp import Context +from fastmcp import Context env = environ.Env() GRAPHQL_URL = env("GRAPHQL_URL", default="http://localhost:8080") diff --git a/mcp-server/server.py b/mcp-server/server.py index 9499cffc9..1dca94afe 100755 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -3,10 +3,7 @@ import argparse # 3rd Party Libraries -import environ -from pydantic import AnyHttpUrl -from mcp.server.fastmcp import FastMCP -from mcp.server.auth.settings import AuthSettings +from fastmcp import FastMCP # Ghostwriter MCP Server Imports from ghostwriter_mcp_server.tools.generate_executive_summary import GenerateExecutiveSummaryTool @@ -18,9 +15,6 @@ def main() -> int: Returns: int: Exit code (0 for success, non-zero for failure) """ - env = environ.Env() - GHOSTWRITER_URL = env("GHOSTWRITER_URL", default="http://localhost:8000") - GRAPHQL_URL = env("GRAPHQL_URL", default="http://localhost:8080/v1/graphql") parser = argparse.ArgumentParser(description='Ghostwriter MCP Server') parser.add_argument('--host', default='localhost', help='Host for the MCP server') @@ -30,14 +24,9 @@ def main() -> int: mcp = FastMCP( "Ghostwriter MCP Server", - token_verifier=GhostwriterTokenVerifier(), host=args.host, port=args.port, - auth=AuthSettings( - issuer_url=AnyHttpUrl(GHOSTWRITER_URL), - resource_server_url=AnyHttpUrl(GRAPHQL_URL), - required_scopes=["user"], - ), + auth=GhostwriterTokenVerifier(), ) GenerateExecutiveSummaryTool(mcp) diff --git a/requirements/mcp_requirements.txt b/requirements/mcp_requirements.txt index 8da280149..abf1588e7 100755 --- a/requirements/mcp_requirements.txt +++ b/requirements/mcp_requirements.txt @@ -1,7 +1,6 @@ django-environ==0.10.0 # https://github.com/joke2k/django-environ httpx==0.28.1 -pydantic==2.11.7 starlette==0.47.2 pyyaml==6.0.1 pyjwt==2.10.1 -mcp==1.12.4 \ No newline at end of file +fastmcp==2.11.3 \ No newline at end of file From 8f50b95fef347649be496fc8ee5168c737766ea4 Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Sun, 17 Aug 2025 16:05:35 +0100 Subject: [PATCH 5/6] Add resources for clients findings and report findings --- .../resources/__init__.py | 0 .../resources/client.py | 39 +++++++++++++++ .../resources/finding.py | 47 +++++++++++++++++++ .../resources/reported_finding.py | 47 +++++++++++++++++++ mcp-server/server.py | 9 ++++ 5 files changed, 142 insertions(+) create mode 100644 mcp-server/ghostwriter_mcp_server/resources/__init__.py create mode 100644 mcp-server/ghostwriter_mcp_server/resources/client.py create mode 100644 mcp-server/ghostwriter_mcp_server/resources/finding.py create mode 100644 mcp-server/ghostwriter_mcp_server/resources/reported_finding.py diff --git a/mcp-server/ghostwriter_mcp_server/resources/__init__.py b/mcp-server/ghostwriter_mcp_server/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mcp-server/ghostwriter_mcp_server/resources/client.py b/mcp-server/ghostwriter_mcp_server/resources/client.py new file mode 100644 index 000000000..27f4e8c5b --- /dev/null +++ b/mcp-server/ghostwriter_mcp_server/resources/client.py @@ -0,0 +1,39 @@ +# 3rd Party Libraries +from fastmcp import FastMCP, Context + +# Ghostwriter MCP Server Imports +from ghostwriter_mcp_server.utils.graphql import graphql_request + +class ClientResource: + """Resource for a client.""" + + def __init__(self, mcp: FastMCP): + """Initialize the ClientResource.""" + mcp.resource(name='client', uri="client://{client_id}")(self.client_resource) + + async def client_resource( + self, + client_id: int, + ctx: Context + ) -> dict: + """ + Get a client by its ID. + + Args: + client_id (int): The ID of the client. + + Returns: + dict: The client data. + """ + await ctx.info(f'Getting client with ID: {client_id}') + graphql_query = '''query GetClient($id: bigint!) { + client(where: {id: {_eq: $id}}) { + shortName + codename + timezone + note + address + } + }''' + response = await graphql_request(graphql_query, ctx, variables={"id": client_id}) + return response.get("data", {}) \ No newline at end of file diff --git a/mcp-server/ghostwriter_mcp_server/resources/finding.py b/mcp-server/ghostwriter_mcp_server/resources/finding.py new file mode 100644 index 000000000..18f76cff0 --- /dev/null +++ b/mcp-server/ghostwriter_mcp_server/resources/finding.py @@ -0,0 +1,47 @@ +# 3rd Party Libraries +from fastmcp import FastMCP, Context + +# Ghostwriter MCP Server Imports +from ghostwriter_mcp_server.utils.graphql import graphql_request + +class FindingResource: + """Resource for a finding.""" + + def __init__(self, mcp: FastMCP): + """Initialize the FindingResource.""" + mcp.resource(name='finding', uri="finding://{finding_id}")(self.finding_resource) + + async def finding_resource( + self, + finding_id: int, + ctx: Context + ) -> dict: + """ + Get a finding in the library. If you want to get a finding from a report use the `reportedfinding://{finding_id}` resource. + + Args: + finding_id (int): The ID of the finding. + + Returns: + dict: The finding data. + """ + await ctx.info(f'Getting finding with ID: {finding_id}') + graphql_query = '''query GetFinding($id: bigint!) { + finding(where: {id: {_eq: $id}}) { + title + type { + findingType + } + severity { + severity + } + cvssScore + cvssVector + description + replication_steps + mitigation + references + } + }''' + response = await graphql_request(graphql_query, ctx, variables={"id": finding_id}) + return response.get("data", {}) \ No newline at end of file diff --git a/mcp-server/ghostwriter_mcp_server/resources/reported_finding.py b/mcp-server/ghostwriter_mcp_server/resources/reported_finding.py new file mode 100644 index 000000000..dce816cb1 --- /dev/null +++ b/mcp-server/ghostwriter_mcp_server/resources/reported_finding.py @@ -0,0 +1,47 @@ +# 3rd Party Libraries +from fastmcp import FastMCP, Context + +# Ghostwriter MCP Server Imports +from ghostwriter_mcp_server.utils.graphql import graphql_request + +class ReportedFindingResource: + """Resource for a reported finding.""" + + def __init__(self, mcp: FastMCP): + """Initialize the ReportedFindingResource.""" + mcp.resource(name='reported_finding', uri="reportedfinding://{report_finding_id}")(self.report_finding_resource) + + async def report_finding_resource( + self, + report_finding_id: int, + ctx: Context + ) -> dict: + """ + Get a finding on a report. If you want to get a finding from the library use the `finding://{finding_id}` resource. + + Args: + finding_id (int): The ID of the report finding. + + Returns: + dict: The report finding data. + """ + await ctx.info(f'Getting reported finding with ID: {report_finding_id}') + graphql_query = '''query GetReportedFinding($id: bigint!) { + reportedFinding(where: {id: {_eq: $id}}) { + title + findingType { + findingType + } + severity { + severity + } + cvssScore + cvssVector + description + replication_steps + mitigation + references + } + }''' + response = await graphql_request(graphql_query, ctx, variables={"id": report_finding_id}) + return response.get("data", {}) \ No newline at end of file diff --git a/mcp-server/server.py b/mcp-server/server.py index 1dca94afe..25f56fc76 100755 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -6,6 +6,9 @@ from fastmcp import FastMCP # Ghostwriter MCP Server Imports +from ghostwriter_mcp_server.resources.client import ClientResource +from ghostwriter_mcp_server.resources.finding import FindingResource +from ghostwriter_mcp_server.resources.reported_finding import ReportedFindingResource from ghostwriter_mcp_server.tools.generate_executive_summary import GenerateExecutiveSummaryTool from ghostwriter_mcp_server.utils.auth import GhostwriterTokenVerifier @@ -29,6 +32,12 @@ def main() -> int: auth=GhostwriterTokenVerifier(), ) + # Resources + ClientResource(mcp) + FindingResource(mcp) + ReportedFindingResource(mcp) + + # Tools GenerateExecutiveSummaryTool(mcp) try: From 52a263d587b872b8da8d24b73ca3b0c7dc45742f Mon Sep 17 00:00:00 2001 From: Dom Whewell Date: Tue, 9 Sep 2025 16:19:15 +0100 Subject: [PATCH 6/6] Remove resources --- .../resources/__init__.py | 0 .../resources/client.py | 39 --------------- .../resources/finding.py | 47 ------------------- .../resources/reported_finding.py | 47 ------------------- mcp-server/server.py | 8 ---- 5 files changed, 141 deletions(-) delete mode 100644 mcp-server/ghostwriter_mcp_server/resources/__init__.py delete mode 100644 mcp-server/ghostwriter_mcp_server/resources/client.py delete mode 100644 mcp-server/ghostwriter_mcp_server/resources/finding.py delete mode 100644 mcp-server/ghostwriter_mcp_server/resources/reported_finding.py diff --git a/mcp-server/ghostwriter_mcp_server/resources/__init__.py b/mcp-server/ghostwriter_mcp_server/resources/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/mcp-server/ghostwriter_mcp_server/resources/client.py b/mcp-server/ghostwriter_mcp_server/resources/client.py deleted file mode 100644 index 27f4e8c5b..000000000 --- a/mcp-server/ghostwriter_mcp_server/resources/client.py +++ /dev/null @@ -1,39 +0,0 @@ -# 3rd Party Libraries -from fastmcp import FastMCP, Context - -# Ghostwriter MCP Server Imports -from ghostwriter_mcp_server.utils.graphql import graphql_request - -class ClientResource: - """Resource for a client.""" - - def __init__(self, mcp: FastMCP): - """Initialize the ClientResource.""" - mcp.resource(name='client', uri="client://{client_id}")(self.client_resource) - - async def client_resource( - self, - client_id: int, - ctx: Context - ) -> dict: - """ - Get a client by its ID. - - Args: - client_id (int): The ID of the client. - - Returns: - dict: The client data. - """ - await ctx.info(f'Getting client with ID: {client_id}') - graphql_query = '''query GetClient($id: bigint!) { - client(where: {id: {_eq: $id}}) { - shortName - codename - timezone - note - address - } - }''' - response = await graphql_request(graphql_query, ctx, variables={"id": client_id}) - return response.get("data", {}) \ No newline at end of file diff --git a/mcp-server/ghostwriter_mcp_server/resources/finding.py b/mcp-server/ghostwriter_mcp_server/resources/finding.py deleted file mode 100644 index 18f76cff0..000000000 --- a/mcp-server/ghostwriter_mcp_server/resources/finding.py +++ /dev/null @@ -1,47 +0,0 @@ -# 3rd Party Libraries -from fastmcp import FastMCP, Context - -# Ghostwriter MCP Server Imports -from ghostwriter_mcp_server.utils.graphql import graphql_request - -class FindingResource: - """Resource for a finding.""" - - def __init__(self, mcp: FastMCP): - """Initialize the FindingResource.""" - mcp.resource(name='finding', uri="finding://{finding_id}")(self.finding_resource) - - async def finding_resource( - self, - finding_id: int, - ctx: Context - ) -> dict: - """ - Get a finding in the library. If you want to get a finding from a report use the `reportedfinding://{finding_id}` resource. - - Args: - finding_id (int): The ID of the finding. - - Returns: - dict: The finding data. - """ - await ctx.info(f'Getting finding with ID: {finding_id}') - graphql_query = '''query GetFinding($id: bigint!) { - finding(where: {id: {_eq: $id}}) { - title - type { - findingType - } - severity { - severity - } - cvssScore - cvssVector - description - replication_steps - mitigation - references - } - }''' - response = await graphql_request(graphql_query, ctx, variables={"id": finding_id}) - return response.get("data", {}) \ No newline at end of file diff --git a/mcp-server/ghostwriter_mcp_server/resources/reported_finding.py b/mcp-server/ghostwriter_mcp_server/resources/reported_finding.py deleted file mode 100644 index dce816cb1..000000000 --- a/mcp-server/ghostwriter_mcp_server/resources/reported_finding.py +++ /dev/null @@ -1,47 +0,0 @@ -# 3rd Party Libraries -from fastmcp import FastMCP, Context - -# Ghostwriter MCP Server Imports -from ghostwriter_mcp_server.utils.graphql import graphql_request - -class ReportedFindingResource: - """Resource for a reported finding.""" - - def __init__(self, mcp: FastMCP): - """Initialize the ReportedFindingResource.""" - mcp.resource(name='reported_finding', uri="reportedfinding://{report_finding_id}")(self.report_finding_resource) - - async def report_finding_resource( - self, - report_finding_id: int, - ctx: Context - ) -> dict: - """ - Get a finding on a report. If you want to get a finding from the library use the `finding://{finding_id}` resource. - - Args: - finding_id (int): The ID of the report finding. - - Returns: - dict: The report finding data. - """ - await ctx.info(f'Getting reported finding with ID: {report_finding_id}') - graphql_query = '''query GetReportedFinding($id: bigint!) { - reportedFinding(where: {id: {_eq: $id}}) { - title - findingType { - findingType - } - severity { - severity - } - cvssScore - cvssVector - description - replication_steps - mitigation - references - } - }''' - response = await graphql_request(graphql_query, ctx, variables={"id": report_finding_id}) - return response.get("data", {}) \ No newline at end of file diff --git a/mcp-server/server.py b/mcp-server/server.py index 25f56fc76..fc37b4b2b 100755 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -6,9 +6,6 @@ from fastmcp import FastMCP # Ghostwriter MCP Server Imports -from ghostwriter_mcp_server.resources.client import ClientResource -from ghostwriter_mcp_server.resources.finding import FindingResource -from ghostwriter_mcp_server.resources.reported_finding import ReportedFindingResource from ghostwriter_mcp_server.tools.generate_executive_summary import GenerateExecutiveSummaryTool from ghostwriter_mcp_server.utils.auth import GhostwriterTokenVerifier @@ -32,11 +29,6 @@ def main() -> int: auth=GhostwriterTokenVerifier(), ) - # Resources - ClientResource(mcp) - FindingResource(mcp) - ReportedFindingResource(mcp) - # Tools GenerateExecutiveSummaryTool(mcp)